Files
Learn_System/frontend/admin.html
T

5369 lines
305 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 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" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" crossorigin="anonymous" />
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" crossorigin="anonymous"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js" crossorigin="anonymous"
onload="window._katexReady=true; if(window._katexCb){window._katexCb(); window._katexCb=null;}"></script>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<style>
.container { padding: 32px 32px 80px; }
.page-title { font-family: 'Unbounded', sans-serif; font-size: 1.65rem; font-weight: 800; margin-bottom: 6px; }
.page-sub { font-size: 1rem; color: var(--text-2); margin-bottom: 40px; }
.section-title { font-family: 'Unbounded', sans-serif; font-size: 0.9rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.07em; margin-bottom: 20px; }
/* stats */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 20px; margin-bottom: 48px; }
.stat-card { background: var(--surface); backdrop-filter: var(--blur); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 28px 24px; }
.stat-val { font-family: 'Unbounded', sans-serif; font-size: 2.4rem; font-weight: 800; margin-bottom: 6px; }
.stat-label { font-size: 0.9rem; color: var(--text-3); font-weight: 600; }
.stat-val.violet { color: var(--violet); }
.stat-val.cyan { color: var(--cyan); }
.stat-val.green { color: var(--green); }
.subj-stats { display: flex; gap: 14px; flex-wrap: wrap; margin-bottom: 48px; }
.subj-stat { background: var(--surface); backdrop-filter: var(--blur); border: 1px solid var(--border); border-radius: 16px; padding: 18px 22px; display: flex; align-items: center; gap: 14px; }
.subj-stat-name { font-size: 0.95rem; font-weight: 700; }
.subj-stat-info { font-size: 0.84rem; color: var(--text-3); }
.subj-stat-pct { font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 800; color: var(--violet); min-width: 48px; text-align: right; }
/* subject config cards */
.sc-list { display: flex; flex-direction: column; gap: 8px; }
.sc-card {
background: #fff; border: 1.5px solid var(--border); border-radius: 16px;
overflow: hidden; transition: border-color 0.18s, box-shadow 0.18s;
}
.sc-card.open { border-color: rgba(155,93,229,0.2); box-shadow: 0 6px 24px rgba(15,23,42,0.07); }
/* Collapsed row — always visible */
.sc-row-top {
display: flex; align-items: center; gap: 14px; padding: 14px 20px;
cursor: pointer; user-select: none; transition: background 0.12s;
}
.sc-row-top:hover { background: rgba(155,93,229,0.02); }
.sc-icon {
width: 42px; height: 42px; border-radius: 12px;
display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: #fff;
}
.sc-icon svg { width: 20px; height: 20px; }
.sc-info { flex: 1; min-width: 0; }
.sc-name { font-family: 'Unbounded', sans-serif; font-size: 0.88rem; font-weight: 800; color: #0F172A; }
.sc-summary {
display: flex; gap: 6px; margin-top: 4px; flex-wrap: wrap; align-items: center;
}
.sc-tag {
font-size: 0.68rem; font-weight: 700; padding: 2px 8px; border-radius: 6px;
background: rgba(15,23,42,0.05); color: #64748B;
}
.sc-tag-mode { background: rgba(155,93,229,0.08); color: var(--violet); }
.sc-qcount { font-size: 0.72rem; color: #8898AA; font-weight: 600; }
.sc-chevron {
width: 20px; height: 20px; color: #cbd5e1; transition: transform 0.2s; flex-shrink: 0;
}
.sc-card.open .sc-chevron { transform: rotate(180deg); color: var(--violet); }
/* Expanded body */
.sc-body {
display: none; padding: 0 20px 20px; flex-direction: column; gap: 14px;
border-top: 1px solid rgba(15,23,42,0.06);
}
.sc-card.open .sc-body { display: flex; }
/* Presets row */
.sc-presets {
display: flex; gap: 6px; flex-wrap: wrap; padding-top: 14px;
}
.sc-preset {
padding: 6px 14px; border: 1.5px solid var(--border); border-radius: 99px;
background: #fff; font-family: 'Manrope', sans-serif; font-size: 0.76rem;
font-weight: 600; color: #64748B; cursor: pointer; transition: all 0.15s;
}
.sc-preset:hover { border-color: rgba(155,93,229,0.35); color: var(--violet); background: rgba(155,93,229,0.04); }
.sc-preset.active { border-color: var(--violet); background: rgba(155,93,229,0.08); color: var(--violet); }
/* Fields */
.sc-fields { display: flex; flex-direction: column; gap: 12px; }
.sc-field { display: flex; align-items: center; gap: 10px; }
.sc-label {
font-size: 0.72rem; color: #8898AA; font-weight: 700; white-space: nowrap;
text-transform: uppercase; letter-spacing: 0.04em; min-width: 68px;
}
.sc-select {
flex: 1; padding: 8px 12px; border: 1.5px solid var(--border); border-radius: 10px;
background: #f8f9fc; font-family: 'Manrope', sans-serif; font-size: 0.82rem;
font-weight: 600; color: var(--text); cursor: pointer; transition: border-color 0.15s;
}
.sc-select:focus { border-color: var(--violet); outline: none; }
.sc-input {
width: 68px; padding: 8px 10px; border: 1.5px solid var(--border); border-radius: 10px;
background: #f8f9fc; font-family: 'Manrope', sans-serif; font-size: 0.82rem;
font-weight: 600; color: var(--text); text-align: center; transition: border-color 0.15s;
}
.sc-input:focus { border-color: var(--violet); outline: none; }
.sc-src-toggle {
display: flex; gap: 0; background: rgba(15,23,42,0.04); border-radius: 10px; padding: 3px; flex: 1;
}
.sc-src-btn {
flex: 1; padding: 6px 12px; border: none; border-radius: 8px; background: transparent;
font-family: 'Manrope', sans-serif; font-size: 0.76rem; font-weight: 600;
color: #8898AA; cursor: pointer; transition: all 0.15s; text-align: center;
}
.sc-src-btn.active { background: #fff; color: var(--violet); box-shadow: 0 1px 4px rgba(15,23,42,0.08); }
.sc-test-pick { display: none; flex-direction: column; gap: 10px; }
.sc-test-pick.open { display: flex; }
/* Footer */
.sc-footer {
display: flex; gap: 8px; align-items: center; padding-top: 14px;
border-top: 1px solid rgba(15,23,42,0.05);
}
.sc-save {
padding: 8px 22px; border: none; border-radius: 999px; background: #0F172A; color: #fff;
font-family: 'Manrope', sans-serif; font-size: 0.8rem; font-weight: 700;
cursor: pointer; transition: all 0.15s;
}
.sc-save:hover { background: #1E293B; }
.sc-save.saved { background: #059652; pointer-events: none; }
.sc-save-add {
padding: 8px 16px; border: 1.5px solid rgba(155,93,229,0.25); border-radius: 999px;
background: rgba(155,93,229,0.06); color: var(--violet);
font-family: 'Manrope', sans-serif; font-size: 0.8rem; font-weight: 700;
cursor: pointer; transition: all 0.15s;
}
.sc-save-add:hover { background: rgba(155,93,229,0.12); border-color: var(--violet); }
/* tables */
.table-wrap { background: var(--surface); backdrop-filter: var(--blur); border: 1px solid var(--border); border-radius: var(--r-lg); overflow: hidden; margin-bottom: 48px; box-shadow: var(--shadow); }
table { width: 100%; border-collapse: collapse; }
th { padding: 14px 20px; text-align: left; font-size: 0.82rem; 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: 15px 20px; font-size: 0.94rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr.clickable { cursor: pointer; transition: background var(--tr); }
tr.clickable:hover td { background: rgba(155,93,229,0.04); }
tr.selected td { background: rgba(155,93,229,0.07); }
.role-badge { display: inline-block; padding: 4px 12px; border-radius: var(--r-pill); font-size: 0.78rem; font-weight: 700; }
.role-badge.student { background: rgba(15,23,42,0.07); color: var(--text-3); }
.role-badge.free_student { background: rgba(16,185,129,0.12); color: #059669; }
.role-badge.teacher { background: rgba(6,214,224,0.12); color: #05aab3; }
.role-badge.admin { background: rgba(155,93,229,0.12); color: var(--violet); }
.role-select { padding: 6px 10px; border: 1.5px solid var(--border-h); border-radius: 8px; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 600; background: transparent; color: var(--text); cursor: pointer; }
.role-select:focus { outline: none; border-color: var(--violet); }
.role-select:disabled { opacity: 0.4; cursor: default; }
.pct-cell { font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 700; }
.pct-hi { color: var(--green); }
.pct-mid { color: var(--amber); }
.pct-lo { color: var(--pink); }
/* user panel */
.user-panel { background: var(--surface); backdrop-filter: var(--blur); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 32px; box-shadow: var(--shadow); display: none; }
.user-panel.visible { display: block; }
.user-panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; }
.user-panel-name { font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 800; }
.user-panel-email { font-size: 0.92rem; color: var(--text-3); margin-top: 3px; }
.btn-close { padding: 8px 18px; 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; transition: all var(--tr); }
.btn-close:hover { border-color: var(--pink); color: var(--pink); }
.sess-list { display: flex; flex-direction: column; gap: 12px; }
.sess-item { display: flex; align-items: center; gap: 16px; padding: 14px 20px; border: 1px solid var(--border); border-radius: 14px; }
.sess-pct { font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 800; min-width: 52px; text-align: center; }
.sess-info { flex: 1; }
.sess-subj { font-size: 0.94rem; font-weight: 700; margin-bottom: 3px; }
.sess-meta { font-size: 0.83rem; color: var(--text-3); }
.sess-score{ font-size: 0.9rem; font-weight: 600; color: var(--text-2); white-space: nowrap; }
.spinner { width: 36px; height: 36px; border: 3px solid var(--border); border-top-color: var(--violet); border-radius: 50%; animation: spin 0.8s linear infinite; margin: 40px auto; display: block; }
.empty { text-align: center; padding: 40px; color: var(--text-3); font-size: 0.95rem; }
.error { color: var(--pink); font-size: 0.92rem; padding: 14px 0; }
/* ── admin two-column layout ── */
.admin-layout { display: flex; gap: 0; align-items: flex-start; }
.admin-nav {
width: 200px; flex-shrink: 0;
position: sticky; top: 24px;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--r-lg); padding: 12px 10px;
box-shadow: var(--shadow);
display: flex; flex-direction: column; gap: 2px;
}
.admin-nav-label {
font-size: .68rem; font-weight: 800; text-transform: uppercase;
letter-spacing: .09em; color: var(--text-3);
padding: 12px 12px 5px; margin-top: 4px;
}
.admin-nav-label:first-child { margin-top: 0; }
.admin-nav-sep { height: 1px; background: var(--border); margin: 8px 6px; }
.admin-nav-item {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px; border-radius: 10px; border: none;
background: transparent; width: 100%; text-align: left;
font-family: 'Manrope', sans-serif; font-size: .93rem; font-weight: 600;
color: var(--text-2); cursor: pointer; transition: all .14s;
white-space: nowrap;
}
.admin-nav-item svg { flex-shrink: 0; opacity: .7; }
.admin-nav-item:hover { background: rgba(155,93,229,.07); color: var(--text); }
.admin-nav-item:hover svg { opacity: 1; }
.admin-nav-item.active {
background: rgba(155,93,229,.1); color: var(--violet);
font-weight: 700;
}
.admin-nav-item.active svg { opacity: 1; color: var(--violet); }
.admin-main { flex: 1; min-width: 0; padding-left: 28px; }
/* tab panes */
.tab-pane { display: none; }
.tab-pane.active { display: block; }
/* permissions tab */
.perm-header { margin-bottom: 24px; }
.perm-role-block { background: var(--surface); border: 1.5px solid var(--border-h); border-radius: var(--r-xl); padding: 20px 24px; margin-bottom: 20px; }
.perm-role-title { margin-bottom: 16px; }
.perm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.perm-card { display: flex; align-items: flex-start; gap: 16px; padding: 16px 20px; border: 1.5px solid var(--border); border-radius: var(--r-lg); background: var(--surface-2, #f8fafc); transition: border-color .2s, box-shadow .2s; }
.perm-card:hover { border-color: var(--violet); box-shadow: 0 2px 12px rgba(109,40,217,.08); }
.perm-card.enabled { border-color: rgba(6,214,160,.5); background: rgba(6,214,160,.04); }
.perm-info { flex: 1; min-width: 0; }
.perm-label { font-size: 15px; font-weight: 700; color: var(--text); line-height: 1.3; }
.perm-desc { font-size: 13px; color: var(--muted); margin-top: 4px; line-height: 1.45; }
/* toggle switch */
.perm-toggle { flex-shrink: 0; position: relative; width: 46px; height: 26px; cursor: pointer; }
.perm-toggle input { opacity: 0; width: 0; height: 0; }
.perm-track { position: absolute; inset: 0; border-radius: 26px; background: var(--border-h); transition: background .2s; }
.perm-thumb { position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.2); transition: transform .2s; }
.perm-toggle input:checked ~ .perm-track { background: var(--green, #06d6a0); }
.perm-toggle input:checked ~ .perm-thumb { transform: translateX(20px); }
.perm-toggle input:focus-visible ~ .perm-track { outline: 2px solid var(--violet); }
/* toolbar */
.t-toolbar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 24px; }
.t-select { padding: 10px 16px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); font-family: 'Manrope', sans-serif; font-size: 0.92rem; font-weight: 600; background: var(--surface); color: var(--text); cursor: pointer; }
.t-select:focus { outline: none; border-color: var(--violet); }
.t-input { padding: 10px 16px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); font-family: 'Manrope', sans-serif; font-size: 0.92rem; background: var(--surface); color: var(--text); width: 220px; }
.t-input:focus { outline: none; border-color: var(--violet); }
.t-count { font-size: 0.9rem; color: var(--text-3); margin-left: auto; }
.btn-add { padding: 10px 24px; border: none; border-radius: var(--r-pill); background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.92rem; font-weight: 700; cursor: pointer; white-space: nowrap; transition: transform var(--tr), box-shadow var(--tr); }
.btn-add:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(6,214,224,0.35); }
.mode-badge { display: inline-block; padding: 3px 10px; border-radius: var(--r-pill); font-size: 0.76rem; font-weight: 700; }
.mode-exam { background: rgba(155,93,229,0.1); color: var(--violet); }
.mode-practice { background: rgba(6,214,224,0.1); color: #05aab3; }
.mode-repeat { background: rgba(6,214,224,0.1); color: #05aab3; }
.mode-ct { background: rgba(255,179,71,0.12); color: var(--amber); }
.mode-topic { background: rgba(255,179,71,0.12); color: var(--amber); }
.mode-random { background: rgba(15,23,42,0.07); color: var(--text-3); }
/* ── assignment card rows ── */
.a-summary { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 18px; }
.a-sum-chip { padding: 6px 16px; border-radius: var(--r-pill); font-size: 0.85rem; font-weight: 700; font-family: 'Manrope', sans-serif; display: flex; align-items: center; gap: 7px; }
.a-sum-chip.s-all { background: rgba(15,23,42,0.06); color: var(--text-3); }
.a-sum-chip.s-active { background: rgba(155,93,229,0.1); color: var(--violet); }
.a-sum-chip.s-overdue { background: rgba(241,91,181,0.12); color: var(--pink); }
.a-sum-chip.s-done { background: rgba(6,214,100,0.12); color: #059652; }
.a-filter-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 18px; }
.a-f-chip { padding: 6px 18px; border-radius: var(--r-pill); border: 1.5px solid var(--border-h); background: var(--surface); color: var(--text-3); font-family: 'Manrope', sans-serif; font-size: 0.85rem; font-weight: 700; cursor: pointer; transition: all var(--tr); }
.a-f-chip:hover { border-color: var(--violet); color: var(--violet); }
.a-f-chip.active { background: #0F172A; color: #fff; border-color: #0F172A; }
.a-filter-sep { width: 1px; height: 22px; background: var(--border-h); margin: 0 4px; }
.a-sort-sel { padding: 6px 14px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: var(--surface); color: var(--text-3); font-family: 'Manrope', sans-serif; font-size: 0.85rem; font-weight: 600; cursor: pointer; }
.a-sort-sel:focus { outline: none; border-color: var(--violet); }
.a-rows { display: flex; flex-direction: column; gap: 6px; }
.a-row {
display: flex; align-items: center; gap: 14px;
background: var(--surface);
border: 1.5px solid var(--border);
border-left: 4px solid var(--ac, var(--violet));
border-radius: 14px;
padding: 0 16px 0 12px;
height: 68px;
transition: box-shadow 0.15s, transform 0.15s;
}
.a-row:hover { box-shadow: 0 4px 18px rgba(15,23,42,0.1); transform: translateX(2px); }
.a-row.a-overdue { --ac: var(--pink); background: rgba(241,91,181,0.02); }
.a-row.a-done { --ac: var(--green); opacity: 0.76; }
.a-row.a-urgent { --ac: #FF4C29; }
.a-icon { width: 36px; height: 36px; border-radius: 9px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; font-size: 1.05rem; }
.a-main { flex: 1; min-width: 0; }
.a-title { font-size: 0.94rem; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); line-height: 1.2; }
.a-meta { font-size: 0.78rem; color: var(--text-3); margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.a-meta .a-tag-over { color: var(--pink); font-weight: 700; }
.a-meta .a-tag-urgent { color: #E83A1E; font-weight: 700; }
.a-prog { flex-shrink: 0; width: 120px; }
.a-prog-nums { display: flex; justify-content: space-between; font-size: 0.76rem; color: var(--text-3); font-weight: 600; margin-bottom: 5px; }
.a-prog-pct { font-family: 'Unbounded', sans-serif; font-size: 0.84rem; font-weight: 900; }
.a-prog-bar { height: 6px; border-radius: 99px; background: rgba(15,23,42,0.08); overflow: hidden; }
.a-prog-fill { height: 100%; border-radius: 99px; transition: width 0.3s; }
.a-actions { display: flex; gap: 6px; flex-shrink: 0; }
/* session drawer */
.sess-drawer { overflow: hidden; max-height: 0; transition: max-height 0.35s ease; }
.sess-drawer.open { max-height: 4000px; }
.sess-drawer-inner { padding: 28px 32px; background: rgba(238,242,255,0.6); border-top: 1px solid var(--border); }
.drawer-header { display: flex; align-items: center; gap: 18px; margin-bottom: 24px; flex-wrap: wrap; }
.drawer-meta { font-size: 0.9rem; color: var(--text-3); }
.drawer-score { font-family: 'Unbounded', sans-serif; font-size: 1.8rem; font-weight: 900; }
.qb-list { display: flex; flex-direction: column; gap: 12px; }
.qb-item { border-radius: 14px; padding: 16px 20px; border: 1px solid var(--border); background: var(--surface); }
.qb-item.correct { border-left: 3px solid var(--green); }
.qb-item.wrong { border-left: 3px solid var(--pink); }
.qb-item.skipped { border-left: 3px solid var(--text-3); }
.qb-header { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.qb-badge { padding: 3px 10px; border-radius: var(--r-pill); font-size: 0.74rem; font-weight: 700; }
.qb-badge.correct { background: rgba(6,214,100,0.1); color: var(--green); }
.qb-badge.wrong { background: rgba(241,91,181,0.1); color: var(--pink); }
.qb-badge.skipped { background: rgba(15,23,42,0.06); color: var(--text-3); }
.qb-qnum { font-size: 0.82rem; color: var(--text-3); font-weight: 600; }
.qb-time { margin-left: auto; font-size: 0.79rem; color: var(--text-3); }
.qb-text { font-size: 0.96rem; line-height: 1.6; margin-bottom: 12px; }
.qb-opts { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; }
.qb-opt { display: flex; align-items: center; gap: 9px; padding: 8px 12px; border-radius: 9px; font-size: 0.9rem; }
.qb-opt.correct-opt { background: rgba(6,214,100,0.08); color: var(--green); font-weight: 600; }
.qb-opt.chosen-wrong { background: rgba(241,91,181,0.08); color: var(--pink); }
.qb-opt-icon { font-size: 0.88rem; width: 18px; text-align: center; flex-shrink: 0; }
.qb-expl { font-size: 0.88rem; color: var(--text-2); background: rgba(155,93,229,0.05); border: 1px solid rgba(155,93,229,0.12); border-radius: 9px; padding: 10px 14px; line-height: 1.6; }
/* ── E: upgraded stat cards ── */
.stat-card { position: relative; overflow: hidden; }
.stat-card::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; background: var(--stat-top, var(--violet)); opacity: 0.7; }
.stat-card-icon { width: 48px; height: 48px; border-radius: 14px; display: flex; align-items: center; justify-content: center; font-size: 1.5rem; margin-bottom: 14px; }
/* ── F: performance bar in users table ── */
.perf-bar { height: 4px; background: rgba(15,23,42,0.06); border-radius: 99px; margin-top: 6px; min-width: 70px; overflow: hidden; }
.perf-fill { height: 100%; border-radius: 99px; }
.perf-fill.pct-hi { background: var(--green); }
.perf-fill.pct-mid { background: var(--amber); }
.perf-fill.pct-lo { background: var(--pink); }
/* ── H: session timeline ── */
.sess-tl-day { font-family: 'Unbounded', sans-serif; font-size: 0.76rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; margin: 24px 0 12px; display: flex; align-items: center; gap: 10px; }
.sess-tl-day::after { content: ''; flex: 1; height: 1px; background: var(--border); }
.sess-tl-day:first-child { margin-top: 0; }
.sess-tl-item { display: flex; align-items: center; gap: 16px; padding: 15px 20px; border: 1px solid var(--border); border-radius: 14px; margin-bottom: 10px; transition: all var(--tr); cursor: pointer; background: var(--surface); }
.sess-tl-item:hover { border-color: var(--border-h); transform: translateX(3px); box-shadow: 0 4px 20px rgba(15,23,42,0.07); }
.sess-tl-item.open { border-color: var(--violet); background: rgba(155,93,229,0.04); }
.sess-tl-ring { flex-shrink: 0; }
.sess-tl-user { flex: 1; min-width: 0; }
.sess-tl-name { font-size: 0.94rem; font-weight: 700; margin-bottom: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sess-tl-meta { font-size: 0.81rem; color: var(--text-3); }
.sess-tl-score { font-size: 0.8rem; font-weight: 600; color: var(--text-2); white-space: nowrap; }
.sess-tl-time { font-size: 0.75rem; color: var(--text-3); white-space: nowrap; }
.sess-tl-drawer { overflow: hidden; max-height: 0; transition: max-height 0.35s ease; margin-bottom: 0; }
.sess-tl-drawer.open { max-height: 4000px; margin-bottom: 8px; }
.sess-tl-wrap { display: flex; flex-direction: column; }
/* ═══ QUESTION EDITOR ═══════════════════════════════════════════════ */
.q-list { display: flex; flex-direction: column; gap: 12px; }
.q-card { background: var(--surface); backdrop-filter: var(--blur); border: 1px solid var(--border); border-radius: var(--r-lg); overflow: hidden; transition: border-color var(--tr), box-shadow var(--tr); }
.q-card:hover { border-color: var(--border-h); box-shadow: 0 4px 20px rgba(15,23,42,0.07); }
.q-card-head { display: flex; align-items: flex-start; gap: 14px; padding: 18px 20px; }
.q-card-num { font-family: 'Unbounded', sans-serif; font-size: 0.7rem; font-weight: 700; color: var(--text-3); min-width: 36px; padding-top: 3px; flex-shrink: 0; }
.q-card-body { flex: 1; min-width: 0; cursor: pointer; }
.q-card-text { font-size: 0.9rem; font-weight: 600; line-height: 1.5; margin-bottom: 8px; }
.q-card-meta { display: flex; gap: 8px; flex-wrap: wrap; }
.q-badge { display: inline-block; padding: 2px 8px; border-radius: var(--r-pill); font-size: 0.68rem; font-weight: 700; }
.q-badge-subj { background: rgba(155,93,229,0.1); color: var(--violet); }
.q-badge-topic { background: rgba(6,214,224,0.1); color: #05aab3; }
.diff-1 { background: rgba(6,214,100,0.1); color: var(--green); }
.diff-2 { background: rgba(255,179,71,0.1); color: var(--amber); }
.diff-3 { background: rgba(241,91,181,0.1); color: var(--pink); }
.q-card-actions { display: flex; gap: 6px; flex-shrink: 0; align-items: flex-start; padding-top: 2px; }
.btn-edit-q { padding: 5px 12px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.75rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); }
.btn-edit-q:hover { border-color: var(--violet); color: var(--violet); }
.btn-dup-q { padding: 5px 10px; border: 1.5px solid transparent; border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.75rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); }
.btn-dup-q:hover { border-color: var(--cyan); color: #05aab3; }
.btn-del-q { padding: 5px 10px; border: 1.5px solid transparent; border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.75rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); }
.btn-del-q:hover { border-color: var(--pink); color: var(--pink); }
.q-card-detail { display: none; padding: 0 20px 16px 70px; border-top: 1px solid var(--border); margin-top: -1px; }
.q-card-detail.open { display: block; }
.q-opt-row { display: flex; align-items: center; gap: 10px; padding: 7px 0; font-size: 0.85rem; border-bottom: 1px solid var(--border); }
.q-opt-row:last-of-type { border-bottom: none; }
.q-opt-row.correct { color: var(--green); font-weight: 700; }
.q-opt-icon { font-size: 0.85rem; width: 18px; flex-shrink: 0; }
.q-expl { margin-top: 12px; font-size: 0.8rem; color: var(--text-2); background: rgba(155,93,229,0.05); border: 1px solid rgba(155,93,229,0.12); border-radius: 10px; padding: 10px 14px; line-height: 1.6; }
/* modal */
.q-modal { position: fixed; inset: 0; z-index: 400; display: none; align-items: flex-start; justify-content: center; padding: 32px 20px 60px; background: rgba(15,23,42,0.5); backdrop-filter: blur(10px); overflow-y: auto; }
.q-modal.open { display: flex; }
.q-modal-box { background: #fff; border-radius: 24px; padding: 36px 40px; width: 100%; max-width: 780px; box-shadow: 0 40px 100px rgba(15,23,42,0.26); }
.q-modal-title { font-family: 'Unbounded', sans-serif; font-size: 1.05rem; font-weight: 800; margin-bottom: 28px; }
.form-row { margin-bottom: 18px; }
.form-row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 18px; }
.form-row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; margin-bottom: 18px; }
.form-label { display: block; font-size: 0.72rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; }
.form-hint { font-size: 0.72rem; color: var(--text-3); margin-top: 4px; }
.form-ctrl { width: 100%; padding: 10px 14px; border: 1.5px solid rgba(15,23,42,0.16); border-radius: 10px; font-family: 'Manrope', sans-serif; font-size: 0.9rem; color: var(--text); background: #f8f9ff; resize: vertical; transition: border-color 0.2s; }
.form-ctrl:focus { outline: none; border-color: var(--violet); background: #fff; }
.char-counter { font-size: 0.72rem; color: var(--text-3); text-align: right; margin-top: 3px; }
.char-counter.warn { color: var(--amber); }
.char-counter.over { color: var(--pink); }
/* options in modal */
.opts-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.opts-label { font-size: 0.72rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; }
.opts-grid { display: flex; flex-direction: column; gap: 8px; margin-bottom: 10px; }
.opt-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 12px; border: 1.5px solid rgba(15,23,42,0.12); background: #f8f9ff; transition: border-color 0.2s, background 0.2s; }
.opt-row.opt-correct { border-color: var(--green); background: rgba(6,214,100,0.06); }
.opt-letter { font-size: 0.78rem; font-weight: 800; color: var(--text-3); min-width: 18px; font-family: 'Unbounded', sans-serif; }
.opt-row.opt-correct .opt-letter { color: var(--green); }
.opt-radio { width: 18px; height: 18px; accent-color: var(--green); flex-shrink: 0; cursor: pointer; }
.opt-input { flex: 1; border: none; background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.9rem; color: var(--text); outline: none; }
.opt-row.opt-correct .opt-input { font-weight: 600; color: #1a7a3e; }
.btn-rem-opt { width: 24px; height: 24px; border: none; border-radius: 50%; background: rgba(15,23,42,0.07); color: var(--text-3); font-size: 1rem; line-height: 1; cursor: pointer; flex-shrink: 0; transition: background 0.2s, color 0.2s; display: flex; align-items: center; justify-content: center; }
.btn-rem-opt:hover { background: rgba(241,91,181,0.12); color: var(--pink); }
.btn-add-opt { padding: 7px 16px; border: 1.5px dashed rgba(15,23,42,0.2); border-radius: 10px; background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 600; color: var(--text-3); cursor: pointer; width: 100%; margin-bottom: 18px; transition: border-color 0.2s, color 0.2s; }
.btn-add-opt:hover { border-color: var(--violet); color: var(--violet); }
.img-upload-row { display: flex; gap: 8px; align-items: center; }
.btn-img-upload { display: inline-flex; align-items: center; gap: 6px; padding: 0 16px; height: 40px; border: 1.5px solid rgba(15,23,42,0.18); border-radius: 10px; background: var(--surface); font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 600; color: var(--text-2); cursor: pointer; white-space: nowrap; transition: border-color 0.2s, color 0.2s; flex-shrink: 0; }
.btn-img-upload:hover { border-color: var(--violet); color: var(--violet); }
.btn-img-upload:disabled { opacity: 0.5; cursor: not-allowed; }
#qf-image-preview { display: none; }
#qf-image-preview.visible { display: inline-block !important; }
.modal-footer { display: flex; gap: 10px; justify-content: flex-end; margin-top: 24px; border-top: 1px solid var(--border); padding-top: 20px; }
.btn-cancel2 { padding: 10px 22px; border: 1.5px solid rgba(15,23,42,0.2); border-radius: 999px; 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: 999px; background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 700; cursor: pointer; min-width: 130px; transition: opacity 0.2s; }
.btn-save:disabled { opacity: 0.5; cursor: not-allowed; }
.form-error { font-size: 0.82rem; color: var(--pink); margin-top: 8px; min-height: 20px; }
/* question type buttons */
.type-btn { 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-3); cursor: pointer; transition: all var(--tr); }
.type-btn.active { background: var(--violet); border-color: var(--violet); color: #fff; }
/* ═══ TESTS TAB ══════════════════════════════════════════════════════ */
.tst-drawer { border-top: 1px solid var(--border); background: rgba(238,242,255,0.5); }
.tst-drawer-inner { padding: 20px 24px; }
.tst-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media(max-width:700px){ .tst-cols { grid-template-columns: 1fr; } }
.tst-panel-title { font-size: 0.72rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 10px; }
.tst-q-list { display: flex; flex-direction: column; gap: 2px; max-height: 400px; overflow-y: auto; border: 1px solid var(--border); border-radius: 10px; background: #fff; padding: 4px; }
.tst-q-item { display: flex; align-items: flex-start; gap: 8px; padding: 8px 10px; border-radius: 7px; font-size: 0.83rem; transition: background var(--tr); }
.tst-q-item:hover { background: rgba(15,23,42,0.03); }
.tst-q-num { font-size: 0.68rem; font-weight: 700; color: var(--text-3); min-width: 22px; padding-top: 2px; }
.tst-q-body { flex: 1; min-width: 0; }
.tst-q-text { display: block; line-height: 1.45; word-break: break-word; margin-bottom: 4px; }
.tst-q-meta { display: flex; gap: 4px; flex-wrap: wrap; }
.tst-q-badge { font-size: 0.62rem; font-weight: 700; padding: 1px 6px; border-radius: 999px; flex-shrink: 0; }
.tst-q-opts { font-size: 0.68rem; color: var(--text-3); padding-top: 2px; }
.btn-tst-rem { width: 22px; height: 22px; border: none; border-radius: 50%; background: rgba(241,91,181,0.1); color: var(--pink); font-size: 0.85rem; cursor: pointer; flex-shrink: 0; display: flex; align-items: center; justify-content: center; transition: background var(--tr); }
.btn-tst-rem:hover { background: rgba(241,91,181,0.22); }
.btn-tst-add { width: 22px; height: 22px; border: none; border-radius: 50%; background: rgba(6,214,100,0.12); color: var(--green); font-size: 1rem; line-height: 1; cursor: pointer; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-weight: 700; transition: background var(--tr); }
.btn-tst-add:hover { background: rgba(6,214,100,0.25); }
.btn-tst-add.added { background: rgba(6,214,100,0.25); cursor: default; opacity: 0.6; }
.tst-search { width: 100%; padding: 7px 12px; border: 1.5px solid var(--border-h); border-radius: 8px; font-family: 'Manrope', sans-serif; font-size: 0.83rem; background: #fff; color: var(--text); margin-bottom: 8px; outline: none; }
.tst-search:focus { border-color: var(--violet); }
.tst-empty { text-align: center; padding: 20px; color: var(--text-3); font-size: 0.82rem; }
.src-toggle { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
/* formula bar */
.formula-bar { display: flex; gap: 4px; align-items: center; flex-wrap: wrap; padding: 8px 12px; background: #f0f2ff; border-radius: 10px; margin-bottom: 8px; }
.fml { padding: 4px 9px; border: 1px solid rgba(155,93,229,0.2); border-radius: 6px; background: #fff; font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 600; color: var(--violet); cursor: pointer; transition: background 0.15s; }
.fml:hover { background: rgba(155,93,229,0.08); }
/* preview */
.q-preview-wrap { background: rgba(155,93,229,0.04); border: 1px solid rgba(155,93,229,0.15); border-radius: 10px; padding: 12px 16px; margin-top: 8px; margin-bottom: 18px; min-height: 40px; }
.q-preview-text { font-size: 0.95rem; line-height: 1.6; color: var(--text); }
.form-hint { font-size: 0.72rem; color: var(--text-3); margin-top: 4px; }
/* ── Mobile responsive ── */
@media (max-width: 768px) {
.container { padding: 16px 12px 80px; }
.page-title { font-size: 1.1rem; }
.page-sub { font-size: 0.83rem; margin-bottom: 24px; }
/* Stats */
.stats-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; }
.stat-val { font-size: 1.5rem; }
/* Admin nav — collapse to horizontal scroll strip on mobile */
.admin-layout { flex-direction: column; gap: 0; align-items: stretch; }
.admin-nav {
width: 100%; position: static;
flex-direction: row; overflow-x: auto; overflow-y: hidden;
flex-wrap: nowrap; padding: 6px; gap: 2px;
scrollbar-width: none; border-radius: 14px;
margin-bottom: 20px;
}
.admin-nav::-webkit-scrollbar { display: none; }
.admin-nav-label { display: none; }
.admin-nav-sep { display: none !important; }
.admin-nav-item { padding: 7px 12px; font-size: .78rem; white-space: nowrap; border-radius: 8px; }
.admin-nav-item svg { display: none; }
/* admin-main must fill full width when stacked */
.admin-main { padding-left: 0; width: 100%; min-width: 0; box-sizing: border-box; }
/* Toolbar: selects wrap and fill available space */
.t-toolbar { gap: 7px; flex-wrap: wrap; }
.t-select { flex: 1 1 120px; min-width: 0; max-width: 100%; font-size: 0.82rem; padding: 8px 10px; }
.t-input { width: 100%; flex: 1 1 140px; min-width: 0; }
.t-count { margin-left: 0; width: 100%; }
.btn-add { width: 100%; justify-content: center; }
/* Tables: horizontal scroll container */
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
table { min-width: 540px; }
th, td { padding: 10px 12px; }
/* Assignment rows */
.a-row { height: auto; min-height: 56px; padding: 8px 10px; gap: 8px; flex-wrap: wrap; }
.a-prog { display: none; } /* hide progress column on mobile */
.a-meta { white-space: normal; }
/* Subject config cards */
.sc-row-top { padding: 12px 14px; gap: 10px; }
.sc-icon { width: 36px; height: 36px; border-radius: 10px; }
.sc-icon svg { width: 16px; height: 16px; }
.sc-name { font-size: 0.82rem; }
.sc-body { padding: 0 14px 16px; gap: 10px; }
.sc-presets { gap: 4px; padding-top: 10px; }
.sc-preset { font-size: 0.7rem; padding: 5px 10px; }
.sc-field { flex-wrap: wrap; gap: 6px; }
.sc-label { min-width: 56px; font-size: 0.68rem; }
.sc-input { width: 60px; }
.sc-footer { gap: 6px; }
/* Question editor modal */
.q-modal { padding: 0; align-items: flex-end; overflow-y: hidden; }
.q-modal-box {
border-radius: 22px 22px 0 0;
padding: 24px 16px 32px;
max-height: 92vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.q-modal-title { font-size: 0.9rem; margin-bottom: 20px; }
.form-row-2, .form-row-3 { grid-template-columns: 1fr; }
/* User panel */
.user-panel { padding: 18px 14px; }
.user-panel-header { flex-wrap: wrap; gap: 10px; }
/* Session drawer */
.sess-drawer-inner { padding: 16px 12px; }
.drawer-header { gap: 10px; }
.drawer-score { font-size: 1.2rem; }
/* Session timeline */
.sess-tl-item { padding: 10px 12px; gap: 10px; }
.sess-tl-time { display: none; }
/* Subj stats: stretch to fill row */
.subj-stats { gap: 8px; }
.subj-stat { padding: 10px 12px; flex: 1 1 calc(50% - 8px); min-width: 0; box-sizing: border-box; }
.subj-stat-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.subj-stat-info { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* adm-panel (shop/gam/perms tabs): reduce padding */
.adm-panel { padding: 18px 14px; }
/* q-card-detail: reduce deep left indent */
.q-card-detail { padding-left: 20px; }
/* modal footer: wrap buttons on narrow screens */
.q-modal-box .modal-footer { flex-wrap: wrap; }
.q-modal-box .modal-footer .btn-cancel2,
.q-modal-box .modal-footer .btn-save { flex: 1; min-width: 0; text-align: center; justify-content: center; }
/* subj-stat: don't let % value push items off screen */
.subj-stat-pct { min-width: 36px; font-size: 0.95rem; }
}
@media (max-width: 480px) {
.container { padding: 14px 10px 80px; }
.stats-grid { grid-template-columns: 1fr; gap: 8px; }
.stat-val { font-size: 1.2rem; }
.stat-card { padding: 16px 14px; }
.admin-nav-item { padding: 6px 11px; font-size: 0.75rem; }
.a-actions { gap: 4px; }
.a-icon { display: none; }
/* subj-stat: 1 column on very narrow */
.subj-stat { flex: 1 1 100%; }
/* perm-grid: single column at 480px */
.perm-grid { grid-template-columns: 1fr; }
/* adm-form-row: full-width inputs */
.adm-form-group input,
.adm-form-group select,
.adm-form-group textarea { width: 100%; box-sizing: border-box; }
/* toolbar: each select full row */
.t-select { flex: 1 1 100%; }
}
/* ═══ SHOP / GAM / TPL tabs ═══ */
.shop-items-table td, .gam-top-table td, .gam-log-table td, .tpl-table td { font-size: 0.92rem; }
.adm-panel { background: var(--surface); backdrop-filter: var(--blur); border: 1.5px solid var(--border); border-radius: var(--r-lg); padding: 28px 32px; margin-bottom: 28px; }
.adm-panel-title { font-family: 'Unbounded', sans-serif; font-size: 0.88rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.07em; margin-bottom: 20px; }
.adm-form-row { display: flex; gap: 14px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 16px; }
.adm-form-group { display: flex; flex-direction: column; gap: 5px; }
.adm-form-group label { font-size: 0.76rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.04em; }
.adm-form-group input, .adm-form-group select, .adm-form-group textarea { padding: 10px 14px; border: 1.5px solid var(--border-h); border-radius: 10px; font-family: 'Manrope', sans-serif; font-size: 0.92rem; background: var(--surface); color: var(--text); }
.adm-form-group input:focus, .adm-form-group select:focus, .adm-form-group textarea:focus { outline: none; border-color: var(--violet); }
.adm-form-group textarea { resize: vertical; min-height: 70px; }
.adm-btn { padding: 10px 24px; border: none; border-radius: var(--r-pill); font-family: 'Manrope', sans-serif; font-size: 0.92rem; font-weight: 700; cursor: pointer; transition: opacity .2s; }
.adm-btn-primary { background: var(--grad-1); color: #fff; }
.adm-btn-danger { background: var(--pink); color: #fff; }
.adm-btn-small { padding: 6px 14px; font-size: 0.84rem; }
.adm-btn:hover { opacity: 0.88; }
.adm-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.adm-toggle { position: relative; width: 42px; height: 24px; cursor: pointer; display: inline-block; vertical-align: middle; }
.adm-toggle input { opacity: 0; width: 0; height: 0; }
.adm-toggle .track { position: absolute; inset: 0; border-radius: 24px; background: var(--border-h); transition: background .2s; }
.adm-toggle .thumb { position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.15); transition: transform .2s; }
.adm-toggle input:checked ~ .track { background: var(--green, #06d6a0); }
.adm-toggle input:checked ~ .thumb { transform: translateX(18px); }
.adm-user-search { position: relative; }
.adm-user-search .us-results { position: absolute; top: 100%; left: 0; right: 0; z-index: 50; background: #fff; border: 1.5px solid var(--border-h); border-radius: 12px; max-height: 240px; overflow-y: auto; box-shadow: 0 8px 24px rgba(15,23,42,0.12); display: none; }
.adm-user-search .us-results.open { display: block; }
.adm-user-search .us-item { padding: 10px 16px; font-size: 0.92rem; cursor: pointer; transition: background .15s; display: flex; justify-content: space-between; align-items: center; }
.adm-user-search .us-item:hover { background: rgba(155,93,229,0.06); }
.adm-user-search .us-item .us-role { font-size: 0.76rem; color: var(--text-3); }
/* ── Submission log ── */
.sl-wrap {
overflow-x: auto; border: 1.5px solid var(--border); border-radius: 16px;
background: #fff; box-shadow: 0 2px 12px rgba(15,23,42,0.05);
}
.sl-table { width: 100%; border-collapse: collapse; min-width: 800px; }
.sl-table th {
padding: 12px 14px; text-align: left; font-size: 0.68rem; font-weight: 800;
color: var(--violet); text-transform: uppercase; letter-spacing: 0.07em;
border-bottom: 2px solid rgba(155,93,229,0.12);
background: linear-gradient(180deg, rgba(155,93,229,0.04) 0%, rgba(155,93,229,0.01) 100%);
white-space: nowrap;
}
.sl-table td {
padding: 12px 14px; font-size: 0.82rem; color: #1E293B;
border-bottom: 1px solid rgba(15,23,42,0.06); vertical-align: middle;
}
.sl-table tr:last-child td { border-bottom: none; }
.sl-table tbody tr { transition: background 0.12s; }
.sl-table tbody tr:hover td { background: rgba(155,93,229,0.03); }
.sl-table tbody tr:nth-child(even) td { background: rgba(15,23,42,0.015); }
.sl-table tbody tr:nth-child(even):hover td { background: rgba(155,93,229,0.04); }
.sl-date { font-size: 0.78rem; color: #64748B; white-space: nowrap; }
.sl-student {
font-weight: 700; color: #0F172A; display: flex; align-items: center; gap: 8px;
}
.sl-student-avatar {
width: 28px; height: 28px; border-radius: 8px; flex-shrink: 0;
background: linear-gradient(135deg, rgba(155,93,229,0.15), rgba(6,214,224,0.1));
display: flex; align-items: center; justify-content: center;
font-size: 0.65rem; font-weight: 800; color: var(--violet);
}
.sl-file {
max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: 0.78rem; color: #64748B; font-family: monospace;
}
.sl-assignment { font-weight: 600; color: #3D4F6B; }
.sl-class { font-size: 0.78rem; color: #8898AA; }
.sl-status {
display: inline-flex; align-items: center; gap: 4px;
padding: 3px 10px; border-radius: 99px; font-size: 0.7rem; font-weight: 700;
white-space: nowrap;
}
.sl-status-new { background: rgba(6,214,224,0.1); color: #06aab3; }
.sl-status-reviewed { background: rgba(5,150,82,0.08); color: #059652; }
.sl-status-accepted { background: rgba(5,150,82,0.12); color: #047857; }
.sl-status-revision { background: rgba(241,91,181,0.08); color: #c0306a; }
.sl-status-resubmitted { background: rgba(59,130,246,0.08); color: #3B82F6; }
.sl-grade {
font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800;
min-width: 32px; text-align: center;
}
.sl-grade-hi { color: #059652; }
.sl-grade-mid { color: #c07c00; }
.sl-grade-lo { color: #c0306a; }
.sl-grade-none { color: #cbd5e1; font-weight: 400; font-family: 'Manrope', sans-serif; font-size: 0.78rem; }
.sl-deleted-by {
display: flex; align-items: center; gap: 6px;
font-size: 0.78rem; color: #64748B;
}
.sl-role-badge {
font-size: 0.62rem; font-weight: 700; padding: 1px 6px; border-radius: 4px;
text-transform: uppercase; letter-spacing: 0.04em;
}
.sl-role-admin { background: rgba(241,91,181,0.1); color: #c0306a; }
.sl-role-teacher { background: rgba(155,93,229,0.1); color: var(--violet); }
.sl-role-student { background: rgba(6,214,224,0.1); color: #06aab3; }
.sl-empty {
padding: 48px 24px; text-align: center; color: #8898AA; font-size: 0.88rem;
}
.sl-empty-icon { margin-bottom: 12px; opacity: 0.3; }
.sl-filter-row { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; flex-wrap: wrap; }
.sl-filter-select {
padding: 8px 14px; border: 1.5px solid var(--border); border-radius: 10px;
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 600;
color: #3D4F6B; background: #fff; cursor: pointer; min-width: 200px;
transition: border-color 0.15s;
}
.sl-filter-select:focus { border-color: var(--violet); outline: none; }
.sl-count { font-size: 0.78rem; color: #8898AA; font-weight: 600; }
/* ══════════ CLASSROOM ADMIN TAB ══════════ */
.cr-admin-section { margin-bottom: 40px; }
.cr-admin-section-title {
font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800;
color: var(--text-3); text-transform: uppercase; letter-spacing: 0.07em;
margin-bottom: 16px; display: flex; align-items: center; gap: 10px;
}
.cr-admin-section-title::after { content: ''; flex: 1; height: 1px; background: var(--border); }
/* Active session card */
.cr-live-list { display: flex; flex-direction: column; gap: 10px; }
.cr-live-card {
display: flex; align-items: center; gap: 14px;
background: var(--surface); border: 1.5px solid var(--border);
border-left: 4px solid #EF4444; border-radius: 16px;
padding: 14px 18px; transition: box-shadow 0.15s, transform 0.15s;
}
.cr-live-card:hover { box-shadow: 0 4px 20px rgba(15,23,42,0.1); transform: translateX(2px); }
.cr-live-pulse {
width: 10px; height: 10px; border-radius: 50%; background: #EF4444; flex-shrink: 0;
animation: pulse-live 1.4s ease-in-out infinite;
}
@keyframes pulse-live {
0%,100% { box-shadow: 0 0 0 0 rgba(239,68,68,0.5); }
50% { box-shadow: 0 0 0 6px rgba(239,68,68,0); }
}
.cr-live-info { flex: 1; min-width: 0; }
.cr-live-title { font-size: 0.96rem; font-weight: 700; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cr-live-meta { font-size: 0.81rem; color: var(--text-3); }
.cr-live-badges { display: flex; gap: 6px; flex-shrink: 0; align-items: center; }
.cr-badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 10px; border-radius: 99px; font-size: 0.76rem; font-weight: 700;
}
.cr-badge-online { background: rgba(6,214,100,0.12); color: #059652; }
.cr-badge-msgs { background: rgba(6,214,224,0.12); color: #05aab3; }
.cr-badge-dur { background: rgba(155,93,229,0.1); color: var(--violet); }
.cr-live-actions { display: flex; gap: 6px; flex-shrink: 0; }
/* History session row */
.cr-hist-list { display: flex; flex-direction: column; gap: 8px; }
.cr-hist-row {
display: flex; align-items: center; gap: 14px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 14px; padding: 12px 16px; cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.cr-hist-row:hover { border-color: var(--violet); box-shadow: 0 2px 12px rgba(109,40,217,0.07); }
.cr-hist-row.open { border-color: var(--violet); background: rgba(155,93,229,0.03); border-radius: 14px 14px 0 0; border-bottom: none; }
.cr-hist-icon { width: 38px; height: 38px; border-radius: 10px; background: rgba(155,93,229,0.1); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.cr-hist-main { flex: 1; min-width: 0; }
.cr-hist-title { font-size: 0.94rem; font-weight: 700; margin-bottom: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cr-hist-meta { font-size: 0.79rem; color: var(--text-3); }
.cr-hist-chips { display: flex; gap: 6px; flex-shrink: 0; flex-wrap: wrap; }
.cr-hist-chevron { width: 18px; height: 18px; color: var(--text-3); transition: transform 0.2s; flex-shrink: 0; }
.cr-hist-row.open .cr-hist-chevron { transform: rotate(180deg); color: var(--violet); }
/* Session detail drawer */
.cr-detail-drawer {
overflow: hidden; max-height: 0; transition: max-height 0.35s ease;
border: 1px solid var(--violet); border-top: none;
border-radius: 0 0 14px 14px; background: rgba(238,242,255,0.5);
margin-bottom: 0;
}
.cr-detail-drawer.open { max-height: 3000px; margin-bottom: 8px; }
.cr-detail-inner { padding: 20px 24px; }
.cr-detail-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
@media(max-width:700px) { .cr-detail-grid { grid-template-columns: repeat(2, 1fr); } }
.cr-detail-stat {
background: #fff; border: 1px solid var(--border); border-radius: 12px;
padding: 14px 16px; text-align: center;
}
.cr-detail-val { font-family: 'Unbounded', sans-serif; font-size: 1.3rem; font-weight: 800; color: var(--violet); margin-bottom: 4px; }
.cr-detail-label { font-size: 0.72rem; color: var(--text-3); font-weight: 700; text-transform: uppercase; }
.cr-attend-list { display: flex; flex-direction: column; gap: 6px; margin-top: 12px; }
.cr-attend-row {
display: flex; align-items: center; gap: 12px; padding: 8px 12px;
border: 1px solid var(--border); border-radius: 10px; background: #fff;
font-size: 0.88rem;
}
.cr-attend-name { flex: 1; font-weight: 600; }
.cr-attend-time { color: var(--text-3); font-size: 0.8rem; }
.cr-attend-dur { color: var(--cyan); font-weight: 700; font-size: 0.8rem; min-width: 60px; text-align: right; }
.cr-pages-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px,1fr)); gap: 8px; margin-top: 10px; }
.cr-page-chip {
display: flex; align-items: center; justify-content: space-between;
background: #fff; border: 1px solid var(--border); border-radius: 8px;
padding: 8px 12px; font-size: 0.82rem;
}
.cr-page-num { font-weight: 700; color: var(--violet); }
.cr-page-cnt { color: var(--text-3); font-size: 0.76rem; }
.cr-detail-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--border); }
.btn-cr-export { padding: 8px 18px; border: 1.5px solid var(--cyan); border-radius: 99px; background: rgba(6,214,224,0.06); color: #05aab3; font-family:'Manrope',sans-serif; font-size:0.82rem; font-weight:700; cursor:pointer; transition: all 0.15s; }
.btn-cr-export:hover { background: rgba(6,214,224,0.15); }
.btn-cr-del { padding: 8px 18px; border: 1.5px solid rgba(241,91,181,0.4); border-radius: 99px; background: transparent; color: var(--pink); font-family:'Manrope',sans-serif; font-size:0.82rem; font-weight:700; cursor:pointer; transition: all 0.15s; }
.btn-cr-del:hover { background: rgba(241,91,181,0.08); border-color: var(--pink); }
.btn-cr-end { padding: 8px 18px; border: none; border-radius: 99px; background: #EF4444; color: #fff; font-family:'Manrope',sans-serif; font-size:0.82rem; font-weight:700; cursor:pointer; transition: opacity 0.15s; }
.btn-cr-end:hover { opacity: 0.85; }
/* Pagination */
.cr-pagination { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 24px; flex-wrap: wrap; }
.cr-page-btn {
min-width: 36px; height: 36px; padding: 0 12px; border: 1.5px solid var(--border);
border-radius: 10px; background: var(--surface); font-family:'Manrope',sans-serif;
font-size:0.85rem; font-weight:700; color:var(--text-2); cursor:pointer; transition:all 0.14s;
display:flex; align-items:center; justify-content:center;
}
.cr-page-btn:hover:not(:disabled) { border-color:var(--violet); color:var(--violet); }
.cr-page-btn.active { background:var(--violet); border-color:var(--violet); color:#fff; }
.cr-page-btn:disabled { opacity:0.4; cursor:default; }
.cr-page-info { font-size:0.82rem; color:var(--text-3); font-weight:600; }
/* toolbar for classroom history */
.cr-hist-toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
.cr-hist-search { flex: 1; min-width: 180px; padding: 9px 14px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); font-family:'Manrope',sans-serif; font-size:0.88rem; background:var(--surface); color:var(--text); }
.cr-hist-search:focus { outline:none; border-color:var(--violet); }
.cr-hist-count { font-size:0.85rem; color:var(--text-3); font-weight:600; white-space:nowrap; }
/* ── Avatar moderation ── */
.av-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px,1fr)); gap: 16px; }
.av-card {
background: var(--surface); border: 1.5px solid var(--border-h);
border-radius: 14px; padding: 16px; display: flex; flex-direction: column; gap: 12px;
}
.av-card-top { display: flex; align-items: center; gap: 12px; }
.av-imgs { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
.av-img-wrap { text-align: center; }
.av-img-wrap span { font-size: 0.62rem; color: var(--text-3); font-weight: 600; display: block; margin-bottom: 4px; }
.av-img {
width: 64px; height: 64px; border-radius: 50%; object-fit: cover;
border: 2px solid var(--border-h); background: var(--surface-2);
display: flex; align-items: center; justify-content: center;
font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800;
color: var(--text-2); overflow: hidden;
}
.av-img img { width: 100%; height: 100%; object-fit: cover; }
.av-arrow { color: var(--text-3); flex-shrink: 0; }
.av-user-name { font-size: 0.82rem; font-weight: 700; color: var(--text); }
.av-date { font-size: 0.7rem; color: var(--text-3); margin-top: 2px; }
.av-actions { display: flex; gap: 8px; }
.av-approve { flex: 1; padding: 7px; border-radius: 8px; border: none; cursor: pointer; font-size: 0.8rem; font-weight: 700; background: rgba(6,214,96,0.12); color: #06d660; transition: background .15s; }
.av-approve:hover { background: rgba(6,214,96,0.22); }
.av-reject { flex: 1; padding: 7px; border-radius: 8px; border: none; cursor: pointer; font-size: 0.8rem; font-weight: 700; background: rgba(241,91,181,0.12); color: #F15BB5; transition: background .15s; }
.av-reject:hover { background: rgba(241,91,181,0.22); }
.av-empty { text-align: center; padding: 60px 0; color: var(--text-3); font-size: 0.85rem; }
.admin-badge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 18px; height: 18px; border-radius: 99px; padding: 0 5px;
background: #F15BB5; color: #fff; font-size: 0.65rem; font-weight: 800;
margin-left: 4px; vertical-align: middle;
}
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<div class="sb-content">
<div class="container">
<div class="page-title">Панель управления</div>
<div class="page-sub" id="page-sub">Загрузка…</div>
<div class="admin-layout">
<nav class="admin-nav" id="admin-nav">
<div class="admin-nav-label">Аналитика</div>
<button class="admin-nav-item active" data-tab="stats" onclick="switchTab(this)">
<i data-lucide="bar-chart-2" style="width:15px;height:15px"></i> Статистика
</button>
<button class="admin-nav-item" data-tab="sessions" onclick="switchTab(this)">
<i data-lucide="clock" style="width:15px;height:15px"></i> История сессий
</button>
<button class="admin-nav-item" data-tab="classroom" onclick="switchTab(this)">
<i data-lucide="video" style="width:15px;height:15px"></i> Онлайн-уроки
</button>
<div class="admin-nav-sep"></div>
<div class="admin-nav-label">Контент</div>
<button class="admin-nav-item" data-tab="questions" onclick="switchTab(this)">
<i data-lucide="help-circle" style="width:15px;height:15px"></i> Вопросы
</button>
<button class="admin-nav-item" data-tab="tests" onclick="switchTab(this)">
<i data-lucide="clipboard-list" style="width:15px;height:15px"></i> Тесты
</button>
<button class="admin-nav-item" data-tab="assignments" onclick="switchTab(this)">
<i data-lucide="file-check" style="width:15px;height:15px"></i> Задания
</button>
<button class="admin-nav-item" data-tab="subjects" onclick="switchTab(this)" id="btn-tab-subjects" style="display:none">
<i data-lucide="book-marked" style="width:15px;height:15px"></i> Доступные тесты
</button>
<button class="admin-nav-item" data-tab="tpl" onclick="switchTab(this)" id="btn-tab-tpl" style="display:none">
<i data-lucide="copy" style="width:15px;height:15px"></i> Шаблоны
</button>
<div class="admin-nav-sep"></div>
<div class="admin-nav-label">Пользователи</div>
<button class="admin-nav-item" data-tab="users" onclick="switchTab(this)">
<i data-lucide="users" style="width:15px;height:15px"></i> Пользователи
</button>
<button class="admin-nav-item" data-tab="permissions" onclick="switchTab(this)" id="btn-tab-permissions" style="display:none">
<i data-lucide="shield" style="width:15px;height:15px"></i> Права доступа
</button>
<button class="admin-nav-item" data-tab="avatars" onclick="switchTab(this);loadAvatarRequests()">
<i data-lucide="image" style="width:15px;height:15px"></i> Аватары
<span class="admin-badge" id="av-badge" style="display:none"></span>
</button>
<div class="admin-nav-sep" id="admin-nav-system-sep" style="display:none"></div>
<div class="admin-nav-label" id="admin-nav-system-label" style="display:none">Система</div>
<button class="admin-nav-item" data-tab="shop" onclick="switchTab(this)" id="btn-tab-shop" style="display:none">
<i data-lucide="shopping-bag" style="width:15px;height:15px"></i> Магазин
</button>
<button class="admin-nav-item" data-tab="gam" onclick="switchTab(this)" id="btn-tab-gam" style="display:none">
<i data-lucide="trophy" style="width:15px;height:15px"></i> Геймификация
</button>
<button class="admin-nav-item" data-tab="sims" onclick="switchTab(this)" id="btn-tab-sims" style="display:none">
<i data-lucide="atom" style="width:15px;height:15px"></i> Симуляции
</button>
<button class="admin-nav-item" data-tab="games" onclick="switchTab(this)" id="btn-tab-games" style="display:none">
<i data-lucide="gamepad-2" style="width:15px;height:15px"></i> Игры
</button>
<button class="admin-nav-item" data-tab="sublog" onclick="switchTab(this)">
<i data-lucide="file-x" style="width:15px;height:15px"></i> Журнал работ
</button>
<button class="admin-nav-item" data-tab="topics" onclick="switchTab(this)">
<i data-lucide="list-tree" style="width:15px;height:15px"></i> Темы
</button>
<button class="admin-nav-item" data-tab="broadcast" onclick="switchTab(this)">
<i data-lucide="megaphone" style="width:15px;height:15px"></i> Рассылка
</button>
<button class="admin-nav-item" data-tab="audit" onclick="switchTab(this)">
<i data-lucide="scroll-text" style="width:15px;height:15px"></i> Аудит-лог
</button>
<button class="admin-nav-item" data-tab="errors" onclick="switchTab(this)">
<i data-lucide="bug" style="width:15px;height:15px"></i> Ошибки
</button>
<button class="admin-nav-item" data-tab="health" onclick="switchTab(this)">
<i data-lucide="activity" style="width:15px;height:15px"></i> Здоровье
</button>
</nav>
<div class="admin-main">
<!-- ── Статистика ── -->
<div class="tab-pane active" id="tab-stats">
<div class="section-title">Общая статистика</div>
<div class="stats-grid" id="stats-grid"><div class="spinner"></div></div>
<div class="section-title">По предметам</div>
<div class="subj-stats" id="subj-stats"><div class="spinner"></div></div>
</div>
<!-- ── Вопросы ── -->
<div class="tab-pane" id="tab-questions">
<div class="t-toolbar">
<select class="t-select" id="q-subject" onchange="onQSubjectChange()">
<option value="">Все предметы</option>
<option value="bio">Биология</option>
<option value="chem">Химия</option>
<option value="math">Математика</option>
<option value="phys">Физика</option>
</select>
<select class="t-select" id="q-topic" onchange="loadQuestions()">
<option value="">Все темы</option>
</select>
<select class="t-select" id="q-sort" onchange="loadQuestions()">
<option value="date_desc">Новые сначала</option>
<option value="date_asc">Старые сначала</option>
<option value="diff_asc">Сложность <svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></option>
<option value="diff_desc">Сложность <svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg></option>
</select>
<input class="t-input" id="q-search" type="text" placeholder="Поиск по тексту…" oninput="renderQuestions()" />
<span class="t-count" id="q-count"></span>
<button class="btn-add" onclick="openQModal()">+ Добавить вопрос</button>
<button class="btn-add" style="background:var(--grad-1)" onclick="document.getElementById('csv-file-input').click()"><i data-lucide="upload" style="width:13px;height:13px;vertical-align:-2px"></i> Импорт CSV</button>
<input type="file" id="csv-file-input" accept=".csv,.txt" style="display:none" onchange="importCSVFile(this)" />
<a id="csv-template-link" href="#" onclick="downloadCSVTemplate(event)" style="font-size:0.78rem;color:var(--violet);text-decoration:none;margin-left:4px"><i data-lucide="file-text" style="width:12px;height:12px;vertical-align:-2px"></i> Шаблон</a>
</div>
<div id="q-list-wrap"><div class="empty">Выберите предмет или загрузите все вопросы</div></div>
</div>
<!-- ── Тесты (шаблоны) ── -->
<div class="tab-pane" id="tab-tests">
<div class="t-toolbar">
<select class="t-select" id="tst-subj" onchange="loadTests()">
<option value="">Все предметы</option>
<option value="bio">Биология</option>
<option value="chem">Химия</option>
<option value="math">Математика</option>
<option value="phys">Физика</option>
</select>
<input class="t-input" id="tst-search" type="text" placeholder="Поиск по названию…" oninput="renderTests()" />
<span class="t-count" id="tst-count"></span>
<button class="btn-add" onclick="openTstModal()">+ Создать тест</button>
</div>
<div id="tst-list-wrap"><div class="empty">Загрузка…</div></div>
</div>
<!-- ── Задания ── -->
<div class="tab-pane" id="tab-assignments">
<div class="t-toolbar" style="margin-bottom:12px">
<input class="t-input" id="a-search" type="text" placeholder="Поиск по названию…" oninput="renderAssignments()" style="flex:1;max-width:280px" />
<select class="t-select" id="a-subject" onchange="renderAssignments()">
<option value="">Все предметы</option>
<option value="bio">Биология</option>
<option value="chem">Химия</option>
<option value="math">Математика</option>
<option value="phys">Физика</option>
</select>
<span class="t-count" id="a-count" style="margin-left:0"></span>
</div>
<div class="a-summary" id="a-summary"></div>
<div class="a-filter-row">
<button class="a-f-chip active" onclick="setAFilter('all')">Все</button>
<button class="a-f-chip" onclick="setAFilter('active')">Активные</button>
<button class="a-f-chip" onclick="setAFilter('overdue')">Просрочены</button>
<button class="a-f-chip" onclick="setAFilter('done')">Завершены</button>
<div class="a-filter-sep"></div>
<select class="a-sort-sel" id="a-sort" onchange="renderAssignments()">
<option value="date">По дате <svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg></option>
<option value="deadline">По дедлайну <svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></option>
<option value="progress_asc">Прогресс <svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></option>
<option value="progress_desc">Прогресс <svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg></option>
</select>
<a class="btn-add" href="/classes" style="text-decoration:none;margin-left:auto">Перейти в классы <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></a>
</div>
<div class="a-rows" id="a-body"><div class="spinner"></div></div>
</div>
<!-- ── Доступные тесты (настройки предметов) ── -->
<div class="tab-pane" id="tab-subjects">
<div class="section-title" style="margin-bottom:6px">Настройка доступных тестов</div>
<div class="perm-desc" style="margin-bottom:20px">Настройте что увидят ученики на дашборде: режим, количество вопросов, источник.</div>
<div class="sc-list" id="subj-config-list"></div>
</div>
<!-- ── Пользователи ── -->
<div class="tab-pane" id="tab-users">
<div class="section-title">Пользователи</div>
<div class="table-wrap">
<table>
<thead><tr><th>Пользователь</th><th>Роль</th><th>Тестов</th><th>Средний %</th><th>Регистрация</th><th>Посл. вход</th><th></th></tr></thead>
<tbody id="users-body"><tr><td colspan="7"><div class="spinner"></div></td></tr></tbody>
</table>
</div>
<div class="user-panel" id="user-panel">
<div class="user-panel-header">
<div><div class="user-panel-name" id="up-name"></div><div class="user-panel-email" id="up-email"></div></div>
<div style="display:flex;gap:8px;align-items:center">
<button class="btn-edit-q" id="up-edit-btn" onclick="openEditUserModal()" style="display:none"><i data-lucide="pencil" style="width:13px;height:13px;vertical-align:-2px"></i> Изменить</button>
<button class="btn-edit-q" id="up-perms-btn" onclick="openUserPermsModal()" style="display:none"><i data-lucide="shield" style="width:13px;height:13px;vertical-align:-2px"></i> Права</button>
<button class="btn-del-q" id="up-clear-btn" onclick="clearUserHistory()" style="display:none"><i data-lucide="trash-2" style="width:13px;height:13px;vertical-align:-2px"></i> История</button>
<button class="btn-del-q" id="up-ban-btn" onclick="toggleBanUser()" style="display:none"><i data-lucide="ban" style="width:13px;height:13px;vertical-align:-2px"></i> <span id="up-ban-label">Заблокировать</span></button>
<button class="btn-del-q" id="up-delete-btn" onclick="confirmDeleteUser()" style="display:none;background:rgba(239,68,68,.12);color:#EF4444;border-color:rgba(239,68,68,.25)"><i data-lucide="user-x" style="width:13px;height:13px;vertical-align:-2px"></i> Удалить</button>
<button class="btn-close" onclick="closeUserPanel()"><i data-lucide="x" style="width:13px;height:13px;vertical-align:-2px"></i> Закрыть</button>
</div>
</div>
<div class="section-title">История тестов</div>
<div id="up-sessions"><div class="spinner"></div></div>
</div>
</div>
<!-- ── Тесты (сессии) ── -->
<div class="tab-pane" id="tab-sessions">
<div class="t-toolbar">
<select class="t-select" id="t-subject" onchange="loadSessions()">
<option value="">Все предметы</option>
<option value="bio">Биология</option>
<option value="chem">Химия</option>
<option value="math">Математика</option>
<option value="phys">Физика</option>
</select>
<select class="t-select" id="t-mode" onchange="renderSessions()">
<option value="">Все режимы</option>
<option value="exam">Экзамен</option>
<option value="practice">Тренировка</option>
<option value="repeat">Обычный</option>
<option value="ct">ЦТ/ЦЭ</option>
</select>
<input class="t-input" id="t-search" type="text" placeholder="Поиск по имени…" oninput="renderSessions()" />
<span class="t-count" id="t-count"></span>
</div>
<div id="t-body"><div class="spinner"></div></div>
</div>
<!-- ── Онлайн-уроки ── -->
<div class="tab-pane" id="tab-classroom">
<!-- Module master toggle -->
<div style="display:flex;align-items:center;justify-content:space-between;background:var(--surface);border:1.5px solid var(--border-h);border-radius:var(--r-lg);padding:20px 24px;margin-bottom:32px">
<div>
<div style="font-size:0.97rem;font-weight:700;margin-bottom:4px">Модуль онлайн-уроков</div>
<div class="perm-desc" style="margin:0">Если отключить, учителя не смогут создавать новые уроки. Уже активные сессии продолжат работу до завершения.</div>
</div>
<label class="perm-toggle" id="cr-master-lbl" title="Включить / выключить модуль" style="margin-left:24px;flex-shrink:0">
<input type="checkbox" id="cr-master-chk" onchange="crMasterToggle(this.checked)" checked />
<span class="perm-track"></span>
<span class="perm-thumb"></span>
</label>
</div>
<!-- Active sessions -->
<div class="cr-admin-section">
<div class="cr-admin-section-title">
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8"/></svg>
Активные уроки
<span id="cr-live-refresh-btn" style="font-size:0.76rem;font-weight:600;color:var(--violet);cursor:pointer;text-transform:none;letter-spacing:0;margin-left:-4px" onclick="loadCrActiveSessions()">
<svg class="ic" viewBox="0 0 24 24" style="width:12px;height:12px;vertical-align:-2px"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
Обновить
</span>
</div>
<div id="cr-live-list"><div class="spinner"></div></div>
</div>
<!-- Session history -->
<div class="cr-admin-section">
<div class="cr-admin-section-title">
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
История уроков
</div>
<div class="cr-hist-toolbar">
<input class="cr-hist-search" id="cr-hist-q" type="text" placeholder="Поиск по теме или учителю…" oninput="crHistDebounce()">
<span class="cr-hist-count" id="cr-hist-count"></span>
</div>
<div id="cr-hist-list"><div class="spinner"></div></div>
<div id="cr-hist-pagination"></div>
</div>
</div>
<!-- ── Права доступа ── -->
<!-- ── Avatars moderation tab ── -->
<div class="tab-pane" id="tab-avatars">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;flex-wrap:wrap;gap:10px">
<div class="section-title" style="margin:0">Аватары на модерации</div>
<button class="btn-outline" onclick="loadAvatarRequests()" style="padding:6px 14px;font-size:0.8rem">
<i data-lucide="refresh-cw" style="width:13px;height:13px;vertical-align:-2px"></i> Обновить
</button>
</div>
<div id="av-list">
<div style="color:var(--muted);text-align:center;padding:40px 0;font-size:0.85rem">Загрузка...</div>
</div>
</div>
<div class="tab-pane" id="tab-permissions">
<div class="perm-header">
<div class="section-title" style="margin:0">Права доступа по ролям</div>
<p style="color:var(--muted);font-size:13px;margin:4px 0 0">Настройте, что могут делать учителя и ученики. Администраторы имеют все права всегда.</p>
</div>
<div class="perm-role-block">
<div class="perm-role-title">
<span class="badge badge-warn" style="font-size:13px;padding:4px 12px">Учитель</span>
</div>
<div class="perm-grid" id="perm-teacher"></div>
</div>
<div class="perm-role-block">
<div class="perm-role-title">
<span class="badge badge-info" style="font-size:13px;padding:4px 12px">Ученик</span>
</div>
<div class="perm-grid" id="perm-student"></div>
</div>
</div>
<!-- ── Магазин ── -->
<div class="tab-pane" id="tab-shop">
<div class="section-title">Магазин</div>
<div class="stats-grid" id="shop-stats-grid"><div class="spinner"></div></div>
<div class="section-title" style="margin-top:32px">Товары</div>
<div style="margin-bottom:14px">
<button class="btn-add" onclick="shopAdminCreateItem()">+ Добавить товар</button>
</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>ID</th><th>Название</th><th>Тип</th><th>Цена</th><th>Продано</th><th>Активен</th><th>Действия</th>
</tr></thead>
<tbody id="shop-items-body"><tr><td colspan="7"><div class="spinner"></div></td></tr></tbody>
</table>
</div>
<div class="adm-panel" id="shop-item-form" style="display:none">
<div class="adm-panel-title" id="shop-form-title">Новый товар</div>
<div class="adm-form-row">
<div class="adm-form-group" style="flex:1">
<label>Название</label>
<input type="text" id="shop-f-name" placeholder="Название товара" />
</div>
<div class="adm-form-group" style="flex:1">
<label>Тип</label>
<select id="shop-f-type">
<option value="frame">Рамка</option>
<option value="title">Титул</option>
<option value="theme">Тема</option>
<option value="effect">Эффект</option>
</select>
</div>
<div class="adm-form-group" style="width:100px">
<label>Цена</label>
<input type="number" id="shop-f-price" min="0" value="100" />
</div>
</div>
<div class="adm-form-row">
<div class="adm-form-group" style="flex:1">
<label>Описание</label>
<textarea id="shop-f-desc" rows="2" placeholder="Описание товара"></textarea>
</div>
</div>
<div class="adm-form-row">
<div class="adm-form-group" style="flex:1">
<label>Иконка (emoji/код)</label>
<input type="text" id="shop-f-icon" placeholder="SVG-код или эмодзи" />
</div>
<div class="adm-form-group" style="flex:1">
<label>Данные (JSON)</label>
<input type="text" id="shop-f-data" placeholder='{"key":"value"}' />
</div>
<div class="adm-form-group">
<label>Активен</label>
<label class="adm-toggle">
<input type="checkbox" id="shop-f-active" checked />
<span class="track"></span><span class="thumb"></span>
</label>
</div>
</div>
<div style="display:flex;gap:10px">
<button class="adm-btn adm-btn-primary" onclick="shopAdminSaveItem()">Сохранить</button>
<button class="adm-btn" style="background:var(--border-h);color:var(--text-3)" onclick="shopAdminCancelForm()">Отмена</button>
</div>
</div>
<div class="section-title" style="margin-top:32px">Начислить монеты</div>
<div class="adm-panel">
<div class="adm-form-row">
<div class="adm-form-group adm-user-search" style="flex:1">
<label>Пользователь</label>
<input type="text" id="shop-award-user" placeholder="Поиск по имени…" autocomplete="off" oninput="shopSearchUser(this.value)" />
<div class="us-results" id="shop-award-results"></div>
<input type="hidden" id="shop-award-uid" />
</div>
<div class="adm-form-group" style="width:120px">
<label>Кол-во монет</label>
<input type="number" id="shop-award-amount" min="1" value="10" />
</div>
<div class="adm-form-group" style="flex:1">
<label>Причина</label>
<input type="text" id="shop-award-reason" placeholder="За активность" />
</div>
<button class="adm-btn adm-btn-primary" onclick="shopAdminAwardCoins()" style="align-self:flex-end">Начислить</button>
</div>
</div>
</div>
<!-- ── Геймификация ── -->
<div class="tab-pane" id="tab-gam">
<div class="section-title">Геймификация</div>
<div class="stats-grid" id="gam-stats-grid"><div class="spinner"></div></div>
<div class="section-title" style="margin-top:32px">Топ-10 по XP</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>#</th><th>Имя</th><th>XP</th><th>Уровень</th><th>Монеты</th>
</tr></thead>
<tbody id="gam-top-body"><tr><td colspan="5"><div class="spinner"></div></td></tr></tbody>
</table>
</div>
<div class="section-title" style="margin-top:32px">Покупки в магазине</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>Время</th><th>Пользователь</th><th>Предмет</th><th>Тип</th><th>Цена</th>
</tr></thead>
<tbody id="gam-purchases-body"><tr><td colspan="5"><div class="spinner"></div></td></tr></tbody>
</table>
</div>
<div class="section-title" style="margin-top:32px">Последние начисления XP</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>Время</th><th>Имя</th><th>XP</th><th>Причина</th>
</tr></thead>
<tbody id="gam-log-body"><tr><td colspan="4"><div class="spinner"></div></td></tr></tbody>
</table>
</div>
<div class="section-title" style="margin-top:32px">Начислить XP / Монеты</div>
<div class="adm-panel">
<div class="adm-form-row">
<div class="adm-form-group adm-user-search" style="flex:1">
<label>Пользователь</label>
<input type="text" id="gam-award-user" placeholder="Поиск по имени…" autocomplete="off" oninput="gamSearchUser(this.value,'gam-award')" />
<div class="us-results" id="gam-award-results"></div>
<input type="hidden" id="gam-award-uid" />
</div>
<div class="adm-form-group" style="width:100px">
<label>XP</label>
<input type="number" id="gam-award-xp" min="0" value="10" />
</div>
<div class="adm-form-group" style="width:100px">
<label>Монеты</label>
<input type="number" id="gam-award-coins" min="0" value="0" />
</div>
<div class="adm-form-group" style="flex:1">
<label>Причина</label>
<input type="text" id="gam-award-reason" placeholder="За участие" />
</div>
<button class="adm-btn adm-btn-primary" onclick="gamAdminAward()" style="align-self:flex-end">Начислить</button>
</div>
</div>
<div class="section-title" style="margin-top:32px">Сбросить прогресс пользователя</div>
<div class="adm-panel">
<div class="adm-form-row">
<div class="adm-form-group adm-user-search" style="flex:1">
<label>Пользователь</label>
<input type="text" id="gam-reset-user" placeholder="Поиск по имени…" autocomplete="off" oninput="gamSearchUser(this.value,'gam-reset')" />
<div class="us-results" id="gam-reset-results"></div>
<input type="hidden" id="gam-reset-uid" />
</div>
<button class="adm-btn adm-btn-danger" onclick="gamAdminReset()" style="align-self:flex-end">Сбросить прогресс</button>
</div>
</div>
</div>
<!-- ── Шаблоны ── -->
<div class="tab-pane" id="tab-tpl">
<div class="section-title">Шаблоны курсов</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>ID</th><th>Название</th><th>Предмет</th><th>Категория</th><th>Автор</th><th>Публичный</th><th>Действия</th>
</tr></thead>
<tbody id="tpl-course-body"><tr><td colspan="7"><div class="spinner"></div></td></tr></tbody>
</table>
</div>
<div class="section-title" style="margin-top:32px">Шаблоны уроков</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>ID</th><th>Название</th><th>Предмет</th><th>Категория</th><th>Автор</th><th>Публичный</th><th>Действия</th>
</tr></thead>
<tbody id="tpl-lesson-body"><tr><td colspan="7"><div class="spinner"></div></td></tr></tbody>
</table>
</div>
</div>
<div class="tab-pane" id="tab-sims">
<div class="section-title">Управление симуляциями</div>
<!-- Master toggle -->
<div class="perm-role-block" style="margin-bottom:24px">
<div style="display:flex;align-items:center;justify-content:space-between;gap:16px">
<div>
<div class="perm-label" style="font-size:15px">Модуль симуляций</div>
<div class="perm-desc">Отключить полностью — страница «Лаборатория» станет недоступна для всех пользователей</div>
</div>
<label class="perm-toggle" id="sims-master-lbl" title="Включить / выключить весь модуль">
<input type="checkbox" id="sims-master-chk" onchange="simsMasterToggle(this.checked)" />
<span class="perm-track"></span>
<span class="perm-thumb"></span>
</label>
</div>
</div>
<!-- Per-sim grid -->
<div class="perm-desc" style="margin-bottom:16px">Отключённые симуляции не отображаются в лаборатории. Симуляции в статусе «скоро» не показываются независимо от этой настройки.</div>
<div class="perm-grid" id="sims-grid">
<div style="color:var(--muted);font-size:0.84rem">Загрузка…</div>
</div>
</div>
<!-- ── Игры ── -->
<div class="tab-pane" id="tab-games">
<div class="section-title">Управление играми</div>
<div class="perm-desc" style="margin-bottom:20px">Отключённые игры скрываются из бокового меню и становятся недоступны для всех пользователей.</div>
<div class="perm-grid" id="games-features-grid">
<div style="color:var(--muted);font-size:0.84rem">Загрузка…</div>
</div>
<div class="section-title" style="margin-top:32px">Модули для «Своб. ученика»</div>
<div class="perm-desc" style="margin-bottom:20px">Отключённые модули скрываются только для пользователей с ролью <b>Своб. ученик</b>. Глобальные настройки выше применяются поверх этих.</div>
<div class="perm-grid" id="fs-features-grid">
<div style="color:var(--muted);font-size:0.84rem">Загрузка…</div>
</div>
</div>
<!-- ── Журнал удалённых работ ── -->
<div class="tab-pane" id="tab-sublog">
<div class="section-title">Журнал удалённых работ</div>
<div class="perm-desc" style="margin-bottom:16px">Все удалённые работы учеников записываются сюда. Данные сохраняются даже после удаления файлов.</div>
<div class="sl-filter-row">
<select class="sl-filter-select" id="sublog-class-filter" onchange="loadSubmissionLog()">
<option value="">Все классы</option>
</select>
<span class="sl-count" id="sublog-count"></span>
<button class="btn-del-q" id="btn-clear-sublog" style="display:none;margin-left:auto" onclick="clearSubmissionLog()">
<i data-lucide="trash-2" style="width:13px;height:13px;vertical-align:-2px"></i> Очистить журнал
</button>
</div>
<div id="sublog-list"></div>
</div>
<!-- ── Темы ── -->
<div class="tab-pane" id="tab-topics">
<div class="section-title">Управление темами</div>
<div style="display:flex;gap:12px;margin-bottom:18px;flex-wrap:wrap;align-items:center">
<select class="t-select" id="topics-subj-filter" onchange="loadTopics()" style="max-width:220px"></select>
<button class="adm-btn adm-btn-primary adm-btn-small" onclick="showAddTopic()">+ Добавить тему</button>
<span class="sl-count" id="topics-count"></span>
</div>
<div id="topics-add-row" style="display:none;margin-bottom:16px">
<div class="adm-panel" style="padding:16px 20px">
<div class="adm-form-row" style="margin:0">
<div class="adm-form-group" style="flex:1"><label>Название</label><input type="text" id="topics-new-name" placeholder="Название темы" /></div>
<button class="adm-btn adm-btn-primary adm-btn-small" onclick="createTopic()" style="align-self:flex-end">Создать</button>
<button class="adm-btn adm-btn-small" style="background:var(--border-h);color:var(--text-3);align-self:flex-end" onclick="document.getElementById('topics-add-row').style.display='none'">Отмена</button>
</div>
</div>
</div>
<div id="topics-list"></div>
</div>
<!-- ── Рассылка ── -->
<div class="tab-pane" id="tab-broadcast">
<div class="section-title">Рассылка уведомлений</div>
<div class="adm-panel">
<div class="adm-form-row">
<div class="adm-form-group" style="flex:1">
<label>Сообщение</label>
<textarea id="bc-message" rows="3" placeholder="Текст уведомления (макс. 500 символов)" maxlength="500"></textarea>
</div>
</div>
<div class="adm-form-row">
<div class="adm-form-group" style="width:200px">
<label>Кому</label>
<select id="bc-role">
<option value="all">Всем пользователям</option>
<option value="student">Только ученикам</option>
<option value="teacher">Только учителям</option>
<option value="free_student">Свободным ученикам</option>
</select>
</div>
<div class="adm-form-group" style="flex:1">
<label>Ссылка (необязательно)</label>
<input type="text" id="bc-link" placeholder="/dashboard" />
</div>
<button class="adm-btn adm-btn-primary" onclick="sendBroadcast()" style="align-self:flex-end">Отправить</button>
</div>
<div id="bc-result" style="font-size:0.85rem;color:var(--green);margin-top:8px"></div>
</div>
</div>
<!-- ── Аудит-лог ── -->
<div class="tab-pane" id="tab-audit">
<div class="section-title" style="display:flex;align-items:center;justify-content:space-between">
Журнал действий администраторов
<button class="adm-btn adm-btn-danger adm-btn-small" onclick="clearAuditLog()">Очистить</button>
</div>
<div id="audit-list"></div>
</div>
<!-- ── Ошибки ── -->
<div class="tab-pane" id="tab-errors">
<div class="section-title" style="display:flex;align-items:center;justify-content:space-between">
Журнал ошибок сервера
<button class="adm-btn adm-btn-danger adm-btn-small" onclick="clearErrorLog()">Очистить</button>
</div>
<div id="errors-list"></div>
</div>
<!-- ── Здоровье системы ── -->
<div class="tab-pane" id="tab-health">
<div class="section-title">Здоровье системы</div>
<div id="health-content"></div>
</div>
</div><!-- /admin-main -->
</div><!-- /admin-layout -->
</div><!-- /container -->
<!-- ═══ MODAL ПРАВ ПОЛЬЗОВАТЕЛЯ ════════════════════════════════════ -->
<div class="q-modal" id="up-modal" onclick="if(event.target===this)closeUserPermsModal()">
<div class="q-modal-box" style="max-width:520px">
<div class="q-modal-title" id="up-modal-title">Права пользователя</div>
<p style="font-size:12.5px;color:var(--muted);margin:-8px 0 16px">Индивидуальные настройки переопределяют права роли для этого учителя.</p>
<div id="up-modal-list" style="display:flex;flex-direction:column;gap:8px;max-height:420px;overflow-y:auto;padding-right:4px"></div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:20px;gap:12px">
<button class="btn-del-q" onclick="doResetAllUserPerms()" id="up-modal-reset-btn">
<i data-lucide="rotate-ccw" style="width:13px;height:13px;vertical-align:-2px"></i> Сбросить всё по умолчанию
</button>
<button class="btn-close" onclick="closeUserPermsModal()">Закрыть</button>
</div>
</div>
</div>
<!-- ═══ MODAL РЕДАКТИРОВАНИЯ ПОЛЬЗОВАТЕЛЯ ═══════════════════════════ -->
<div class="q-modal" id="eu-modal" onclick="if(event.target===this)closeEditUserModal()">
<div class="q-modal-box" style="max-width:460px">
<div class="q-modal-title">Редактировать пользователя</div>
<div class="form-row">
<label class="form-label">Имя *</label>
<input type="text" class="form-ctrl" id="eu-name" placeholder="Имя пользователя" />
</div>
<div class="form-row">
<label class="form-label">Email *</label>
<input type="email" class="form-ctrl" id="eu-email" placeholder="email@example.com" />
</div>
<div class="form-row">
<label class="form-label">Новый пароль</label>
<input type="password" class="form-ctrl" id="eu-password" placeholder="Оставьте пустым, чтобы не менять (мин. 6 символов)" />
</div>
<div class="form-error" id="eu-error"></div>
<div class="modal-footer">
<button class="btn-cancel2" onclick="closeEditUserModal()">Отмена</button>
<button class="btn-save" id="eu-save" onclick="saveEditUser()">Сохранить</button>
</div>
</div>
</div>
<!-- ═══ MODAL РЕДАКТОРА ВОПРОСА ══════════════════════════════════════ -->
<div class="q-modal" id="q-modal" onclick="if(event.target===this)closeQModal()">
<div class="q-modal-box">
<div class="q-modal-title" id="q-modal-title">Добавить вопрос</div>
<div class="form-row">
<label class="form-label">Тип вопроса</label>
<div style="display:flex;gap:8px;flex-wrap:wrap" id="qf-type-btns">
<button type="button" class="type-btn active" data-type="single" onclick="setQType('single')"><i data-lucide="circle-dot" style="width:12px;height:12px;vertical-align:-1px"></i> Один ответ</button>
<button type="button" class="type-btn" data-type="multi" onclick="setQType('multi')"><i data-lucide="list-checks" style="width:12px;height:12px;vertical-align:-1px"></i> Несколько ответов</button>
<button type="button" class="type-btn" data-type="true_false" onclick="setQType('true_false')"><i data-lucide="check-circle-2" style="width:12px;height:12px;vertical-align:-1px"></i> Верно/Неверно</button>
<button type="button" class="type-btn" data-type="short_answer" onclick="setQType('short_answer')"><i data-lucide="pencil" style="width:12px;height:12px;vertical-align:-1px"></i> Краткий ответ</button>
<button type="button" class="type-btn" data-type="matching" onclick="setQType('matching')"><i data-lucide="git-compare" style="width:12px;height:12px;vertical-align:-1px"></i> Сопоставление</button>
</div>
</div>
<div class="form-row-3">
<div>
<label class="form-label">Предмет *</label>
<select class="form-ctrl" id="qf-subject" onchange="loadQModalTopics()">
<option value="">— выберите —</option>
<option value="bio">Биология</option>
<option value="chem">Химия</option>
<option value="math">Математика</option>
<option value="phys">Физика</option>
</select>
</div>
<div>
<label class="form-label">Тема</label>
<input type="text" class="form-ctrl" id="qf-topic-text" placeholder="Введите или выберите…" list="qf-topic-list" autocomplete="off" />
<datalist id="qf-topic-list"></datalist>
<div class="form-hint">Введите новую — создастся автоматически</div>
</div>
<div>
<label class="form-label">Сложность</label>
<select class="form-ctrl" id="qf-difficulty">
<option value="1">1 — Лёгкий</option>
<option value="2" selected>2 — Средний</option>
<option value="3">3 — Сложный</option>
</select>
</div>
</div>
<div class="formula-bar" id="formula-bar">
<span style="font-size:0.7rem;font-weight:700;color:var(--text-3);text-transform:uppercase;letter-spacing:0.05em">Формулы:</span>
<button type="button" class="fml" onclick="ins('\\frac{a}{b}')" title="Дробь">ᵃ⁄ᵦ</button>
<button type="button" class="fml" onclick="ins('\\sqrt{x}')" title="Корень"></button>
<button type="button" class="fml" onclick="ins('x^{n}')" title="Степень">xⁿ</button>
<button type="button" class="fml" onclick="ins('x_{n}')" title="Индекс">xₙ</button>
<button type="button" class="fml" onclick="ins('\\pi')" title="Пи">π</button>
<button type="button" class="fml" onclick="ins('\\alpha')" title="Альфа">α</button>
<button type="button" class="fml" onclick="ins('\\beta')" title="Бета">β</button>
<button type="button" class="fml" onclick="ins('\\theta')" title="Тета">θ</button>
<button type="button" class="fml" onclick="ins('\\infty')" title="Бесконечность"></button>
<button type="button" class="fml" onclick="ins('\\leq')" title="≤"></button>
<button type="button" class="fml" onclick="ins('\\geq')" title="≥"></button>
<button type="button" class="fml" onclick="ins('\\neq')" title="≠"></button>
<button type="button" class="fml" onclick="ins('\\pm')" title="±">±</button>
<button type="button" class="fml" onclick="ins('\\cdot')" title="·">·</button>
<button type="button" class="fml" onclick="ins('\\log_{a}{x}')" title="Логарифм">log</button>
<button type="button" class="fml" onclick="ins('\\sin')" title="Синус">sin</button>
<button type="button" class="fml" onclick="ins('\\cos')" title="Косинус">cos</button>
<button type="button" class="fml" onclick="wrapMath()" title="Обернуть в \(...\)" style="font-weight:800">\( \)</button>
</div>
<div class="form-row">
<label class="form-label">Текст вопроса *</label>
<textarea class="form-ctrl" id="qf-text" rows="3" placeholder="Введите текст вопроса…" oninput="updateCharCounter(this, 'qf-text-cnt', 500)" maxlength="500"></textarea>
<div class="char-counter" id="qf-text-cnt">0 / 500</div>
</div>
<div class="q-preview-wrap" id="q-preview-wrap">
<div style="font-size:0.7rem;font-weight:700;color:var(--text-3);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:6px">Предпросмотр</div>
<div class="q-preview-text" id="q-preview-text">Введите текст вопроса…</div>
</div>
<div class="opts-header" id="qf-opts-header">
<span class="opts-label">Варианты ответов — отметьте правильный <svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor" stroke="none"/></svg></span>
</div>
<div class="opts-grid" id="qf-opts"></div>
<!-- short_answer input -->
<div id="qf-short-wrap" style="display:none;margin-bottom:18px">
<label class="form-label">Правильный ответ *</label>
<input type="text" class="form-ctrl" id="qf-correct-text" placeholder="Введите точный правильный ответ (регистр не важен)…" />
<div class="form-hint">Сравнение без учёта регистра и лишних пробелов</div>
</div>
<!-- matching pairs UI -->
<div id="qf-match-wrap" style="display:none;margin-bottom:18px">
<div style="display:grid;grid-template-columns:1fr 1fr auto;gap:8px;align-items:center;margin-bottom:8px">
<span class="form-label" style="margin:0">Левая часть</span>
<span class="form-label" style="margin:0">Правая часть (пара)</span>
<span></span>
</div>
<div id="qf-match-rows"></div>
<button type="button" class="btn-add-opt" onclick="addMatchPair()">+ Добавить пару</button>
</div>
<button type="button" class="btn-add-opt" id="btn-add-opt" onclick="addOpt()">+ Добавить вариант</button>
<div class="form-row">
<label class="form-label">Изображение к вопросу</label>
<div class="img-upload-row">
<button type="button" class="btn-img-upload" id="btn-img-upload" onclick="document.getElementById('qf-image-file').click()">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<span id="btn-img-upload-lbl">Загрузить</span>
</button>
<input type="file" id="qf-image-file" accept="image/*" style="display:none" onchange="handleImageFileSelect(this)">
<input type="text" class="form-ctrl" id="qf-image" placeholder="или вставьте URL" style="flex:1;min-width:0" />
</div>
<div id="qf-image-preview" style="display:none;margin-top:8px;position:relative;display:inline-block">
<img id="qf-image-img" style="max-width:100%;max-height:180px;border-radius:8px;border:1px solid var(--border);display:block" />
<button type="button" onclick="clearQuestionImage()" style="position:absolute;top:6px;right:6px;background:rgba(0,0,0,.6);color:#fff;border:none;border-radius:50%;width:26px;height:26px;cursor:pointer;font-size:15px;line-height:26px;text-align:center"><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>
</div>
<div class="form-row">
<label class="form-label">Пояснение к правильному ответу</label>
<textarea class="form-ctrl" id="qf-explanation" rows="2" placeholder="Необязательно. Появится после ответа в режиме тренировки…"></textarea>
</div>
<div class="form-error" id="qf-error"></div>
<div class="modal-footer">
<button class="btn-cancel2" onclick="closeQModal()">Отмена</button>
<button class="btn-save" id="qf-save" onclick="saveQuestion()">Сохранить</button>
</div>
</div>
</div>
<!-- ═══ MODAL СОЗДАНИЯ ЗАДАНИЯ ═══════════════════════════════════════ -->
<div class="q-modal" id="ac-modal" onclick="if(event.target===this)closeCreateAModal()">
<div class="q-modal-box">
<div class="q-modal-title">Создать задание</div>
<div class="form-row">
<label class="form-label">Назначить</label>
<div class="src-toggle">
<button type="button" class="type-btn active" data-actgt="class" onclick="setAcTarget('class')"><i data-lucide="users" style="width:12px;height:12px;vertical-align:-1px"></i> Классу</button>
<button type="button" class="type-btn" data-actgt="user" onclick="setAcTarget('user')"><i data-lucide="user" style="width:12px;height:12px;vertical-align:-1px"></i> Ученику</button>
</div>
</div>
<div id="acf-class-field">
<div class="form-row">
<label class="form-label">Класс *</label>
<select class="form-ctrl" id="acf-class">
<option value="">— загрузка… —</option>
</select>
</div>
</div>
<div id="acf-user-field" style="display:none">
<div class="form-row">
<label class="form-label">Ученик *</label>
<div style="position:relative">
<input type="text" class="form-ctrl" id="acf-student-search" placeholder="Поиск по имени или email…" autocomplete="off"
oninput="filterAcStudents(this.value)" onfocus="openAcStudentDrop()" onblur="setTimeout(closeAcStudentDrop,180)" />
<div id="acf-student-drop" style="display:none;position:absolute;top:100%;left:0;right:0;background:#fff;border:1px solid #d1d5db;border-radius:6px;max-height:180px;overflow-y:auto;z-index:200;box-shadow:0 4px 12px rgba(0,0,0,.12)"></div>
</div>
<div id="acf-student-selected" style="display:none;margin-top:6px;padding:6px 10px;background:#f0fdf4;border-radius:6px;font-size:13px;color:#166534"></div>
</div>
</div>
<div class="form-row">
<label class="form-label">Название задания *</label>
<input type="text" class="form-ctrl" id="acf-title" placeholder="Например: Контрольная работа по биологии" />
</div>
<div class="form-row">
<label class="form-label">Источник</label>
<div class="src-toggle">
<button type="button" class="type-btn active" data-src="random" onclick="setAcSrc('random')"><i data-lucide="shuffle" style="width:12px;height:12px;vertical-align:-1px"></i> Случайные</button>
<button type="button" class="type-btn" data-src="test" onclick="setAcSrc('test')"><i data-lucide="clipboard-list" style="width:12px;height:12px;vertical-align:-1px"></i> Тест</button>
<button type="button" class="type-btn" data-src="file" onclick="setAcSrc('file')"><i data-lucide="paperclip" style="width:12px;height:12px;vertical-align:-1px"></i> Файл</button>
</div>
</div>
<!-- Random source fields -->
<div id="acf-random-fields">
<div class="form-row-3">
<div>
<label class="form-label">Предмет *</label>
<select class="form-ctrl" id="acf-subject">
<option value="">— выберите —</option>
<option value="bio">Биология</option>
<option value="chem">Химия</option>
<option value="math">Математика</option>
<option value="phys">Физика</option>
</select>
</div>
<div>
<label class="form-label">Режим</label>
<select class="form-ctrl" id="acf-mode">
<option value="exam">Экзамен (1 раз, таймер)</option>
<option value="repeat">Обычный (многократно)</option>
<option value="ct">ЦТ/ЦЭ (A+Б части)</option>
</select>
</div>
<div>
<label class="form-label">Кол-во вопросов</label>
<input type="number" class="form-ctrl" id="acf-count" min="5" max="100" value="25" />
</div>
</div>
</div>
<!-- Test source fields -->
<div id="acf-test-fields" style="display:none">
<div class="form-row-2">
<div>
<label class="form-label">Тест *</label>
<select class="form-ctrl" id="acf-test">
<option value="">— загрузка тестов… —</option>
</select>
<div class="form-hint">Вопросы берутся из теста в заданном порядке</div>
</div>
<div>
<label class="form-label">Режим</label>
<select class="form-ctrl" id="acf-mode-test">
<option value="exam">Экзамен (1 раз, таймер)</option>
<option value="repeat">Обычный (многократно)</option>
<option value="ct">ЦТ/ЦЭ (A+Б части)</option>
</select>
</div>
</div>
</div>
<!-- File source fields -->
<div id="acf-file-fields" style="display:none">
<div class="form-row">
<label class="form-label">Файл из библиотеки *</label>
<input type="text" class="form-ctrl" id="acf-file-search" placeholder="Поиск по названию…" oninput="filterAcFiles(this.value)" />
<div id="acf-file-list" style="max-height:180px;overflow-y:auto;border:1.5px solid rgba(15,23,42,0.15);border-radius:8px;margin-top:6px"></div>
<div id="acf-file-selected" style="display:none;margin-top:6px;font-size:0.82rem;color:var(--violet);font-weight:600"></div>
</div>
</div>
<div class="form-row">
<label class="form-label">Дедлайн (необязательно)</label>
<input type="date" class="form-ctrl" id="acf-deadline" style="max-width:220px" />
</div>
<div class="form-error" id="acf-error"></div>
<div class="modal-footer">
<button class="btn-cancel2" onclick="closeCreateAModal()">Отмена</button>
<button class="btn-save" id="acf-save" onclick="saveNewAssignment()">Создать</button>
</div>
</div>
</div>
<!-- ═══ MODAL РЕДАКТОРА ЗАДАНИЯ ══════════════════════════════════════ -->
<div class="q-modal" id="a-modal" onclick="if(event.target===this)closeAModal()">
<div class="q-modal-box">
<div class="q-modal-title" id="a-modal-title">Редактировать задание</div>
<div class="form-row">
<label class="form-label">Название задания *</label>
<input type="text" class="form-ctrl" id="af-title" placeholder="Например: Итоговый тест по биологии" />
</div>
<div class="form-row">
<label class="form-label">Источник вопросов</label>
<div class="src-toggle">
<button type="button" class="type-btn active" data-afsrc="random" onclick="setAfSrc('random')"><i data-lucide="shuffle" style="width:12px;height:12px;vertical-align:-1px"></i> Случайные из предмета</button>
<button type="button" class="type-btn" data-afsrc="test" onclick="setAfSrc('test')"><i data-lucide="clipboard-list" style="width:12px;height:12px;vertical-align:-1px"></i> Готовый тест</button>
</div>
</div>
<!-- Random source fields -->
<div id="af-random-fields">
<div class="form-row-2">
<div>
<label class="form-label">Предмет *</label>
<select class="form-ctrl" id="af-subject">
<option value="">— выберите —</option>
<option value="bio">Биология</option>
<option value="chem">Химия</option>
<option value="math">Математика</option>
<option value="phys">Физика</option>
</select>
</div>
<div>
<label class="form-label">Режим</label>
<select class="form-ctrl" id="af-mode">
<option value="exam">Экзамен (1 раз, таймер)</option>
<option value="repeat">Обычный (многократно)</option>
<option value="ct">ЦТ/ЦЭ (A+Б части)</option>
</select>
</div>
</div>
<div class="form-row">
<label class="form-label">Количество вопросов</label>
<input type="number" class="form-ctrl" id="af-count" min="5" max="100" value="25" style="max-width:160px" />
</div>
</div>
<!-- Test source fields -->
<div id="af-test-fields" style="display:none">
<div class="form-row-2">
<div>
<label class="form-label">Тест *</label>
<select class="form-ctrl" id="af-test">
<option value="">— загрузка… —</option>
</select>
<div class="form-hint">Вопросы берутся из теста в заданном порядке</div>
</div>
<div>
<label class="form-label">Режим</label>
<select class="form-ctrl" id="af-mode-test">
<option value="exam">Экзамен (1 раз, таймер)</option>
<option value="repeat">Обычный (многократно)</option>
<option value="ct">ЦТ/ЦЭ (A+Б части)</option>
</select>
</div>
</div>
</div>
<div class="form-row">
<label class="form-label">Дедлайн (необязательно)</label>
<input type="date" class="form-ctrl" id="af-deadline" style="max-width:220px" />
</div>
<div class="form-error" id="af-error"></div>
<div class="modal-footer">
<button class="btn-cancel2" onclick="closeAModal()">Отмена</button>
<button class="btn-save" id="af-save" onclick="saveAssignment()">Сохранить</button>
</div>
</div>
</div>
<!-- ═══ MODAL СОЗДАНИЯ/РЕДАКТИРОВАНИЯ ТЕСТА ════════════════════════ -->
<div class="q-modal" id="tst-modal" onclick="if(event.target===this)closeTstModal()">
<div class="q-modal-box" style="max-width:560px">
<div class="q-modal-title" id="tst-modal-title">Создать тест</div>
<div class="form-row-2">
<div>
<label class="form-label">Название *</label>
<input type="text" class="form-ctrl" id="tstf-title" placeholder="Название теста…" />
</div>
<div>
<label class="form-label">Предмет *</label>
<select class="form-ctrl" id="tstf-subject">
<option value="">— выберите —</option>
<option value="bio">Биология</option>
<option value="chem">Химия</option>
<option value="math">Математика</option>
<option value="phys">Физика</option>
</select>
</div>
</div>
<div class="form-row">
<label class="form-label">Описание (необязательно)</label>
<textarea class="form-ctrl" id="tstf-desc" rows="2" placeholder="Краткое описание теста…"></textarea>
</div>
<div class="form-row">
<label class="form-label">Лимит времени (мин)</label>
<input type="number" class="form-ctrl" id="tstf-time" min="1" max="600" placeholder="по умолчанию: кол-во вопросов × 1.5 мин" style="max-width:320px" />
<div class="form-hint">Оставьте пустым — время рассчитывается автоматически</div>
</div>
<div class="form-row">
<label class="form-label">После завершения</label>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button type="button" class="type-btn active" id="tstf-show-yes" onclick="setTstShowAnswers(true)"><i data-lucide="eye" style="width:12px;height:12px;vertical-align:-1px"></i> Показать ответы и решения</button>
<button type="button" class="type-btn" id="tstf-show-no" onclick="setTstShowAnswers(false)"><i data-lucide="eye-off" style="width:12px;height:12px;vertical-align:-1px"></i> Скрыть ответы</button>
</div>
<div class="form-hint">При «Скрыть» ученики видят только итоговый балл</div>
</div>
<div class="form-error" id="tstf-error"></div>
<div class="modal-footer">
<button class="btn-cancel2" onclick="closeTstModal()">Отмена</button>
<button class="btn-save" id="tstf-save" onclick="saveTst()">Сохранить</button>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script>
const { user, isTeacher, isAdmin } = LS.initPage();
if (!isTeacher) { window.location.href = '/dashboard'; throw new Error(); }
document.getElementById('page-sub').textContent =
isAdmin ? 'Администратор · полный доступ' : 'Учитель · просмотр статистики';
if (isAdmin) {
['btn-tab-subjects','btn-tab-permissions','btn-tab-shop','btn-tab-gam','btn-tab-tpl','btn-tab-sims','btn-tab-games'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = '';
});
document.getElementById('admin-nav-system-sep').style.display = '';
document.getElementById('admin-nav-system-label').style.display = '';
}
LS.showBoardIfAllowed();
LS.hideDisabledFeatures?.();
LS.notif?.init();
const MODES = { exam:'Экзамен', practice:'Тренировка', repeat:'Обычный', ct:'ЦТ/ЦЭ', topic:'По теме', random:'Случайный' };
const DIFFS = { 1:'Лёгкий', 2:'Средний', 3:'Сложный' };
function pctClass(p) { return p === null ? '' : p >= 75 ? 'pct-hi' : p >= 50 ? 'pct-mid' : 'pct-lo'; }
function fmtDate(d) { return new Date(d).toLocaleDateString('ru',{day:'numeric',month:'short',year:'numeric'}); }
function fmtTime(sec) {
if (!sec || sec < 0) return '—';
const m = Math.floor(sec / 60), s = sec % 60;
return m ? `${m} мин ${s} сек` : `${s} сек`;
}
/* ─── Tabs ─── */
let questionsInited = false, testsInited = false, assignmentsInited = false, usersInited = false, sessionsInited = false, subjectsInited = false, permissionsInited = false, shopInited = false, gamInited = false, tplInited = false, simsInited = false, gamesInited = false, sublogInited = false;
function switchTab(btn) {
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.admin-nav-item').forEach(b => b.classList.remove('active'));
const name = btn.dataset.tab;
document.getElementById('tab-' + name).classList.add('active');
btn.classList.add('active');
if (name === 'questions' && !questionsInited) { questionsInited = true; loadQuestions(); }
if (name === 'tests' && !testsInited) { testsInited = true; loadTests(); }
if (name === 'assignments' && !assignmentsInited) { assignmentsInited = true; loadAssignments(); }
if (name === 'subjects' && !subjectsInited) { subjectsInited = true; loadSubjectConfig(); }
if (name === 'users' && !usersInited) { usersInited = true; loadUsers(); }
if (name === 'sessions' && !sessionsInited) { sessionsInited = true; loadSessions(); }
if (name === 'permissions' && !permissionsInited) { permissionsInited = true; loadPermissions(); }
if (name === 'shop' && !shopInited) { shopInited = true; loadShopAdmin(); }
if (name === 'gam' && !gamInited) { gamInited = true; loadGamAdmin(); }
if (name === 'tpl' && !tplInited) { tplInited = true; loadTplAdmin(); }
if (name === 'sims' && !simsInited) { simsInited = true; loadSimsAdmin(); }
if (name === 'games' && !gamesInited) { gamesInited = true; loadGamesAdmin(); loadFsFeatures(); }
if (name === 'sublog' && !sublogInited) { sublogInited = true; loadSubmissionLog(); }
}
/* Переход к вопросам конкретного предмета с открытием формы */
async function goAddQuestion(slug) {
// переключаем на вкладку Вопросы
const qBtn = document.querySelector('[data-tab="questions"]');
switchTab(qBtn);
// выставляем предмет в фильтре и обновляем список
document.getElementById('q-subject').value = slug;
if (!questionsInited) { questionsInited = true; }
await loadQuestions();
// открываем форму с предзаполненным предметом
openQModal();
document.getElementById('qf-subject').value = slug;
await loadQModalTopics();
}
/* ════════════════════════════════════════════════
СТАТИСТИКА
════════════════════════════════════════════════ */
async function loadStats() {
try {
const s = await LS.adminGetStats();
document.getElementById('stats-grid').innerHTML = `
<div class="stat-card" style="--stat-top:var(--violet)">
<div class="stat-card-icon" style="background:rgba(155,93,229,0.1)"><i data-lucide="users" class="stat-icon"></i></div>
<div class="stat-val violet">${s.totalUsers}</div>
<div class="stat-label">Пользователей</div>
</div>
<div class="stat-card" style="--stat-top:var(--cyan)">
<div class="stat-card-icon" style="background:rgba(6,214,224,0.1)"><i data-lucide="file-text" class="stat-icon"></i></div>
<div class="stat-val cyan">${s.totalTests}</div>
<div class="stat-label">Тестов пройдено</div>
</div>
<div class="stat-card" style="--stat-top:var(--green)">
<div class="stat-card-icon" style="background:rgba(6,214,100,0.1)"><i data-lucide="target" class="stat-icon"></i></div>
<div class="stat-val green">${s.avgScore ?? '—'}%</div>
<div class="stat-label">Средний результат</div>
</div>`;
if (window.lucide) lucide.createIcons();
const subjEl = document.getElementById('subj-stats');
if (!s.bySubject?.length) { subjEl.innerHTML = '<div class="empty">Нет данных</div>'; return; }
subjEl.innerHTML = s.bySubject.map(b => {
const pct = b.avg_pct ?? 0;
const barColor = pct >= 75 ? 'var(--green)' : pct >= 50 ? 'var(--amber)' : 'var(--pink)';
return `<div class="subj-stat">
<div><div class="subj-stat-name">${esc(b.name)}</div><div class="subj-stat-info">${b.tests} тестов</div></div>
<div>
<div class="subj-stat-pct">${b.avg_pct ?? '—'}%</div>
<div style="width:60px;height:3px;background:rgba(15,23,42,0.06);border-radius:99px;margin-top:5px;overflow:hidden"><div style="width:${pct}%;height:100%;background:${barColor};border-radius:99px"></div></div>
</div>
</div>`;
}).join('');
} catch (e) {
document.getElementById('stats-grid').innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
/* ════════════════════════════════════════════════
РЕДАКТОР ВОПРОСОВ
════════════════════════════════════════════════ */
let allQuestions = [];
let editingQId = null;
let openQId = null;
let _topicMap = {}; // topic name (lower) <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> id, for current subject
/* ─── KaTeX rendering ─── */
const KATEX_OPTS = {
delimiters: [
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
],
throwOnError: false,
};
function renderMath(el) {
if (!el) return;
const run = () => { if (window.renderMathInElement) renderMathInElement(el, KATEX_OPTS); };
if (window._katexReady) run(); else window._katexCb = run;
}
function updateCharCounter(el, cntId, max) {
const n = el.value.length;
const cnt = document.getElementById(cntId);
if (!cnt) return;
cnt.textContent = `${n} / ${max}`;
cnt.className = 'char-counter' + (n > max * 0.9 ? ' warn' : '') + (n >= max ? ' over' : '');
}
async function onQSubjectChange() {
const slug = document.getElementById('q-subject').value;
const sel = document.getElementById('q-topic');
sel.innerHTML = '<option value="">Все темы</option>';
if (slug) {
try {
const topics = await LS.getTopics(slug);
topics.forEach(t => sel.appendChild(new Option(t.name, t.id)));
} catch {}
}
loadQuestions();
}
async function loadQuestions() {
const subject = document.getElementById('q-subject').value;
const topic_id = document.getElementById('q-topic').value;
const sort = document.getElementById('q-sort').value;
const wrap = document.getElementById('q-list-wrap');
wrap.innerHTML = LS.skeleton(5);
try {
allQuestions = await LS.getQuestions(subject || null, topic_id || null, sort);
renderQuestions();
} catch (e) {
wrap.innerHTML = `<div class="error">Ошибка загрузки: ${esc(e.message)}</div>`;
}
}
function renderQuestions() {
const search = document.getElementById('q-search').value.toLowerCase();
const filtered = search
? allQuestions.filter(q => q.text.toLowerCase().includes(search) || (q.topic||'').toLowerCase().includes(search))
: allQuestions;
document.getElementById('q-count').textContent = `${filtered.length} вопросов`;
if (!filtered.length) {
document.getElementById('q-list-wrap').innerHTML = '<div class="empty">Вопросов не найдено</div>';
return;
}
const wrap = document.getElementById('q-list-wrap');
wrap.innerHTML =
`<div class="q-list">${filtered.map(q => {
const diffCls = `diff-${q.difficulty}`;
const optsHtml = (q.options || []).map(o =>
`<div class="q-opt-row ${o.is_correct ? 'correct' : ''}">
<span class="q-opt-icon">${o.is_correct ? '<i data-lucide="check" style="width:13px;height:13px"></i>' : '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg>'}</span>${esc(o.text)}
</div>`).join('');
const explHtml = q.explanation
? `<div class="q-expl"><strong>Пояснение:</strong> ${esc(q.explanation)}</div>` : '';
return `<div class="q-card" id="qcard-${q.id}">
<div class="q-card-head">
<span class="q-card-num">#${q.id}</span>
<div class="q-card-body" onclick="toggleQDetail(${q.id})">
<div class="q-card-text">${esc(q.text)}</div>
<div class="q-card-meta">
${q.subject_name ? `<span class="q-badge q-badge-subj">${esc(q.subject_name)}</span>` : ''}
${q.topic ? `<span class="q-badge q-badge-topic">${esc(q.topic)}</span>` : ''}
<span class="q-badge ${diffCls}">${DIFFS[q.difficulty]||q.difficulty}</span>
<span style="font-size:0.72rem;color:var(--text-3);background:rgba(15,23,42,0.05);padding:2px 7px;border-radius:999px">${{single:'Один',multi:'Несколько',true_false:'Верно/Неверно',short_answer:'Краткий',matching:'Сопост.'}[q.type]||q.type||'Один'}</span>
<span style="font-size:0.75rem;color:var(--text-3)">${q.options?.length||0} вар.</span>
</div>
</div>
<div class="q-card-actions">
<button class="btn-edit-q" onclick="editQ(${q.id})">Изменить</button>
<button class="btn-dup-q" onclick="dupQ(${q.id})" title="Дублировать">⧉</button>
<button class="btn-del-q" onclick="deleteQ(${q.id})" title="Удалить"><i data-lucide="x" style="width:14px;height:14px"></i></button>
</div>
</div>
<div class="q-card-detail" id="qdetail-${q.id}">
${optsHtml}${explHtml}
</div>
</div>`;
}).join('')}</div>`;
renderMath(wrap);
if (window.lucide) lucide.createIcons();
}
function toggleQDetail(id) {
if (openQId === id) {
document.getElementById('qdetail-' + id)?.classList.remove('open');
openQId = null; return;
}
if (openQId) document.getElementById('qdetail-' + openQId)?.classList.remove('open');
document.getElementById('qdetail-' + id)?.classList.add('open');
openQId = id;
}
async function dupQ(id) {
try {
const { id: newId } = await LS.duplicateQuestion(id);
await loadQuestions();
// scroll to new card
setTimeout(() => document.getElementById('qcard-' + newId)?.scrollIntoView({ behavior:'smooth', block:'center' }), 300);
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
async function deleteQ(id) {
if (!await LS.confirm(`Удалить вопрос #${id}?`, { title: 'Удалить вопрос', confirmText: 'Удалить' })) return;
try {
await LS.deleteQuestion(id);
allQuestions = allQuestions.filter(q => q.id !== id);
renderQuestions();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
/* ─── Question type ─── */
let _currentType = 'single';
let _matchPairs = []; // [{left:'', right:''}]
function setQType(type) {
_currentType = type;
document.querySelectorAll('[data-type]').forEach(b => b.classList.toggle('active', b.dataset.type === type));
const isMatching = type === 'matching';
const isShort = type === 'short_answer';
const showOpts = !isShort && !isMatching;
const optsHeader = document.getElementById('qf-opts-header');
if (optsHeader) optsHeader.style.display = showOpts ? '' : 'none';
document.getElementById('qf-opts').style.display = showOpts ? '' : 'none';
document.getElementById('qf-short-wrap').style.display = isShort ? '' : 'none';
document.getElementById('qf-match-wrap').style.display = isMatching ? '' : 'none';
document.getElementById('btn-add-opt').style.display = showOpts && type !== 'true_false' ? '' : 'none';
if (type === 'true_false') {
initOpts([{ text:'Верно', is_correct:false }, { text:'Неверно', is_correct:false }]);
} else if (isShort) {
_opts = [];
} else if (isMatching) {
_opts = [];
if (_matchPairs.length === 0) _matchPairs = [{left:'',right:''},{left:'',right:''},{left:'',right:''}];
renderMatchRows();
} else {
if (_opts.length === 0 || _opts[0]?.text === 'Верно') initOpts([{},{},{},{}]);
else renderOptRows(_opts);
}
}
function renderMatchRows() {
const cont = document.getElementById('qf-match-rows');
cont.innerHTML = _matchPairs.map((p, i) => `
<div style="display:grid;grid-template-columns:1fr 1fr auto;gap:8px;margin-bottom:8px" data-mi="${i}">
<input type="text" class="form-ctrl" placeholder="Элемент…" value="${esc(p.left)}"
oninput="_matchPairs[${i}].left=this.value" style="margin:0" />
<input type="text" class="form-ctrl" placeholder="Пара к нему…" value="${esc(p.right)}"
oninput="_matchPairs[${i}].right=this.value" style="margin:0" />
<button type="button" onclick="removeMatchPair(${i})" style="border:none;background:none;color:var(--text-3);cursor:pointer;padding:0 6px;display:flex;align-items:center" title="Удалить"><i data-lucide="x" style="width:15px;height:15px"></i></button>
</div>`).join('');
if (window.lucide) lucide.createIcons();
}
function addMatchPair() {
_matchPairs.push({left:'',right:''});
renderMatchRows();
}
function removeMatchPair(i) {
_matchPairs.splice(i, 1);
renderMatchRows();
}
/* ─── Formula bar ─── */
let _focusedInput = null;
document.addEventListener('focusin', e => {
if (e.target.closest && e.target.closest('#q-modal') &&
(e.target.tagName === 'TEXTAREA' || (e.target.tagName === 'INPUT' && e.target.type === 'text'))) {
_focusedInput = e.target;
}
});
function ins(latex) {
const el = _focusedInput || document.getElementById('qf-text');
if (!el) return;
const s = el.selectionStart ?? el.value.length;
const e2= el.selectionEnd ?? el.value.length;
const before = el.value.slice(0, s), after = el.value.slice(e2);
const opens = (before.match(/\\\(/g)||[]).length;
const closes = (before.match(/\\\)/g)||[]).length;
const insert = opens > closes ? latex : `\\(${latex}\\)`;
el.value = before + insert + after;
el.setSelectionRange(s + insert.length, s + insert.length);
el.focus();
updateQPreview();
}
function wrapMath() {
const el = _focusedInput || document.getElementById('qf-text');
if (!el) return;
const s = el.selectionStart, e2 = el.selectionEnd;
const sel = el.value.slice(s, e2) || 'x';
el.value = el.value.slice(0, s) + `\\(${sel}\\)` + el.value.slice(e2);
el.focus();
updateQPreview();
}
/* ─── Live preview ─── */
let _prevTimer = null;
function updateQPreview() {
clearTimeout(_prevTimer);
_prevTimer = setTimeout(() => {
const text = document.getElementById('qf-text').value || 'Введите текст вопроса…';
const el = document.getElementById('q-preview-text');
el.textContent = text;
renderMath(el);
}, 150);
}
// Wire textarea input to preview
setTimeout(() => {
const ta = document.getElementById('qf-text');
if (ta) ta.addEventListener('input', updateQPreview);
}, 0);
/* ─── Dynamic options ─── */
const OPT_LETTERS = 'АБВГДЕ';
function renderOptRows(opts) {
const grid = document.getElementById('qf-opts');
const isMulti = _currentType === 'multi';
grid.innerHTML = opts.map((o, i) => `
<div class="opt-row${o.is_correct ? ' opt-correct' : ''}" data-i="${i}">
<span class="opt-letter">${OPT_LETTERS[i]}</span>
${isMulti
? `<input type="checkbox" class="opt-radio" value="${i}" ${o.is_correct ? 'checked' : ''}
onchange="onCheckChange(${i}, this.checked)" />`
: `<input type="radio" name="qf-correct" class="opt-radio" value="${i}" ${o.is_correct ? 'checked' : ''}
onchange="onRadioChange(${i})" />`}
<input type="text" class="opt-input" placeholder="Вариант ${OPT_LETTERS[i]}"
value="${esc(o.text||'')}" oninput="syncOptText(${i}, this.value)" />
${opts.length > 2
? `<button type="button" class="btn-rem-opt" onclick="removeOpt(${i})" title="Удалить"></button>`
: '<span style="width:24px;flex-shrink:0"></span>'}
</div>`).join('');
document.getElementById('btn-add-opt').style.display = opts.length >= 6 ? 'none' : '';
}
function onCheckChange(idx, checked) {
_opts[idx].is_correct = checked;
document.querySelector(`#qf-opts .opt-row[data-i="${idx}"]`)?.classList.toggle('opt-correct', checked);
}
let _opts = []; // current options state
function initOpts(opts) {
_opts = opts.length ? opts.map(o => ({ text: o.text||'', is_correct: !!o.is_correct }))
: [{text:'',is_correct:false},{text:'',is_correct:false},{text:'',is_correct:false},{text:'',is_correct:false}];
renderOptRows(_opts);
}
function onRadioChange(idx) {
_opts.forEach((o, i) => o.is_correct = (i === idx));
renderOptRows(_opts);
}
function syncOptText(idx, val) { _opts[idx].text = val; }
function addOpt() {
if (_opts.length >= 6) return;
_opts.push({ text: '', is_correct: false });
renderOptRows(_opts);
// focus new input
const rows = document.querySelectorAll('#qf-opts .opt-row');
rows[rows.length - 1]?.querySelector('input[type=text]')?.focus();
}
function removeOpt(idx) {
if (_opts.length <= 2) return;
const wasCorrect = _opts[idx].is_correct;
_opts.splice(idx, 1);
if (wasCorrect && _opts.length > 0) _opts[0].is_correct = true;
renderOptRows(_opts);
}
/* ─── Modal ─── */
function openQModal(q = null) {
editingQId = q ? q.id : null;
document.getElementById('q-modal-title').textContent = q ? `Редактировать вопрос #${q.id}` : 'Новый вопрос';
const textEl = document.getElementById('qf-text');
textEl.value = q?.text || '';
updateCharCounter(textEl, 'qf-text-cnt', 500);
document.getElementById('qf-explanation').value = q?.explanation || '';
document.getElementById('qf-difficulty').value = q?.difficulty ?? 2;
document.getElementById('qf-subject').value = q?.subject_slug || '';
document.getElementById('qf-topic-text').value = q?.topic || '';
document.getElementById('qf-correct-text').value = q?.correct_text || '';
document.getElementById('qf-error').textContent = '';
const imgVal = q?.image || '';
document.getElementById('qf-image').value = imgVal;
updateImagePreview(imgVal);
if (q?.type === 'matching') {
_matchPairs = (q.options || []).map(o => ({ left: o.text, right: o.match_pair || '' }));
if (!_matchPairs.length) _matchPairs = [{left:'',right:''},{left:'',right:''},{left:'',right:''}];
} else {
_matchPairs = [];
}
setQType(q?.type || 'single');
if (q?.type !== 'matching') initOpts(q?.options || []);
updateQPreview();
loadQModalTopics();
document.getElementById('q-modal').classList.add('open');
setTimeout(() => textEl.focus(), 80);
}
function editQ(id) {
const q = allQuestions.find(x => x.id === id);
if (q) openQModal(q);
}
function closeQModal() {
document.getElementById('q-modal').classList.remove('open');
editingQId = null;
}
async function loadQModalTopics() {
const slug = document.getElementById('qf-subject').value;
const dl = document.getElementById('qf-topic-list');
dl.innerHTML = '';
_topicMap = {};
if (!slug) return;
try {
const topics = await LS.getTopics(slug);
topics.forEach(t => {
dl.appendChild(new Option(t.name));
_topicMap[t.name.toLowerCase()] = t.id;
});
} catch {}
}
async function saveQuestion() {
const text = document.getElementById('qf-text').value.trim();
const explanation = document.getElementById('qf-explanation').value.trim();
const difficulty = Number(document.getElementById('qf-difficulty').value);
const subject_slug = document.getElementById('qf-subject').value;
const topicText = document.getElementById('qf-topic-text').value.trim();
const type = _currentType;
const errEl = document.getElementById('qf-error');
errEl.textContent = '';
if (!subject_slug) { errEl.textContent = 'Выберите предмет'; return; }
if (!text) { errEl.textContent = 'Введите текст вопроса'; return; }
let options = null;
let correct_text = null;
if (type === 'short_answer') {
correct_text = document.getElementById('qf-correct-text').value.trim();
if (!correct_text) { errEl.textContent = 'Введите правильный ответ'; return; }
} else if (type === 'matching') {
// sync text from DOM inputs
document.querySelectorAll('#qf-match-rows [data-mi]').forEach((row, i) => {
const [l, r] = row.querySelectorAll('input');
if (_matchPairs[i]) { _matchPairs[i].left = l.value.trim(); _matchPairs[i].right = r.value.trim(); }
});
if (_matchPairs.length < 2) { errEl.textContent = 'Нужно минимум 2 пары'; return; }
if (_matchPairs.some(p => !p.left || !p.right)) { errEl.textContent = 'Заполните все пары'; return; }
options = _matchPairs.map(p => ({ text: p.left, match_pair: p.right, is_correct: 0 }));
} else {
// sync any unsaved text from DOM inputs
document.querySelectorAll('#qf-opts .opt-row').forEach((row, i) => {
if (_opts[i]) _opts[i].text = row.querySelector('input[type=text]').value.trim();
});
options = _opts.map(o => ({ text: o.text, is_correct: o.is_correct }));
if (options.length < 2) { errEl.textContent = 'Нужно минимум 2 варианта ответа'; return; }
if (options.some(o => !o.text)) { errEl.textContent = 'Заполните все варианты ответов'; return; }
if (!options.some(o => o.is_correct)) { errEl.textContent = 'Отметьте правильный ответ'; return; }
}
// resolve topic: use id if known, else send topic_name for find-or-create
const knownId = _topicMap[topicText.toLowerCase()];
const topic_id = knownId || null;
const topic_name = !knownId && topicText ? topicText : null;
const image = document.getElementById('qf-image').value.trim() || null;
const btn = document.getElementById('qf-save');
btn.disabled = true; btn.textContent = 'Сохранение…';
try {
if (editingQId) {
await LS.updateQuestion(editingQId, { text, type, correct_text, difficulty, explanation: explanation||null, topic_id, topic_name, options, image });
} else {
await LS.createQuestion({ subject_slug, topic_id, topic_name, text, type, correct_text, difficulty, explanation: explanation||null, options, image });
}
closeQModal();
loadQuestions();
} catch (e) {
errEl.textContent = 'Ошибка: ' + e.message;
} finally {
btn.disabled = false; btn.textContent = 'Сохранить';
}
}
/* ── Image upload & preview ── */
function updateImagePreview(url) {
const wrap = document.getElementById('qf-image-preview');
const img = document.getElementById('qf-image-img');
if (url) { img.src = url; wrap.classList.add('visible'); }
else { wrap.classList.remove('visible'); img.src = ''; }
}
function clearQuestionImage() {
document.getElementById('qf-image').value = '';
updateImagePreview('');
}
async function handleImageFileSelect(input) {
const file = input.files[0];
if (!file) return;
input.value = '';
const btn = document.getElementById('btn-img-upload');
const lbl = document.getElementById('btn-img-upload-lbl');
btn.disabled = true;
lbl.textContent = 'Загрузка…';
try {
const fd = new FormData();
fd.append('file', file);
fd.append('title', 'Question image: ' + file.name);
fd.append('is_public', '1');
const res = await fetch('/api/files', {
method: 'POST',
headers: { Authorization: 'Bearer ' + localStorage.getItem('ls_token') },
body: fd
});
if (!res.ok) throw new Error((await res.json()).error || res.statusText);
const { id } = await res.json();
const url = `/api/files/${id}/download`;
document.getElementById('qf-image').value = url;
updateImagePreview(url);
} catch (e) {
document.getElementById('qf-error').textContent = 'Ошибка загрузки: ' + e.message;
} finally {
btn.disabled = false;
lbl.textContent = 'Загрузить';
}
}
document.addEventListener('DOMContentLoaded', () => {
const imgInput = document.getElementById('qf-image');
if (imgInput) imgInput.addEventListener('input', e => updateImagePreview(e.target.value.trim()));
});
/* ── CSV Import ── */
async function importCSVFile(input) {
const file = input.files[0];
if (!file) return;
input.value = '';
const fd = new FormData();
fd.append('file', file);
const btn = document.querySelector('[onclick="document.getElementById(\'csv-file-input\').click()"]');
if (btn) { btn.disabled = true; btn.textContent = 'Импорт…'; }
try {
const { imported, errors } = await LS.importQuestions(fd);
LS.toast(`Импортировано: ${imported} вопросов${errors.length ? ` (${errors.length} ошибок)` : ''}`, imported > 0 ? 'success' : 'warn', 5000);
loadQuestions();
} catch (e) {
LS.toast('Ошибка импорта: ' + e.message, 'error');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = '<i data-lucide="upload" style="width:14px;height:14px;vertical-align:-2px"></i> Импорт CSV'; if(window.lucide)lucide.createIcons(); }
}
}
function downloadCSVTemplate(e) {
e.preventDefault();
const header = 'subject_slug;topic;text;difficulty;type;opt1;c1;opt2;c2;opt3;c3;opt4;c4;correct_text;explanation;year';
const example = [
'bio;Клетки;Что является «электростанцией» клетки?;2;single;Митохондрия;1;Рибосома;0;Лизосома;0;Ядро;0;;Митохондрии синтезируют АТФ;2024',
'bio;Клетки;Какие органоиды участвуют в синтезе белка?;2;multi;Рибосома;1;Митохондрия;0;Эндоплазматическая сеть;1;Лизосома;0;;',
'chem;Кислоты;Формула серной кислоты;1;short_answer;;;;;;;;H2SO4;;',
].join('\n');
const blob = new Blob(['\ufeff' + header + '\n' + example], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'questions_template.csv';
a.click();
}
/* ════════════════════════════════════════════════
ПОЛЬЗОВАТЕЛИ
════════════════════════════════════════════════ */
async function loadUsers() {
try {
const users = await LS.adminGetUsers();
const tbody = document.getElementById('users-body');
if (!users.length) { tbody.innerHTML = '<tr><td colspan="7"><div class="empty">Пользователей нет</div></td></tr>'; return; }
tbody.innerHTML = users.map(u => {
const pc = pctClass(u.avg_pct);
const initials = (u.name||'?').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'?';
const avatarBg = u.role==='admin' ? 'linear-gradient(135deg,#9B5DE5,#c084fc)' : u.role==='teacher' ? 'linear-gradient(135deg,#06D6E0,#9B5DE5)' : u.role==='free_student' ? 'linear-gradient(135deg,#10B981,#059669)' : 'linear-gradient(135deg,#8898AA,#3D4F6B)';
const roleCell = isAdmin && u.id !== user.id
? `<select class="role-select" data-uid="${u.id}" onchange="changeRole(this)">
<option value="student" ${u.role==='student' ?'selected':''}>Ученик</option>
<option value="free_student" ${u.role==='free_student' ?'selected':''}>Своб. ученик</option>
<option value="teacher" ${u.role==='teacher' ?'selected':''}>Учитель</option>
<option value="admin" ${u.role==='admin' ?'selected':''}>Админ</option>
</select>`
: `<span class="role-badge ${u.role}">${{student:'Ученик',free_student:'Своб. ученик',teacher:'Учитель',admin:'Админ'}[u.role]||u.role}</span>`;
return `<tr class="clickable${u.is_banned ? ' banned-row' : ''}" onclick="openUserPanel(event,${u.id},'${u.role}')">
<td>
<div style="display:flex;align-items:center;gap:12px">
<div style="width:36px;height:36px;border-radius:10px;background:${avatarBg};display:flex;align-items:center;justify-content:center;font-family:'Unbounded',sans-serif;font-size:0.62rem;font-weight:800;color:#fff;flex-shrink:0;${u.is_banned?'filter:grayscale(1);opacity:.5':''}">${initials}</div>
<div>
<div style="font-weight:700;font-size:0.88rem;color:var(--text)">${esc(u.name)}${u.is_banned ? ' <span style="font-size:0.7rem;background:rgba(239,68,68,.12);color:#EF4444;border-radius:4px;padding:1px 5px;font-weight:600;vertical-align:middle">заблокирован</span>' : ''}</div>
<div style="color:var(--text-3);font-size:0.76rem">${esc(u.email)}</div>
</div>
</div>
</td>
<td onclick="event.stopPropagation()">${roleCell}</td>
<td style="font-weight:700">${u.tests_count}</td>
<td>
<span class="pct-cell ${pc}">${u.avg_pct !== null ? u.avg_pct+'%' : '—'}</span>
${u.avg_pct !== null ? `<div class="perf-bar"><div class="perf-fill ${pc}" style="width:${u.avg_pct}%"></div></div>` : ''}
</td>
<td style="color:var(--text-3);font-size:0.8rem">${fmtDate(u.created_at)}</td>
<td style="color:var(--text-3);font-size:0.8rem">${u.last_login ? new Date(u.last_login).toLocaleDateString('ru',{day:'numeric',month:'short'}) : '—'}</td>
<td style="text-align:right;color:var(--text-3);font-size:0.85rem;opacity:0.4"></td>
</tr>`;
}).join('');
} catch (e) {
document.getElementById('users-body').innerHTML = `<tr><td colspan="7" class="error">Ошибка: ${esc(e.message)}</td></tr>`;
}
}
async function changeRole(select) {
select.disabled = true;
try { await LS.adminUpdateRole(select.dataset.uid, select.value); LS.toast('Роль изменена', 'success', 2000); }
catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
finally { select.disabled = false; }
}
let activeTr = null;
let activeUid = null;
let activeUserRole = null;
async function openUserPanel(e, uid, role) {
if (activeTr) activeTr.classList.remove('selected');
activeTr = e.currentTarget; activeTr.classList.add('selected');
activeUid = uid;
activeUserRole = role;
const panel = document.getElementById('user-panel');
panel.classList.add('visible');
panel.scrollIntoView({ behavior:'smooth', block:'nearest' });
document.getElementById('up-sessions').innerHTML = LS.skeleton(3, 'row');
document.getElementById('up-name').textContent = '…';
document.getElementById('up-email').textContent = '';
if (isAdmin) {
document.getElementById('up-edit-btn').style.display = '';
document.getElementById('up-clear-btn').style.display = '';
document.getElementById('up-perms-btn').style.display = role === 'teacher' ? '' : 'none';
document.getElementById('up-ban-btn').style.display = '';
document.getElementById('up-delete-btn').style.display = '';
}
await reloadUserPanel(uid);
}
async function reloadUserPanel(uid) {
try {
const { user: u, sessions } = await LS.adminGetUserSessions(uid);
activeUserRole = u.role;
document.getElementById('up-name').innerHTML = u.name + (u.is_banned ? ' <svg class="ic" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>' : '');
document.getElementById('up-email').textContent = u.email;
// Sync button in case role changed after panel was opened
if (isAdmin) {
document.getElementById('up-perms-btn').style.display = u.role === 'teacher' ? '' : 'none';
const banBtn = document.getElementById('up-ban-btn');
const banLbl = document.getElementById('up-ban-label');
if (u.is_banned) {
banBtn.style.background = 'rgba(34,197,94,.12)';
banBtn.style.color = '#22C55E';
banBtn.style.borderColor = 'rgba(34,197,94,.25)';
banLbl.textContent = 'Разблокировать';
} else {
banBtn.style.background = '';
banBtn.style.color = '';
banBtn.style.borderColor = '';
banLbl.textContent = 'Заблокировать';
}
}
const el = document.getElementById('up-sessions');
if (!sessions.length) { el.innerHTML = '<div class="empty">Тестов нет</div>'; return; }
el.innerHTML = '<div class="sess-list">' + sessions.map(s => {
const pct = s.score !== null ? Math.round((s.score/s.total)*100) : null;
return `<div class="sess-item">
<div class="sess-pct ${pctClass(pct)}">${pct !== null ? pct+'%' : '—'}</div>
<div class="sess-info"><div class="sess-subj">${s.subject_name||'Тест'}</div><div class="sess-meta">${fmtDate(s.started_at)} · ${MODES[s.mode]||s.mode}</div></div>
<div class="sess-score">${s.score??'—'} / ${s.total}</div>
</div>`;
}).join('') + '</div>';
} catch (e) { document.getElementById('up-sessions').innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`; }
}
function closeUserPanel() {
document.getElementById('user-panel').classList.remove('visible');
if (activeTr) { activeTr.classList.remove('selected'); activeTr = null; }
activeUid = null;
}
async function clearUserHistory() {
const name = document.getElementById('up-name').textContent;
if (!await LS.confirm(`Удалить всю историю тестов пользователя «${name}»?\nЭто действие нельзя отменить.`, { title: 'Очистить историю', confirmText: 'Удалить историю' })) return;
try {
await LS.adminClearUserSessions(activeUid);
await reloadUserPanel(activeUid);
loadUsers();
} catch (e) { LS.toast('Ошибка очистки истории: ' + e.message, 'error'); }
}
async function toggleBanUser() {
const banLbl = document.getElementById('up-ban-label');
const isBanning = banLbl.textContent === 'Заблокировать';
const name = document.getElementById('up-name').innerHTML.replace(' <svg class="ic" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>','');
const msg = isBanning
? `Заблокировать пользователя «${name}»?\nОн не сможет войти в систему.`
: `Разблокировать пользователя «${name}»?`;
if (!await LS.confirm(msg, { title: isBanning ? 'Блокировка' : 'Разблокировка', confirmText: isBanning ? 'Заблокировать' : 'Разблокировать' })) return;
try {
await LS.adminBanUser(activeUid, isBanning);
LS.toast(isBanning ? 'Пользователь заблокирован' : 'Пользователь разблокирован', isBanning ? 'warning' : 'success');
await reloadUserPanel(activeUid);
loadUsers();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
async function confirmDeleteUser() {
const name = document.getElementById('up-name').innerHTML.replace(' <svg class="ic" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>','');
if (!await LS.confirm(`Удалить пользователя «${name}» навсегда?\nВсе его данные, тесты и прогресс будут удалены. Это действие нельзя отменить.`, { title: 'Удалить пользователя', confirmText: 'Удалить навсегда' })) return;
try {
await LS.adminDeleteUser(activeUid);
LS.toast('Пользователь удалён', 'success');
closeUserPanel();
loadUsers();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
let _editUid = null;
function closeEditUserModal() {
document.getElementById('eu-modal').classList.remove('open');
_editUid = null;
}
function openEditUserModal() {
_editUid = activeUid;
document.getElementById('eu-name').value = document.getElementById('up-name').textContent;
document.getElementById('eu-email').value = document.getElementById('up-email').textContent;
document.getElementById('eu-password').value = '';
document.getElementById('eu-error').textContent = '';
document.getElementById('eu-modal').classList.add('open');
setTimeout(() => document.getElementById('eu-name').focus(), 80);
}
async function saveEditUser() {
const name = document.getElementById('eu-name').value.trim();
const email = document.getElementById('eu-email').value.trim();
const password = document.getElementById('eu-password').value;
const errEl = document.getElementById('eu-error');
errEl.textContent = '';
if (!name) { errEl.textContent = 'Введите имя'; return; }
if (!email) { errEl.textContent = 'Введите email'; return; }
if (password && password.length < 6) { errEl.textContent = 'Пароль должен быть не менее 6 символов'; return; }
const payload = { name, email };
if (password) payload.password = password;
const btn = document.getElementById('eu-save');
btn.disabled = true; btn.textContent = 'Сохранение…';
try {
await LS.adminUpdateUser(_editUid, payload);
closeEditUserModal();
await reloadUserPanel(activeUid);
loadUsers();
} catch (e) {
errEl.textContent = 'Ошибка: ' + e.message;
} finally {
btn.disabled = false; btn.textContent = 'Сохранить';
}
}
/* ════════════════════════════════════════════════
СЕССИИ
════════════════════════════════════════════════ */
let allSessions = [];
let openDrawerId = null;
async function loadSessions() {
const subject = document.getElementById('t-subject').value;
document.getElementById('t-body').innerHTML = '<div class="spinner"></div>';
openDrawerId = null;
try {
allSessions = await LS.adminGetSessions({ subject: subject || undefined });
renderSessions();
} catch (e) {
document.getElementById('t-body').innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
function sessPctRing(pct) {
const pc = pctClass(pct);
const colorMap = {'pct-hi':'var(--green)','pct-mid':'var(--amber)','pct-lo':'var(--pink)'};
const color = colorMap[pc] || 'var(--text-3)';
const circ = 106.8;
const dash = (pct / 100 * circ).toFixed(1);
return `<svg class="sess-tl-ring" width="48" height="48" viewBox="0 0 48 48">
<circle cx="24" cy="24" r="17" fill="none" stroke="rgba(15,23,42,0.08)" stroke-width="4"/>
<circle cx="24" cy="24" r="17" fill="none" stroke="${color}" stroke-width="4"
stroke-dasharray="${dash} ${circ}" stroke-dashoffset="26.7" stroke-linecap="round"
transform="rotate(-90 24 24)"/>
<text x="24" y="28" text-anchor="middle" font-family="Unbounded,sans-serif" font-size="8" font-weight="800" fill="${color}">${pct}%</text>
</svg>`;
}
function renderSessions() {
const modeF = document.getElementById('t-mode').value;
const searchF = document.getElementById('t-search').value.toLowerCase();
const filtered = allSessions.filter(s => {
if (modeF && s.mode !== modeF) return false;
if (searchF && !s.user_name.toLowerCase().includes(searchF) && !s.user_email.toLowerCase().includes(searchF)) return false;
return true;
});
document.getElementById('t-count').textContent = `${filtered.length} тестов`;
if (!filtered.length) {
document.getElementById('t-body').innerHTML = '<div class="empty">Нет тестов</div>';
return;
}
// Group by date
const groups = {};
filtered.forEach(s => {
const key = fmtDate(s.started_at);
(groups[key] = groups[key] || []).push(s);
});
document.getElementById('t-body').innerHTML = Object.entries(groups).map(([date, sessions]) =>
`<div class="sess-tl-day">${date}</div>
<div class="sess-tl-wrap">${sessions.map(s => {
const ring = s.percent !== null
? sessPctRing(s.percent)
: `<div style="width:48px;height:48px;display:flex;align-items:center;justify-content:center;font-family:'Unbounded',sans-serif;font-size:0.85rem;font-weight:800;color:var(--text-3)">—</div>`;
return `<div class="sess-tl-item" id="trow-${s.id}" onclick="toggleDrawer(${s.id})">
${ring}
<div class="sess-tl-user">
<div class="sess-tl-name">${esc(s.user_name)}</div>
<div class="sess-tl-meta">${esc(s.subject_name||'?')} · <span class="mode-badge mode-${s.mode}">${MODES[s.mode]||s.mode}</span></div>
</div>
<div class="sess-tl-score">${s.score??'—'} / ${s.total}</div>
<div class="sess-tl-time">${fmtTime(s.duration_sec)}</div>
</div>
<div class="sess-tl-drawer" id="tdrawer-${s.id}">
<div class="sess-drawer" id="drawer-${s.id}">
<div class="sess-drawer-inner" id="drawer-inner-${s.id}"><div class="spinner"></div></div>
</div>
</div>`;
}).join('')}</div>`
).join('');
}
async function toggleDrawer(id) {
const drawerEl = document.getElementById('tdrawer-' + id);
const drawer = document.getElementById('drawer-' + id);
const trow = document.getElementById('trow-' + id);
if (openDrawerId && openDrawerId !== id) {
document.getElementById('tdrawer-' + openDrawerId)?.classList.remove('open');
document.getElementById('drawer-' + openDrawerId)?.classList.remove('open');
document.getElementById('trow-' + openDrawerId)?.classList.remove('open');
}
if (openDrawerId === id) {
drawerEl.classList.remove('open'); drawer.classList.remove('open'); trow.classList.remove('open');
openDrawerId = null; return;
}
openDrawerId = id; trow.classList.add('open');
drawerEl.classList.add('open');
requestAnimationFrame(() => drawer.classList.add('open'));
const inner = document.getElementById('drawer-inner-' + id);
if (inner.dataset.loaded) return;
inner.dataset.loaded = '1';
try {
const d = await LS.adminGetSessionDetail(id);
renderDrawer(inner, d);
} catch (e) { inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`; }
}
function renderDrawer(el, d) {
const pct = d.score !== null && d.total ? Math.round((d.score/d.total)*100) : null;
const pc = pctClass(pct);
const correct = d.questions.filter(q => q.is_correct).length;
const wrong = d.questions.filter(q => !q.is_correct && q.chosen_option_id).length;
const skipped = d.questions.filter(q => !q.chosen_option_id).length;
const qHtml = d.questions.map((q,i) => {
const status = !q.chosen_option_id ? 'skipped' : q.is_correct ? 'correct' : 'wrong';
const badgeTxt = { correct:'Верно', wrong:'Неверно', skipped:'Пропущено' }[status];
const opts = q.options.map(o => {
const isCor = o.is_correct, isCho = o.id === q.chosen_option_id;
let cls='', icon='<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg>';
if (isCor) { cls='correct-opt'; icon='<i data-lucide="check" style="width:13px;height:13px"></i>'; }
else if (isCho && !isCor) { cls='chosen-wrong'; icon='<i data-lucide="x" style="width:13px;height:13px"></i>'; }
return `<div class="qb-opt ${cls}"><span class="qb-opt-icon">${icon}</span>${esc(o.text)}</div>`;
}).join('');
const expl = q.explanation ? `<div class="qb-expl"><strong>Пояснение:</strong> ${esc(q.explanation)}</div>` : '';
return `<div class="qb-item ${status}">
<div class="qb-header"><span class="qb-qnum">Вопрос ${i+1}</span><span class="qb-badge ${status}">${badgeTxt}</span><span class="qb-time">${q.time_spent_sec?q.time_spent_sec+' сек':''}</span></div>
<div class="qb-text">${esc(q.text)}</div>
<div class="qb-opts">${opts}</div>${expl}
</div>`;
}).join('');
el.innerHTML = `
<div class="drawer-header">
<div>
<div style="font-family:'Unbounded',sans-serif;font-weight:800;font-size:0.95rem">${esc(d.user_name)}</div>
<div class="drawer-meta">${esc(d.user_email)} · ${d.subject_name||'?'} · ${MODES[d.mode]||d.mode} · ${fmtDate(d.started_at)}</div>
</div>
<div class="drawer-score ${pc}">${pct !== null ? pct+'%' : '—'}</div>
<div style="display:flex;gap:20px;margin-left:auto;text-align:center">
<div><div style="font-family:'Unbounded',sans-serif;color:var(--green);font-weight:700">${correct}</div><div style="font-size:0.72rem;color:var(--text-3)">Верно</div></div>
<div><div style="font-family:'Unbounded',sans-serif;color:var(--pink);font-weight:700">${wrong}</div><div style="font-size:0.72rem;color:var(--text-3)">Неверно</div></div>
<div><div style="font-family:'Unbounded',sans-serif;color:var(--text-3);font-weight:700">${skipped}</div><div style="font-size:0.72rem;color:var(--text-3)">Пропущено</div></div>
<div><div style="font-family:'Unbounded',sans-serif;color:var(--text-2);font-weight:700">${fmtTime(d.duration_sec)}</div><div style="font-size:0.72rem;color:var(--text-3)">Время</div></div>
</div>
</div>
<div class="qb-list">${qHtml||'<div class="empty">Вопросы не найдены</div>'}</div>`;
renderMath(el);
if (window.lucide) lucide.createIcons();
}
/* ════════════════════════════════════════════════
ТЕСТЫ (ШАБЛОНЫ)
════════════════════════════════════════════════ */
let allTests = [];
let openTstId = null;
let editingTstId = null;
const DIFF_LABELS = { 1:'Лёгкий', 2:'Средний', 3:'Сложный' };
const TYPE_LABELS = { single:'Один', multi:'Несколько', true_false:'Верно/Нет', short_answer:'Краткий', matching:'Сопоставление' };
async function loadTests() {
const subj = document.getElementById('tst-subj').value;
const wrap = document.getElementById('tst-list-wrap');
wrap.innerHTML = '<div class="spinner"></div>';
try {
allTests = await LS.getTests(subj || null);
renderTests();
} catch (e) {
wrap.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
function renderTests() {
const search = document.getElementById('tst-search').value.toLowerCase();
const filtered = search ? allTests.filter(t => t.title.toLowerCase().includes(search)) : allTests;
document.getElementById('tst-count').textContent = `${filtered.length} тестов`;
const wrap = document.getElementById('tst-list-wrap');
if (!filtered.length) { wrap.innerHTML = '<div class="empty">Тестов не найдено</div>'; return; }
const SUBJ_N = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
wrap.innerHTML = `<div class="q-list">${filtered.map(t => `
<div class="q-card" id="tstcard-${t.id}">
<div class="q-card-head">
<span class="q-card-num">#${t.id}</span>
<div class="q-card-body" onclick="toggleTstDrawer(${t.id})">
<div class="q-card-text">${esc(t.title)}</div>
<div class="q-card-meta">
<span class="q-badge q-badge-subj">${SUBJ_N[t.subject_slug]||t.subject_slug}</span>
<span style="font-size:0.75rem;color:var(--text-3)">${t.question_count} вопросов</span>
<span style="font-size:0.75rem;color:var(--text-3)">${fmtDate(t.created_at)}</span>
${t.description ? `<span style="font-size:0.75rem;color:var(--text-2)">${esc(t.description)}</span>` : ''}
</div>
</div>
<div class="q-card-actions">
<button class="btn-edit-q" onclick="editTst(${t.id})">Изменить</button>
<button class="btn-del-q" onclick="deleteTst(${t.id})" title="Удалить"><i data-lucide="x" style="width:14px;height:14px"></i></button>
</div>
</div>
<div class="tst-drawer" id="tstdrawer-${t.id}" style="display:none">
<div class="tst-drawer-inner" id="tstdinner-${t.id}">
<div class="spinner"></div>
</div>
</div>
</div>`).join('')}</div>`;
if (window.lucide) lucide.createIcons();
}
async function toggleTstDrawer(id) {
const drawer = document.getElementById('tstdrawer-' + id);
if (!drawer) return;
if (openTstId && openTstId !== id) {
const old = document.getElementById('tstdrawer-' + openTstId);
if (old) old.style.display = 'none';
}
if (openTstId === id) {
drawer.style.display = 'none'; openTstId = null; return;
}
openTstId = id;
drawer.style.display = '';
await renderTstDrawer(id);
}
async function renderTstDrawer(id) {
const inner = document.getElementById('tstdinner-' + id);
if (!inner) return;
inner.innerHTML = '<div class="spinner"></div>';
try {
const [t, subjectQs] = await Promise.all([
LS.getTest(id),
LS.getQuestions(
(_tstPickerCache[id]?.subject_slug) || allTests.find(x => x.id === id)?.subject_slug || '',
null, 'date_asc'
).catch(() => []),
]);
const inIds = new Set(t.questions.map(q => q.id));
// Update cache so filterTstPicker can filter locally
_tstPickerCache[id] = { subjectQs, inIds, subject_slug: t.subject_slug };
inner.innerHTML = `
<div class="tst-cols">
<div>
<div class="tst-panel-title">Вопросы в тесте (${t.questions.length})</div>
<div class="tst-q-list" id="tstql-${id}">${renderTstQList(t.questions, id)}</div>
</div>
<div>
<div class="tst-panel-title">Добавить вопросы</div>
<input class="tst-search" id="tstps-${id}" placeholder="Поиск вопросов…" oninput="filterTstPicker(${id})" />
<div class="tst-q-list" id="tstpicker-${id}">${renderTstPicker(subjectQs, inIds, id)}</div>
</div>
</div>`;
renderMath(inner);
if (window.lucide) lucide.createIcons();
} catch (e) {
inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
function qTypeBadge(type) {
const MAP = { single:'Один', multi:'Несколько', true_false:'Верно/Нет', short_answer:'Ответ', matching:'Сопост.' };
const CLR = { single:'rgba(155,93,229,0.12)', multi:'rgba(6,214,224,0.12)', true_false:'rgba(255,179,71,0.14)', short_answer:'rgba(6,214,100,0.12)', matching:'rgba(241,91,181,0.10)' };
const TXT = { single:'var(--violet)', multi:'#05aab3', true_false:'var(--amber)', short_answer:'var(--green)', matching:'var(--pink)' };
return `<span class="tst-q-badge" style="background:${CLR[type]||'rgba(15,23,42,0.06)'};color:${TXT[type]||'var(--text-3)'}">${MAP[type]||type}</span>`;
}
function qOptsPreview(q) {
if (q.type === 'short_answer') return q.correct_text ? `<span class="tst-q-opts">Ответ: ${esc(q.correct_text)}</span>` : '';
if (!q.options?.length) return '';
const correct = q.options.filter(o => o.is_correct).map(o => esc(o.text)).join(', ');
return `<span class="tst-q-opts"><i data-lucide="check" style="width:12px;height:12px;vertical-align:-2px"></i> ${correct}</span>`;
}
function renderTstQList(questions, tid) {
if (!questions.length) return '<div class="tst-empty">Вопросов нет. Добавьте справа <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></div>';
return questions.map((q, i) => `
<div class="tst-q-item" id="tstqitem-${tid}-${q.id}">
<span class="tst-q-num">${i+1}.</span>
<div class="tst-q-body">
<span class="tst-q-text">${esc(q.text)}</span>
<div class="tst-q-meta">
<span class="tst-q-badge diff-${q.difficulty}">${DIFF_LABELS[q.difficulty]||q.difficulty}</span>
${qTypeBadge(q.type)}
${qOptsPreview(q)}
</div>
</div>
<button class="btn-tst-rem" onclick="tstRemoveQ(${tid},${q.id})" title="Убрать"></button>
</div>`).join('');
}
function renderTstPicker(questions, inIds, tid) {
if (!questions.length) return '<div class="tst-empty">Вопросов нет в этом предмете</div>';
return questions.map(q => {
const added = inIds.has(q.id);
return `<div class="tst-q-item" id="tstpick-${tid}-${q.id}">
<div class="tst-q-body">
<span class="tst-q-text">${esc(q.text)}</span>
<div class="tst-q-meta">
<span class="tst-q-badge diff-${q.difficulty}">${DIFF_LABELS[q.difficulty]||''}</span>
${qTypeBadge(q.type)}
${qOptsPreview(q)}
</div>
</div>
<button class="btn-tst-add${added?' added':''}" id="tstbtn-${tid}-${q.id}"
title="${added?'Уже в тесте':'Добавить'}" ${added?'disabled':'onclick="tstAddQ('+tid+','+q.id+')"'}>${added?'<i data-lucide="check" style="width:14px;height:14px"></i>':'+'}</button>
</div>`;
}).join('');
}
// Cache for picker: { tid: { subjectQs:[], inIds: Set } }
const _tstPickerCache = {};
async function filterTstPicker(tid) {
const search = document.getElementById('tstps-'+tid)?.value.toLowerCase() || '';
const cache = _tstPickerCache[tid];
if (!cache) return; // not loaded yet
const filtered = search
? cache.subjectQs.filter(q => q.text.toLowerCase().includes(search))
: cache.subjectQs;
const picker = document.getElementById('tstpicker-'+tid);
if (picker) { picker.innerHTML = renderTstPicker(filtered, cache.inIds, tid); renderMath(picker); if(window.lucide)lucide.createIcons(); }
}
async function tstAddQ(tid, qid) {
const btn = document.getElementById(`tstbtn-${tid}-${qid}`);
if (btn) { btn.disabled = true; btn.textContent = '…'; }
try {
await LS.addQuestionsToTest(tid, [qid]);
const t = allTests.find(x => x.id === tid);
if (t) t.question_count++;
renderTests();
openTstId = tid;
document.getElementById('tstdrawer-' + tid).style.display = '';
await renderTstDrawer(tid);
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); if (btn) { btn.disabled=false; btn.textContent='+'; } }
}
async function tstRemoveQ(tid, qid) {
try {
await LS.removeQFromTest(tid, qid);
const t = allTests.find(x => x.id === tid);
if (t) t.question_count = Math.max(0, t.question_count - 1);
renderTests();
openTstId = tid;
document.getElementById('tstdrawer-' + tid).style.display = '';
await renderTstDrawer(tid);
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
/* ── Test modal ── */
let _tstShowAnswers = true;
function setTstShowAnswers(val) {
_tstShowAnswers = val;
document.getElementById('tstf-show-yes').classList.toggle('active', val);
document.getElementById('tstf-show-no').classList.toggle('active', !val);
}
function openTstModal(t = null) {
editingTstId = t ? t.id : null;
document.getElementById('tst-modal-title').textContent = t ? `Редактировать: ${t.title}` : 'Создать тест';
document.getElementById('tstf-title').value = t?.title || '';
document.getElementById('tstf-subject').value = t?.subject_slug || '';
document.getElementById('tstf-desc').value = t?.description || '';
document.getElementById('tstf-time').value = t?.time_limit || '';
document.getElementById('tstf-error').textContent = '';
setTstShowAnswers(t ? (t.show_answers !== 0) : true);
document.getElementById('tst-modal').classList.add('open');
setTimeout(() => document.getElementById('tstf-title').focus(), 80);
}
function editTst(id) {
const t = allTests.find(x => x.id === id);
if (t) openTstModal(t);
}
function closeTstModal() {
document.getElementById('tst-modal').classList.remove('open');
editingTstId = null;
}
async function saveTst() {
const title = document.getElementById('tstf-title').value.trim();
const subject_slug= document.getElementById('tstf-subject').value;
const description = document.getElementById('tstf-desc').value.trim();
const errEl = document.getElementById('tstf-error');
errEl.textContent = '';
if (!title) { errEl.textContent = 'Введите название'; return; }
if (!subject_slug) { errEl.textContent = 'Выберите предмет'; return; }
const btn = document.getElementById('tstf-save');
btn.disabled = true; btn.textContent = 'Сохранение…';
const show_answers = _tstShowAnswers ? 1 : 0;
const timeVal = parseInt(document.getElementById('tstf-time').value, 10);
const time_limit = timeVal >= 1 ? Math.min(600, timeVal) : null;
try {
if (editingTstId) {
await LS.updateTest(editingTstId, { title, subject_slug, description: description||null, show_answers, time_limit });
const idx = allTests.findIndex(x => x.id === editingTstId);
if (idx !== -1) Object.assign(allTests[idx], { title, subject_slug, description, show_answers, time_limit });
} else {
const { id } = await LS.createTest({ title, subject_slug, description: description||null, show_answers, time_limit });
allTests.unshift({ id, title, subject_slug, description, question_count: 0, created_at: new Date().toISOString() });
closeTstModal();
renderTests();
// Auto-open drawer so teacher can immediately add questions
openTstId = id;
document.getElementById('tstdrawer-' + id).style.display = '';
await renderTstDrawer(id);
return;
}
closeTstModal();
renderTests();
} catch (e) {
errEl.textContent = 'Ошибка: ' + e.message;
} finally {
btn.disabled = false; btn.textContent = 'Сохранить';
}
}
async function deleteTst(id) {
const t = allTests.find(x => x.id === id);
if (!await LS.confirm(`Удалить тест «${t?.title}»?`, { title: 'Удалить тест', confirmText: 'Удалить' })) return;
try {
await LS.deleteTest(id);
allTests = allTests.filter(x => x.id !== id);
if (openTstId === id) openTstId = null;
renderTests();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
/* ════════════════════════════════════════════════
ЗАДАНИЯ
════════════════════════════════════════════════ */
let allAssignments = [];
let editingAId = null;
const SUBJ_NAMES = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
async function loadAssignments() {
document.getElementById('a-body').innerHTML = '<div class="spinner"></div>';
try {
allAssignments = await LS.teacherAssignments();
renderAssignments();
} catch (e) {
document.getElementById('a-body').innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
const SUBJ_COLORS_A = { bio:'#9B5DE5', chem:'#06D6A0', math:'#06B6D4', phys:'#F59E0B' };
const SUBJ_ICONS_A = { bio:'dna', chem:'flask-conical', math:'calculator', phys:'zap' };
let _aFilter = 'all';
function setAFilter(f) {
_aFilter = f;
document.querySelectorAll('.a-f-chip').forEach(c =>
c.classList.toggle('active', c.textContent.trim() === {all:'Все',active:'Активные',overdue:'Просрочены',done:'Завершены'}[f])
);
renderAssignments();
}
function aClassify(a) {
const pct = a.total_members ? Math.round(a.completed_count / a.total_members * 100) : null;
if (pct === 100) return 'done';
if (a.deadline && new Date(a.deadline) < new Date()) return 'overdue';
return 'active';
}
function renderAssignments() {
const subjF = document.getElementById('a-subject').value;
const searchF = document.getElementById('a-search').value.toLowerCase();
const sortF = document.getElementById('a-sort')?.value || 'date';
let list = allAssignments.filter(a => {
if (subjF && a.subject_slug !== subjF) return false;
if (searchF && !a.title.toLowerCase().includes(searchF)) return false;
if (_aFilter === 'active' && aClassify(a) !== 'active') return false;
if (_aFilter === 'overdue' && aClassify(a) !== 'overdue') return false;
if (_aFilter === 'done' && aClassify(a) !== 'done') return false;
return true;
});
// Sort
list = [...list].sort((a, b) => {
if (sortF === 'deadline') {
const da = a.deadline ? new Date(a.deadline) : new Date(9e15);
const db = b.deadline ? new Date(b.deadline) : new Date(9e15);
return da - db;
}
if (sortF === 'progress_asc') {
const pa = a.total_members ? a.completed_count / a.total_members : 0;
const pb = b.total_members ? b.completed_count / b.total_members : 0;
return pa - pb;
}
if (sortF === 'progress_desc') {
const pa = a.total_members ? a.completed_count / a.total_members : 0;
const pb = b.total_members ? b.completed_count / b.total_members : 0;
return pb - pa;
}
return 0; // date: keep server order
});
// Summary chips
const all = allAssignments;
const nActive = all.filter(a => aClassify(a) === 'active').length;
const nOverdue = all.filter(a => aClassify(a) === 'overdue').length;
const nDone = all.filter(a => aClassify(a) === 'done').length;
document.getElementById('a-summary').innerHTML = [
`<span class="a-sum-chip s-all">Всего: ${all.length}</span>`,
nActive ? `<span class="a-sum-chip s-active">Активных: ${nActive}</span>` : '',
nOverdue ? `<span class="a-sum-chip s-overdue">Просрочено: ${nOverdue}</span>` : '',
nDone ? `<span class="a-sum-chip s-done">Завершено: ${nDone}</span>` : '',
].join('');
document.getElementById('a-count').textContent = `${list.length} заданий`;
const container = document.getElementById('a-body');
if (!list.length) {
container.innerHTML = '<div class="empty">Заданий нет</div>';
return;
}
const now = new Date();
container.innerHTML = list.map(a => {
const pct = a.total_members ? Math.round(a.completed_count / a.total_members * 100) : null;
const cls = aClassify(a);
const rowCls = cls === 'overdue' ? 'a-overdue' : cls === 'done' ? 'a-done' : '';
const sColor = SUBJ_COLORS_A[a.subject_slug] || '#9B5DE5';
const dlMs = a.deadline ? new Date(a.deadline) - now : Infinity;
const isUrgent = cls === 'active' && dlMs > 0 && dlMs < 24 * 3600 * 1000;
const dl = a.deadline
? new Date(a.deadline).toLocaleDateString('ru', {day:'numeric', month:'short'})
: null;
const targetStr = a.target_user_id
? esc(a.target_user_name || 'Ученик')
: esc(a.class_name || '—');
const metaParts = [
targetStr,
SUBJ_NAMES[a.subject_slug] || a.subject_slug,
`<span class="mode-badge mode-${a.mode}">${MODES[a.mode]||a.mode}</span>`,
a.count + ' вопр.',
dl ? `до ${dl}` : null,
isUrgent ? `<span class="a-tag-urgent"><i data-lucide="zap" style="width:10px;height:10px;vertical-align:-1px"></i> срочно</span>` : null,
cls === 'overdue' ? `<span class="a-tag-over">просрочено</span>` : null,
].filter(Boolean);
const barColor = pct >= 75 ? '#06D6A0' : pct >= 40 ? '#F59E0B' : '#F15BB5';
const pctLabel = pct !== null ? `${pct}%` : '—';
return `<div class="a-row ${rowCls}${isUrgent ? ' a-urgent' : ''}" style="--ac:${sColor}">
<div class="a-icon" style="background:${sColor}18;color:${sColor}"><i data-lucide="${SUBJ_ICONS_A[a.subject_slug]||'file-text'}" style="width:18px;height:18px"></i></div>
<div class="a-main">
<div class="a-title">${esc(a.title)}</div>
<div class="a-meta">${metaParts.join(' · ')}</div>
</div>
<div class="a-prog">
<div class="a-prog-nums">
<span>${a.completed_count} / ${a.total_members} сдали</span>
<span class="a-prog-pct ${pctClass(pct)}">${pctLabel}</span>
</div>
<div class="a-prog-bar">
<div class="a-prog-fill" style="width:${pct||0}%;background:${barColor}"></div>
</div>
</div>
<div class="a-actions">
<button class="btn-edit-q" onclick="openAModal(${a.id})">Изменить</button>
<button class="btn-del-q" onclick="deleteAsgn(${a.id})"><i data-lucide="x" style="width:14px;height:14px"></i></button>
</div>
</div>`;
}).join('');
if (window.lucide) lucide.createIcons();
}
let _afSrc = 'random';
let _afLoadedTests = [];
function setAfSrc(src) {
_afSrc = src;
document.querySelectorAll('[data-afsrc]').forEach(b => b.classList.toggle('active', b.dataset.afsrc === src));
document.getElementById('af-random-fields').style.display = src === 'random' ? '' : 'none';
document.getElementById('af-test-fields').style.display = src === 'test' ? '' : 'none';
}
async function openAModal(id) {
const a = allAssignments.find(x => x.id === id);
if (!a) return;
editingAId = id;
document.getElementById('a-modal-title').textContent = `Редактировать: ${a.title}`;
document.getElementById('af-title').value = a.title;
document.getElementById('af-deadline').value = a.deadline ? a.deadline.split('T')[0] : '';
document.getElementById('af-error').textContent = '';
// Load tests for the dropdown
const testSel = document.getElementById('af-test');
testSel.innerHTML = '<option value="">Загрузка…</option>';
try {
_afLoadedTests = await LS.getTests();
testSel.innerHTML = _afLoadedTests.length
? '<option value="">— выберите тест —</option>' + _afLoadedTests.map(t => `<option value="${t.id}">${esc(t.title)} (${t.question_count} вопр.)</option>`).join('')
: '<option value="">Нет тестов</option>';
} catch {
testSel.innerHTML = '<option value="">Ошибка загрузки</option>';
_afLoadedTests = [];
}
if (a.test_id) {
setAfSrc('test');
testSel.value = a.test_id;
document.getElementById('af-mode-test').value = a.mode;
} else {
setAfSrc('random');
document.getElementById('af-subject').value = a.subject_slug;
document.getElementById('af-mode').value = a.mode;
document.getElementById('af-count').value = a.count;
}
document.getElementById('a-modal').classList.add('open');
setTimeout(() => document.getElementById('af-title').focus(), 80);
}
function closeAModal() {
document.getElementById('a-modal').classList.remove('open');
editingAId = null;
}
async function saveAssignment() {
const title = document.getElementById('af-title').value.trim();
const deadline = document.getElementById('af-deadline').value || null;
const errEl = document.getElementById('af-error');
errEl.textContent = '';
if (!title) { errEl.textContent = 'Введите название'; return; }
let payload = { title, deadline };
if (_afSrc === 'test') {
const test_id = document.getElementById('af-test').value;
const mode = document.getElementById('af-mode-test').value;
if (!test_id) { errEl.textContent = 'Выберите тест'; return; }
const testObj = _afLoadedTests.find(t => t.id === Number(test_id));
if (testObj && testObj.question_count === 0) { errEl.textContent = 'В выбранном тесте нет вопросов'; return; }
payload = { ...payload, test_id: Number(test_id), mode };
} else {
const subject_slug = document.getElementById('af-subject').value;
const mode = document.getElementById('af-mode').value;
const count = Number(document.getElementById('af-count').value);
if (!subject_slug) { errEl.textContent = 'Выберите предмет'; return; }
if (!count || count < 1) { errEl.textContent = 'Введите количество вопросов'; return; }
payload = { ...payload, subject_slug, mode, count, test_id: null };
}
const btn = document.getElementById('af-save');
btn.disabled = true; btn.textContent = 'Сохранение…';
try {
await LS.updateAssignment(editingAId, payload);
const idx = allAssignments.findIndex(x => x.id === editingAId);
if (idx !== -1) Object.assign(allAssignments[idx], payload);
closeAModal();
renderAssignments();
} catch (e) {
errEl.textContent = 'Ошибка: ' + e.message;
} finally {
btn.disabled = false; btn.textContent = 'Сохранить';
}
}
/* ─── Create assignment modal ─── */
let _acSrc = 'random'; // 'random' | 'test'
let _acTarget = 'class'; // 'class' | 'user'
let _acFileId = null, _acAllFiles = null;
let _acStudentId = null, _acAllStudents = null;
function setAcTarget(t) {
_acTarget = t;
document.querySelectorAll('[data-actgt]').forEach(b => b.classList.toggle('active', b.dataset.actgt === t));
document.getElementById('acf-class-field').style.display = t === 'class' ? '' : 'none';
document.getElementById('acf-user-field').style.display = t === 'user' ? '' : 'none';
if (t === 'user' && !_acAllStudents) loadAcStudents();
}
async function loadAcStudents() {
const drop = document.getElementById('acf-student-drop');
drop.innerHTML = '<div style="padding:8px 12px;font-size:13px;color:#9ca3af">Загрузка…</div>';
drop.style.display = '';
try {
_acAllStudents = await LS.getStudentsList();
openAcStudentDrop();
} catch(e) {
_acAllStudents = [];
drop.innerHTML = `<div style="padding:8px 12px;font-size:13px;color:#ef4444">Ошибка загрузки: ${e.message}</div>`;
}
}
function filterAcStudents(q) {
openAcStudentDrop(q);
}
function openAcStudentDrop(q) {
const drop = document.getElementById('acf-student-drop');
if (_acAllStudents === null) { loadAcStudents(); return; }
const list = _acAllStudents;
const term = (q !== undefined ? q : document.getElementById('acf-student-search').value).toLowerCase().trim();
const filtered = term ? list.filter(s => s.name.toLowerCase().includes(term) || s.email.toLowerCase().includes(term)) : list;
if (!filtered.length) {
drop.innerHTML = '<div style="padding:8px 12px;font-size:13px;color:#9ca3af">Нет учеников</div>';
drop.style.display = '';
return;
}
drop.innerHTML = filtered.slice(0, 50).map(s =>
`<div style="padding:8px 12px;cursor:pointer;border-bottom:1px solid #f3f4f6;font-size:13px" data-id="${s.id}" data-name="${esc(s.name)}" data-email="${esc(s.email)}" onmousedown="selectAcStudent(+this.dataset.id,this.dataset.name,this.dataset.email)" onmouseover="this.style.background='#f9fafb'" onmouseout="this.style.background=''">${esc(s.name)} <span style="color:#9ca3af">${esc(s.email)}</span></div>`
).join('');
drop.style.display = '';
}
function closeAcStudentDrop() {
document.getElementById('acf-student-drop').style.display = 'none';
}
function selectAcStudent(id, name, email) {
_acStudentId = id;
document.getElementById('acf-student-search').value = name;
document.getElementById('acf-student-selected').textContent = `${name} (${email})`;
document.getElementById('acf-student-selected').style.display = '';
closeAcStudentDrop();
}
function setAcSrc(src) {
_acSrc = src;
document.querySelectorAll('[data-src]').forEach(b => b.classList.toggle('active', b.dataset.src === src));
document.getElementById('acf-random-fields').style.display = src === 'random' ? '' : 'none';
document.getElementById('acf-test-fields').style.display = src === 'test' ? '' : 'none';
document.getElementById('acf-file-fields').style.display = src === 'file' ? '' : 'none';
if (src === 'file' && !_acAllFiles) loadAcFiles();
}
async function loadAcFiles() {
try {
_acAllFiles = await LS.getFiles();
renderAcFiles('');
} catch { _acAllFiles = []; }
}
function renderAcFiles(q) {
const el = document.getElementById('acf-file-list');
if (!_acAllFiles) { el.innerHTML = '<div style="padding:10px;color:#8898AA;font-size:.82rem;text-align:center">Загрузка…</div>'; return; }
const lq = q.toLowerCase();
const items = q ? _acAllFiles.filter(f => (f.title||'').toLowerCase().includes(lq)) : _acAllFiles;
const SUBJ = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
if (!items.length) { el.innerHTML = '<div style="padding:10px;color:#8898AA;font-size:.82rem;text-align:center">Нет файлов</div>'; return; }
el.innerHTML = items.map(f => `
<div onclick="selectAcFile(${f.id},'${esc(f.title||'Файл')}','${f.subject_slug||''}')"
style="padding:9px 12px;cursor:pointer;border-bottom:1px solid rgba(15,23,42,0.07);display:flex;align-items:center;gap:8px;${_acFileId===f.id?'background:rgba(155,93,229,0.08);':''} transition:background .15s">
<div style="flex:1">
<div style="font-size:.84rem;font-weight:600">${esc(f.title||'Файл')}</div>
<div style="font-size:.74rem;color:#8898AA">${SUBJ[f.subject_slug]||f.subject_slug||''}</div>
</div>
${_acFileId===f.id ? '<span style="color:var(--violet)"><i data-lucide="check" style="width:15px;height:15px"></i></span>' : ''}
</div>`).join('');
if (window.lucide) lucide.createIcons();
}
function filterAcFiles(q) { renderAcFiles(q); }
function selectAcFile(id, title, subject_slug) {
_acFileId = id;
renderAcFiles(document.getElementById('acf-file-search').value);
const sel = document.getElementById('acf-file-selected');
sel.textContent = 'Выбран: ' + title;
sel.style.display = '';
}
async function openCreateAModal() {
_acSrc = 'random'; _acTarget = 'class'; _acFileId = null; _acStudentId = null; _acAllStudents = null;
setAcSrc('random');
setAcTarget('class');
loadAcStudents(); // preload students so search is ready
document.getElementById('acf-title').value = '';
document.getElementById('acf-subject').value = '';
document.getElementById('acf-mode').value = 'exam';
document.getElementById('acf-mode-test').value = 'exam';
document.getElementById('acf-count').value = '25';
document.getElementById('acf-deadline').value = '';
document.getElementById('acf-student-search').value = '';
document.getElementById('acf-student-selected').style.display = 'none';
_acStudentId = null;
document.getElementById('acf-error').textContent = '';
document.getElementById('acf-file-search').value = '';
document.getElementById('acf-file-selected').style.display = 'none';
// load classes and tests in parallel
const [clsSel, testSel] = [document.getElementById('acf-class'), document.getElementById('acf-test')];
clsSel.innerHTML = '<option value="">Загрузка…</option>';
testSel.innerHTML = '<option value="">Загрузка…</option>';
const [classesP, testsP] = await Promise.allSettled([LS.getClasses(), LS.getTests()]);
if (classesP.status === 'fulfilled') {
const classes = classesP.value;
clsSel.innerHTML = classes.length
? '<option value="">— выберите класс —</option>' + classes.map(c => `<option value="${c.id}">${esc(c.name)} (${c.member_count} уч.)</option>`).join('')
: '<option value="">Нет классов — создайте класс</option>';
} else {
clsSel.innerHTML = `<option value="">Ошибка загрузки классов</option>`;
}
if (testsP.status === 'fulfilled') {
const tests = testsP.value;
testSel.innerHTML = tests.length
? '<option value="">— выберите тест —</option>' + tests.map(t => `<option value="${t.id}">${esc(t.title)} (${t.question_count} вопр.)</option>`).join('')
: '<option value="">Нет тестов — создайте тест</option>';
} else {
testSel.innerHTML = `<option value="">Ошибка загрузки тестов</option>`;
}
document.getElementById('ac-modal').classList.add('open');
setTimeout(() => document.getElementById('acf-title').focus(), 80);
}
function closeCreateAModal() {
document.getElementById('ac-modal').classList.remove('open');
}
async function saveNewAssignment() {
const title = document.getElementById('acf-title').value.trim();
const deadline = document.getElementById('acf-deadline').value || null;
const errEl = document.getElementById('acf-error');
errEl.textContent = '';
if (!title) { errEl.textContent = 'Введите название'; return; }
let payload = { title, deadline };
if (_acSrc === 'file') {
if (!_acFileId) { errEl.textContent = 'Выберите файл из библиотеки'; return; }
const f = _acAllFiles.find(x => x.id === _acFileId);
payload = { ...payload, file_id: _acFileId, subject_slug: f?.subject_slug || 'bio', mode: 'exam', count: 1 };
} else if (_acSrc === 'test') {
const test_id = document.getElementById('acf-test').value;
const mode = document.getElementById('acf-mode-test').value;
if (!test_id) { errEl.textContent = 'Выберите тест'; return; }
const selOpt = document.querySelector(`#acf-test option[value="${test_id}"]`);
if (selOpt && selOpt.textContent.includes('(0 вопр.)')) { errEl.textContent = 'В выбранном тесте нет вопросов. Добавьте вопросы во вкладке «Тесты».'; return; }
payload = { ...payload, test_id: Number(test_id), mode };
} else {
const subject_slug = document.getElementById('acf-subject').value;
const mode = document.getElementById('acf-mode').value;
const count = Number(document.getElementById('acf-count').value);
if (!subject_slug) { errEl.textContent = 'Выберите предмет'; return; }
if (!count || count < 1) { errEl.textContent = 'Укажите количество вопросов'; return; }
payload = { ...payload, subject_slug, mode, count };
}
const btn = document.getElementById('acf-save');
btn.disabled = true; btn.textContent = 'Создание…';
try {
if (_acTarget === 'user') {
if (!_acStudentId) { errEl.textContent = 'Выберите ученика из списка'; btn.disabled=false; btn.textContent='Создать'; return; }
await LS.createDirectAssignment({ ...payload, student_id: _acStudentId });
} else {
const class_id = document.getElementById('acf-class').value;
if (!class_id) { errEl.textContent = 'Выберите класс'; btn.disabled=false; btn.textContent='Создать'; return; }
await LS.createAssignment(class_id, payload);
}
closeCreateAModal();
loadAssignments();
} catch (e) {
errEl.textContent = 'Ошибка: ' + e.message;
} finally {
btn.disabled = false; btn.textContent = 'Создать';
}
}
async function deleteAsgn(id) {
const a = allAssignments.find(x => x.id === id);
if (!await LS.confirm(`Удалить задание «${a?.title}»?\nВсе связанные сессии будут удалены.`, { title: 'Удалить задание', confirmText: 'Удалить' })) return;
try {
await LS.deleteAssignment(id);
allAssignments = allAssignments.filter(x => x.id !== id);
renderAssignments();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
/* ════════════════════════════════════════════════
ДОСТУПНЫЕ ТЕСТЫ — настройка предметов
════════════════════════════════════════════════ */
let _subjConfigInited = false;
const SC_MODES = { exam: 'Экзамен', practice: 'Пробный тест', topic: 'По теме', random: 'Случайный' };
const SC_ICONS = { bio:'dna', chem:'flask-conical', math:'calculator', phys:'zap' };
const SC_COLORS = { bio:'#9B5DE5', chem:'#06D6A0', math:'#06B6D4', phys:'#F59E0B' };
// кэш тестов по предмету для селектора
const _scTests = {};
async function loadScTests(slug) {
if (_scTests[slug]) return _scTests[slug];
const tests = await LS.getTests(slug);
_scTests[slug] = tests;
return tests;
}
function setSrcMode(slug, src) {
const rndBtn = document.getElementById(`sc-src-rnd-${slug}`);
const fixBtn = document.getElementById(`sc-src-fix-${slug}`);
const pick = document.getElementById(`sc-test-pick-${slug}`);
const cntWrap = document.getElementById(`sc-count-wrap-${slug}`);
rndBtn.classList.toggle('active', src === 'random');
fixBtn.classList.toggle('active', src === 'fixed');
pick.classList.toggle('open', src === 'fixed');
cntWrap.style.display = src === 'random' ? '' : 'none';
if (src === 'fixed') {
loadAndRenderTestPick(slug);
} else {
// скрыть drawer вопросов при переключении на случайный
const dr = document.getElementById(`sc-qdr-${slug}`);
if (dr) { dr.style.display = 'none'; }
}
}
async function loadAndRenderTestPick(slug) {
const sel = document.getElementById(`sc-test-sel-${slug}`);
if (sel.dataset.loaded) return;
sel.innerHTML = '<option value="">Загрузка…</option>';
try {
const tests = await loadScTests(slug);
const cur = document.getElementById(`sc-card-${slug}`)?.dataset.testId || '';
sel.innerHTML = `<option value="">— случайные вопросы —</option>` +
tests.map(t => `<option value="${t.id}"${String(t.id) === cur ? ' selected' : ''}>${esc(t.title)} (${t.question_count ?? '?'} вопр.)</option>`).join('');
sel.dataset.loaded = '1';
} catch(e) {
sel.innerHTML = '<option value="">Ошибка загрузки</option>';
}
}
async function loadSubjectConfig() {
const wrap = document.getElementById('subj-config-list');
wrap.innerHTML = LS.skeleton(4);
try {
const subjects = await LS.getSubjects();
wrap.innerHTML = subjects.map(s => {
const hasFix = !!s.default_test_id;
const color = SC_COLORS[s.slug] || '#9B5DE5';
const mode = s.default_mode || 'exam';
const count = s.default_count || 25;
const srcLabel = hasFix ? 'Фикс. тест' : `${count} вопросов`;
return `
<div class="sc-card" id="sc-card-${s.slug}" data-test-id="${s.default_test_id || ''}">
<div class="sc-row-top" onclick="toggleScCard('${s.slug}')">
<div class="sc-icon" style="background:${color}"><i data-lucide="${SC_ICONS[s.slug]||'book'}"></i></div>
<div class="sc-info">
<div class="sc-name">${esc(s.name)}</div>
<div class="sc-summary" id="sc-sum-${s.slug}">
<span class="sc-tag sc-tag-mode">${SC_MODES[mode]}</span>
<span class="sc-tag">${srcLabel}</span>
<span class="sc-qcount">${s.question_count ?? 0} в базе</span>
</div>
</div>
<i data-lucide="chevron-down" class="sc-chevron"></i>
</div>
<div class="sc-body">
<!-- Quick presets -->
<div class="sc-presets">
<button class="sc-preset${mode==='exam'&&count===25&&!hasFix?' active':''}" onclick="applyPreset('${s.slug}','exam',25)">Экзамен 25</button>
<button class="sc-preset${mode==='exam'&&count===40&&!hasFix?' active':''}" onclick="applyPreset('${s.slug}','exam',40)">Экзамен 40</button>
<button class="sc-preset${mode==='practice'&&count===15&&!hasFix?' active':''}" onclick="applyPreset('${s.slug}','practice',15)">Практика 15</button>
<button class="sc-preset${mode==='practice'&&count===25&&!hasFix?' active':''}" onclick="applyPreset('${s.slug}','practice',25)">Практика 25</button>
</div>
<!-- Detailed fields -->
<div class="sc-fields">
<div class="sc-field">
<span class="sc-label">Режим</span>
<select class="sc-select" id="sc-mode-${s.slug}">
${Object.entries(SC_MODES).map(([v, l]) =>
`<option value="${v}"${mode === v ? ' selected' : ''}>${l}</option>`
).join('')}
</select>
</div>
<div class="sc-field">
<span class="sc-label">Источник</span>
<div class="sc-src-toggle">
<button class="sc-src-btn${hasFix ? '' : ' active'}" id="sc-src-rnd-${s.slug}" onclick="setSrcMode('${s.slug}','random')">Случайные</button>
<button class="sc-src-btn${hasFix ? ' active' : ''}" id="sc-src-fix-${s.slug}" onclick="setSrcMode('${s.slug}','fixed')">Из теста</button>
</div>
</div>
<div class="sc-field" id="sc-count-wrap-${s.slug}" style="${hasFix ? 'display:none' : ''}">
<span class="sc-label">Вопросов</span>
<input class="sc-input" type="number" id="sc-count-${s.slug}" min="5" max="100" value="${count}" />
</div>
<div class="sc-test-pick${hasFix ? ' open' : ''}" id="sc-test-pick-${s.slug}">
<div class="sc-field">
<span class="sc-label">Тест</span>
<select class="sc-select" id="sc-test-sel-${s.slug}" onchange="onScTestChange('${s.slug}')">
<option value="${s.default_test_id || ''}" selected>Загрузка...</option>
</select>
</div>
<button class="sc-save-add" id="sc-qdr-btn-${s.slug}" style="display:${hasFix?'':'none'};align-self:flex-start"
onclick="toggleScDrawer('${s.slug}')"><i data-lucide="list" style="width:13px;height:13px;vertical-align:-2px"></i> Вопросы</button>
</div>
</div>
<!-- Footer -->
<div class="sc-footer">
<button class="sc-save" id="sc-save-btn-${s.slug}" onclick="saveSubjectConfig('${s.slug}')">Сохранить</button>
<button class="sc-save-add" onclick="goAddQuestion('${s.slug}')"><i data-lucide="plus" style="width:13px;height:13px;vertical-align:-2px"></i> Вопрос</button>
</div>
</div>
</div>
<div id="sc-qdr-${s.slug}" style="display:none;border-top:1px solid var(--border);padding:20px 24px;background:rgba(238,242,255,0.5)">
<div id="sc-qdr-inner-${s.slug}"></div>
</div>`;
}).join('');
if (window.lucide) lucide.createIcons();
// pre-load test selectors and show Вопросы button for subjects already using a fixed test
subjects.filter(s => s.default_test_id).forEach(s => {
loadAndRenderTestPick(s.slug);
const btn = document.getElementById(`sc-qdr-btn-${s.slug}`);
if (btn) btn.style.display = '';
});
} catch (e) {
wrap.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
function toggleScCard(slug) {
const card = document.getElementById('sc-card-' + slug);
if (!card) return;
const wasOpen = card.classList.contains('open');
// Close all
document.querySelectorAll('.sc-card.open').forEach(c => c.classList.remove('open'));
if (!wasOpen) {
card.classList.add('open');
if (window.lucide) lucide.createIcons({ nodes: [card] });
}
}
function applyPreset(slug, mode, count) {
document.getElementById('sc-mode-' + slug).value = mode;
document.getElementById('sc-count-' + slug).value = count;
setSrcMode(slug, 'random');
// Highlight active preset
const card = document.getElementById('sc-card-' + slug);
card.querySelectorAll('.sc-preset').forEach(p => p.classList.remove('active'));
const isFix = document.getElementById('sc-src-fix-' + slug).classList.contains('active');
card.querySelectorAll('.sc-preset').forEach(p => {
const txt = p.textContent.trim();
const mLabel = SC_MODES[mode];
if (txt === mLabel + ' ' + count && !isFix) p.classList.add('active');
});
// Auto-save
saveSubjectConfig(slug);
}
function updateScSummary(slug) {
const el = document.getElementById('sc-sum-' + slug);
if (!el) return;
const mode = document.getElementById('sc-mode-' + slug).value;
const isFix = document.getElementById('sc-src-fix-' + slug).classList.contains('active');
const count = document.getElementById('sc-count-' + slug).value;
const srcLabel = isFix ? 'Фикс. тест' : count + ' вопросов';
el.innerHTML = `<span class="sc-tag sc-tag-mode">${SC_MODES[mode]}</span><span class="sc-tag">${srcLabel}</span>`;
}
function scPreviewText(s) {
if (s.default_test_id) return `На дашборде: «${SC_MODES[s.default_mode || 'exam']}», фиксированный тест`;
return `На дашборде: «${SC_MODES[s.default_mode || 'exam']}», ${s.default_count || 25} вопросов (случайные)`;
}
async function saveSubjectConfig(slug) {
const btn = document.getElementById(`sc-save-btn-${slug}`);
const mode = document.getElementById(`sc-mode-${slug}`).value;
const isFix = document.getElementById(`sc-src-fix-${slug}`).classList.contains('active');
const count = Number(document.getElementById(`sc-count-${slug}`)?.value || 25);
const testId = isFix ? (document.getElementById(`sc-test-sel-${slug}`).value || null) : null;
if (btn) { btn.disabled = true; btn.textContent = '...'; }
const payload = { default_mode: mode, default_count: count, default_test_id: testId ? Number(testId) : null };
try {
await LS.updateSubject(slug, payload);
document.getElementById(`sc-card-${slug}`).dataset.testId = testId || '';
if (isFix) document.getElementById(`sc-test-sel-${slug}`).dataset.loaded = '';
updateScSummary(slug);
// Visual feedback
if (btn) { btn.classList.add('saved'); btn.textContent = 'Сохранено'; }
setTimeout(() => { if (btn) { btn.classList.remove('saved'); btn.textContent = 'Сохранить'; btn.disabled = false; } }, 1500);
} catch (e) {
LS.toast('Ошибка: ' + e.message, 'error');
if (btn) { btn.disabled = false; btn.textContent = 'Сохранить'; }
}
}
/* ── Subject drawer: управление вопросами теста ── */
function onScTestChange(slug) {
const tid = document.getElementById(`sc-test-sel-${slug}`).value;
const btn = document.getElementById(`sc-qdr-btn-${slug}`);
btn.style.display = tid ? '' : 'none';
// скрыть drawer если тест сменился
const dr = document.getElementById(`sc-qdr-${slug}`);
dr.style.display = 'none';
document.getElementById(`sc-qdr-inner-${slug}`).innerHTML = '';
}
const _scDrOpen = {}; // slug <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> bool
async function toggleScDrawer(slug) {
const dr = document.getElementById(`sc-qdr-${slug}`);
const tid = Number(document.getElementById(`sc-test-sel-${slug}`).value);
if (!tid) return;
if (dr.style.display !== 'none') { dr.style.display = 'none'; return; }
dr.style.display = '';
await renderScDrawer(slug, tid);
}
const _scCache = {}; // tid <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> { test, subjectQs }
async function renderScDrawer(slug, tid) {
const inner = document.getElementById(`sc-qdr-inner-${slug}`);
inner.innerHTML = LS.skeleton(3, 'row');
try {
const [t, subjectQs] = await Promise.all([
LS.getTest(tid),
LS.getQuestions(slug, null, 'date_asc').catch(() => []),
]);
_scCache[tid] = { test: t, subjectQs };
inner.innerHTML = `
<div class="tst-cols">
<div>
<div class="tst-panel-title">Вопросы в тесте (<span id="sc-qcnt-${tid}">${t.questions.length}</span>)</div>
<div class="tst-q-list" id="sc-ql-${tid}">${renderScQList(t.questions, tid, slug)}</div>
</div>
<div>
<div class="tst-panel-title">Добавить из базы</div>
<input class="tst-search" placeholder="Поиск…" oninput="filterScPicker(${tid},'${slug}',this.value)" />
<div class="tst-q-list" id="sc-pick-${tid}">${renderScPicker(subjectQs, new Set(t.questions.map(q=>q.id)), tid, slug)}</div>
</div>
</div>`;
renderMath(inner);
} catch(e) {
inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
function renderScQList(questions, tid, slug) {
if (!questions.length) return '<div class="tst-empty">Пусто. Добавьте вопросы справа <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></div>';
return questions.map((q,i) => `
<div class="tst-q-item" id="sc-qi-${tid}-${q.id}">
<span class="tst-q-num">${i+1}.</span>
<div class="tst-q-body">
<span class="tst-q-text">${esc(q.text)}</span>
<div class="tst-q-meta">
<span class="tst-q-badge diff-${q.difficulty}">${DIFF_LABELS[q.difficulty]||q.difficulty}</span>
${qTypeBadge(q.type)}
${qOptsPreview(q)}
</div>
</div>
<button class="btn-tst-rem" onclick="scRemoveQ(${tid},'${slug}',${q.id})" title="Убрать"></button>
</div>`).join('');
}
function renderScPicker(questions, inIds, tid, slug) {
if (!questions.length) return '<div class="tst-empty">Вопросов нет в этом предмете</div>';
return questions.map(q => {
const added = inIds.has(q.id);
return `
<div class="tst-q-item" id="sc-pick-item-${tid}-${q.id}" style="${added?'opacity:0.4;pointer-events:none':''}">
<div class="tst-q-body" style="flex:1">
<span class="tst-q-text">${esc(q.text)}</span>
<div class="tst-q-meta">
<span class="tst-q-badge diff-${q.difficulty}">${DIFF_LABELS[q.difficulty]||q.difficulty}</span>
${qTypeBadge(q.type)}
${q.topic ? `<span class="tst-q-badge" style="background:rgba(6,214,224,0.1);color:#05aab3">${esc(q.topic)}</span>` : ''}
</div>
</div>
<button class="btn-tst-add" id="sc-add-btn-${tid}-${q.id}" onclick="scAddQ(${tid},'${slug}',${q.id},this)" title="Добавить">${added?'<i data-lucide="check" style="width:14px;height:14px"></i>':'+' }</button>
</div>`;
}).join('');
}
function filterScPicker(tid, slug, q) {
const cache = _scCache[tid];
if (!cache) return;
const lq = q.toLowerCase();
const filtered = lq.length < 1
? cache.subjectQs
: cache.subjectQs.filter(x => x.text.toLowerCase().includes(lq) || (x.topic||'').toLowerCase().includes(lq));
const inIds = new Set(cache.test.questions.map(x=>x.id));
document.getElementById(`sc-pick-${tid}`).innerHTML = renderScPicker(filtered, inIds, tid, slug);
}
async function scAddQ(tid, slug, qid, btn) {
btn.disabled = true; btn.textContent = '…';
try {
await LS.addQuestionsToTest(tid, [qid]);
// обновить кэш и списки
const t = await LS.getTest(tid);
_scCache[tid].test = t;
const inIds = new Set(t.questions.map(q=>q.id));
document.getElementById(`sc-ql-${tid}`).innerHTML = renderScQList(t.questions, tid, slug);
document.getElementById(`sc-qcnt-${tid}`).textContent = t.questions.length;
// пометить кнопку в пикере
const item = document.getElementById(`sc-pick-item-${tid}-${qid}`);
if (item) { item.style.opacity='0.4'; item.style.pointerEvents='none'; }
const addBtn = document.getElementById(`sc-add-btn-${tid}-${qid}`);
if (addBtn) { addBtn.innerHTML = '<i data-lucide="check" style="width:14px;height:14px"></i>'; if(window.lucide)lucide.createIcons(); }
renderMath(document.getElementById(`sc-ql-${tid}`));
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); btn.disabled=false; btn.textContent='+'; }
}
async function scRemoveQ(tid, slug, qid) {
try {
await LS.removeQFromTest(tid, qid);
const t = await LS.getTest(tid);
_scCache[tid].test = t;
const inIds = new Set(t.questions.map(q=>q.id));
document.getElementById(`sc-ql-${tid}`).innerHTML = renderScQList(t.questions, tid, slug);
document.getElementById(`sc-qcnt-${tid}`).textContent = t.questions.length;
// разблокировать в пикере
const item = document.getElementById(`sc-pick-item-${tid}-${qid}`);
if (item) { item.style.opacity=''; item.style.pointerEvents=''; }
const addBtn = document.getElementById(`sc-add-btn-${tid}-${qid}`);
if (addBtn) { addBtn.textContent='+'; addBtn.disabled=false; }
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
/* ─── User permissions modal ───────────────────────────────────────── */
let _upPermsData = null;
function closeUserPermsModal() {
document.getElementById('up-modal').classList.remove('open');
_upPermsData = null;
}
async function openUserPermsModal() {
if (!activeUid) return;
const name = document.getElementById('up-name').textContent;
document.getElementById('up-modal-title').textContent = `Права: ${name}`;
document.getElementById('up-modal-list').innerHTML = LS.skeleton(5, 'row');
document.getElementById('up-modal').classList.add('open');
try {
_upPermsData = await LS.getUserPermissions(activeUid);
renderUserPerms();
} catch(e) {
document.getElementById('up-modal-list').innerHTML = `<p style="color:var(--danger);font-size:13px">Ошибка: ${esc(e.message)}</p>`;
}
}
function renderUserPerms() {
if (!_upPermsData) return;
const list = document.getElementById('up-modal-list');
list.innerHTML = _upPermsData.permissions.map(p => {
const hasOverride = p.userVal !== undefined;
const checked = p.effective;
const badge = hasOverride
? `<span style="font-size:10px;padding:2px 7px;border-radius:var(--r-pill);background:rgba(155,93,229,0.12);color:var(--violet);font-weight:700">Инд.</span>`
: `<span style="font-size:10px;padding:2px 7px;border-radius:var(--r-pill);background:rgba(136,152,170,0.12);color:var(--text-3);font-weight:700">По роли</span>`;
const resetBtn = hasOverride
? `<button style="background:none;border:none;cursor:pointer;color:var(--text-3);padding:3px 6px;border-radius:6px;font-size:11px;font-weight:700;transition:color .2s"
onmouseover="this.style.color='var(--danger)'" onmouseout="this.style.color='var(--text-3)'"
onclick="doResetOneUserPerm('${esc(p.key)}')" title="Сбросить к роли">×</button>`
: '';
return `
<div class="perm-card${checked ? ' enabled' : ''}" id="up-perm-card-${p.key.replace('.','_')}">
<div class="perm-info">
<div style="display:flex;align-items:center;gap:7px">
<span class="perm-label">${esc(p.label)}</span>
${badge}
${resetBtn}
</div>
<div class="perm-desc">${esc(p.desc)}</div>
</div>
<label class="perm-toggle">
<input type="checkbox" ${checked ? 'checked' : ''}
onchange="doSetUserPerm('${esc(p.key)}', this.checked, this)">
<span class="perm-track"></span>
<span class="perm-thumb"></span>
</label>
</div>`;
}).join('');
// update reset-all btn visibility
const hasAny = _upPermsData.permissions.some(p => p.userVal !== undefined);
document.getElementById('up-modal-reset-btn').style.opacity = hasAny ? '1' : '0.4';
}
async function doSetUserPerm(key, enabled, checkbox) {
checkbox.disabled = true;
try {
await LS.setUserPermission(activeUid, key, enabled);
_upPermsData = await LS.getUserPermissions(activeUid);
renderUserPerms();
LS.toast(enabled ? 'Право включено' : 'Право отключено', 'success');
} catch(e) {
checkbox.checked = !enabled;
LS.toast('Ошибка: ' + e.message, 'error');
} finally {
checkbox.disabled = false;
}
}
async function doResetOneUserPerm(key) {
try {
await LS.resetUserPermissions(activeUid, key);
_upPermsData = await LS.getUserPermissions(activeUid);
renderUserPerms();
LS.toast('Сброшено к значению роли', 'success');
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
async function doResetAllUserPerms() {
const name = document.getElementById('up-name').textContent;
if (!await LS.confirm(`Сбросить все индивидуальные права «${name}»?\nБудут применены права роли.`, { title: 'Сбросить права', confirmText: 'Сбросить' })) return;
try {
await LS.resetUserPermissions(activeUid);
_upPermsData = await LS.getUserPermissions(activeUid);
renderUserPerms();
LS.toast('Права сброшены к роли', 'success');
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
/* ─── Permissions tab ──────────────────────────────────────────────── */
let _permData = null;
async function loadPermissions() {
try {
_permData = await LS.getPermissions();
renderPermissions();
} catch(e) {
document.getElementById('perm-teacher').innerHTML =
`<p style="color:var(--danger);font-size:13px">Ошибка загрузки: ${esc(e.message)}</p>`;
}
}
function renderPermissions() {
if (!_permData) return;
const { permissions, definitions } = _permData;
['teacher', 'student'].forEach(role => {
const container = document.getElementById('perm-' + role);
const defs = definitions.filter(d => d.role === role);
container.innerHTML = defs.map(def => {
const enabled = permissions[role]?.[def.key] ?? def.default;
return `
<div class="perm-card${enabled ? ' enabled' : ''}" id="perm-card-${role}-${def.key.replace('.','_')}">
<div class="perm-info">
<div class="perm-label">${esc(def.label)}</div>
<div class="perm-desc">${esc(def.desc)}</div>
</div>
<label class="perm-toggle" title="${enabled ? 'Выключить' : 'Включить'}">
<input type="checkbox" ${enabled ? 'checked' : ''}
onchange="togglePermission('${esc(role)}','${esc(def.key)}',this.checked,this)">
<span class="perm-track"></span>
<span class="perm-thumb"></span>
</label>
</div>`;
}).join('');
});
}
async function togglePermission(role, key, enabled, checkbox) {
checkbox.disabled = true;
try {
await LS.setPermission(role, key, enabled);
// update local cache
if (!_permData.permissions[role]) _permData.permissions[role] = {};
_permData.permissions[role][key] = enabled;
// update card style
const safeKey = key.replace('.', '_');
const card = document.getElementById(`perm-card-${role}-${safeKey}`);
if (card) card.classList.toggle('enabled', enabled);
LS.toast(enabled ? 'Право включено' : 'Право отключено', 'success');
} catch(e) {
checkbox.checked = !enabled; // revert
LS.toast('Ошибка: ' + e.message, 'error');
} finally {
checkbox.disabled = false;
}
}
/* ════════════════════════════════════════════════
МАГАЗИН (Shop)
════════════════════════════════════════════════ */
let _shopItems = [];
let _shopEditId = null;
async function loadShopAdmin() {
try {
const [stats, items] = await Promise.all([
LS.adminShopStats(),
LS.adminShopGetItems()
]);
const topName = stats.topItems?.[0]?.name || '—';
document.getElementById('shop-stats-grid').innerHTML = `
<div class="stat-card" style="--stat-top:var(--violet)">
<div class="stat-card-icon" style="background:rgba(155,93,229,0.1)"><i data-lucide="shopping-bag" class="stat-icon"></i></div>
<div class="stat-val violet">${stats.activeItems}/${stats.totalItems}</div>
<div class="stat-label">Товаров</div>
</div>
<div class="stat-card" style="--stat-top:var(--cyan)">
<div class="stat-card-icon" style="background:rgba(6,214,224,0.1)"><i data-lucide="receipt" class="stat-icon"></i></div>
<div class="stat-val cyan">${stats.totalPurchases}</div>
<div class="stat-label">Покупок</div>
</div>
<div class="stat-card" style="--stat-top:var(--green)">
<div class="stat-card-icon" style="background:rgba(6,214,100,0.1)"><i data-lucide="coins" class="stat-icon"></i></div>
<div class="stat-val green">${stats.totalCoinsInCirculation}</div>
<div class="stat-label">Монет в обороте</div>
</div>
<div class="stat-card" style="--stat-top:var(--amber, #FFB347)">
<div class="stat-card-icon" style="background:rgba(255,179,71,0.1)"><i data-lucide="star" class="stat-icon"></i></div>
<div class="stat-val" style="color:var(--amber, #FFB347);font-size:1.1rem">${esc(topName)}</div>
<div class="stat-label">Топ товар</div>
</div>`;
_shopItems = items;
renderShopItems();
if (window.lucide) lucide.createIcons();
} catch(e) {
document.getElementById('shop-stats-grid').innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
function renderShopItems() {
const body = document.getElementById('shop-items-body');
if (!_shopItems.length) { body.innerHTML = '<tr><td colspan="7" class="empty">Нет товаров</td></tr>'; return; }
const typeLabels = { frame:'Рамка', title:'Титул', theme:'Тема', effect:'Эффект' };
body.innerHTML = _shopItems.map(it => `<tr>
<td>${it.id}</td>
<td><strong>${esc(it.name)}</strong></td>
<td><span class="mode-badge mode-practice">${typeLabels[it.type] || esc(it.type)}</span></td>
<td>${it.price} <i data-lucide="coins" style="width:12px;height:12px;vertical-align:-2px;color:var(--amber, #FFB347)"></i></td>
<td>${it.sold_count || 0}</td>
<td>
<label class="adm-toggle">
<input type="checkbox" ${it.is_active ? 'checked' : ''} onchange="shopAdminToggleActive(${it.id}, this.checked)" />
<span class="track"></span><span class="thumb"></span>
</label>
</td>
<td>
<button class="btn-edit-q" onclick="shopAdminEditItem(${it.id})">Ред.</button>
<button class="btn-del-q" onclick="shopAdminDeleteItem(${it.id})">Удалить</button>
</td>
</tr>`).join('');
if (window.lucide) lucide.createIcons();
}
function shopAdminCreateItem() {
_shopEditId = null;
document.getElementById('shop-form-title').textContent = 'Новый товар';
document.getElementById('shop-f-name').value = '';
document.getElementById('shop-f-type').value = 'frame';
document.getElementById('shop-f-price').value = '100';
document.getElementById('shop-f-desc').value = '';
document.getElementById('shop-f-icon').value = '';
document.getElementById('shop-f-data').value = '';
document.getElementById('shop-f-active').checked = true;
document.getElementById('shop-item-form').style.display = '';
}
function shopAdminEditItem(id) {
const it = _shopItems.find(i => i.id === id);
if (!it) return;
_shopEditId = id;
document.getElementById('shop-form-title').textContent = 'Редактировать товар #' + id;
document.getElementById('shop-f-name').value = it.name || '';
document.getElementById('shop-f-type').value = it.type || 'frame';
document.getElementById('shop-f-price').value = it.price ?? 100;
document.getElementById('shop-f-desc').value = it.description || '';
document.getElementById('shop-f-icon').value = it.icon || '';
document.getElementById('shop-f-data').value = it.data ? (typeof it.data === 'string' ? it.data : JSON.stringify(it.data)) : '';
document.getElementById('shop-f-active').checked = !!it.is_active;
document.getElementById('shop-item-form').style.display = '';
}
function shopAdminCancelForm() {
document.getElementById('shop-item-form').style.display = 'none';
_shopEditId = null;
}
let _shopSaving = false;
async function shopAdminSaveItem() {
if (_shopSaving) return;
_shopSaving = true;
const data = {
name: document.getElementById('shop-f-name').value.trim(),
type: document.getElementById('shop-f-type').value,
price: parseInt(document.getElementById('shop-f-price').value) || 0,
description: document.getElementById('shop-f-desc').value.trim(),
icon: document.getElementById('shop-f-icon').value.trim(),
data: document.getElementById('shop-f-data').value.trim() || null,
is_active: document.getElementById('shop-f-active').checked ? 1 : 0
};
if (!data.name) { LS.toast('Введите название', 'error'); return; }
try {
if (_shopEditId) {
await LS.adminShopUpdateItem(_shopEditId, data);
LS.toast('Товар обновлён', 'success');
} else {
await LS.adminShopCreateItem(data);
LS.toast('Товар создан', 'success');
}
shopAdminCancelForm();
shopInited = false;
loadShopAdmin();
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
finally { _shopSaving = false; }
}
async function shopAdminDeleteItem(id) {
if (!await LS.confirm('Все покупки этого товара будут удалены.', { title: 'Удалить товар?', confirmText: 'Удалить', danger: true })) return;
try {
await LS.adminShopDeleteItem(id);
LS.toast('Товар удалён', 'success');
shopInited = false;
loadShopAdmin();
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
async function shopAdminToggleActive(id, active) {
try {
await LS.adminShopUpdateItem(id, { is_active: active ? 1 : 0 });
LS.toast(active ? 'Товар активирован' : 'Товар деактивирован', 'success');
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
let _shopSearchTimer = null;
async function shopSearchUser(q) {
clearTimeout(_shopSearchTimer);
const box = document.getElementById('shop-award-results');
if (q.length < 2) { box.classList.remove('open'); return; }
_shopSearchTimer = setTimeout(async () => {
try {
const r = await LS.adminGetUsers({ q, limit: 8 });
box.innerHTML = (r.users || []).map(u => `<div class="us-item" onclick="shopPickUser(${u.id}, '${esc(u.name || u.email)}')">
<span>${esc(u.name || u.email)}</span><span class="us-role">${u.role}</span>
</div>`).join('') || '<div class="us-item" style="color:var(--text-3)">Не найдено</div>';
box.classList.add('open');
} catch(e) { box.classList.remove('open'); }
}, 300);
}
function shopPickUser(id, name) {
document.getElementById('shop-award-uid').value = id;
document.getElementById('shop-award-user').value = name;
document.getElementById('shop-award-results').classList.remove('open');
}
let _coinsAwarding = false;
async function shopAdminAwardCoins() {
if (_coinsAwarding) return;
const userId = parseInt(document.getElementById('shop-award-uid').value);
const amount = parseInt(document.getElementById('shop-award-amount').value);
const reason = document.getElementById('shop-award-reason').value.trim();
if (!userId) { LS.toast('Выберите пользователя', 'error'); return; }
if (!amount || amount <= 0) { LS.toast('Введите количество монет', 'error'); return; }
_coinsAwarding = true;
try {
const r = await LS.adminShopAwardCoins({ userId, amount, reason });
LS.toast(`Начислено ${amount} монет. Баланс: ${r.coins}`, 'success');
document.getElementById('shop-award-uid').value = '';
document.getElementById('shop-award-user').value = '';
document.getElementById('shop-award-reason').value = '';
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
finally { _coinsAwarding = false; }
}
/* ════════════════════════════════════════════════
ГЕЙМИФИКАЦИЯ (Gamification)
════════════════════════════════════════════════ */
async function loadGamAdmin() {
try {
const stats = await LS.adminGamStats();
document.getElementById('gam-stats-grid').innerHTML = `
<div class="stat-card" style="--stat-top:var(--violet)">
<div class="stat-card-icon" style="background:rgba(155,93,229,0.1)"><i data-lucide="zap" class="stat-icon"></i></div>
<div class="stat-val violet">${stats.totalXP}</div>
<div class="stat-label">Суммарный XP</div>
</div>
<div class="stat-card" style="--stat-top:var(--cyan)">
<div class="stat-card-icon" style="background:rgba(6,214,224,0.1)"><i data-lucide="coins" class="stat-icon"></i></div>
<div class="stat-val cyan">${stats.totalCoins}</div>
<div class="stat-label">Суммарные монеты</div>
</div>
<div class="stat-card" style="--stat-top:var(--green)">
<div class="stat-card-icon" style="background:rgba(6,214,100,0.1)"><i data-lucide="bar-chart-3" class="stat-icon"></i></div>
<div class="stat-val green">${(stats.avgLevel ?? 0).toFixed(1)}</div>
<div class="stat-label">Средний уровень</div>
</div>
<div class="stat-card" style="--stat-top:var(--amber, #FFB347)">
<div class="stat-card-icon" style="background:rgba(255,179,71,0.1)"><i data-lucide="trophy" class="stat-icon"></i></div>
<div class="stat-val" style="color:var(--amber, #FFB347)">${stats.achievementCount}</div>
<div class="stat-label">Достижений выдано</div>
</div>
<div class="stat-card" style="--stat-top:#FF9F1C">
<div class="stat-card-icon" style="background:rgba(255,159,28,0.1)"><i data-lucide="shopping-bag" class="stat-icon"></i></div>
<div class="stat-val" style="color:#FF9F1C">${stats.totalPurchases || 0}</div>
<div class="stat-label">Покупок</div>
</div>`;
// Top-10
const topBody = document.getElementById('gam-top-body');
if (stats.topByXP?.length) {
topBody.innerHTML = stats.topByXP.slice(0, 10).map((u, i) => `<tr>
<td><strong>${i + 1}</strong></td>
<td>${esc(u.name || u.email || 'ID:' + (u.id || u.user_id))}</td>
<td><span style="color:var(--violet);font-weight:700">${u.xp}</span></td>
<td>${u.level}</td>
<td>${u.coins} <i data-lucide="coins" style="width:12px;height:12px;vertical-align:-2px;color:var(--amber, #FFB347)"></i></td>
</tr>`).join('');
} else {
topBody.innerHTML = '<tr><td colspan="5" class="empty">Нет данных</td></tr>';
}
// Recent XP
const XP_REASONS = {
'daily_activity': ['sun', '#F59E0B', 'Ежедневная активность'],
'correct_answers':['check-circle', '#10B981', 'Правильные ответы'],
'test_complete': ['file-text', '#06B6D4', 'Тест завершён'],
'test_90+': ['zap', '#9B5DE5', 'Тест на 90%+'],
'test_perfect': ['trophy', '#F59E0B', 'Идеальный тест (100%)'],
'lab_experiment': ['atom', '#06D6A0', 'Лабораторный эксперимент'],
'daily_goal': ['target', '#EF476F', 'Ежедневная цель выполнена'],
'Admin award': ['crown', '#9B5DE5', 'Начисление администратором'],
};
function fmtXPReason(reason) {
if (!reason) return '—';
const entry = XP_REASONS[reason];
if (entry) {
const [icon, color, label] = entry;
return `<span style="display:inline-flex;align-items:center;gap:5px"><span style="color:${color};display:inline-flex">${lsIcon(icon,14)}</span>${label}</span>`;
}
if (reason.startsWith('achievement:')) {
return `<span style="display:inline-flex;align-items:center;gap:5px"><span style="color:#F59E0B;display:inline-flex">${lsIcon('award',14)}</span>Достижение: ${esc(reason.slice(12))}</span>`;
}
if (reason.startsWith('Испытание:')) {
return `<span style="display:inline-flex;align-items:center;gap:5px"><span style="color:#EF476F;display:inline-flex">${lsIcon('swords',14)}</span>${esc(reason)}</span>`;
}
return esc(reason);
}
const logBody = document.getElementById('gam-log-body');
if (stats.recentXP?.length) {
logBody.innerHTML = stats.recentXP.slice(0, 20).map(e => `<tr>
<td style="font-size:0.78rem;color:var(--text-3)">${fmtDate(e.created_at || e.date)}</td>
<td>${esc(e.name || e.user_name || '—')}</td>
<td><span style="color:var(--violet);font-weight:700">+${e.amount}</span></td>
<td style="font-size:0.82rem;color:var(--text-2)">${fmtXPReason(e.reason)}</td>
</tr>`).join('');
} else {
logBody.innerHTML = '<tr><td colspan="4" class="empty">Нет данных</td></tr>';
}
// Purchases
const purchBody = document.getElementById('gam-purchases-body');
if (stats.recentPurchases?.length) {
purchBody.innerHTML = stats.recentPurchases.slice(0, 20).map(p => `<tr>
<td style="font-size:0.78rem;color:var(--text-3)">${fmtDate(p.purchased_at)}</td>
<td>${esc(p.user_name || '—')}</td>
<td style="font-weight:600">${esc(p.item_name || '—')}</td>
<td><span class="badge" style="font-size:0.7rem">${esc(p.type || '—')}</span></td>
<td style="color:var(--amber,#FFB347);font-weight:700">${p.price} <i data-lucide="coins" style="width:12px;height:12px;vertical-align:-2px"></i></td>
</tr>`).join('');
} else {
purchBody.innerHTML = '<tr><td colspan="5" class="empty">Нет покупок</td></tr>';
}
if (window.lucide) lucide.createIcons();
} catch(e) {
document.getElementById('gam-stats-grid').innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
let _gamSearchTimer = null;
async function gamSearchUser(q, prefix) {
clearTimeout(_gamSearchTimer);
const box = document.getElementById(prefix + '-results');
if (q.length < 2) { box.classList.remove('open'); return; }
_gamSearchTimer = setTimeout(async () => {
try {
const r = await LS.adminGetUsers({ q, limit: 8 });
box.innerHTML = (r.users || []).map(u => `<div class="us-item" onclick="gamPickUser(${u.id}, '${esc(u.name || u.email)}', '${prefix}')">
<span>${esc(u.name || u.email)}</span><span class="us-role">${u.role}</span>
</div>`).join('') || '<div class="us-item" style="color:var(--text-3)">Не найдено</div>';
box.classList.add('open');
} catch(e) { box.classList.remove('open'); }
}, 300);
}
function gamPickUser(id, name, prefix) {
document.getElementById(prefix + '-uid').value = id;
document.getElementById(prefix + '-user').value = name;
document.getElementById(prefix + '-results').classList.remove('open');
}
let _gamAwarding = false;
async function gamAdminAward() {
if (_gamAwarding) return;
const userId = parseInt(document.getElementById('gam-award-uid').value);
const xp = parseInt(document.getElementById('gam-award-xp').value) || 0;
const coins = parseInt(document.getElementById('gam-award-coins').value) || 0;
const reason = document.getElementById('gam-award-reason').value.trim();
if (!userId) { LS.toast('Выберите пользователя', 'error'); return; }
if (!xp && !coins) { LS.toast('Введите XP или монеты', 'error'); return; }
_gamAwarding = true;
try {
const r = await LS.adminGamAward({ userId, xp, coins, reason });
LS.toast(`Начислено! XP: ${r.xp}, Уровень: ${r.level}, Монеты: ${r.coins}`, 'success');
document.getElementById('gam-award-uid').value = '';
document.getElementById('gam-award-user').value = '';
document.getElementById('gam-award-reason').value = '';
gamInited = false;
loadGamAdmin();
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
finally { _gamAwarding = false; }
}
async function gamAdminReset() {
const userId = parseInt(document.getElementById('gam-reset-uid').value);
const userName = document.getElementById('gam-reset-user').value;
if (!userId) { LS.toast('Выберите пользователя', 'error'); return; }
if (!await LS.confirm(`ВСЕ XP, монеты и достижения «${userName}» будут удалены безвозвратно.`, { title: 'Сбросить прогресс?', confirmText: 'Сбросить', danger: true })) return;
try {
await LS.adminGamReset({ userId });
LS.toast('Прогресс сброшен', 'success');
document.getElementById('gam-reset-uid').value = '';
document.getElementById('gam-reset-user').value = '';
gamInited = false;
loadGamAdmin();
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
/* ════════════════════════════════════════════════
ШАБЛОНЫ (Templates)
════════════════════════════════════════════════ */
async function loadTplAdmin() {
try {
const [courses, lessons] = await Promise.all([
LS.getCourseTemplates().catch(() => []),
LS.getLessonTemplates().catch(() => [])
]);
renderTplTable('tpl-course-body', courses, 'courses');
renderTplTable('tpl-lesson-body', lessons, 'lessons');
if (window.lucide) lucide.createIcons();
} catch(e) {
document.getElementById('tpl-course-body').innerHTML = `<tr><td colspan="7" class="error">Ошибка: ${esc(e.message)}</td></tr>`;
}
}
function renderTplTable(bodyId, items, type) {
const body = document.getElementById(bodyId);
if (!items || !items.length) {
body.innerHTML = '<tr><td colspan="7" class="empty">Нет шаблонов</td></tr>';
return;
}
body.innerHTML = items.map(t => `<tr>
<td>${t.id}</td>
<td><strong>${esc(t.name || t.title || '—')}</strong></td>
<td>${esc(t.subject || '—')}</td>
<td>${esc(t.category || '—')}</td>
<td>${esc(t.author_name || t.author || '—')}</td>
<td>
<label class="adm-toggle">
<input type="checkbox" ${t.is_public ? 'checked' : ''} onchange="tplTogglePublic('${type}', ${t.id}, this.checked)" />
<span class="track"></span><span class="thumb"></span>
</label>
</td>
<td>
<button class="btn-del-q" onclick="tplDelete('${type}', ${t.id})">Удалить</button>
</td>
</tr>`).join('');
}
async function tplTogglePublic(type, id, isPublic) {
try {
const endpoint = type === 'courses' ? '/api/templates/courses/' : '/api/templates/lessons/';
await LS.api(endpoint + id, { method: 'PUT', body: JSON.stringify({ is_public: isPublic ? 1 : 0 }) });
LS.toast(isPublic ? 'Шаблон опубликован' : 'Шаблон скрыт', 'success');
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
async function tplDelete(type, id) {
if (!confirm('Удалить шаблон #' + id + '?')) return;
try {
if (type === 'courses') await LS.deleteCourseTemplate(id);
else await LS.deleteLessonTemplate(id);
LS.toast('Шаблон удалён', 'success');
tplInited = false;
loadTplAdmin();
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
/* ════════════════════════════════════════════════
СИМУЛЯЦИИ
════════════════════════════════════════════════ */
// Full list of available (non-null id) sims mirrored from /lab
const ADMIN_SIMS = [
{ id: 'graph', cat: 'Математика', title: 'График функции' },
{ id: 'graphtransform', cat: 'Математика', title: 'Трансформации графиков' },
{ id: 'geometry', cat: 'Математика', title: 'Планиметрия' },
{ id: 'triangle', cat: 'Математика', title: 'Геометрия треугольника' },
{ id: 'quadratic', cat: 'Математика', title: 'Корни квадратного уравнения' },
{ id: 'stereo', cat: 'Математика', title: 'Стереометрия 3D' },
{ id: 'probability', cat: 'Математика', title: 'Теория вероятностей' },
{ id: 'trigcircle', cat: 'Математика', title: 'Тригонометрическая окружность' },
{ id: 'normaldist', cat: 'Математика', title: 'Нормальное распределение' },
{ id: 'projectile', cat: 'Физика', title: 'Бросок тела' },
{ id: 'pendulum', cat: 'Физика', title: 'Маятник' },
{ id: 'collision', cat: 'Физика', title: 'Столкновение шаров' },
{ id: 'magnetic', cat: 'Физика', title: 'Магнитное поле токов' },
{ id: 'circuit', cat: 'Физика', title: 'Электрические цепи' },
{ id: 'coulomb', cat: 'Физика', title: 'Закон Кулона' },
{ id: 'hydrostatics', cat: 'Физика', title: 'Гидростатика' },
{ id: 'dynamics', cat: 'Физика', title: 'Динамика' },
{ id: 'thinlens', cat: 'Физика', title: 'Тонкая линза' },
{ id: 'refraction', cat: 'Физика', title: 'Преломление света' },
{ id: 'mirrors', cat: 'Физика', title: 'Зеркала' },
{ id: 'isoprocess', cat: 'Физика', title: 'Изопроцессы' },
{ id: 'waves', cat: 'Физика', title: 'Волны и звук' },
{ id: 'molphys', cat: 'Химия', title: 'Молекулярная физика' },
{ id: 'chemistry', cat: 'Химия', title: 'Химические реакции' },
{ id: 'equilibrium', cat: 'Химия', title: 'Химическое равновесие' },
{ id: 'electrolysis', cat: 'Химия', title: 'Электролиз' },
{ id: 'bohratom', cat: 'Химия', title: 'Атом Бора' },
{ id: 'orbitals', cat: 'Химия', title: 'Молекулярные орбитали' },
{ id: 'titration', cat: 'Химия', title: 'pH и кривая титрования' },
{ id: 'chemsandbox', cat: 'Химия', title: 'Химическая песочница' },
{ id: 'crystal', cat: 'Химия', title: 'Кристаллическая решётка' },
{ id: 'celldivision', cat: 'Биология', title: 'Деление клетки' },
{ id: 'photosynthesis', cat: 'Биология', title: 'Фотосинтез и дыхание' },
{ id: 'angrybirds', cat: 'Игры', title: 'Angry Birds Physics' },
];
let _simsSettings = { module_disabled: false, disabled_ids: [] };
async function loadSimsAdmin() {
try {
const data = await LS.api('/api/settings/sims');
_simsSettings = data;
_renderSimsAdmin();
} catch(e) { LS.toast('Ошибка загрузки настроек: ' + e.message, 'error'); }
}
function _renderSimsAdmin() {
// master toggle
const masterChk = document.getElementById('sims-master-chk');
if (masterChk) masterChk.checked = !_simsSettings.module_disabled;
// per-sim cards
const grid = document.getElementById('sims-grid');
const dis = new Set(_simsSettings.disabled_ids || []);
// group by category
const byCat = {};
ADMIN_SIMS.forEach(s => { (byCat[s.cat] = byCat[s.cat] || []).push(s); });
let html = '';
Object.entries(byCat).forEach(([cat, sims]) => {
html += `<div style="grid-column:1/-1;font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--text-3);margin-top:12px;margin-bottom:2px">${esc(cat)}</div>`;
sims.forEach(s => {
const enabled = !dis.has(s.id);
html += `<div class="perm-card${enabled ? ' enabled' : ''}" id="simcard-${s.id}">
<div class="perm-info">
<div class="perm-label">${esc(s.title)}</div>
<div class="perm-desc" style="font-size:11px;margin-top:2px;opacity:.7">${esc(s.id)}</div>
</div>
<label class="perm-toggle" title="${enabled ? 'Отключить' : 'Включить'}">
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="simToggleOne('${s.id}', this.checked)" />
<span class="perm-track"></span>
<span class="perm-thumb"></span>
</label>
</div>`;
});
});
grid.innerHTML = html;
if (window.lucide) lucide.createIcons();
}
async function simsMasterToggle(checked) {
// checked = module enabled; disabled = !checked
try {
await LS.api('/api/settings/sims', { method: 'PUT', body: JSON.stringify({ module_disabled: !checked }) });
_simsSettings.module_disabled = !checked;
LS.toast(checked ? 'Модуль симуляций включён' : 'Модуль симуляций отключён', checked ? 'success' : 'warning');
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
async function simToggleOne(simId, enabled) {
const dis = new Set(_simsSettings.disabled_ids || []);
if (enabled) dis.delete(simId); else dis.add(simId);
const disabled_ids = [...dis];
try {
await LS.api('/api/settings/sims', { method: 'PUT', body: JSON.stringify({ disabled_ids }) });
_simsSettings.disabled_ids = disabled_ids;
// update card style
const card = document.getElementById('simcard-' + simId);
if (card) card.classList.toggle('enabled', enabled);
LS.toast(enabled ? ${simId}» включена` : ${simId}» отключена`, enabled ? 'success' : 'warning');
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
/* ─── Games features admin ─── */
const GAME_FEATURES = [
{ key: 'hangman', label: 'Виселица', desc: 'Игра «Угадай слово» — отгадывание терминов по буквам', icon: 'gamepad-2' },
{ key: 'crossword', label: 'Кроссворд', desc: 'Кроссворд из терминов — генерируется автоматически по темам', icon: 'grid-3x3' },
{ key: 'pet', label: 'Питомец', desc: 'Виртуальный питомец, отражающий активность ученика', icon: 'heart' },
{ key: 'red_book', label: 'Красная книга', desc: 'Интерактивная Красная книга РБ: виды, биомы, пищевые сети, квесты', icon: 'leaf' },
{ key: 'collection', label: 'Коллекция', desc: 'Коллекция карточек и достижений — игровой прогресс ученика', icon: 'layers' },
{ key: 'flashcards', label: 'Флеш-карточки', desc: 'Карточки для запоминания терминов и понятий методом интервальных повторений', icon: 'square-stack' },
{ key: 'knowledge_map', label: 'Карта знаний', desc: 'Визуальная карта тем и связей между биологическими понятиями', icon: 'share-2' },
{ key: 'board', label: 'Доска', desc: 'Классная доска с объявлениями, постами и обсуждениями', icon: 'layout-dashboard'},
{ key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' },
{ key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' },
];
async function loadGamesAdmin() {
const grid = document.getElementById('games-features-grid');
try {
const features = await LS.api('/api/admin/features');
grid.innerHTML = '';
for (const f of GAME_FEATURES) {
const enabled = features[f.key] !== false;
const card = document.createElement('div');
card.className = 'perm-card' + (enabled ? ' enabled' : '');
card.innerHTML = `
<div class="perm-info">
<div class="perm-label"><i data-lucide="${f.icon}" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px"></i>${f.label}</div>
<div class="perm-desc">${f.desc}</div>
</div>
<label class="perm-toggle">
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="toggleGameFeature('${f.key}', this.checked, this)" />
<span class="perm-track"></span>
<span class="perm-thumb"></span>
</label>`;
grid.appendChild(card);
}
if (window.lucide) lucide.createIcons();
} catch(e) {
grid.innerHTML = '<div class="error">Ошибка загрузки</div>';
}
}
async function toggleGameFeature(key, enabled, checkbox) {
try {
await LS.api('/api/admin/features', {
method: 'PATCH',
body: JSON.stringify({ [key]: enabled }),
});
const card = checkbox.closest('.perm-card');
if (card) card.classList.toggle('enabled', enabled);
LS.toast(enabled ? 'Функция включена' : 'Функция отключена', 'success');
} catch(e) {
checkbox.checked = !enabled;
LS.toast('Ошибка: ' + e.message, 'error');
}
}
/* ─── Free-student module features ─── */
const FS_FEATURES = [
{ key: 'gamification', label: 'Геймификация', desc: 'XP, уровни, достижения, монеты, стрики, магазин', icon: 'trophy' },
{ key: 'hangman', label: 'Виселица', desc: 'Игра «Угадай слово» — отгадывание терминов по буквам', icon: 'gamepad-2' },
{ key: 'crossword', label: 'Кроссворд', desc: 'Кроссворд из терминов — генерируется автоматически', icon: 'grid-3x3' },
{ key: 'pet', label: 'Питомец', desc: 'Виртуальный питомец, отражающий активность ученика', icon: 'heart' },
{ key: 'red_book', label: 'Красная книга', desc: 'Интерактивная Красная книга РБ: виды, биомы, квесты', icon: 'leaf' },
{ key: 'collection', label: 'Коллекция', desc: 'Коллекция карточек и игровой прогресс ученика', icon: 'layers' },
{ key: 'lab', label: 'Лаборатория', desc: 'Виртуальные симуляции и интерактивные опыты', icon: 'flask-conical' },
{ key: 'knowledge_map',label: 'Карта знаний', desc: 'Визуальная карта тем и связей между понятиями', icon: 'map' },
{ key: 'flashcards', label: 'Флеш-карточки', desc: 'Карточки для повторения терминов и понятий', icon: 'square-stack' },
{ key: 'board', label: 'Доска', desc: 'Классная доска с объявлениями и постами', icon: 'layout-dashboard' },
{ key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' },
{ key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' },
];
async function loadFsFeatures() {
const grid = document.getElementById('fs-features-grid');
try {
const features = await LS.api('/api/admin/free-student-features');
grid.innerHTML = '';
for (const f of FS_FEATURES) {
const enabled = features[f.key] !== false;
const card = document.createElement('div');
card.className = 'perm-card' + (enabled ? ' enabled' : '');
card.innerHTML = `
<div class="perm-info">
<div class="perm-label"><i data-lucide="${f.icon}" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px"></i>${f.label}</div>
<div class="perm-desc">${f.desc}</div>
</div>
<label class="perm-toggle">
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="toggleFsFeature('${f.key}', this.checked, this)" />
<span class="perm-track"></span>
<span class="perm-thumb"></span>
</label>`;
grid.appendChild(card);
}
if (window.lucide) lucide.createIcons();
} catch(e) {
grid.innerHTML = '<div class="error">Ошибка загрузки</div>';
}
}
async function toggleFsFeature(key, enabled, checkbox) {
try {
await LS.api('/api/admin/free-student-features', {
method: 'PATCH',
body: JSON.stringify({ [key]: enabled }),
});
const card = checkbox.closest('.perm-card');
if (card) card.classList.toggle('enabled', enabled);
LS.toast(enabled ? 'Модуль включён' : 'Модуль отключён', 'success');
} catch(e) {
checkbox.checked = !enabled;
LS.toast('Ошибка: ' + e.message, 'error');
}
}
/* ─── Submission log ─── */
const SL_STATUSES = { new:'На проверке', reviewed:'Проверено', accepted:'Принято', revision:'На доработке', resubmitted:'Повторно' };
async function loadSubmissionLog() {
const el = document.getElementById('sublog-list');
const countEl = document.getElementById('sublog-count');
const classId = document.getElementById('sublog-class-filter').value;
el.innerHTML = '<div class="spinner"></div>';
countEl.textContent = '';
try {
const url = classId ? `/api/submissions/log?class_id=${classId}` : '/api/submissions/log';
const rows = await LS.api(url);
// Populate class filter on first load
const sel = document.getElementById('sublog-class-filter');
if (sel.options.length <= 1 && rows.length) {
const classMap = new Map();
rows.forEach(r => { if (r.class_id && r.class_name) classMap.set(r.class_id, r.class_name); });
classMap.forEach((name, id) => {
const opt = document.createElement('option');
opt.value = id; opt.textContent = name;
sel.appendChild(opt);
});
}
countEl.textContent = rows.length ? `${rows.length} записей` : '';
if (!rows.length) {
el.innerHTML = `<div class="sl-empty">
<div class="sl-empty-icon"><i data-lucide="inbox" style="width:48px;height:48px"></i></div>
Удалённых работ нет
</div>`;
if (window.lucide) lucide.createIcons({ nodes: [el] });
return;
}
const ROLE_LABELS = { admin: 'Админ', teacher: 'Учитель', student: 'Ученик' };
el.innerHTML = `<div class="sl-wrap"><table class="sl-table">
<thead><tr>
<th>Дата</th>
<th>Ученик</th>
<th>Файл</th>
<th>Задание</th>
<th>Класс</th>
<th>Статус</th>
<th>Оценка</th>
<th>Удалил</th>
</tr></thead>
<tbody>${rows.map(r => {
const dt = r.deleted_at ? new Date(r.deleted_at.includes('T') ? r.deleted_at : r.deleted_at.replace(' ','T')+'Z') : null;
const dateStr = dt ? dt.toLocaleDateString('ru',{day:'numeric',month:'short'}) + ' ' + dt.toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'}) : '—';
const initials = (r.student_name || '?').split(' ').slice(0,2).map(w => w[0]?.toUpperCase() || '').join('');
const st = r.status || 'new';
const gradeVal = r.grade != null ? r.grade : null;
const gradeCls = gradeVal != null ? (gradeVal >= 80 ? 'sl-grade-hi' : gradeVal >= 50 ? 'sl-grade-mid' : 'sl-grade-lo') : 'sl-grade-none';
const roleCls = 'sl-role-' + (r.deleted_by_role || 'student');
return `<tr>
<td><span class="sl-date">${dateStr}</span></td>
<td><span class="sl-student"><span class="sl-student-avatar">${initials}</span>${esc(r.student_name || '—')}</span></td>
<td><span class="sl-file" title="${esc(r.original_name || '')}">${esc(r.original_name || '—')}</span></td>
<td><span class="sl-assignment">${esc(r.assignment_title || '—')}</span></td>
<td><span class="sl-class">${esc(r.class_name || '—')}</span></td>
<td><span class="sl-status sl-status-${st}">${SL_STATUSES[st] || st}</span></td>
<td><span class="sl-grade ${gradeCls}">${gradeVal != null ? gradeVal : '—'}</span></td>
<td><span class="sl-deleted-by">${esc(r.deleted_by_name || '—')} <span class="sl-role-badge ${roleCls}">${ROLE_LABELS[r.deleted_by_role] || r.deleted_by_role || '?'}</span></span></td>
</tr>`;
}).join('')}</tbody>
</table></div>`;
document.getElementById('btn-clear-sublog').style.display = '';
} catch (e) {
el.innerHTML = `<div class="sl-empty" style="color:#c0306a">Ошибка: ${esc(e.message)}</div>`;
}
}
async function clearSubmissionLog() {
if (!await LS.confirm('Очистить весь журнал удалённых работ? Это действие необратимо.', { title: 'Очистка журнала', confirmText: 'Очистить', danger: true })) return;
try {
await LS.api('/api/submissions/log', { method: 'DELETE' });
document.getElementById('btn-clear-sublog').style.display = 'none';
document.getElementById('sublog-count').textContent = '';
document.getElementById('sublog-list').innerHTML = `<div class="sl-empty">
<div class="sl-empty-icon"><i data-lucide="inbox" style="width:48px;height:48px"></i></div>
Журнал очищен
</div>`;
if (window.lucide) lucide.createIcons({ nodes: [document.getElementById('sublog-list')] });
LS.toast('Журнал очищен', 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
/* ═══ TOPICS ═══════════════════════════════════════════════════════ */
let _topicsSubjects = [];
async function loadTopicSubjects() {
if (_topicsSubjects.length) return;
try {
_topicsSubjects = await LS.getSubjects();
const sel = document.getElementById('topics-subj-filter');
sel.innerHTML = _topicsSubjects.map(s => `<option value="${s.id}">${esc(s.name)}</option>`).join('');
} catch {}
}
async function loadTopics() {
await loadTopicSubjects();
const subjId = document.getElementById('topics-subj-filter').value;
const el = document.getElementById('topics-list');
el.innerHTML = LS.skeleton(4, 'row');
try {
const rows = await LS.api(`/api/admin/topics?subject_id=${subjId}`);
document.getElementById('topics-count').textContent = rows.length + ' тем';
if (!rows.length) { el.innerHTML = '<div style="padding:32px;text-align:center;color:#8898AA">Тем нет</div>'; return; }
el.innerHTML = '<div style="display:flex;flex-direction:column;gap:6px">' + rows.map(t => `
<div class="adm-panel" style="padding:12px 18px;margin:0;display:flex;align-items:center;gap:14px">
<span style="font-size:0.75rem;color:var(--text-3);font-weight:700;min-width:28px">#${t.order_index}</span>
<span style="flex:1;font-weight:600">${esc(t.name)}</span>
<span style="font-size:0.78rem;color:var(--text-3)">${t.question_count} вопр.</span>
<button class="adm-btn adm-btn-small" style="background:var(--border-h);color:var(--text-2);padding:5px 12px" onclick="renameTopic(${t.id},'${esc(t.name).replace(/'/g,"\\'")}')">
<i data-lucide="pencil" style="width:12px;height:12px;vertical-align:-1px"></i>
</button>
<button class="adm-btn adm-btn-small" style="background:rgba(241,91,181,0.1);color:var(--pink);padding:5px 12px" onclick="deleteTopic(${t.id},'${esc(t.name).replace(/'/g,"\\'")}',${t.question_count})">
<i data-lucide="trash-2" style="width:12px;height:12px;vertical-align:-1px"></i>
</button>
</div>`).join('') + '</div>';
if (window.lucide) lucide.createIcons({ nodes: [el] });
} catch (e) { el.innerHTML = `<div style="color:var(--pink)">${esc(e.message)}</div>`; }
}
function showAddTopic() { document.getElementById('topics-add-row').style.display = ''; document.getElementById('topics-new-name').focus(); }
async function createTopic() {
const name = document.getElementById('topics-new-name').value.trim();
if (!name) return;
const subjId = document.getElementById('topics-subj-filter').value;
try {
await LS.api('/api/admin/topics', { method:'POST', body: JSON.stringify({ subject_id: subjId, name }) });
document.getElementById('topics-new-name').value = '';
document.getElementById('topics-add-row').style.display = 'none';
LS.toast('Тема создана', 'success');
loadTopics();
} catch (e) { LS.toast(e.message, 'error'); }
}
async function renameTopic(id, oldName) {
const name = prompt('Новое название темы:', oldName);
if (!name || name === oldName) return;
try {
await LS.api(`/api/admin/topics/${id}`, { method:'PATCH', body: JSON.stringify({ name }) });
LS.toast('Тема переименована', 'success');
loadTopics();
} catch (e) { LS.toast(e.message, 'error'); }
}
async function deleteTopic(id, name, qcount) {
if (qcount > 0) { LS.toast(`Нельзя удалить тему с ${qcount} вопросами`, 'warn'); return; }
if (!await LS.confirm(`Удалить тему "${name}"?`, { danger: true })) return;
try {
await LS.api(`/api/admin/topics/${id}`, { method:'DELETE' });
LS.toast('Тема удалена', 'success');
loadTopics();
} catch (e) { LS.toast(e.message, 'error'); }
}
/* ═══ BROADCAST ═════════════════════════════════════════════════════ */
async function sendBroadcast() {
const message = document.getElementById('bc-message').value.trim();
if (!message) { LS.toast('Введите сообщение', 'warn'); return; }
const role = document.getElementById('bc-role').value;
const link = document.getElementById('bc-link').value.trim() || null;
try {
const r = await LS.api('/api/admin/broadcast', { method:'POST', body: JSON.stringify({ message, role, link }) });
document.getElementById('bc-result').textContent = `Отправлено ${r.sent} пользователям`;
document.getElementById('bc-message').value = '';
LS.toast(`Уведомление отправлено ${r.sent} пользователям`, 'success');
} catch (e) { LS.toast(e.message, 'error'); }
}
/* ═══ AUDIT LOG ════════════════════════════════════════════════════ */
async function loadAuditLog() {
const el = document.getElementById('audit-list');
el.innerHTML = LS.skeleton(5, 'row');
try {
const rows = await LS.api('/api/admin/audit-log?limit=200');
if (!rows.length) { el.innerHTML = '<div style="padding:32px;text-align:center;color:#8898AA">Журнал пуст</div>'; return; }
const ACTION_LABELS = {
'user.role_change': 'Смена роли', 'user.edit': 'Редактирование', 'user.ban': 'Блокировка',
'user.unban': 'Разблокировка', 'user.delete': 'Удаление', 'user.clear_sessions': 'Очистка истории',
'features.update': 'Фичи обновлены', 'topic.create': 'Создание темы', 'topic.update': 'Редакт. темы',
'topic.delete': 'Удаление темы', 'broadcast': 'Рассылка',
};
const ACTION_COLORS = {
'user.delete': 'var(--pink)', 'user.ban': 'var(--pink)', 'user.clear_sessions': 'var(--amber)',
};
el.innerHTML = `<div class="sl-wrap"><table class="sl-table">
<thead><tr><th>Дата</th><th>Админ</th><th>Действие</th><th>Цель</th><th>Детали</th><th>IP</th></tr></thead>
<tbody>${rows.map(r => {
const dt = new Date(r.created_at);
const ds = dt.toLocaleDateString('ru',{day:'numeric',month:'short'}) + ' ' + dt.toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'});
const acol = ACTION_COLORS[r.action] || 'var(--violet)';
return `<tr>
<td><span class="sl-date">${ds}</span></td>
<td>${esc(r.admin_name || '—')}</td>
<td><span style="color:${acol};font-weight:700;font-size:0.82rem">${ACTION_LABELS[r.action] || r.action}</span></td>
<td style="font-size:0.82rem;color:var(--text-3)">${esc(r.target || '')}</td>
<td style="font-size:0.82rem;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(r.detail || '')}">${esc(r.detail || '')}</td>
<td style="font-size:0.78rem;color:var(--text-3);font-family:monospace">${esc(r.ip || '')}</td>
</tr>`;
}).join('')}</tbody></table></div>`;
} catch (e) { el.innerHTML = `<div style="color:var(--pink)">${esc(e.message)}</div>`; }
}
async function clearAuditLog() {
if (!await LS.confirm('Очистить весь аудит-лог?', { danger: true })) return;
try {
await LS.api('/api/admin/audit-log', { method:'DELETE' });
document.getElementById('audit-list').innerHTML = '<div style="padding:32px;text-align:center;color:#8898AA">Журнал очищен</div>';
LS.toast('Журнал очищен', 'success');
} catch (e) { LS.toast(e.message, 'error'); }
}
/* ═══ ERROR LOG ════════════════════════════════════════════════════ */
async function loadErrorLog() {
const el = document.getElementById('errors-list');
el.innerHTML = LS.skeleton(3, 'row');
try {
const rows = await LS.api('/api/admin/error-log?limit=200');
if (!rows.length) { el.innerHTML = '<div style="padding:32px;text-align:center;color:#8898AA;font-size:0.88rem">Ошибок нет</div>'; return; }
el.innerHTML = rows.map(r => {
const dt = new Date(r.created_at);
const ds = dt.toLocaleDateString('ru',{day:'numeric',month:'short'}) + ' ' + dt.toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'});
return `<div class="adm-panel" style="padding:14px 18px;margin-bottom:8px;border-left:3px solid var(--pink)">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">
<span style="font-size:0.78rem;color:var(--pink);font-weight:700">${r.method || ''} ${esc(r.route || '')}</span>
<span style="font-size:0.72rem;color:var(--text-3);margin-left:auto">${ds}</span>
${r.user_id ? `<span style="font-size:0.72rem;color:var(--text-3)">user:${r.user_id}</span>` : ''}
</div>
<div style="font-size:0.88rem;font-weight:600;color:var(--text);margin-bottom:4px">${esc(r.message)}</div>
${r.stack ? `<details><summary style="font-size:0.75rem;color:var(--text-3);cursor:pointer">Stack trace</summary><pre style="font-size:0.72rem;color:var(--text-3);white-space:pre-wrap;max-height:200px;overflow:auto;margin-top:6px;padding:8px;background:rgba(0,0,0,0.02);border-radius:8px">${esc(r.stack)}</pre></details>` : ''}
</div>`;
}).join('');
} catch (e) { el.innerHTML = `<div style="color:var(--pink)">${esc(e.message)}</div>`; }
}
async function clearErrorLog() {
if (!await LS.confirm('Очистить журнал ошибок?', { danger: true })) return;
try {
await LS.api('/api/admin/error-log', { method:'DELETE' });
document.getElementById('errors-list').innerHTML = '<div style="padding:32px;text-align:center;color:#8898AA">Журнал очищен</div>';
LS.toast('Журнал очищен', 'success');
} catch (e) { LS.toast(e.message, 'error'); }
}
/* ═══ SYSTEM HEALTH ════════════════════════════════════════════════ */
async function loadHealth() {
const el = document.getElementById('health-content');
el.innerHTML = LS.skeleton(3, 'row');
try {
const h = await LS.api('/api/admin/health');
const fmtBytes = b => b > 1e9 ? (b/1e9).toFixed(1)+' GB' : b > 1e6 ? (b/1e6).toFixed(1)+' MB' : (b/1e3).toFixed(0)+' KB';
const fmtUp = s => { const d=Math.floor(s/86400), hr=Math.floor(s%86400/3600), m=Math.floor(s%3600/60); return d>0?`${d}d ${hr}h`:hr>0?`${hr}h ${m}m`:`${m}m`; };
el.innerHTML = `
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px;margin-bottom:24px">
<div class="adm-panel" style="padding:18px;margin:0;text-align:center">
<div style="font-size:1.3rem;font-weight:800;font-family:'Unbounded',sans-serif;color:var(--green)">${fmtUp(h.uptime)}</div>
<div style="font-size:0.72rem;color:var(--text-3);font-weight:700;text-transform:uppercase;margin-top:4px">Uptime</div>
</div>
<div class="adm-panel" style="padding:18px;margin:0;text-align:center">
<div style="font-size:1.3rem;font-weight:800;font-family:'Unbounded',sans-serif;color:var(--violet)">${fmtBytes(h.db.sizeBytes)}</div>
<div style="font-size:0.72rem;color:var(--text-3);font-weight:700;text-transform:uppercase;margin-top:4px">База данных</div>
</div>
<div class="adm-panel" style="padding:18px;margin:0;text-align:center">
<div style="font-size:1.3rem;font-weight:800;font-family:'Unbounded',sans-serif">${fmtBytes(h.uploads.sizeBytes)}</div>
<div style="font-size:0.72rem;color:var(--text-3);font-weight:700;text-transform:uppercase;margin-top:4px">Файлы</div>
</div>
<div class="adm-panel" style="padding:18px;margin:0;text-align:center">
<div style="font-size:1.3rem;font-weight:800;font-family:'Unbounded',sans-serif;color:${h.recentErrors>0?'var(--pink)':'var(--green)'}">${h.recentErrors}</div>
<div style="font-size:0.72rem;color:var(--text-3);font-weight:700;text-transform:uppercase;margin-top:4px">Ошибок за 24ч</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px">
<div class="adm-panel" style="margin:0">
<div class="adm-panel-title">Платформа</div>
<table style="width:100%;font-size:0.88rem">
<tr><td style="color:var(--text-3);padding:4px 0">Node.js</td><td style="font-weight:600">${h.node}</td></tr>
<tr><td style="color:var(--text-3);padding:4px 0">OS</td><td style="font-weight:600">${h.platform}</td></tr>
<tr><td style="color:var(--text-3);padding:4px 0">CPU ядра</td><td style="font-weight:600">${h.cpus}</td></tr>
<tr><td style="color:var(--text-3);padding:4px 0">RAM использовано</td><td style="font-weight:600">${fmtBytes(h.memory.rss)}</td></tr>
<tr><td style="color:var(--text-3);padding:4px 0">RAM heap</td><td style="font-weight:600">${fmtBytes(h.memory.heapUsed)}</td></tr>
<tr><td style="color:var(--text-3);padding:4px 0">RAM свободно</td><td style="font-weight:600">${fmtBytes(h.freeMem)} / ${fmtBytes(h.totalMem)}</td></tr>
</table>
</div>
<div class="adm-panel" style="margin:0">
<div class="adm-panel-title">Данные</div>
<table style="width:100%;font-size:0.88rem">
<tr><td style="color:var(--text-3);padding:4px 0">Пользователей</td><td style="font-weight:600">${h.db.totalUsers}</td></tr>
<tr><td style="color:var(--text-3);padding:4px 0">Всего сессий</td><td style="font-weight:600">${h.db.totalSessions}</td></tr>
<tr><td style="color:var(--text-3);padding:4px 0">Сессий сегодня</td><td style="font-weight:600;color:var(--violet)">${h.db.todaySessions}</td></tr>
<tr><td style="color:var(--text-3);padding:4px 0">Вопросов в базе</td><td style="font-weight:600">${h.db.totalQuestions}</td></tr>
</table>
</div>
</div>`;
} catch (e) { el.innerHTML = `<div style="color:var(--pink)">${esc(e.message)}</div>`; }
}
/* ════════════════════════════════════════════════
ОНЛАЙН-УРОКИ (classroom admin)
════════════════════════════════════════════════ */
let _crHistPage = 1, _crHistTotal = 0, _crHistPages = 0, _crHistSearch = '';
let _crOpenDetailId = null, _crHistDebTimer = null;
async function loadCrModuleState() {
try {
const features = await LS.api('/api/admin/features');
const chk = document.getElementById('cr-master-chk');
if (chk) chk.checked = features.classroom !== false;
} catch(e) { /* silent */ }
}
async function crMasterToggle(enabled) {
try {
await LS.api('/api/admin/features', { method: 'PATCH', body: JSON.stringify({ classroom: enabled }) });
LS.toast(enabled ? 'Модуль онлайн-уроков включён' : 'Модуль онлайн-уроков отключён', enabled ? 'success' : 'warning', 3000);
} catch(e) {
LS.toast('Ошибка: ' + e.message, 'error');
// revert checkbox
const chk = document.getElementById('cr-master-chk');
if (chk) chk.checked = !enabled;
}
}
function fmtDuration(sec) {
if (!sec || sec < 0) return '—';
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
if (h) return `${h}ч ${m}м`;
if (m) return `${m} мин ${s} сек`;
return `${s} сек`;
}
function fmtLiveDuration(createdAt) {
const sec = Math.round((Date.now() - new Date(createdAt).getTime()) / 1000);
return fmtDuration(sec);
}
async function loadCrActiveSessions() {
const el = document.getElementById('cr-live-list');
try {
const { sessions } = await LS.api('/api/classroom/admin/active');
if (!sessions.length) {
el.innerHTML = '<div class="empty">Нет активных уроков</div>';
return;
}
el.innerHTML = sessions.map(s => {
const dur = fmtLiveDuration(s.created_at);
const title = s.title || `Урок #${s.id}`;
const cls = s.class_name ? `Класс: ${esc(s.class_name)}` : 'Личный урок';
return `<div class="cr-live-card">
<div class="cr-live-pulse"></div>
<div class="cr-live-info">
<div class="cr-live-title">${esc(title)}</div>
<div class="cr-live-meta">${esc(s.teacher_name)} · ${cls}</div>
</div>
<div class="cr-live-badges">
<span class="cr-badge cr-badge-online">
<svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
${s.online_count}
</span>
<span class="cr-badge cr-badge-msgs">
<svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
${s.message_count}
</span>
<span class="cr-badge cr-badge-dur">
<svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
${dur}
</span>
</div>
<div class="cr-live-actions">
<button class="btn-cr-end" onclick="adminEndSession(${s.id})">Завершить</button>
</div>
</div>`;
}).join('');
} catch(e) {
el.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
if (window.lucide) lucide.createIcons();
}
async function adminEndSession(id) {
if (!await LS.confirm(`Завершить урок #${id}? Все участники будут отключены.`, { title: 'Завершить урок', confirmText: 'Завершить' })) return;
try {
await LS.api(`/api/classroom/${id}`, { method: 'DELETE' });
LS.toast('Урок завершён', 'success', 2500);
loadCrActiveSessions();
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
function crHistDebounce() {
clearTimeout(_crHistDebTimer);
_crHistDebTimer = setTimeout(() => { _crHistPage = 1; loadCrHistory(); }, 350);
}
async function loadCrHistory(page) {
if (page) _crHistPage = page;
_crHistSearch = (document.getElementById('cr-hist-q')?.value || '').trim();
const el = document.getElementById('cr-hist-list');
el.innerHTML = '<div class="spinner"></div>';
try {
const params = new URLSearchParams({ page: _crHistPage, limit: 20 });
if (_crHistSearch) params.set('search', _crHistSearch);
const { sessions, total, pages } = await LS.api('/api/classroom/admin/sessions?' + params);
_crHistTotal = total; _crHistPages = pages;
document.getElementById('cr-hist-count').textContent = `${total} уроков`;
if (!sessions.length) {
el.innerHTML = '<div class="empty">Нет завершённых уроков</div>';
renderCrPagination();
return;
}
el.innerHTML = sessions.map(s => {
const title = s.title || `Урок #${s.id}`;
const cls = s.class_name ? `Класс: ${esc(s.class_name)}` : 'Личный урок';
const dur = fmtDuration(s.ended_at ? Math.round((new Date(s.ended_at)-new Date(s.created_at))/1000) : null);
return `<div>
<div class="cr-hist-row${_crOpenDetailId===s.id?' open':''}" onclick="toggleCrDetail(${s.id},this)">
<div class="cr-hist-icon">
<svg class="ic" viewBox="0 0 24 24" style="width:18px;height:18px;color:var(--violet)"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<div class="cr-hist-main">
<div class="cr-hist-title">${esc(title)}</div>
<div class="cr-hist-meta">${esc(s.teacher_name)} · ${cls} · ${fmtDate(s.ended_at || s.created_at)}</div>
</div>
<div class="cr-hist-chips">
<span class="cr-badge cr-badge-online">${s.participant_count} уч.</span>
<span class="cr-badge cr-badge-msgs">${s.message_count} сообщ.</span>
<span class="cr-badge cr-badge-dur">${dur}</span>
</div>
<svg class="cr-hist-chevron ic" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="cr-detail-drawer${_crOpenDetailId===s.id?' open':''}" id="cr-detail-${s.id}">
<div class="cr-detail-inner" id="cr-detail-inner-${s.id}">
<div class="spinner"></div>
</div>
</div>
</div>`;
}).join('');
if (_crOpenDetailId) {
const dr = document.getElementById(`cr-detail-${_crOpenDetailId}`);
if (dr) loadCrSessionDetail(_crOpenDetailId);
}
renderCrPagination();
} catch(e) {
el.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
if (window.lucide) lucide.createIcons();
}
function renderCrPagination() {
const el = document.getElementById('cr-hist-pagination');
if (_crHistPages <= 1) { el.innerHTML = ''; return; }
const p = _crHistPage, total = _crHistPages;
let html = '<div class="cr-pagination">';
html += `<button class="cr-page-btn" onclick="loadCrHistory(${p-1})" ${p<=1?'disabled':''}>
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><polyline points="15 18 9 12 15 6"/></svg>
</button>`;
const range = [];
for (let i=1;i<=total;i++) {
if (i===1||i===total||Math.abs(i-p)<=1) range.push(i);
else if (range[range.length-1]!=='…') range.push('…');
}
range.forEach(r => {
if (r==='…') html += `<span class="cr-page-info">…</span>`;
else html += `<button class="cr-page-btn${r===p?' active':''}" onclick="loadCrHistory(${r})">${r}</button>`;
});
html += `<button class="cr-page-btn" onclick="loadCrHistory(${p+1})" ${p>=total?'disabled':''}>
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><polyline points="9 18 15 12 9 6"/></svg>
</button></div>`;
el.innerHTML = html;
}
async function toggleCrDetail(id, rowEl) {
const wasOpen = _crOpenDetailId === id;
// close all
document.querySelectorAll('.cr-hist-row.open').forEach(r => r.classList.remove('open'));
document.querySelectorAll('.cr-detail-drawer.open').forEach(d => { d.classList.remove('open'); d.style.maxHeight=''; });
_crOpenDetailId = null;
if (wasOpen) return;
// open this one
rowEl.classList.add('open');
const dr = document.getElementById(`cr-detail-${id}`);
if (dr) { dr.classList.add('open'); }
_crOpenDetailId = id;
await loadCrSessionDetail(id);
}
async function loadCrSessionDetail(id) {
const inner = document.getElementById(`cr-detail-inner-${id}`);
if (!inner) return;
inner.innerHTML = '<div class="spinner"></div>';
try {
const { session, stats, attendance, pages } = await LS.api(`/api/classroom/${id}/summary`);
const dur = fmtDuration(stats.duration_sec);
inner.innerHTML = `
<div class="cr-detail-grid">
<div class="cr-detail-stat"><div class="cr-detail-val">${stats.participant_count}</div><div class="cr-detail-label">Участников</div></div>
<div class="cr-detail-stat"><div class="cr-detail-val">${stats.message_count}</div><div class="cr-detail-label">Сообщений</div></div>
<div class="cr-detail-stat"><div class="cr-detail-val">${stats.page_count}</div><div class="cr-detail-label">Страниц</div></div>
<div class="cr-detail-stat"><div class="cr-detail-val" style="font-size:1rem">${dur}</div><div class="cr-detail-label">Длительность</div></div>
</div>
${attendance.length ? `
<div class="section-title" style="font-size:0.72rem;margin-bottom:8px">Посещаемость</div>
<div class="cr-attend-list">
${attendance.map(a => `
<div class="cr-attend-row">
<svg class="ic" viewBox="0 0 24 24" style="width:15px;height:15px;flex-shrink:0;color:var(--violet)"><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>
<span class="cr-attend-name">${esc(a.user_name)}</span>
<span class="cr-attend-time">${a.joined_at ? new Date(a.joined_at).toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'}) : '—'}</span>
<span class="cr-attend-dur">${a.duration_sec ? fmtDuration(a.duration_sec) : (a.left_at ? '—' : '<span style="color:var(--green)">онлайн</span>')}</span>
</div>
`).join('')}
</div>
` : ''}
${pages.length > 1 ? `
<div class="section-title" style="font-size:0.72rem;margin:16px 0 8px">Страницы доски</div>
<div class="cr-pages-list">
${pages.map(p => `
<div class="cr-page-chip">
<span class="cr-page-num">Стр. ${p.page_num}</span>
<span class="cr-page-cnt">${p.stroke_count} штр.</span>
</div>
`).join('')}
</div>
` : ''}
<div class="cr-detail-actions">
<button class="btn-cr-export" onclick="adminExportChat(${id})">
<svg class="ic" viewBox="0 0 24 24" style="width:13px;height:13px;vertical-align:-2px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Экспорт чата
</button>
<button class="btn-cr-del" onclick="adminDeleteSession(${id})">
<svg class="ic" viewBox="0 0 24 24" style="width:13px;height:13px;vertical-align:-2px"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>
Удалить запись
</button>
</div>`;
} catch(e) {
inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
function adminExportChat(id) {
window.open(`/api/classroom/${id}/chat/export`, '_blank');
}
async function adminDeleteSession(id) {
if (!await LS.confirm('Удалить всю запись об этом уроке? Данные нельзя восстановить.', { title: 'Удалить урок', confirmText: 'Удалить', dangerous: true })) return;
try {
await LS.api(`/api/classroom/${id}/history`, { method: 'DELETE' });
LS.toast('Урок удалён', 'success', 2500);
_crOpenDetailId = null;
loadCrHistory();
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
/* ─── wire tab loading ─── */
const _origSwitchTab = window.switchTab;
window.switchTab = function(btn) {
_origSwitchTab(btn);
const tab = btn.dataset.tab;
if (tab === 'topics') loadTopics();
else if (tab === 'audit') loadAuditLog();
else if (tab === 'errors') loadErrorLog();
else if (tab === 'health') loadHealth();
else if (tab === 'classroom') { loadCrModuleState(); loadCrActiveSessions(); loadCrHistory(); }
else if (tab === 'avatars') { loadAvatarRequests(); }
};
/* ── Avatar moderation ─────────────────────────────────────────────── */
async function loadAvatarRequests() {
const list = document.getElementById('av-list');
list.innerHTML = '<div style="color:var(--muted);text-align:center;padding:40px 0;font-size:0.85rem">Загрузка...</div>';
try {
const rows = await LS.get('/api/avatar/pending');
const badge = document.getElementById('av-badge');
if (rows.length) {
badge.textContent = rows.length;
badge.style.display = 'inline-flex';
} else {
badge.style.display = 'none';
}
if (!rows.length) {
list.innerHTML = '<div class="av-empty"><i data-lucide="check-circle" style="width:36px;height:36px;opacity:.3;display:block;margin:0 auto 10px"></i>Нет заявок на модерацию</div>';
if (window.lucide) lucide.createIcons();
return;
}
list.innerHTML = `<div class="av-grid">${rows.map(r => {
const initials = (r.user_name||'LS').split(' ').slice(0,2).map(w=>(w[0]||'').toUpperCase()).join('') || 'LS';
const curAvatar = r.current_avatar
? `<img src="/avatars/${esc(r.current_avatar)}" alt="">`
: initials;
const newAvatar = `<img src="/avatars/${esc(r.filename)}" alt="" onerror="this.parentElement.textContent='?'">`;
const d = new Date(r.created_at).toLocaleString('ru', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' });
return `<div class="av-card" id="av-card-${r.id}">
<div class="av-card-top">
<div class="av-imgs">
<div class="av-img-wrap">
<span>Сейчас</span>
<div class="av-img">${curAvatar}</div>
</div>
<svg class="av-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
<div class="av-img-wrap">
<span>Новый</span>
<div class="av-img">${newAvatar}</div>
</div>
</div>
</div>
<div>
<div class="av-user-name">${esc(r.user_name||r.user_email)}</div>
<div class="av-date">${esc(r.user_email)} · ${d}</div>
</div>
<div class="av-actions">
<button class="av-approve" onclick="avatarApprove(${r.id})">Одобрить</button>
<button class="av-reject" onclick="avatarRejectPrompt(${r.id})">Отклонить</button>
</div>
</div>`;
}).join('')}</div>`;
if (window.lucide) lucide.createIcons();
} catch {
list.innerHTML = '<div class="av-empty">Ошибка загрузки</div>';
}
}
async function avatarApprove(id) {
const card = document.getElementById('av-card-' + id);
if (card) card.style.opacity = '0.5';
try {
await LS.post('/api/avatar/' + id + '/approve', {});
LS.toast('Аватар одобрен', 'success');
loadAvatarRequests();
} catch { LS.toast('Ошибка', 'error'); if (card) card.style.opacity = ''; }
}
function avatarRejectPrompt(id) {
const reason = prompt('Причина отклонения (необязательно):') ?? null;
if (reason === null) return; // cancelled
avatarReject(id, reason);
}
async function avatarReject(id, reason) {
const card = document.getElementById('av-card-' + id);
if (card) card.style.opacity = '0.5';
try {
await LS.patch('/api/avatar/' + id + '/reject', { reason });
LS.toast('Аватар отклонён', 'info');
loadAvatarRequests();
} catch { LS.toast('Ошибка', 'error'); if (card) card.style.opacity = ''; }
}
/* ─── init ─── */
loadStats();
loadAvatarRequests(); // load badge count on page open
if (window.lucide) lucide.createIcons();
</script>
</div>
</div>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
</body>
</html>