Files
Learn_System/frontend/lesson-history.html
T
Maxim Dolgolyov 9c95dc8bff feat(materials): Фаза 6a — учителю своя коллекция «Мои материалы»
- lesson-history.html (страница учителя): подключён board-clip.js, кнопки «К себе»/«Область»
  на доске прошлой сессии (обёртки над _wb + _activeSession).
- sidebar.js: пункт «Мои материалы» теперь виден всем (не только ученикам).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:07:11 +03:00

1876 lines
86 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Архив уроков — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml"/>
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/css/ls.css"/>
<script src="https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<style>
/* ── Local token overrides — LIGHT THEME ── */
.lh-wrap {
--r: 12px;
--lh-bg: #f5f4fb;
--lh-surface: #ffffff;
--lh-surface-2: #f0eefb;
--lh-border: rgba(155,93,229,0.18);
--lh-border-h: rgba(155,93,229,0.4);
--lh-text: #1a1433;
--lh-text-2: #4a3f6b;
--lh-text-3: #8f86a8;
--lh-glow-v: rgba(155,93,229,0.15);
--lh-glow-c: rgba(6,214,224,0.1);
}
/* ── Root height fix: sb-content has no explicit height → give it one ── */
.app-layout { height: 100vh; overflow: hidden; }
.sb-content { height: 100%; overflow: hidden; }
/* ── Layout ── */
.lh-wrap {
display: flex; flex-direction: column; gap: 0;
height: 100%; overflow: hidden;
background: var(--lh-bg);
color: var(--lh-text);
}
/* ── Header ── */
.lh-header {
padding: 16px 32px 14px;
border-bottom: 1px solid var(--lh-border);
background: var(--lh-surface);
flex-shrink: 0;
}
.lh-header-top {
display: flex; align-items: center; justify-content: space-between; gap: 16px;
flex-wrap: wrap;
}
.lh-title {
font-family: 'Unbounded', sans-serif; font-size: 1.2rem;
font-weight: 800; margin: 0;
display: flex; align-items: center; gap: 12px;
background: linear-gradient(135deg, #9B5DE5, #06D6E0);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
}
.lh-title svg {
color: var(--violet);
-webkit-text-fill-color: initial;
filter: drop-shadow(0 0 8px rgba(155,93,229,0.4));
}
.lh-filters {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 10px;
}
.lh-filter-select {
padding: 8px 14px; border-radius: 10px; border: 1.5px solid var(--lh-border);
background: var(--lh-surface); color: var(--lh-text); font-family: 'Manrope', sans-serif;
font-size: 0.82rem; font-weight: 600; cursor: pointer;
transition: border-color .18s, box-shadow .18s;
backdrop-filter: blur(8px);
}
.lh-filter-select:focus {
border-color: var(--violet); outline: none;
box-shadow: 0 0 0 3px rgba(155,93,229,0.15);
}
.lh-filter-select option { background: #ffffff; color: #1a1433; }
/* date inputs inherit filter-select style */
input[type="date"].lh-filter-select { min-width: 130px; color: var(--lh-text); }
input[type="date"].lh-filter-select::-webkit-calendar-picker-indicator { opacity: 0.5; cursor: pointer; }
.lh-filter-label {
font-size: 0.75rem; font-weight: 700; color: var(--lh-text-3);
white-space: nowrap; align-self: center;
}
/* Admin banner */
.lh-admin-banner {
display: inline-flex; align-items: center; gap: 7px;
background: linear-gradient(135deg, rgba(155,93,229,0.1), rgba(6,214,224,0.06));
border: 1px solid rgba(155,93,229,0.25); border-radius: 99px;
padding: 4px 14px; font-size: 0.72rem; font-weight: 700; color: #9B5DE5;
}
/* Teacher badge in card */
.lh-card-teacher {
font-size: 0.72rem; font-weight: 700; color: #9B5DE5;
background: rgba(155,93,229,0.1); padding: 3px 10px; border-radius: 99px;
display: flex; align-items: center; gap: 4px; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* 5th KPI card (admin-only) */
.lh-kpi-card.admin-extra { display: none; }
.lh-admin .lh-kpi-card.admin-extra { display: flex; }
.lh-search {
display: flex; align-items: center; gap: 8px;
border: 1.5px solid var(--lh-border); border-radius: 10px;
padding: 8px 14px; background: var(--lh-surface);
transition: border-color .18s, box-shadow .18s;
backdrop-filter: blur(8px);
}
.lh-search:focus-within {
border-color: var(--violet);
box-shadow: 0 0 0 3px rgba(155,93,229,0.15);
}
.lh-search svg { color: var(--lh-text-3); flex-shrink: 0; }
.lh-search input {
border: none; outline: none; background: none;
font-family: 'Manrope', sans-serif; font-size: 0.82rem; color: var(--lh-text);
width: 190px;
}
.lh-search input::placeholder { color: var(--lh-text-3); }
/* ── KPI cards ── */
.lh-kpi {
display: flex; gap: 8px; padding: 10px 32px 12px; flex-shrink: 0; flex-wrap: wrap;
}
.lh-kpi-card {
flex: 1; min-width: 140px;
background: var(--lh-surface);
border: 1px solid var(--lh-border);
border-radius: 12px; padding: 10px 14px;
display: flex; align-items: center; gap: 12px;
transition: border-color .2s, box-shadow .2s;
position: relative; overflow: hidden;
}
.lh-kpi-card::before {
content: ''; position: absolute; inset: 0;
background: linear-gradient(135deg, rgba(155,93,229,0.03), rgba(6,214,224,0.01));
pointer-events: none;
}
.lh-kpi-card:hover {
border-color: rgba(155,93,229,0.25);
box-shadow: 0 2px 12px rgba(155,93,229,0.1);
}
.lh-kpi-icon {
width: 36px; height: 36px; border-radius: 10px;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.lh-kpi-icon svg { width: 16px; height: 16px; }
.lh-kpi-icon.violet { background: rgba(155,93,229,0.12); color: #9B5DE5; }
.lh-kpi-icon.cyan { background: rgba(6,214,224,0.1); color: #06D6E0; }
.lh-kpi-icon.pink { background: rgba(241,91,181,0.1); color: #F15BB5; }
.lh-kpi-icon.yellow { background: rgba(255,224,102,0.1); color: #FFE066; }
.lh-kpi-val {
font-family: 'Unbounded', sans-serif; font-size: 1.05rem; font-weight: 800;
color: var(--lh-text);
}
@keyframes lh-count-in {
from { opacity: 0; transform: translateY(6px) scale(0.92); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.lh-kpi-lbl { font-size: 0.68rem; color: var(--lh-text-3); margin-top: 1px; font-weight: 500; }
/* ── Body (list + detail) ── */
.lh-body {
display: flex; flex: 1; overflow: hidden; gap: 0;
}
/* ── Session list panel ── */
.lh-list-panel {
width: 350px; flex-shrink: 0;
border-right: 1px solid var(--lh-border);
display: flex; flex-direction: column;
overflow: hidden;
background: var(--lh-surface-2);
}
@media (max-width: 768px) {
.lh-list-panel { width: 100%; border-right: none; }
.lh-list-panel.hidden-mobile { display: none; }
.lh-detail-panel.hidden-mobile { display: none; }
}
.lh-list-scroll {
flex: 1; overflow-y: auto; padding: 12px;
scrollbar-width: thin; scrollbar-color: rgba(155,93,229,0.2) transparent;
}
/* ── Session card ── */
.lh-session-card {
position: relative;
background: var(--lh-surface); border: 1px solid var(--lh-border);
border-left: 3px solid rgba(155,93,229,0.4);
border-radius: var(--r); padding: 14px 16px; cursor: pointer;
transition: all .18s ease; margin-bottom: 8px;
backdrop-filter: blur(8px);
}
.lh-session-card:hover {
border-color: var(--lh-border-h);
border-left-color: #9B5DE5;
box-shadow: 0 8px 32px rgba(155,93,229,0.2);
transform: translateY(-2px);
}
.lh-session-card.active {
border-color: rgba(155,93,229,0.35);
border-left-color: #9B5DE5;
background: linear-gradient(135deg, rgba(155,93,229,0.1), rgba(6,214,224,0.04));
box-shadow: 0 0 24px rgba(155,93,229,0.12);
}
.lh-card-row1 {
display: flex; align-items: flex-start; justify-content: space-between; gap: 8px;
margin-bottom: 8px;
padding-right: 30px; /* clearance for .lh-card-del */
}
.lh-card-title {
font-weight: 700; font-size: 0.85rem; color: var(--lh-text);
line-height: 1.35; flex: 1;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.lh-card-date {
font-size: 0.72rem; color: var(--lh-text-3); white-space: nowrap; flex-shrink: 0; margin-top: 2px;
}
.lh-card-row2 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.lh-card-class {
font-size: 0.72rem; font-weight: 700; color: #9B5DE5;
background: rgba(155,93,229,0.12); padding: 3px 10px; border-radius: 99px;
}
.lh-card-stat {
display: flex; align-items: center; gap: 4px;
font-size: 0.72rem; color: var(--lh-text-2); font-weight: 500;
}
.lh-card-stat svg { color: var(--lh-text-3); }
/* ── Card delete button ── */
.lh-card-del {
position: absolute; top: 10px; right: 10px;
width: 28px; height: 28px; border-radius: 8px;
background: rgba(255,80,80,0.08); border: 1px solid rgba(255,80,80,0.15);
color: #ff6b6b; cursor: pointer;
display: none; align-items: center; justify-content: center;
transition: all .18s; z-index: 2; padding: 0;
backdrop-filter: blur(4px);
}
.lh-session-card:hover .lh-card-del {
display: flex;
animation: lh-fade-in 0.15s ease;
}
@keyframes lh-fade-in { from { opacity: 0; transform: scale(0.85); } to { opacity: 1; transform: scale(1); } }
.lh-card-del:hover {
background: rgba(255,80,80,0.2); border-color: rgba(255,80,80,0.4);
box-shadow: 0 0 16px rgba(255,80,80,0.2);
}
/* ── Date group separator ── */
.lh-date-sep {
font-size: 0.68rem; font-weight: 700; color: var(--lh-text-3);
text-transform: uppercase; letter-spacing: 0.08em;
padding: 10px 8px 6px; margin-top: 6px;
display: flex; align-items: center; gap: 8px;
}
.lh-date-sep::before {
content: '';
width: 16px; height: 16px; border-radius: 6px;
background: rgba(155,93,229,0.1);
flex-shrink: 0;
display: inline-flex; align-items: center; justify-content: center;
/* calendar icon via background */
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%239B5DE5' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2'/%3E%3Cline x1='16' y1='2' x2='16' y2='6'/%3E%3Cline x1='8' y1='2' x2='8' y2='6'/%3E%3Cline x1='3' y1='10' x2='21' y2='10'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: center; background-size: 10px;
}
/* ── Empty states ── */
.lh-empty {
text-align: center; padding: 56px 24px; color: var(--lh-text-3);
display: flex; flex-direction: column; align-items: center; gap: 12px;
}
.lh-empty svg {
display: block; opacity: 0.2;
filter: drop-shadow(0 0 20px rgba(155,93,229,0.3));
}
.lh-empty p { font-size: 0.85rem; margin: 0; }
/* ── Pagination ── */
.lh-pagination {
display: flex; align-items: center; justify-content: center; gap: 8px;
padding: 12px; border-top: 1px solid var(--lh-border); flex-shrink: 0;
}
.lh-page-btn {
padding: 6px 14px; border-radius: 10px; border: 1px solid var(--lh-border);
background: var(--lh-surface); color: var(--lh-text-2); font-size: 0.78rem; font-weight: 600;
cursor: pointer; transition: all .15s;
backdrop-filter: blur(4px);
}
.lh-page-btn:hover:not(:disabled) {
border-color: var(--violet); color: #9B5DE5;
box-shadow: 0 0 12px rgba(155,93,229,0.15);
}
.lh-page-btn:disabled { opacity: 0.35; cursor: default; }
.lh-page-info { font-size: 0.78rem; color: var(--lh-text-3); }
/* ── Sort row ── */
.lh-list-header { padding: 10px 12px 0; flex-shrink: 0; }
.lh-sort-row {
display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0 4px 8px;
}
.lh-count-badge { font-size: 0.75rem; color: var(--lh-text-3); font-weight: 600; }
.lh-sort-select {
padding: 5px 10px; border-radius: 8px; border: 1px solid var(--lh-border);
background: var(--lh-surface); color: var(--lh-text-2); font-family: 'Manrope', sans-serif;
font-size: 0.75rem; font-weight: 600; cursor: pointer; outline: none;
transition: border-color .15s;
}
.lh-sort-select:focus { border-color: var(--violet); }
.lh-sort-select option { background: #ffffff; color: #1a1433; }
/* ── Detail panel ── */
.lh-detail-panel {
flex: 1; display: flex; flex-direction: column; overflow: hidden;
background: var(--lh-bg);
}
.lh-detail-empty {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 18px;
color: var(--lh-text-3); padding: 40px;
}
.lh-detail-empty svg {
opacity: 0.15;
filter: drop-shadow(0 0 30px rgba(155,93,229,0.3));
}
.lh-detail-empty p { font-size: 0.9rem; margin: 0; }
.lh-detail-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
/* ── Detail header ── */
.lh-detail-hdr {
padding: 10px 22px 8px;
border-bottom: 1px solid var(--lh-border);
background: var(--lh-surface);
flex-shrink: 0;
}
.lh-detail-back {
display: none; align-items: center; gap: 6px;
font-size: 0.78rem; font-weight: 600; color: var(--lh-text-3);
cursor: pointer; margin-bottom: 12px;
background: none; border: none; padding: 0;
transition: color .15s;
}
.lh-detail-back:hover { color: #9B5DE5; }
@media (max-width: 768px) { .lh-detail-back { display: flex; } }
.lh-detail-hdr-row {
display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 6px;
}
.lh-detail-title {
font-family: 'Unbounded', sans-serif; font-size: 0.95rem;
font-weight: 800; color: var(--lh-text); margin: 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1;
}
.lh-detail-meta {
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
}
.lh-meta-item {
display: inline-flex; align-items: center; gap: 5px;
font-size: 0.7rem; font-weight: 600;
color: var(--lh-text-2);
background: rgba(155,93,229,0.06);
padding: 3px 9px; border-radius: 99px;
border: 1px solid rgba(155,93,229,0.1);
}
.lh-meta-item svg { color: var(--lh-text-3); }
/* ── Detail stats chips ── */
.lh-detail-stats {
display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px;
}
.lh-stat-chip {
display: flex; align-items: center; gap: 5px;
background: var(--lh-surface);
border: 1px solid var(--lh-border);
border-radius: 99px;
padding: 3px 10px; font-size: 0.7rem; font-weight: 700; color: var(--lh-text-2);
transition: border-color .15s;
}
.lh-stat-chip:hover { border-color: rgba(155,93,229,0.25); }
.lh-stat-chip svg { color: var(--lh-text-3); }
/* ── Detail delete button ── */
.lh-det-del-btn {
display: none; flex-shrink: 0; align-items: center; gap: 5px;
padding: 5px 11px; border-radius: 8px;
background: rgba(255,80,80,0.06); border: 1px solid rgba(255,80,80,0.18);
color: #ff6b6b; font-size: 0.74rem; font-weight: 700; font-family: 'Manrope', sans-serif;
cursor: pointer; transition: all .18s; white-space: nowrap;
}
.lh-det-del-btn.visible { display: flex; }
.lh-det-del-btn:hover {
background: rgba(255,80,80,0.14);
border-color: rgba(255,80,80,0.35);
box-shadow: 0 0 20px rgba(255,80,80,0.15);
}
/* ── Tabs (pill style) ── */
.lh-tabs {
display: flex; gap: 4px; padding: 8px 22px;
border-bottom: 1px solid var(--lh-border);
background: var(--lh-surface);
flex-shrink: 0;
}
.lh-tab {
padding: 6px 14px; border-radius: 8px;
border: 1px solid transparent;
font-size: 0.79rem; font-weight: 600; color: var(--lh-text-3);
cursor: pointer; background: none; transition: all .18s;
display: flex; align-items: center; gap: 6px;
}
.lh-tab:hover {
color: var(--lh-text-2);
background: rgba(155,93,229,0.07);
}
.lh-tab.active {
color: #9B5DE5;
background: rgba(155,93,229,0.1);
border-color: rgba(155,93,229,0.25);
box-shadow: 0 0 16px rgba(155,93,229,0.08);
}
.lh-tab svg { width: 13px; height: 13px; }
.lh-tab-content { flex: 1; overflow: hidden; min-height: 0; }
/* ── Board tab ── */
.lh-board-wrap {
height: 100%; display: flex; flex-direction: column; overflow: hidden;
padding: 0 20px 40px 0;
}
.lh-board-main {
flex: 1; display: flex; flex-direction: column; overflow: hidden; min-height: 0;
position: relative;
}
/* Topbar */
.lh-board-topbar {
height: 48px; flex-shrink: 0; position: relative;
background: #0e0b1e;
border-bottom: 1px solid rgba(155,93,229,0.18);
display: flex; align-items: center; justify-content: space-between;
padding: 0 16px; gap: 8px;
}
.lh-board-nav-btn {
width: 32px; height: 32px; border-radius: 9px;
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.09);
color: rgba(255,255,255,0.55); cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all .15s; flex-shrink: 0;
}
.lh-board-nav-btn:hover:not(:disabled) {
background: rgba(155,93,229,0.25);
border-color: rgba(155,93,229,0.4);
color: #c4a0f7;
}
.lh-board-nav-btn:disabled { opacity: 0.18; cursor: default; }
/* Page indicator button */
.lh-page-indicator {
display: flex; align-items: center; gap: 8px;
padding: 6px 14px; border-radius: 10px;
background: rgba(155,93,229,0.06);
border: 1.5px solid rgba(155,93,229,0.18);
color: rgba(255,255,255,0.85); cursor: pointer;
font-family: 'Manrope', sans-serif; font-size: 12px; font-weight: 700;
transition: all .15s;
white-space: nowrap;
position: relative;
}
.lh-page-indicator::before {
content: ''; position: absolute; inset: -1px; border-radius: 11px;
background: linear-gradient(135deg, rgba(155,93,229,0.2), rgba(6,214,224,0.1));
z-index: -1; opacity: 0;
transition: opacity .15s;
}
.lh-page-indicator:hover {
background: rgba(155,93,229,0.12);
border-color: rgba(155,93,229,0.35);
}
.lh-page-indicator:hover::before { opacity: 1; }
.lh-page-indicator.open {
background: rgba(155,93,229,0.15);
border-color: #9B5DE5;
box-shadow: 0 0 16px rgba(155,93,229,0.2);
}
.lh-page-ind-title { max-width: 160px; overflow: hidden; text-overflow: ellipsis; }
.lh-page-ind-counter { color: rgba(155,93,229,0.75); font-size: 11px; }
.lh-page-ind-chevron { color: rgba(255,255,255,0.35); transition: transform .18s; }
.lh-page-indicator.open .lh-page-ind-chevron { transform: rotate(180deg); }
/* Pages popup */
.lh-pages-popup {
position: absolute; top: calc(100% + 8px); left: 50%; transform: translateX(-50%);
z-index: 200;
background: rgba(18,14,32,0.92); border: 1px solid rgba(155,93,229,0.25);
border-radius: 16px; padding: 14px;
box-shadow: 0 20px 60px rgba(0,0,0,0.7), 0 0 40px rgba(155,93,229,0.08);
backdrop-filter: blur(20px);
display: grid; grid-template-columns: repeat(4, 120px); gap: 10px;
max-height: 70vh; overflow-y: auto;
scrollbar-width: thin; scrollbar-color: rgba(155,93,229,0.3) transparent;
}
.lh-pages-popup.hidden { display: none; }
@media (max-width: 700px) { .lh-pages-popup { grid-template-columns: repeat(2, 140px); } }
/* Thumbnail card */
.lh-thumb-item {
cursor: pointer; border-radius: 10px;
border: 2px solid rgba(255,255,255,0.06);
background: rgba(28,22,48,0.8); overflow: hidden;
transition: all .18s;
}
.lh-thumb-item:hover {
border-color: rgba(155,93,229,0.45);
box-shadow: 0 4px 20px rgba(0,0,0,0.4), 0 0 16px rgba(155,93,229,0.12);
transform: translateY(-2px);
}
.lh-thumb-item.active {
border-color: #9B5DE5;
box-shadow: 0 0 0 3px rgba(155,93,229,0.2), 0 4px 20px rgba(0,0,0,0.3);
}
.lh-thumb-canvas-wrap { width: 100%; aspect-ratio: 16/9; background: #2d5a2d; overflow: hidden; }
.lh-thumb-canvas-wrap canvas { display: block; width: 100%; height: 100%; }
.lh-thumb-footer {
padding: 6px 8px;
display: flex; align-items: center; justify-content: space-between; gap: 4px;
}
.lh-thumb-lbl {
font-size: 10px; color: rgba(255,255,255,0.55); font-family: 'Manrope', sans-serif;
font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1;
}
.lh-thumb-num {
font-size: 10px; color: rgba(155,93,229,0.65); font-family: 'Manrope', sans-serif; font-weight: 800;
}
/* Canvas */
.lh-board-canvas-wrap {
flex: 1; position: relative; overflow: hidden;
background: #2d5a2d; min-height: 0;
}
.lh-board-canvas-wrap::after {
content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 5;
background:
linear-gradient(to bottom, rgba(0,0,0,0.18) 0%, transparent 60px),
linear-gradient(to top, rgba(0,0,0,0.12) 0%, transparent 80px);
}
.lh-board-canvas { position: absolute; top: 0; left: 0; display: block; }
/* Zoom bar */
.lh-board-zoom {
position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
display: flex; align-items: center; gap: 1px; z-index: 20;
background: rgba(10,7,24,0.88);
border: 1px solid rgba(155,93,229,0.22);
border-radius: 14px; padding: 5px 6px;
backdrop-filter: blur(16px);
box-shadow: 0 4px 24px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.04) inset;
}
.lh-zoom-btn {
width: 34px; height: 34px; border-radius: 9px; background: transparent; border: none;
color: rgba(255,255,255,0.55); cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background .15s, color .15s;
}
.lh-zoom-btn:hover {
background: rgba(155,93,229,0.25);
color: #c4a0f7;
}
.lh-zoom-lbl {
font-size: 12px; color: rgba(255,255,255,0.7); font-family: 'Manrope', sans-serif;
font-weight: 700; min-width: 44px; text-align: center;
}
.lh-zoom-divider { width: 1px; height: 18px; background: rgba(255,255,255,0.08); margin: 0 4px; }
/* Export */
.lh-board-export { position: absolute; top: 14px; right: 16px; z-index: 20; }
.lh-export-btn {
display: flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 10px;
background: rgba(10,7,24,0.75);
border: 1px solid rgba(155,93,229,0.22);
color: rgba(255,255,255,0.8); font-size: 11px; font-family: 'Manrope', sans-serif;
font-weight: 700; cursor: pointer; transition: all .18s;
backdrop-filter: blur(12px);
box-shadow: 0 2px 12px rgba(0,0,0,0.35);
}
.lh-export-btn:hover {
background: rgba(155,93,229,0.3);
color: #fff; border-color: rgba(155,93,229,0.45);
box-shadow: 0 4px 20px rgba(155,93,229,0.2);
}
/* Loading */
.lh-board-loading {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 14px;
background: #2d5a2d; color: rgba(255,255,255,0.45);
font-family: 'Manrope', sans-serif; font-size: 0.82rem; z-index: 30;
}
.lh-board-spinner {
width: 36px; height: 36px; border-radius: 50%;
border: 3px solid rgba(155,93,229,0.12); border-top-color: #9B5DE5;
animation: lh-spin 0.75s linear infinite;
}
@keyframes lh-spin { to { transform: rotate(360deg); } }
/* ── Chat tab ── */
.lh-chat-wrap {
height: 100%; overflow-y: auto; padding: 18px 22px;
display: flex; flex-direction: column; gap: 6px;
scrollbar-width: thin; scrollbar-color: rgba(155,93,229,0.2) transparent;
}
.lh-chat-toolbar {
display: flex; justify-content: flex-end; padding: 12px 22px 0;
flex-shrink: 0;
}
.lh-chat-export-btn {
display: flex; align-items: center; gap: 7px;
padding: 7px 16px; border-radius: 10px;
background: rgba(155,93,229,0.08); border: 1px solid rgba(155,93,229,0.2);
color: #9B5DE5; font-size: 0.78rem; font-weight: 700; cursor: pointer;
text-decoration: none; transition: all .15s;
}
.lh-chat-export-btn:hover {
background: rgba(155,93,229,0.15);
box-shadow: 0 0 12px rgba(155,93,229,0.1);
}
/* Message bubble */
.lh-msg {
display: flex; align-items: flex-start; gap: 12px;
padding: 10px 14px; border-radius: 12px;
transition: background .15s;
}
.lh-msg:hover { background: rgba(155,93,229,0.04); }
.lh-msg-avatar {
width: 34px; height: 34px; border-radius: 50%; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 700; color: #fff;
box-shadow: 0 0 0 2px rgba(155,93,229,0.2), 0 0 0 4px rgba(155,93,229,0.05);
}
.lh-msg-body { flex: 1; min-width: 0; }
.lh-msg-head {
display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px;
}
.lh-msg-name { font-size: 0.8rem; font-weight: 700; color: var(--lh-text); }
.lh-msg-time { font-size: 0.72rem; color: var(--lh-text-3); }
.lh-msg-text {
font-size: 0.83rem; color: var(--lh-text-2); line-height: 1.55;
word-break: break-word; white-space: pre-wrap;
background: rgba(155,93,229,0.04);
padding: 8px 14px; border-radius: 0 12px 12px 12px;
border: 1px solid rgba(155,93,229,0.06);
}
.lh-msg-img { max-width: 220px; border-radius: 10px; margin-top: 6px; display: block; }
/* Pinned message */
.lh-msg.pinned {
background: rgba(255,224,102,0.04);
border: 1px solid rgba(255,224,102,0.12);
border-radius: 12px; padding: 10px 14px;
}
.lh-msg.pinned .lh-msg-name {
color: #FFE066;
}
.lh-msg.pinned .lh-msg-name::after {
content: '';
display: inline-block; width: 12px; height: 12px; margin-left: 6px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23FFE066' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='12' y1='17' x2='12' y2='22'/%3E%3Cpath d='M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-size: contain;
vertical-align: middle;
}
/* ── Attendance tab ── */
.lh-attend-wrap {
height: 100%; overflow-y: auto; padding: 18px 22px;
scrollbar-width: thin; scrollbar-color: rgba(155,93,229,0.2) transparent;
}
.lh-attend-summary {
display: flex; gap: 12px; margin-bottom: 18px; flex-wrap: wrap;
}
.lh-attend-kpi {
background: var(--lh-surface);
border: 1px solid var(--lh-border);
border-radius: var(--r); padding: 12px 18px;
text-align: center; min-width: 110px;
backdrop-filter: blur(8px);
}
.lh-attend-kpi-val {
font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 800; color: var(--lh-text);
}
.lh-attend-kpi-lbl { font-size: 0.72rem; color: var(--lh-text-3); margin-top: 3px; }
/* Attendance table */
.lh-attend-table { width: 100%; border-collapse: collapse; }
.lh-attend-table th, .lh-attend-table td {
text-align: left; padding: 11px 14px;
border-bottom: 1px solid var(--lh-border);
font-size: 0.82rem;
}
.lh-attend-table th {
font-weight: 700; color: var(--lh-text-3); font-size: 0.73rem;
text-transform: uppercase; letter-spacing: 0.05em;
background: rgba(155,93,229,0.04);
position: sticky; top: 0; z-index: 2;
backdrop-filter: blur(6px);
}
.lh-attend-table td { color: var(--lh-text-2); }
.lh-attend-table tbody tr:nth-child(even) { background: rgba(155,93,229,0.02); }
.lh-attend-table tbody tr:hover { background: rgba(155,93,229,0.06); }
.lh-attend-table td strong { color: var(--lh-text); }
.lh-attend-dur {
display: inline-flex; align-items: center; gap: 4px;
background: linear-gradient(135deg, rgba(155,93,229,0.1), rgba(6,214,224,0.06));
border: 1px solid rgba(155,93,229,0.15);
border-radius: 99px;
padding: 3px 11px; font-size: 0.75rem; font-weight: 700; color: #9B5DE5;
}
/* ── Notes tab ── */
.lh-notes-wrap {
height: 100%; overflow-y: auto; padding: 18px 22px;
scrollbar-width: thin; scrollbar-color: rgba(155,93,229,0.2) transparent;
}
.lh-note-card {
background: var(--lh-surface); border: 1px solid var(--lh-border);
border-radius: var(--r); padding: 16px 18px; margin-bottom: 12px;
backdrop-filter: blur(8px);
transition: border-color .15s, box-shadow .15s;
}
.lh-note-card:hover {
border-color: rgba(155,93,229,0.2);
box-shadow: 0 4px 16px rgba(155,93,229,0.08);
}
.lh-note-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 10px;
}
.lh-note-author {
font-weight: 700; font-size: 0.82rem; color: var(--lh-text);
display: flex; align-items: center; gap: 7px;
}
.lh-note-author svg { color: var(--lh-text-3); }
.lh-note-time { font-size: 0.72rem; color: var(--lh-text-3); }
.lh-note-text {
font-size: 0.83rem; color: var(--lh-text-2); line-height: 1.65;
white-space: pre-wrap; word-break: break-word;
}
.lh-own-note-box {
background: rgba(155,93,229,0.04); border: 1px solid rgba(155,93,229,0.15);
border-radius: var(--r); padding: 18px; margin-bottom: 12px;
}
.lh-own-note-lbl {
font-size: 0.78rem; font-weight: 700; color: #9B5DE5; margin-bottom: 10px;
}
/* ── Skeleton ── */
.lh-skeleton {
background: linear-gradient(90deg, rgba(155,93,229,0.06) 25%, rgba(155,93,229,0.12) 50%, rgba(155,93,229,0.06) 75%);
background-size: 200% 100%;
border-radius: 8px;
animation: lh-shimmer 1.6s ease-in-out infinite;
}
@keyframes lh-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
.lh-sk-card { height: 80px; border-radius: var(--r); margin-bottom: 8px; }
/* ── Confirmation modal ── */
.lh-confirm-overlay {
position: fixed; inset: 0; background: rgba(6,4,14,0.8);
display: flex; align-items: center; justify-content: center;
z-index: 2000; backdrop-filter: blur(8px);
}
.lh-confirm-overlay.hidden { display: none; }
.lh-confirm-dialog {
background: #ffffff;
border: 1.5px solid rgba(255,80,80,0.2);
border-radius: 20px; padding: 32px 32px 26px; max-width: 420px; width: 92%;
box-shadow: 0 24px 80px rgba(0,0,0,0.25), 0 0 40px rgba(255,80,80,0.04);
position: relative; overflow: hidden;
}
.lh-confirm-dialog::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
background: linear-gradient(90deg, transparent, rgba(255,80,80,0.5), transparent);
}
.lh-confirm-icon {
width: 52px; height: 52px; border-radius: 16px;
background: rgba(255,80,80,0.08); border: 1.5px solid rgba(255,80,80,0.2);
display: flex; align-items: center; justify-content: center;
color: #ff6b6b; margin-bottom: 18px;
animation: lh-pulse-icon 2s ease-in-out infinite;
}
@keyframes lh-pulse-icon {
0%, 100% { box-shadow: 0 0 0 0 rgba(255,80,80,0.2); }
50% { box-shadow: 0 0 0 8px rgba(255,80,80,0); }
}
.lh-confirm-cancel {
padding: 9px 20px; border-radius: 10px; border: 1px solid rgba(155,93,229,0.18);
background: transparent; color: #4a3f6b; font-family: 'Manrope', sans-serif;
font-size: 0.82rem; font-weight: 700; cursor: pointer; transition: all .15s;
}
.lh-confirm-cancel:hover { border-color: #8f86a8; color: #1a1433; }
.lh-confirm-title {
font-family: 'Unbounded', sans-serif; font-size: 0.95rem; font-weight: 800;
color: #1a1433; margin: 0 0 10px;
}
.lh-confirm-desc {
font-size: 0.83rem; color: #4a3f6b; line-height: 1.6; margin: 0 0 24px;
}
.lh-confirm-sname { font-weight: 700; color: #1a1433; }
.lh-confirm-actions { display: flex; gap: 10px; justify-content: flex-end; }
.lh-confirm-delete {
padding: 9px 20px; border-radius: 10px; border: 1.5px solid rgba(255,80,80,0.3);
background: rgba(255,80,80,0.08); color: #ff6b6b;
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700;
cursor: pointer; transition: all .15s;
}
.lh-confirm-delete:hover {
background: rgba(255,80,80,0.18);
box-shadow: 0 0 20px rgba(255,80,80,0.15);
}
.lh-confirm-delete:disabled { opacity: 0.4; cursor: default; }
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<main class="sb-content">
<div class="lh-wrap">
<!-- Header + filters -->
<div class="lh-header">
<div class="lh-header-top">
<h1 class="lh-title">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>
Архив уроков
</h1>
</div>
<div class="lh-filters">
<!-- Teacher filter: admin only -->
<select class="lh-filter-select" id="lh-teacher-filter" onchange="onTeacherFilter()" style="display:none">
<option value="">Все учителя</option>
</select>
<!-- Class filter: teacher only (admin uses search) -->
<select class="lh-filter-select" id="lh-class-filter" onchange="onClassFilter()">
<option value="">Все классы</option>
</select>
<!-- Date range: admin only -->
<span class="lh-filter-label" id="lh-date-label" style="display:none">с</span>
<input type="date" class="lh-filter-select" id="lh-date-from" onchange="onDateFilter()" style="display:none" title="С даты"/>
<span class="lh-filter-label" id="lh-date-label2" style="display:none">по</span>
<input type="date" class="lh-filter-select" id="lh-date-to" onchange="onDateFilter()" style="display:none" title="По дату"/>
<div class="lh-search">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<input type="text" id="lh-search" placeholder="Поиск по названию..." oninput="onSearch()"/>
</div>
</div>
</div>
<!-- KPI row -->
<div class="lh-kpi" id="lh-kpi">
<div class="lh-kpi-card">
<div class="lh-kpi-icon violet"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg></div>
<div><div class="lh-kpi-val" id="kpi-total">&mdash;</div><div class="lh-kpi-lbl" id="kpi-total-lbl">Уроков проведено</div></div>
</div>
<div class="lh-kpi-card">
<div class="lh-kpi-icon cyan"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></div>
<div><div class="lh-kpi-val" id="kpi-time">&mdash;</div><div class="lh-kpi-lbl">Общее время</div></div>
</div>
<!-- Admin-only: teachers count -->
<div class="lh-kpi-card admin-extra">
<div class="lh-kpi-icon violet" style="background:rgba(255,159,67,0.1);color:#FF9F43"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></div>
<div><div class="lh-kpi-val" id="kpi-teachers">&mdash;</div><div class="lh-kpi-lbl">Учителей</div></div>
</div>
<div class="lh-kpi-card">
<div class="lh-kpi-icon pink"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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></div>
<div><div class="lh-kpi-val" id="kpi-students">&mdash;</div><div class="lh-kpi-lbl">Учеников охвачено</div></div>
</div>
<div class="lh-kpi-card">
<div class="lh-kpi-icon yellow"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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></div>
<div><div class="lh-kpi-val" id="kpi-msgs">&mdash;</div><div class="lh-kpi-lbl">Сообщений в чатах</div></div>
</div>
</div>
<!-- Body: list + detail -->
<div class="lh-body">
<!-- Session list -->
<div class="lh-list-panel" id="lh-list-panel">
<div class="lh-list-header">
<div class="lh-sort-row">
<span class="lh-count-badge" id="lh-count-badge"></span>
<select class="lh-sort-select" id="lh-sort" onchange="onSort()">
<option value="newest">Сначала новые</option>
<option value="oldest">Сначала старые</option>
<option value="longest">По длительности</option>
<option value="most_students" class="admin-opt" style="display:none">По числу учеников</option>
</select>
</div>
</div>
<div class="lh-list-scroll" id="lh-list">
<div class="lh-sk-card lh-skeleton"></div>
<div class="lh-sk-card lh-skeleton"></div>
<div class="lh-sk-card lh-skeleton"></div>
</div>
<div class="lh-pagination" id="lh-pagination" style="display:none">
<button class="lh-page-btn" id="lh-prev" onclick="changePage(-1)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
</button>
<span class="lh-page-info" id="lh-page-info">1 / 1</span>
<button class="lh-page-btn" id="lh-next" onclick="changePage(1)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>
</button>
</div>
</div>
<!-- Detail view -->
<div class="lh-detail-panel" id="lh-detail-panel">
<div class="lh-detail-empty" id="lh-detail-empty">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>
<p>Выберите урок из списка слева</p>
</div>
<div class="lh-detail-content" id="lh-detail-content" style="display:none">
<div class="lh-detail-hdr">
<button class="lh-detail-back" onclick="backToList()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
Назад к списку
</button>
<div class="lh-detail-hdr-row">
<h2 class="lh-detail-title" id="det-title">&mdash;</h2>
<button class="lh-det-del-btn" id="det-del-btn" onclick="confirmDelete(null)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
Удалить
</button>
</div>
<div class="lh-detail-meta">
<span class="lh-meta-item" id="det-date">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
</span>
<span class="lh-meta-item" id="det-teacher">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>
<span class="lh-meta-item" id="det-class" style="display:none">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 10v6M2 10l10-5 10 5-10 5z"/><path d="M6 12v5c3 3 9 3 12 0v-5"/></svg>
</span>
</div>
<div class="lh-detail-stats" id="det-stats"></div>
</div>
<!-- Tabs -->
<div class="lh-tabs">
<button class="lh-tab active" id="tab-board" onclick="switchTab('board')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
Доска
</button>
<button class="lh-tab" id="tab-chat" onclick="switchTab('chat')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>
Чат
<span id="tab-chat-count" style="display:none;background:rgba(155,93,229,.15);color:#9B5DE5;font-size:10px;padding:2px 7px;border-radius:99px;font-weight:700"></span>
</button>
<button class="lh-tab" id="tab-attend" onclick="switchTab('attend')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>
Посещаемость
</button>
<button class="lh-tab" id="tab-notes" onclick="switchTab('notes')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Заметки
</button>
</div>
<!-- Tab contents -->
<div class="lh-tab-content">
<!-- Board -->
<div id="tc-board" class="lh-board-wrap">
<div class="lh-board-main">
<!-- Topbar: prev / [page indicator] / next -->
<div class="lh-board-topbar">
<button class="lh-board-nav-btn" id="lh-page-prev" onclick="lhPageNav(-1)" title="Предыдущая (←)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
</button>
<!-- Page indicator -->
<button class="lh-page-indicator" id="lh-page-indicator" onclick="lhTogglePages()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
<span class="lh-page-ind-title" id="lh-page-title">Страница 1</span>
<span class="lh-page-ind-counter" id="lh-page-total"></span>
<svg class="lh-page-ind-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</button>
<!-- Pages popup (thumbnails grid) -->
<div class="lh-pages-popup hidden" id="lh-pages-popup">
<div id="lh-thumbs"></div>
</div>
<button class="lh-board-nav-btn" id="lh-page-next" onclick="lhPageNav(1)" title="Следующая (→)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>
</button>
</div>
<!-- Canvas -->
<div class="lh-board-canvas-wrap">
<canvas id="lh-canvas" class="lh-board-canvas"></canvas>
<div class="lh-board-loading" id="lh-board-loading">
<div class="lh-board-spinner"></div>
<span>Загрузка доски...</span>
</div>
<div class="lh-board-export">
<button class="lh-export-btn" onclick="saveBoardToMaterials(this)" title="Сохранить страницу в «Мои материалы»">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
К себе
</button>
<button class="lh-export-btn" onclick="saveBoardRegion(this)" title="Выделить и сохранить часть доски">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2v14a2 2 0 0 0 2 2h14"/><path d="M18 22V8a2 2 0 0 0-2-2H2"/></svg>
Область
</button>
<button class="lh-export-btn" onclick="exportBoardPage()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" 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="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
PNG
</button>
</div>
<div class="lh-board-zoom">
<button class="lh-zoom-btn" onclick="lhZoom(-0.25)" title="-">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<span class="lh-zoom-lbl" id="lh-zoom-lbl">100%</span>
<button class="lh-zoom-btn" onclick="lhZoom(0.25)" title="+">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<div class="lh-zoom-divider"></div>
<button class="lh-zoom-btn" onclick="lhZoomFit()" title="Вписать (0)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
</button>
</div>
</div>
</div>
</div>
<!-- Chat -->
<div id="tc-chat" style="display:none;height:100%;flex-direction:column;overflow:hidden">
<div class="lh-chat-toolbar" id="lh-chat-toolbar"></div>
<div class="lh-chat-wrap" id="lh-chat-list"></div>
</div>
<!-- Attendance -->
<div id="tc-attend" style="display:none">
<div class="lh-attend-wrap">
<div class="lh-attend-summary" id="lh-attend-summary"></div>
<table class="lh-attend-table">
<thead><tr>
<th>Участник</th><th>Вошёл</th><th>Вышел</th><th>Время</th>
</tr></thead>
<tbody id="lh-attend-body"></tbody>
</table>
</div>
</div>
<!-- Notes -->
<div id="tc-notes" style="display:none">
<div class="lh-notes-wrap" id="lh-notes-list"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- Confirmation modal -->
<div class="lh-confirm-overlay hidden" id="lh-confirm-overlay">
<div class="lh-confirm-dialog">
<div class="lh-confirm-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</div>
<h3 class="lh-confirm-title">Удалить запись урока?</h3>
<p class="lh-confirm-desc">Урок <span class="lh-confirm-sname" id="confirm-sname"></span> будет безвозвратно удалён вместе с доской, чатом, заметками и данными посещаемости. Это действие нельзя отменить.</p>
<div class="lh-confirm-actions">
<button class="lh-confirm-cancel" onclick="hideConfirmDelete()">Отмена</button>
<button class="lh-confirm-delete" id="confirm-del-btn" onclick="doDelete()">Удалить навсегда</button>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/mobile.js"></script>
<script src="/js/whiteboard.js"></script>
<script src="/js/board-clip.js"></script>
<script>
/* ══════════════════════════════════════════════════════
Lesson History — main script
══════════════════════════════════════════════════════ */
let _me = null;
let _isTeacher = false;
let _isAdmin = false;
let _classes = [];
let _sessions = [];
let _totalPages = 1;
let _curPage = 1;
let _classFilter = '';
let _teacherFilter = '';
let _dateFrom = '';
let _dateTo = '';
let _searchTimer = null;
let _sortMode = 'newest';
let _deleteId = null;
let _activeSession = null;
let _wb = null;
let _wbPages = [];
let _wbCurrentPage = 1;
/* ─── Init ─── */
(function() {
const { user } = LS.initPage();
if (!user) return;
_me = user;
_isTeacher = user.role === 'teacher' || user.role === 'admin';
_isAdmin = user.role === 'admin';
if (!_isTeacher) { location.replace('/my-lessons'); return; }
if (window.lucide) lucide.createIcons();
if (_isAdmin) {
// Show admin-specific UI
document.querySelector('.lh-wrap')?.classList.add('lh-admin');
document.getElementById('lh-teacher-filter').style.display = '';
document.getElementById('lh-class-filter').style.display = 'none'; // admin uses search instead
document.getElementById('lh-date-label').style.display = '';
document.getElementById('lh-date-label2').style.display = '';
document.getElementById('lh-date-from').style.display = '';
document.getElementById('lh-date-to').style.display = '';
document.getElementById('lh-search').placeholder = 'Поиск по названию, учителю, классу...';
document.getElementById('kpi-total-lbl').textContent = 'Всего уроков';
// Show "most_students" sort option
document.querySelector('.admin-opt')?.removeAttribute('style');
loadTeachersList();
} else {
loadClasses();
}
loadSessions();
checkUrlSession();
})();
function checkUrlSession() {
const sp = new URLSearchParams(location.search);
const sid = sp.get('session');
if (sid) setTimeout(() => openSession(Number(sid)), 600);
}
/* ─── Admin: teachers list ─── */
async function loadTeachersList() {
try {
const data = await LS.crAdminGetTeachersList();
const sel = document.getElementById('lh-teacher-filter');
(data.teachers || []).forEach(t => {
const opt = document.createElement('option');
opt.value = t.id; opt.textContent = t.name;
sel.appendChild(opt);
});
} catch {}
}
/* ─── Teacher: classes filter ─── */
async function loadClasses() {
try {
const data = await LS.getClasses();
_classes = data.classes || data || [];
const sel = document.getElementById('lh-class-filter');
_classes.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id; opt.textContent = c.name;
sel.appendChild(opt);
});
} catch {}
}
function onClassFilter() {
_classFilter = document.getElementById('lh-class-filter').value;
_curPage = 1;
loadSessions();
}
function onTeacherFilter() {
_teacherFilter = document.getElementById('lh-teacher-filter').value;
_curPage = 1;
loadSessions();
}
function onDateFilter() {
_dateFrom = document.getElementById('lh-date-from').value;
_dateTo = document.getElementById('lh-date-to').value;
_curPage = 1;
loadSessions();
}
function onSearch() {
clearTimeout(_searchTimer);
_searchTimer = setTimeout(() => { _curPage = 1; loadSessions(); }, 350);
}
function onSort() {
_sortMode = document.getElementById('lh-sort').value;
if (_isAdmin) {
_curPage = 1;
loadSessions(); // server-side sort for admin
} else {
renderList(_sessions); // client-side sort for teacher
}
}
/* ─── Sessions list ─── */
async function loadSessions() {
const search = document.getElementById('lh-search').value.trim();
const list = document.getElementById('lh-list');
list.innerHTML = '<div class="lh-sk-card lh-skeleton"></div><div class="lh-sk-card lh-skeleton"></div><div class="lh-sk-card lh-skeleton"></div>';
try {
let data;
if (_isAdmin) {
data = await LS.crAdminGetAllHistory({
page: _curPage, search,
teacher: _teacherFilter,
class_id: '', // admin class filter via teacher filter + search
date_from: _dateFrom,
date_to: _dateTo,
sort: _sortMode,
});
updateKPIFromAgg(data.agg, data.total);
} else {
if (_classFilter) {
data = await LS.crGetClassHistory(Number(_classFilter), _curPage, search);
} else {
data = await LS.crGetMyHistory(_curPage);
}
updateKPI(data.sessions || []);
}
_sessions = data.sessions || [];
_totalPages = data.pages || 1;
renderList(_sessions);
renderPagination(_curPage, _totalPages, data.total || 0);
} catch (e) {
list.innerHTML = `<div class="lh-empty"><p>Ошибка загрузки: ${LS.escapeHtml(e.message || 'неизвестная ошибка')}</p></div>`;
}
}
function renderList(sessions) {
const list = document.getElementById('lh-list');
const badge = document.getElementById('lh-count-badge');
if (badge) badge.textContent = sessions.length ? plural(sessions.length, 'урок','урока','уроков') : '';
if (!sessions.length) {
list.innerHTML = `<div class="lh-empty">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>
<p>Завершённых уроков пока нет</p></div>`;
return;
}
// Sort
const sorted = [...sessions].sort((a, b) => {
if (_sortMode === 'oldest') return new Date(a.ended_at||0) - new Date(b.ended_at||0);
if (_sortMode === 'longest') {
const da = a.ended_at && a.created_at ? new Date(a.ended_at)-new Date(a.created_at) : 0;
const db_ = b.ended_at && b.created_at ? new Date(b.ended_at)-new Date(b.created_at) : 0;
return db_ - da;
}
return new Date(b.ended_at||0) - new Date(a.ended_at||0); // newest
});
// Group by month
const groups = [];
let lastKey = null;
sorted.forEach(s => {
const d = s.ended_at ? new Date(s.ended_at) : null;
const key = d ? d.toLocaleDateString('ru-RU', { month: 'long', year: 'numeric' }) : 'Неизвестно';
if (key !== lastKey) { groups.push({ key, items: [] }); lastKey = key; }
groups[groups.length-1].items.push(s);
});
list.innerHTML = groups.map(g =>
`<div class="lh-date-sep">${g.key}</div>` + g.items.map(s => renderCard(s)).join('')
).join('');
}
function renderCard(s) {
const dateStr = s.ended_at ? new Date(s.ended_at).toLocaleDateString('ru-RU', { day:'numeric', month:'short' }) : '—';
const dur = s.ended_at && s.created_at ? fmtDuration(Math.round((new Date(s.ended_at) - new Date(s.created_at)) / 1000)) : null;
const active = _activeSession?.session?.id === s.id ? ' active' : '';
const cls = s.class_name ? `<span class="lh-card-class">${LS.escapeHtml(s.class_name)}</span>` : '';
const teacher = _isAdmin && s.teacher_name
? `<span class="lh-card-teacher"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><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>${LS.escapeHtml(s.teacher_name)}</span>` : '';
const delBtn = _isTeacher
? `<button class="lh-card-del" onclick="event.stopPropagation();confirmDelete(${s.id})" title="Удалить урок">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</button>` : '';
return `<div class="lh-session-card${active}" data-id="${s.id}" onclick="openSession(${s.id})">
${delBtn}
<div class="lh-card-row1">
<div class="lh-card-title">${LS.escapeHtml(s.title || 'Без названия')}</div>
<div class="lh-card-date">${dateStr}</div>
</div>
<div class="lh-card-row2">
${teacher}${cls}
${dur ? `<span class="lh-card-stat"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>${dur}</span>` : ''}
<span class="lh-card-stat"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg>${s.participant_count || 0}</span>
${(s.message_count||0) > 0 ? `<span class="lh-card-stat"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>` : ''}
${(s.page_count||1) > 1 ? `<span class="lh-card-stat"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>${s.page_count} стр.</span>` : ''}
${_isAdmin && (s.stroke_count||0) > 0 ? `<span class="lh-card-stat"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>${s.stroke_count}</span>` : ''}
</div>
</div>`;
}
function renderPagination(page, total, count) {
const pg = document.getElementById('lh-pagination');
const prev = document.getElementById('lh-prev');
const next = document.getElementById('lh-next');
const info = document.getElementById('lh-page-info');
pg.style.display = total > 1 ? 'flex' : 'none';
prev.disabled = page <= 1;
next.disabled = page >= total;
info.textContent = `${page} / ${total}`;
}
function changePage(dir) {
_curPage = Math.max(1, Math.min(_totalPages, _curPage + dir));
loadSessions();
}
function updateKPI(sessions) {
const durSec = sessions.reduce((a, s) => {
if (!s.ended_at || !s.created_at) return a;
return a + Math.round((new Date(s.ended_at) - new Date(s.created_at)) / 1000);
}, 0);
const students = sessions.reduce((a, s) => a + (s.participant_count || 0), 0);
const msgs = sessions.reduce((a, s) => a + (s.message_count || 0), 0);
document.getElementById('kpi-total').textContent = sessions.length;
document.getElementById('kpi-time').textContent = fmtDuration(durSec);
document.getElementById('kpi-students').textContent = students;
document.getElementById('kpi-msgs').textContent = msgs;
}
function updateKPIFromAgg(agg, total) {
if (!agg) return;
document.getElementById('kpi-total').textContent = total ?? agg.total_sessions ?? '—';
document.getElementById('kpi-time').textContent = fmtDuration(agg.total_duration_sec || 0);
document.getElementById('kpi-teachers').textContent = agg.total_teachers ?? '—';
document.getElementById('kpi-students').textContent = agg.total_participants ?? '—';
document.getElementById('kpi-msgs').textContent = agg.total_messages ?? '—';
}
/* ─── Session detail ─── */
async function openSession(id) {
// Highlight card
document.querySelectorAll('.lh-session-card').forEach(el => {
el.classList.toggle('active', Number(el.dataset.id) === id);
});
// Show detail area
document.getElementById('lh-detail-empty').style.display = 'none';
document.getElementById('lh-detail-content').style.display = 'flex';
document.getElementById('lh-detail-content').style.flexDirection = 'column';
// Mobile: hide list
if (window.innerWidth <= 768) {
document.getElementById('lh-list-panel').classList.add('hidden-mobile');
}
// Update URL
const url = new URL(location.href);
url.searchParams.set('session', id);
history.replaceState({}, '', url);
// Reset lazy-load flags for new session
_chatLoaded = false; _attendLoaded = false; _notesLoaded = false;
// Load summary
try {
const data = await LS.crGetSessionSummary(id);
_activeSession = data;
renderDetailHeader(data);
_wbPages = data.pages || [];
_wbCurrentPage = 1;
// Default to board tab
switchTab('board');
renderThumbs();
loadBoardPage(1);
} catch (e) {
LS.toast('Ошибка загрузки урока: ' + (e.message || ''), 'error');
}
}
function renderDetailHeader(data) {
const s = data.session;
const st = data.stats;
document.getElementById('det-title').textContent = s.title || 'Без названия';
// Delete button — teacher sees it for own sessions; admin always
const delBtn = document.getElementById('det-del-btn');
if (delBtn) {
const canDel = _isTeacher && (_me?.role === 'admin' || s.teacher_id === _me?.id);
delBtn.classList.toggle('visible', canDel);
if (canDel) delBtn.onclick = () => confirmDelete(s.id);
}
const d = s.ended_at ? new Date(s.ended_at) : null;
const dateEl = document.getElementById('det-date');
dateEl.querySelector('.det-meta-txt')?.remove();
const dateTxt = document.createElement('span');
dateTxt.className = 'det-meta-txt';
dateTxt.textContent = d ? d.toLocaleDateString('ru-RU', { day:'numeric', month:'long', year:'numeric' }) : '—';
dateEl.appendChild(dateTxt);
const techEl = document.getElementById('det-teacher');
techEl.querySelector('.det-meta-txt')?.remove();
const techTxt = document.createElement('span');
techTxt.className = 'det-meta-txt';
techTxt.textContent = s.teacher_name || '';
techEl.appendChild(techTxt);
const clsEl = document.getElementById('det-class');
if (s.class_name) {
clsEl.style.display = 'flex';
clsEl.querySelector('.det-meta-txt')?.remove();
const clsTxt = document.createElement('span');
clsTxt.className = 'det-meta-txt';
clsTxt.textContent = s.class_name;
clsEl.appendChild(clsTxt);
} else {
clsEl.style.display = 'none';
}
// Stats chips
const chips = [];
if (st.duration_sec) chips.push(`<span class="lh-stat-chip"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>${fmtDuration(st.duration_sec)}</span>`);
if (st.participant_count) chips.push(`<span class="lh-stat-chip"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg>${st.participant_count} уч.</span>`);
if (st.page_count) chips.push(`<span class="lh-stat-chip"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>${st.page_count} стр.</span>`);
if (st.stroke_count) chips.push(`<span class="lh-stat-chip"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>${st.stroke_count} штрихов</span>`);
if (st.message_count) chips.push(`<span class="lh-stat-chip"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>${st.message_count} сообщ.</span>`);
document.getElementById('det-stats').innerHTML = chips.join('');
}
function backToList() {
document.getElementById('lh-list-panel').classList.remove('hidden-mobile');
document.getElementById('lh-detail-content').style.display = 'none';
document.getElementById('lh-detail-empty').style.display = 'flex';
const url = new URL(location.href);
url.searchParams.delete('session');
history.replaceState({}, '', url);
}
/* ─── Tabs ─── */
function switchTab(name) {
['board','chat','attend','notes'].forEach(t => {
document.getElementById(`tab-${t}`)?.classList.toggle('active', t === name);
const tc = document.getElementById(`tc-${t}`);
if (tc) tc.style.display = t === name ? (t === 'board' || t === 'chat' ? 'flex' : 'block') : 'none';
});
if (name === 'chat' && _activeSession && !_chatLoaded) loadChat();
if (name === 'attend' && _activeSession && !_attendLoaded) loadAttendance();
if (name === 'notes' && _activeSession && !_notesLoaded) loadNotes();
}
let _chatLoaded = false, _attendLoaded = false, _notesLoaded = false;
/* ─── Board ─── */
function renderThumbs() {
const container = document.getElementById('lh-thumbs');
if (!container) return;
container.innerHTML = '';
// Popup uses CSS grid — wrap all items directly
container.style.cssText = 'display:contents';
_wbPages.forEach(p => {
const item = document.createElement('div');
item.className = 'lh-thumb-item' + (p.page_num === _wbCurrentPage ? ' active' : '');
item.dataset.page = p.page_num;
item.onclick = () => { loadBoardPage(p.page_num); lhClosePages(); };
const wrap = document.createElement('div');
wrap.className = 'lh-thumb-canvas-wrap';
const cvs = document.createElement('canvas');
cvs.width = 192; cvs.height = 108;
wrap.appendChild(cvs);
const footer = document.createElement('div');
footer.className = 'lh-thumb-footer';
const lbl = document.createElement('span');
lbl.className = 'lh-thumb-lbl';
lbl.textContent = p.name || `Стр. ${p.page_num}`;
const num = document.createElement('span');
num.className = 'lh-thumb-num';
num.textContent = p.page_num;
footer.append(lbl, num);
item.append(wrap, footer);
container.appendChild(item);
});
updateBoardTopbar();
}
let _pagesPopupOpen = false;
function lhTogglePages() {
_pagesPopupOpen ? lhClosePages() : lhOpenPages();
}
function lhOpenPages() {
_pagesPopupOpen = true;
document.getElementById('lh-pages-popup').classList.remove('hidden');
document.getElementById('lh-page-indicator').classList.add('open');
// Render all thumbnails in background now that popup is visible
if (_activeSession) _renderAllThumbs(_activeSession.session.id);
}
function lhClosePages() {
_pagesPopupOpen = false;
document.getElementById('lh-pages-popup')?.classList.add('hidden');
document.getElementById('lh-page-indicator')?.classList.remove('open');
}
async function loadBoardPage(pageNum) {
if (!_activeSession) return;
const sessionId = _activeSession.session.id;
_wbCurrentPage = pageNum;
// Highlight thumb + scroll into view
document.querySelectorAll('.lh-thumb-item').forEach(el => {
const active = Number(el.dataset.page) === pageNum;
el.classList.toggle('active', active);
if (active) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
});
updateBoardTopbar();
const loading = document.getElementById('lh-board-loading');
const canvas = document.getElementById('lh-canvas');
loading.style.display = 'flex';
try {
const res = await LS.get(`/api/classroom/${sessionId}/strokes?page_num=${pageNum}`);
const strokes = res.strokes || [];
const template = res.template || 'blank';
// Init or reinit whiteboard (readOnly)
if (_wb) { _wb.destroy(); _wb = null; }
// Wait one frame so aspect-ratio layout is calculated before fit()
await new Promise(r => requestAnimationFrame(r));
_wb = new Whiteboard(canvas, {
readOnly: true,
onZoomChange: z => {
const el = document.getElementById('lh-zoom-lbl');
if (el) el.textContent = Math.round(z * 100) + '%';
},
});
_wb.setTemplate(template);
_wb.loadStrokes(strokes);
// Fit view to the actual stroke content (not the full 1920×1080 empty canvas)
_wb.zoomFitStrokes();
// Render thumbnail for this page
const thumbEl = document.querySelector(`.lh-thumb-item[data-page="${pageNum}"] canvas`);
if (thumbEl) _wb.renderThumbnail(thumbEl);
loading.style.display = 'none';
// Lazily render all other thumbnails in background
_renderAllThumbs(sessionId);
} catch (e) {
loading.innerHTML = '<span style="color:rgba(255,100,100,.7)">Ошибка загрузки страницы</span>';
}
}
function updateBoardTopbar() {
const cur = _wbCurrentPage;
const total = _wbPages.length;
const page = _wbPages.find(p => p.page_num === cur);
const idx = _wbPages.findIndex(p => p.page_num === cur) + 1;
const titleEl = document.getElementById('lh-page-title');
const totalEl = document.getElementById('lh-page-total');
const prevBtn = document.getElementById('lh-page-prev');
const nextBtn = document.getElementById('lh-page-next');
if (titleEl) titleEl.textContent = page?.name ? `${page.name}` : `Страница ${cur}`;
if (totalEl) totalEl.textContent = total > 1 ? `${idx} / ${total}` : '';
if (prevBtn) prevBtn.disabled = idx <= 1;
if (nextBtn) nextBtn.disabled = idx >= total;
}
function lhPageNav(dir) {
const idx = _wbPages.findIndex(p => p.page_num === _wbCurrentPage);
const next = _wbPages[idx + dir];
if (next) loadBoardPage(next.page_num);
}
let _thumbRenderJob = null;
async function _renderAllThumbs(sessionId) {
// Cancel previous job
const jobId = Date.now();
_thumbRenderJob = jobId;
for (const p of _wbPages) {
if (_thumbRenderJob !== jobId) return; // cancelled
if (p.page_num === _wbCurrentPage) continue; // already rendered
const thumbEl = document.querySelector(`.lh-thumb-item[data-page="${p.page_num}"] canvas`);
if (!thumbEl || thumbEl._rendered) continue;
try {
const res = await LS.get(`/api/classroom/${sessionId}/strokes?page_num=${p.page_num}`);
if (_thumbRenderJob !== jobId) return;
const tmpCvs = document.createElement('canvas');
tmpCvs.width = 192; tmpCvs.height = 108;
const tmpWb = new Whiteboard(tmpCvs, { readOnly: true });
tmpWb.setTemplate(res.template || 'blank');
tmpWb.loadStrokes(res.strokes || []);
tmpWb.renderThumbnail(thumbEl);
tmpWb.destroy();
thumbEl._rendered = true;
} catch {}
// Small yield between requests
await new Promise(r => setTimeout(r, 80));
}
}
function lhZoom(delta) {
if (!_wb) return;
_wb.zoomTo(Math.max(0.25, Math.min(4, (_wb._zoom || 1) + delta)));
document.getElementById('lh-zoom-lbl').textContent = Math.round(_wb._zoom * 100) + '%';
}
function lhZoomFit() {
if (!_wb) return;
_wb.zoomFitStrokes();
}
function exportBoardPage() {
if (!_wb) return;
_wb.exportPNG();
}
/* Сохранить доску/фрагмент в «Мои материалы» (учитель) — общий модуль board-clip.js */
function _matSource() {
const s = _activeSession && _activeSession.session;
return { sourceSessionId: s ? s.id : null, sourceTitle: s ? (s.title || 'Урок') : null, pageNum: (_wbCurrentPage || 1) };
}
function saveBoardToMaterials(btn) {
if (!_wb) { LS.toast('Откройте страницу доски', 'warn'); return; }
BoardClip.savePage(_wb, _matSource(), btn);
}
function saveBoardRegion(btn) {
if (!_wb) { LS.toast('Откройте страницу доски', 'warn'); return; }
BoardClip.saveRegion(_wb, _matSource(), btn);
}
/* ─── Chat ─── */
async function loadChat() {
_chatLoaded = true;
const sessionId = _activeSession.session.id;
const container = document.getElementById('lh-chat-list');
const toolbar = document.getElementById('lh-chat-toolbar');
container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--lh-text-3)">Загрузка...</div>';
// Export button (teacher only)
if (_isTeacher) {
toolbar.innerHTML = `<a class="lh-chat-export-btn" href="/api/classroom/${sessionId}/chat/export" download>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" 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="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Экспортировать чат
</a>`;
}
try {
const data = await LS.crGetChat(sessionId);
const msgs = data.messages || [];
// Update tab count
const cnt = document.getElementById('tab-chat-count');
if (msgs.length) { cnt.textContent = msgs.length; cnt.style.display = 'inline'; }
if (!msgs.length) {
container.innerHTML = `<div class="lh-empty"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><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><p>В этом уроке сообщений нет</p></div>`;
return;
}
container.innerHTML = msgs.map(m => renderMsg(m)).join('');
} catch (e) {
container.innerHTML = `<div class="lh-empty"><p>Ошибка загрузки чата</p></div>`;
}
}
function renderMsg(m) {
const color = strToColor(m.user_name || '');
const time = m.created_at ? new Date(m.created_at).toLocaleTimeString('ru-RU', { hour:'2-digit', minute:'2-digit' }) : '';
const pinned = m.pinned ? ' pinned' : '';
const avatarLetter = (m.user_name || '?')[0].toUpperCase();
let body = m.message ? `<div class="lh-msg-text">${LS.escapeHtml(m.message)}</div>` : '';
if (m.attachment_url) {
const isImg = /\.(jpe?g|png|gif|webp)(\?|$)/i.test(m.attachment_url);
if (isImg) body += `<img class="lh-msg-img" src="${LS.escapeHtml(m.attachment_url)}" alt="вложение" loading="lazy"/>`;
else body += `<a href="${LS.escapeHtml(m.attachment_url)}" target="_blank" style="font-size:.78rem;color:#9B5DE5"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;margin-right:3px"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg> Вложение</a>`;
}
return `<div class="lh-msg${pinned}">
<div class="lh-msg-avatar" style="background:${color}">${avatarLetter}</div>
<div class="lh-msg-body">
<div class="lh-msg-head">
<span class="lh-msg-name">${LS.escapeHtml(m.user_name || '?')}</span>
<span class="lh-msg-time">${time}</span>
</div>
${body}
</div>
</div>`;
}
/* ─── Attendance ─── */
async function loadAttendance() {
_attendLoaded = true;
const sessionId = _activeSession.session.id;
const tbody = document.getElementById('lh-attend-body');
const sumEl = document.getElementById('lh-attend-summary');
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--lh-text-3);padding:24px">Загрузка...</td></tr>';
const attendance = _activeSession.attendance || [];
if (!attendance.length) {
sumEl.innerHTML = '';
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--lh-text-3);padding:24px">Нет данных о посещаемости</td></tr>';
return;
}
const avgDur = attendance.filter(a => a.duration_sec).reduce((s,a) => s + a.duration_sec, 0) / (attendance.filter(a => a.duration_sec).length || 1);
sumEl.innerHTML = `
<div class="lh-attend-kpi"><div class="lh-attend-kpi-val">${attendance.length}</div><div class="lh-attend-kpi-lbl">Участников</div></div>
<div class="lh-attend-kpi"><div class="lh-attend-kpi-val">${fmtDuration(Math.round(avgDur))}</div><div class="lh-attend-kpi-lbl">Среднее время</div></div>
`;
tbody.innerHTML = attendance.map(a => {
const joined = a.joined_at ? new Date(a.joined_at).toLocaleTimeString('ru-RU', { hour:'2-digit', minute:'2-digit' }) : '—';
const left = a.left_at ? new Date(a.left_at).toLocaleTimeString('ru-RU', { hour:'2-digit', minute:'2-digit' }) : '—';
const dur = a.duration_sec ? `<span class="lh-attend-dur">${fmtDuration(a.duration_sec)}</span>` : '—';
return `<tr>
<td><strong>${LS.escapeHtml(a.user_name || '?')}</strong></td>
<td>${joined}</td><td>${left}</td><td>${dur}</td>
</tr>`;
}).join('');
}
/* ─── Notes ─── */
async function loadNotes() {
_notesLoaded = true;
const sessionId = _activeSession.session.id;
const container = document.getElementById('lh-notes-list');
container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--lh-text-3)">Загрузка...</div>';
try {
if (_isTeacher) {
const data = await LS.crGetAllNotes(sessionId);
const notes = data.notes || [];
if (!notes.length) {
container.innerHTML = `<div class="lh-empty"><p>Ученики не оставили заметок</p></div>`;
return;
}
container.innerHTML = notes.map(n => `
<div class="lh-note-card">
<div class="lh-note-head">
<div class="lh-note-author">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>
${LS.escapeHtml(n.user_name || '?')}
</div>
<div class="lh-note-time">${n.updated_at ? new Date(n.updated_at).toLocaleDateString('ru-RU') : ''}</div>
</div>
<div class="lh-note-text">${LS.escapeHtml(n.content)}</div>
</div>`).join('');
} else {
// Student: own notes
const data = await LS.get(`/api/classroom/${sessionId}/notes`);
const content = data.content || '';
if (!content.trim()) {
container.innerHTML = `<div class="lh-empty"><p>Вы не оставили заметок к этому уроку</p></div>`;
return;
}
container.innerHTML = `<div class="lh-own-note-box">
<div class="lh-own-note-lbl">Мои заметки</div>
<div class="lh-note-text">${LS.escapeHtml(content)}</div>
</div>`;
}
} catch {
container.innerHTML = `<div class="lh-empty"><p>Ошибка загрузки заметок</p></div>`;
}
}
/* ─── Delete history ─── */
function confirmDelete(id) {
const sid = id ?? _activeSession?.session?.id;
if (!sid) return;
_deleteId = sid;
const session = _sessions.find(s => s.id === sid) || _activeSession?.session;
const name = session?.title || 'Без названия';
document.getElementById('confirm-sname').textContent = `"${name}"`;
document.getElementById('confirm-del-btn').disabled = false;
document.getElementById('lh-confirm-overlay').classList.remove('hidden');
}
function hideConfirmDelete() {
_deleteId = null;
document.getElementById('lh-confirm-overlay').classList.add('hidden');
}
async function doDelete() {
if (!_deleteId) return;
const btn = document.getElementById('confirm-del-btn');
btn.disabled = true;
btn.textContent = 'Удаление...';
try {
await LS.crDeleteHistory(_deleteId);
// Remove from local list
_sessions = _sessions.filter(s => s.id !== _deleteId);
renderList(_sessions);
updateKPI(_sessions);
// If deleted session was open, close detail
if (_activeSession?.session?.id === _deleteId) {
_activeSession = null;
_chatLoaded = false; _attendLoaded = false; _notesLoaded = false;
document.getElementById('lh-detail-empty').style.display = 'flex';
document.getElementById('lh-detail-content').style.display = 'none';
const url = new URL(location.href);
url.searchParams.delete('session');
history.replaceState({}, '', url);
}
hideConfirmDelete();
LS.toast('Урок удалён', 'success');
} catch (e) {
btn.disabled = false;
btn.textContent = 'Удалить навсегда';
LS.toast('Ошибка удаления: ' + (e.message || ''), 'error');
}
}
/* ─── Helpers ─── */
function fmtDuration(sec) {
if (!sec || sec < 0) return '0 мин';
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
if (h > 0) return `${h}ч ${m}м`;
if (m > 0) return `${m} мин`;
return `${sec} сек`;
}
function plural(n, one, few, many) {
const mod10 = n % 10, mod100 = n % 100;
if (mod10 === 1 && mod100 !== 11) return `${n} ${one}`;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return `${n} ${few}`;
return `${n} ${many}`;
}
function strToColor(str) {
const colors = ['#9B5DE5','#06D6E0','#FF6B6B','#4361EE','#A8E063','#F15BB5','#FF9F43','#06D6A0'];
let h = 0;
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) & 0xffff;
return colors[h % colors.length];
}
window.addEventListener('resize', () => {
if (window.innerWidth > 768) {
document.getElementById('lh-list-panel').classList.remove('hidden-mobile');
}
});
// Close pages popup when clicking outside
document.addEventListener('click', e => {
if (!_pagesPopupOpen) return;
const popup = document.getElementById('lh-pages-popup');
const btn = document.getElementById('lh-page-indicator');
if (popup && !popup.contains(e.target) && btn && !btn.contains(e.target)) {
lhClosePages();
}
});
// Keyboard navigation for board pages
document.addEventListener('keydown', e => {
if (!_activeSession || e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
const boardVisible = document.getElementById('tc-board')?.style.display !== 'none';
if (!boardVisible) return;
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); lhPageNav(-1); }
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); lhPageNav(1); }
if (e.key === '0') lhZoomFit();
});
</script>
</body>
</html>