Files
Maxim Dolgolyov fcb8ef77bd feat(materials): сохранять доску/фрагмент прямо на онлайн-уроке
Выделение области и сохранение страницы доски теперь доступны ученику ВО ВРЕМЯ живого урока
(classroom.html), не только в просмотре прошлых уроков.

- Вынес общий модуль /js/board-clip.js (BoardClip.savePage / saveRegion + кроп-оверлей),
  переиспользуется в classroom.html и my-lessons.html (убрал дубль ~120 строк из my-lessons).
- classroom.html: кнопки «Область» и «К себе» в ученической панели (#cr-student-nav),
  обёртки crSaveBoardPage/crSaveBoardRegion над живым _wb + контекст сессии.
- Бэкенд без изменений (используется существующий /api/files + /api/materials).

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

1184 lines
59 KiB
HTML
Raw Permalink 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>
.ml-wrap {
--r: 12px;
--ml-bg: #f5f4fb;
--ml-surface: #ffffff;
--ml-surface-2: #f0eefb;
--ml-border: rgba(155,93,229,0.18);
--ml-border-h: rgba(155,93,229,0.4);
--ml-text: #1a1433;
--ml-text-2: #4a3f6b;
--ml-text-3: #8f86a8;
}
.app-layout { height: 100vh; overflow: hidden; }
.sb-content { height: 100%; overflow: hidden; }
.ml-wrap {
display: flex; flex-direction: column; gap: 0;
height: 100%; overflow: hidden;
background: var(--ml-bg); color: var(--ml-text);
}
/* ── Header ── */
.ml-header {
padding: 16px 32px 14px;
border-bottom: 1px solid var(--ml-border);
background: var(--ml-surface);
flex-shrink: 0;
}
.ml-header-top {
display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
}
.ml-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;
}
.ml-title svg { color: #9B5DE5; -webkit-text-fill-color: initial; filter: drop-shadow(0 0 8px rgba(155,93,229,0.4)); }
.ml-filters {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 10px;
}
.ml-search {
display: flex; align-items: center; gap: 8px;
border: 1.5px solid var(--ml-border); border-radius: 10px;
padding: 8px 14px; background: var(--ml-surface);
transition: border-color .18s, box-shadow .18s;
}
.ml-search:focus-within { border-color: var(--violet); box-shadow: 0 0 0 3px rgba(155,93,229,0.15); }
.ml-search svg { color: var(--ml-text-3); flex-shrink: 0; }
.ml-search input {
border: none; outline: none; background: none;
font-family: 'Manrope', sans-serif; font-size: 0.82rem; color: var(--ml-text);
width: 190px;
}
.ml-search input::placeholder { color: var(--ml-text-3); }
/* ── KPI ── */
.ml-kpi {
display: flex; gap: 8px; padding: 10px 32px 12px; flex-shrink: 0; flex-wrap: wrap;
}
.ml-kpi-card {
flex: 1; min-width: 140px;
background: var(--ml-surface); border: 1px solid var(--ml-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;
}
.ml-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;
}
.ml-kpi-card:hover { border-color: rgba(155,93,229,0.25); box-shadow: 0 2px 12px rgba(155,93,229,0.1); }
.ml-kpi-icon {
width: 36px; height: 36px; border-radius: 10px;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.ml-kpi-icon svg { width: 16px; height: 16px; }
.ml-kpi-icon.violet { background: rgba(155,93,229,0.12); color: #9B5DE5; }
.ml-kpi-icon.cyan { background: rgba(6,214,224,0.1); color: #06D6E0; }
.ml-kpi-icon.pink { background: rgba(241,91,181,0.1); color: #F15BB5; }
.ml-kpi-icon.yellow { background: rgba(255,224,102,0.1); color: #FFE066; }
.ml-kpi-val { font-family: 'Unbounded', sans-serif; font-size: 1.05rem; font-weight: 800; color: var(--ml-text); }
.ml-kpi-lbl { font-size: 0.68rem; color: var(--ml-text-3); margin-top: 1px; font-weight: 500; }
/* ── Body ── */
.ml-body { display: flex; flex: 1; overflow: hidden; gap: 0; }
/* ── List panel ── */
.ml-list-panel {
width: 340px; flex-shrink: 0;
border-right: 1px solid var(--ml-border);
display: flex; flex-direction: column; overflow: hidden;
background: var(--ml-surface-2);
}
@media (max-width: 768px) {
.ml-list-panel { width: 100%; border-right: none; }
.ml-list-panel.hidden-mobile { display: none; }
.ml-detail-panel.hidden-mobile { display: none; }
}
.ml-list-scroll {
flex: 1; overflow-y: auto; padding: 12px;
scrollbar-width: thin; scrollbar-color: rgba(155,93,229,0.2) transparent;
}
.ml-list-header { padding: 10px 12px 0; flex-shrink: 0; }
.ml-sort-row {
display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0 4px 8px;
}
.ml-count-badge { font-size: 0.75rem; color: var(--ml-text-3); font-weight: 600; }
.ml-sort-select {
padding: 5px 10px; border-radius: 8px; border: 1px solid var(--ml-border);
background: var(--ml-surface); color: var(--ml-text-2); font-family: 'Manrope', sans-serif;
font-size: 0.75rem; font-weight: 600; cursor: pointer; outline: none;
transition: border-color .15s;
}
.ml-sort-select:focus { border-color: var(--violet); }
.ml-sort-select option { background: #ffffff; color: #1a1433; }
/* ── Session card ── */
.ml-session-card {
position: relative;
background: var(--ml-surface); border: 1px solid var(--ml-border);
border-left: 3px solid rgba(6,214,224,0.45);
border-radius: var(--r); padding: 14px 16px; cursor: pointer;
transition: all .18s ease; margin-bottom: 8px;
}
.ml-session-card:hover {
border-color: var(--ml-border-h);
border-left-color: #06D6E0;
box-shadow: 0 8px 32px rgba(6,214,224,0.15);
transform: translateY(-2px);
}
.ml-session-card.active {
border-color: rgba(6,214,224,0.35); border-left-color: #06D6E0;
background: linear-gradient(135deg, rgba(6,214,224,0.07), rgba(155,93,229,0.04));
box-shadow: 0 0 24px rgba(6,214,224,0.1);
}
.ml-card-row1 {
display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 8px;
}
.ml-card-title {
font-weight: 700; font-size: 0.85rem; color: var(--ml-text);
line-height: 1.35; flex: 1;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.ml-card-date { font-size: 0.72rem; color: var(--ml-text-3); white-space: nowrap; flex-shrink: 0; margin-top: 2px; }
.ml-card-row2 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.ml-card-teacher {
font-size: 0.72rem; font-weight: 700; color: #06D6E0;
background: rgba(6,214,224,0.1); padding: 3px 10px; border-radius: 99px;
display: flex; align-items: center; gap: 5px;
}
.ml-card-class {
font-size: 0.72rem; font-weight: 700; color: #9B5DE5;
background: rgba(155,93,229,0.1); padding: 3px 10px; border-radius: 99px;
}
.ml-card-stat {
display: flex; align-items: center; gap: 4px;
font-size: 0.72rem; color: var(--ml-text-2); font-weight: 500;
}
.ml-card-stat svg { color: var(--ml-text-3); }
/* My time badge */
.ml-card-mytime {
display: inline-flex; align-items: center; gap: 5px;
background: linear-gradient(135deg, rgba(6,214,224,0.1), rgba(155,93,229,0.07));
border: 1px solid rgba(6,214,224,0.2);
border-radius: 99px; padding: 3px 10px;
font-size: 0.72rem; font-weight: 700; color: #06D6E0;
}
/* ── Date separator ── */
.ml-date-sep {
font-size: 0.68rem; font-weight: 700; color: var(--ml-text-3);
text-transform: uppercase; letter-spacing: 0.08em;
padding: 10px 8px 6px; margin-top: 6px;
display: flex; align-items: center; gap: 8px;
}
.ml-date-sep::before {
content: '';
width: 16px; height: 16px; border-radius: 6px;
background: rgba(6,214,224,0.1);
flex-shrink: 0;
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='%2306D6E0' 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 ── */
.ml-empty {
text-align: center; padding: 56px 24px; color: var(--ml-text-3);
display: flex; flex-direction: column; align-items: center; gap: 12px;
}
.ml-empty svg { display: block; opacity: 0.2; filter: drop-shadow(0 0 20px rgba(6,214,224,0.3)); }
.ml-empty p { font-size: 0.85rem; margin: 0; }
/* ── Pagination ── */
.ml-pagination {
display: flex; align-items: center; justify-content: center; gap: 8px;
padding: 12px; border-top: 1px solid var(--ml-border); flex-shrink: 0;
}
.ml-page-btn {
padding: 6px 14px; border-radius: 10px; border: 1px solid var(--ml-border);
background: var(--ml-surface); color: var(--ml-text-2); font-size: 0.78rem; font-weight: 600;
cursor: pointer; transition: all .15s;
}
.ml-page-btn:hover:not(:disabled) { border-color: var(--violet); color: #9B5DE5; box-shadow: 0 0 12px rgba(155,93,229,0.15); }
.ml-page-btn:disabled { opacity: 0.35; cursor: default; }
.ml-page-info { font-size: 0.78rem; color: var(--ml-text-3); }
/* ── Skeleton ── */
.ml-skeleton {
background: linear-gradient(90deg, rgba(6,214,224,0.06) 25%, rgba(6,214,224,0.12) 50%, rgba(6,214,224,0.06) 75%);
background-size: 200% 100%; border-radius: 8px;
animation: ml-shimmer 1.6s ease-in-out infinite;
}
@keyframes ml-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
.ml-sk-card { height: 84px; border-radius: var(--r); margin-bottom: 8px; }
/* ── Detail panel ── */
.ml-detail-panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; background: var(--ml-bg); }
.ml-detail-empty {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 18px;
color: var(--ml-text-3); padding: 40px;
}
.ml-detail-empty svg { opacity: 0.15; filter: drop-shadow(0 0 30px rgba(6,214,224,0.3)); }
.ml-detail-empty p { font-size: 0.9rem; margin: 0; }
.ml-detail-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
/* ── Detail header ── */
.ml-detail-hdr {
padding: 10px 22px 8px; border-bottom: 1px solid var(--ml-border);
background: var(--ml-surface); flex-shrink: 0;
}
.ml-detail-back {
display: none; align-items: center; gap: 6px;
font-size: 0.78rem; font-weight: 600; color: var(--ml-text-3);
cursor: pointer; margin-bottom: 12px; background: none; border: none; padding: 0; transition: color .15s;
}
.ml-detail-back:hover { color: #06D6E0; }
@media (max-width: 768px) { .ml-detail-back { display: flex; } }
.ml-detail-title {
font-family: 'Unbounded', sans-serif; font-size: 0.95rem; font-weight: 800;
color: var(--ml-text); margin: 0 0 6px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ml-detail-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 6px; }
.ml-meta-item {
display: inline-flex; align-items: center; gap: 5px;
font-size: 0.7rem; font-weight: 600; color: var(--ml-text-2);
background: rgba(6,214,224,0.06); padding: 3px 9px;
border-radius: 99px; border: 1px solid rgba(6,214,224,0.12);
}
.ml-meta-item svg { color: var(--ml-text-3); }
.ml-detail-stats { display: flex; gap: 6px; flex-wrap: wrap; }
.ml-stat-chip {
display: flex; align-items: center; gap: 5px;
background: var(--ml-surface); border: 1px solid var(--ml-border);
border-radius: 99px; padding: 3px 10px;
font-size: 0.7rem; font-weight: 700; color: var(--ml-text-2);
}
.ml-stat-chip svg { color: var(--ml-text-3); }
/* My time chip — highlighted */
.ml-stat-chip.mytime {
background: linear-gradient(135deg, rgba(6,214,224,0.08), rgba(155,93,229,0.06));
border-color: rgba(6,214,224,0.25); color: #06D6E0;
}
/* ── Tabs ── */
.ml-tabs {
display: flex; gap: 4px; padding: 8px 22px;
border-bottom: 1px solid var(--ml-border); background: var(--ml-surface); flex-shrink: 0;
}
.ml-tab {
padding: 6px 14px; border-radius: 8px; border: 1px solid transparent;
font-size: 0.79rem; font-weight: 600; color: var(--ml-text-3);
cursor: pointer; background: none; transition: all .18s;
display: flex; align-items: center; gap: 6px;
}
.ml-tab:hover { color: var(--ml-text-2); background: rgba(6,214,224,0.07); }
.ml-tab.active {
color: #06D6E0; background: rgba(6,214,224,0.1);
border-color: rgba(6,214,224,0.25); box-shadow: 0 0 16px rgba(6,214,224,0.08);
}
.ml-tab svg { width: 13px; height: 13px; }
.ml-tab-content { flex: 1; overflow: hidden; min-height: 0; }
/* ── Board tab (identical to lesson-history) ── */
.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; }
.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(6,214,224,0.2); border-color: rgba(6,214,224,0.4); color: #06D6E0; }
.lh-board-nav-btn:disabled { opacity: 0.18; cursor: default; }
.lh-page-indicator {
display: flex; align-items: center; gap: 8px; padding: 6px 14px; border-radius: 10px;
background: rgba(6,214,224,0.06); border: 1.5px solid rgba(6,214,224,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;
}
.lh-page-indicator:hover { background: rgba(6,214,224,0.12); border-color: rgba(6,214,224,0.35); }
.lh-page-indicator.open { background: rgba(6,214,224,0.15); border-color: #06D6E0; box-shadow: 0 0 16px rgba(6,214,224,0.2); }
.lh-page-ind-title { max-width: 160px; overflow: hidden; text-overflow: ellipsis; }
.lh-page-ind-counter { color: rgba(6,214,224,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); }
.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(6,214,224,0.25);
border-radius: 16px; padding: 14px;
box-shadow: 0 20px 60px rgba(0,0,0,0.7); backdrop-filter: blur(20px);
display: grid; grid-template-columns: repeat(4, 120px); gap: 10px;
max-height: 70vh; overflow-y: auto; scrollbar-width: thin;
}
.lh-pages-popup.hidden { display: none; }
@media (max-width: 700px) { .lh-pages-popup { grid-template-columns: repeat(2, 140px); } }
.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(6,214,224,0.45); transform: translateY(-2px); }
.lh-thumb-item.active { border-color: #06D6E0; box-shadow: 0 0 0 3px rgba(6,214,224,0.2); }
.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(6,214,224,0.65); font-family: 'Manrope', sans-serif; font-weight: 800; }
.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; }
.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(6,214,224,0.22);
border-radius: 14px; padding: 5px 6px; backdrop-filter: blur(16px);
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
}
.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(6,214,224,0.2); color: #06D6E0; }
.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; }
.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(6,214,224,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);
}
.lh-export-btn:hover { background: rgba(6,214,224,0.25); color: #fff; border-color: rgba(6,214,224,0.45); }
.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(6,214,224,0.12); border-top-color: #06D6E0;
animation: ml-spin 0.75s linear infinite;
}
@keyframes ml-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(6,214,224,0.2) transparent;
}
.lh-chat-toolbar { display: flex; justify-content: flex-end; padding: 12px 22px 0; flex-shrink: 0; }
.lh-msg {
display: flex; align-items: flex-start; gap: 12px;
padding: 10px 14px; border-radius: 12px; transition: background .15s;
}
.lh-msg:hover { background: rgba(6,214,224,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;
}
.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(--ml-text); }
.lh-msg-time { font-size: 0.72rem; color: var(--ml-text-3); }
.lh-msg-text {
font-size: 0.83rem; color: var(--ml-text-2); line-height: 1.55;
word-break: break-word; white-space: pre-wrap;
background: rgba(6,214,224,0.04); padding: 8px 14px; border-radius: 0 12px 12px 12px;
border: 1px solid rgba(6,214,224,0.08);
}
.lh-msg-img { max-width: 220px; border-radius: 10px; margin-top: 6px; display: block; }
.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.mine .lh-msg-text {
background: rgba(6,214,224,0.1); border-color: rgba(6,214,224,0.2); color: var(--ml-text);
}
/* ── Notes tab ── */
.lh-notes-wrap {
height: 100%; overflow-y: auto; padding: 18px 22px;
scrollbar-width: thin; scrollbar-color: rgba(6,214,224,0.2) transparent;
}
.lh-own-note-box {
background: rgba(6,214,224,0.04); border: 1px solid rgba(6,214,224,0.15);
border-radius: var(--r); padding: 18px; margin-bottom: 12px;
}
.lh-own-note-lbl { font-size: 0.78rem; font-weight: 700; color: #06D6E0; margin-bottom: 10px; }
.lh-note-text { font-size: 0.83rem; color: var(--ml-text-2); line-height: 1.65; white-space: pre-wrap; word-break: break-word; }
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<main class="sb-content">
<div class="ml-wrap">
<!-- Header -->
<div class="ml-header">
<div class="ml-header-top">
<h1 class="ml-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="M22 10v6M2 10l10-5 10 5-10 5z"/><path d="M6 12v5c3 3 9 3 12 0v-5"/></svg>
Мои уроки
</h1>
</div>
<div class="ml-filters">
<div class="ml-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="ml-search" placeholder="Поиск по названию, учителю..." oninput="onSearch()"/>
</div>
</div>
</div>
<!-- KPI -->
<div class="ml-kpi" id="ml-kpi">
<div class="ml-kpi-card">
<div class="ml-kpi-icon cyan"><svg 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></div>
<div><div class="ml-kpi-val" id="kpi-total">&mdash;</div><div class="ml-kpi-lbl">Уроков посещено</div></div>
</div>
<div class="ml-kpi-card">
<div class="ml-kpi-icon violet"><svg 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="ml-kpi-val" id="kpi-time">&mdash;</div><div class="ml-kpi-lbl">Общее время</div></div>
</div>
<div class="ml-kpi-card">
<div class="ml-kpi-icon pink"><svg 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="ml-kpi-val" id="kpi-teachers">&mdash;</div><div class="ml-kpi-lbl">Учителей</div></div>
</div>
<div class="ml-kpi-card">
<div class="ml-kpi-icon yellow"><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></div>
<div><div class="ml-kpi-val" id="kpi-msgs">&mdash;</div><div class="ml-kpi-lbl">Сообщений в чатах</div></div>
</div>
</div>
<!-- Body -->
<div class="ml-body">
<!-- Session list -->
<div class="ml-list-panel" id="ml-list-panel">
<div class="ml-list-header">
<div class="ml-sort-row">
<span class="ml-count-badge" id="ml-count-badge"></span>
<select class="ml-sort-select" id="ml-sort" onchange="onSort()">
<option value="newest">Сначала новые</option>
<option value="oldest">Сначала старые</option>
<option value="longest">По длительности</option>
</select>
</div>
</div>
<div class="ml-list-scroll" id="ml-list">
<div class="ml-sk-card ml-skeleton"></div>
<div class="ml-sk-card ml-skeleton"></div>
<div class="ml-sk-card ml-skeleton"></div>
</div>
<div class="ml-pagination" id="ml-pagination" style="display:none">
<button class="ml-page-btn" id="ml-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="ml-page-info" id="ml-page-info">1 / 1</span>
<button class="ml-page-btn" id="ml-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 -->
<div class="ml-detail-panel" id="ml-detail-panel">
<div class="ml-detail-empty" id="ml-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="M22 10v6M2 10l10-5 10 5-10 5z"/><path d="M6 12v5c3 3 9 3 12 0v-5"/></svg>
<p>Выберите урок из списка слева</p>
</div>
<div class="ml-detail-content" id="ml-detail-content" style="display:none">
<div class="ml-detail-hdr">
<button class="ml-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>
<h2 class="ml-detail-title" id="det-title">&mdash;</h2>
<div class="ml-detail-meta">
<span class="ml-meta-item" id="det-date">
<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="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="ml-meta-item" id="det-teacher">
<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="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="ml-meta-item" id="det-class" style="display:none">
<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="M22 10v6M2 10l10-5 10 5-10 5z"/><path d="M6 12v5c3 3 9 3 12 0v-5"/></svg>
</span>
</div>
<div class="ml-detail-stats" id="det-stats"></div>
</div>
<!-- Tabs -->
<div class="ml-tabs">
<button class="ml-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="ml-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(6,214,224,.15);color:#06D6E0;font-size:10px;padding:2px 7px;border-radius:99px;font-weight:700"></span>
</button>
<button class="ml-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="ml-tab-content">
<!-- Board -->
<div id="tc-board" class="lh-board-wrap">
<div class="lh-board-main">
<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>
<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>
<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>
<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)"><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)"><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()"><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>
<!-- 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>
<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>
let _me = null;
let _sessions = [];
let _allSessions = []; // unfiltered for search
let _totalPages = 1;
let _curPage = 1;
let _searchTimer = null;
let _sortMode = 'newest';
let _activeSession = null;
let _wb = null;
let _wbPages = [];
let _wbCurrentPage = 1;
/* ─── Init ─── */
(function() {
const { user } = LS.initPage();
if (!user) return;
// Teachers and admins belong on lesson-history
if (user.role === 'teacher' || user.role === 'admin') {
location.replace('/lesson-history');
return;
}
_me = user;
if (window.lucide) lucide.createIcons();
loadSessions();
checkUrlSession();
})();
function checkUrlSession() {
const sp = new URLSearchParams(location.search);
const sid = sp.get('session');
if (sid) setTimeout(() => openSession(Number(sid)), 600);
}
/* ─── Sessions list ─── */
async function loadSessions() {
const list = document.getElementById('ml-list');
list.innerHTML = '<div class="ml-sk-card ml-skeleton"></div><div class="ml-sk-card ml-skeleton"></div><div class="ml-sk-card ml-skeleton"></div>';
try {
const data = await LS.crGetMyHistory(_curPage);
_allSessions = data.sessions || [];
_sessions = _allSessions;
_totalPages = data.pages || 1;
applySearch();
renderPagination(_curPage, _totalPages, data.total || 0);
updateKPI(_allSessions);
} catch (e) {
list.innerHTML = `<div class="ml-empty"><p>Ошибка загрузки: ${LS.escapeHtml(e.message || 'неизвестная ошибка')}</p></div>`;
}
}
function onSearch() {
clearTimeout(_searchTimer);
_searchTimer = setTimeout(applySearch, 250);
}
function applySearch() {
const q = (document.getElementById('ml-search')?.value || '').trim().toLowerCase();
_sessions = q
? _allSessions.filter(s =>
(s.title || '').toLowerCase().includes(q) ||
(s.teacher_name || '').toLowerCase().includes(q) ||
(s.class_name || '').toLowerCase().includes(q))
: _allSessions;
renderList(_sessions);
}
function onSort() {
_sortMode = document.getElementById('ml-sort').value;
renderList(_sessions);
}
function renderList(sessions) {
const list = document.getElementById('ml-list');
const badge = document.getElementById('ml-count-badge');
if (badge) badge.textContent = sessions.length ? plural(sessions.length, 'урок','урока','уроков') : '';
if (!sessions.length) {
list.innerHTML = `<div class="ml-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="M22 10v6M2 10l10-5 10 5-10 5z"/><path d="M6 12v5c3 3 9 3 12 0v-5"/></svg>
<p>Нет посещённых уроков</p></div>`;
return;
}
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);
});
// 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="ml-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 teacher = s.teacher_name
? `<span class="ml-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 cls = s.class_name
? `<span class="ml-card-class">${LS.escapeHtml(s.class_name)}</span>` : '';
const durChip = dur
? `<span class="ml-card-mytime">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
${dur}
</span>` : '';
const msgs = (s.message_count||0) > 0
? `<span class="ml-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>` : '';
return `<div class="ml-session-card${active}" data-id="${s.id}" onclick="openSession(${s.id})">
<div class="ml-card-row1">
<div class="ml-card-title">${LS.escapeHtml(s.title || 'Без названия')}</div>
<div class="ml-card-date">${dateStr}</div>
</div>
<div class="ml-card-row2">${teacher}${cls}${durChip}${msgs}</div>
</div>`;
}
function renderPagination(page, total, count) {
const pg = document.getElementById('ml-pagination');
const prev = document.getElementById('ml-prev');
const next = document.getElementById('ml-next');
const info = document.getElementById('ml-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 teachers = new Set(sessions.map(s => s.teacher_name).filter(Boolean)).size;
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-teachers').textContent = teachers;
document.getElementById('kpi-msgs').textContent = msgs;
}
/* ─── Session detail ─── */
async function openSession(id) {
document.querySelectorAll('.ml-session-card').forEach(el => {
el.classList.toggle('active', Number(el.dataset.id) === id);
});
document.getElementById('ml-detail-empty').style.display = 'none';
document.getElementById('ml-detail-content').style.display = 'flex';
document.getElementById('ml-detail-content').style.flexDirection = 'column';
if (window.innerWidth <= 768) {
document.getElementById('ml-list-panel').classList.add('hidden-mobile');
}
const url = new URL(location.href);
url.searchParams.set('session', id);
history.replaceState({}, '', url);
_chatLoaded = false; _notesLoaded = false;
try {
const data = await LS.crGetSessionSummary(id);
_activeSession = data;
renderDetailHeader(data);
_wbPages = data.pages || [];
_wbCurrentPage = 1;
switchTab('board');
renderThumbs();
loadBoardPage(1);
} catch (e) {
LS.toast('Ошибка загрузки урока: ' + (e.message || ''), 'error');
}
}
function renderDetailHeader(data) {
const s = data.session;
const st = data.stats;
const me = data.attendance?.find(a => a.user_id === _me?.id);
document.getElementById('det-title').textContent = s.title || 'Без названия';
const d = s.ended_at ? new Date(s.ended_at) : null;
const dateEl = document.getElementById('det-date');
dateEl.innerHTML = `<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="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>
${d ? d.toLocaleDateString('ru-RU', { day:'numeric', month:'long', year:'numeric' }) : '—'}`;
const teachEl = document.getElementById('det-teacher');
teachEl.innerHTML = `<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="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 || '')}`;
const clsEl = document.getElementById('det-class');
if (s.class_name) {
clsEl.style.display = 'flex';
clsEl.innerHTML = `<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="M22 10v6M2 10l10-5 10 5-10 5z"/><path d="M6 12v5c3 3 9 3 12 0v-5"/></svg>
${LS.escapeHtml(s.class_name)}`;
} else {
clsEl.style.display = 'none';
}
// Stats chips
const chips = [];
if (st.duration_sec) chips.push(`<span class="ml-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 (me?.duration_sec) chips.push(`<span class="ml-stat-chip mytime"><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(me.duration_sec)}</span>`);
if (st.page_count > 1) chips.push(`<span class="ml-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.message_count) chips.push(`<span class="ml-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('');
// Chat count badge
const cc = document.getElementById('tab-chat-count');
if (cc && st.message_count) {
cc.style.display = 'inline';
cc.textContent = st.message_count;
} else if (cc) {
cc.style.display = 'none';
}
}
function backToList() {
document.getElementById('ml-list-panel').classList.remove('hidden-mobile');
document.getElementById('ml-detail-content').style.display = 'none';
document.getElementById('ml-detail-empty').style.display = 'flex';
const url = new URL(location.href);
url.searchParams.delete('session');
history.replaceState({}, '', url);
}
/* ─── Tabs ─── */
let _chatLoaded = false, _notesLoaded = false;
function switchTab(name) {
['board','chat','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 === 'chat' || t === 'board' ? 'flex' : 'block') : 'none';
});
if (name === 'chat' && _activeSession && !_chatLoaded) loadChat();
if (name === 'notes' && _activeSession && !_notesLoaded) loadNotes();
}
/* ─── Board ─── */
function renderThumbs() {
const container = document.getElementById('lh-thumbs');
if (!container) return;
container.innerHTML = '';
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');
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;
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');
loading.style.display = 'flex';
try {
const res = await LS.get(`/api/classroom/${sessionId}/strokes?page_num=${pageNum}`);
if (_wb) { _wb.destroy(); _wb = null; }
await new Promise(r => requestAnimationFrame(r));
_wb = new Whiteboard(document.getElementById('lh-canvas'), {
readOnly: true,
onZoomChange: z => {
const el = document.getElementById('lh-zoom-lbl');
if (el) el.textContent = Math.round(z * 100) + '%';
},
});
_wb.setTemplate(res.template || 'blank');
_wb.loadStrokes(res.strokes || []);
_wb.zoomFitStrokes();
const thumbEl = document.querySelector(`.lh-thumb-item[data-page="${pageNum}"] canvas`);
if (thumbEl) _wb.renderThumbnail(thumbEl);
loading.style.display = 'none';
_renderAllThumbs(sessionId);
} catch {
loading.innerHTML = '<span style="color:rgba(255,100,100,.7)">Ошибка загрузки страницы</span>';
}
}
function updateBoardTopbar() {
const cur = _wbCurrentPage, 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 || `Страница ${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) {
const jobId = Date.now();
_thumbRenderJob = jobId;
for (const p of _wbPages) {
if (_thumbRenderJob !== jobId) return;
if (p.page_num === _wbCurrentPage) continue;
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 {}
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) _wb.zoomFitStrokes(); }
function exportBoardPage() { if (_wb) _wb.exportPNG(); }
/* ─── Сохранить материалы урока к себе («Мои материалы») ─── */
let _myNoteText = '';
function _matSource() {
const s = _activeSession && _activeSession.session;
return { sourceSessionId: s ? s.id : null, sourceTitle: s ? (s.title || 'Урок') : null };
}
function saveBoardToMaterials(btn) {
if (!_wb) { LS.toast('Откройте страницу доски', 'warn'); return; }
const s = _matSource();
BoardClip.savePage(_wb, { sourceSessionId: s.sourceSessionId, sourceTitle: s.sourceTitle, pageNum: (_wbCurrentPage || 1) }, btn);
}
async function saveNoteToMaterials(btn) {
const text = (_myNoteText || '').trim();
if (!text) { LS.toast('Заметка пустая', 'warn'); return; }
if (btn) btn.disabled = true;
try {
const src = _matSource();
await LS.saveMaterial({
kind: 'note',
title: 'Заметка · ' + (src.sourceTitle || 'Урок'),
body: text,
sourceSessionId: src.sourceSessionId, sourceTitle: src.sourceTitle,
});
LS.toast('Заметка сохранена в «Мои материалы»', 'success');
} catch (e) {
LS.toast(e.message || 'Ошибка сохранения', 'error');
} finally { if (btn) btn.disabled = false; }
}
/* Сохранить ЧАСТЬ доски: снимок страницы → выделение области → обрезка → «Мои материалы».
Логика вынесена в /js/board-clip.js (общая с онлайн-уроком). */
function saveBoardRegion(btn) {
if (!_wb) { LS.toast('Откройте страницу доски', 'warn'); return; }
const s = _matSource();
BoardClip.saveRegion(_wb, { sourceSessionId: s.sourceSessionId, sourceTitle: s.sourceTitle, pageNum: (_wbCurrentPage || 1) }, btn);
}
/* ─── Chat ─── */
async function loadChat() {
_chatLoaded = true;
const sessionId = _activeSession.session.id;
const container = document.getElementById('lh-chat-list');
container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--ml-text-3)">Загрузка...</div>';
try {
const data = await LS.crGetChat(sessionId);
const messages = data.messages || [];
if (!messages.length) {
container.innerHTML = '<div class="ml-empty"><p>Чат был пуст</p></div>';
return;
}
container.innerHTML = messages.map(m => renderMsg(m)).join('');
container.scrollTop = container.scrollHeight;
} catch {
container.innerHTML = '<div class="ml-empty"><p>Ошибка загрузки чата</p></div>';
}
}
function renderMsg(m) {
const time = m.created_at ? new Date(m.created_at).toLocaleTimeString('ru-RU', { hour:'2-digit', minute:'2-digit' }) : '';
const color = strToColor(m.user_name || '');
const ini = (m.user_name || '?')[0].toUpperCase();
const isMine = m.user_id === _me?.id;
let body = '';
if (m.message) body += `<div class="lh-msg-text">${LS.escapeHtml(m.message)}</div>`;
if (m.attachment_url) body += `<img class="lh-msg-img" src="${LS.escapeHtml(m.attachment_url)}" alt="вложение" loading="lazy"/>`;
return `<div class="lh-msg${m.is_pinned ? ' pinned' : ''}${isMine ? ' mine' : ''}">
<div class="lh-msg-avatar" style="background:${color}">${ini}</div>
<div class="lh-msg-body">
<div class="lh-msg-head">
<span class="lh-msg-name">${LS.escapeHtml(m.user_name || '?')}${isMine ? ' <span style="font-size:10px;color:var(--ml-text-3);font-weight:500">(вы)</span>' : ''}</span>
<span class="lh-msg-time">${time}</span>
</div>
${body}
</div>
</div>`;
}
/* ─── 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(--ml-text-3)">Загрузка...</div>';
try {
const data = await LS.get(`/api/classroom/${sessionId}/notes`);
const content = data.content || '';
_myNoteText = content;
if (!content.trim()) {
container.innerHTML = `<div class="ml-empty"><p>Вы не оставили заметок к этому уроку</p></div>`;
return;
}
container.innerHTML = `<div class="lh-own-note-box">
<div class="lh-own-note-lbl">Мои заметки
<button class="lh-export-btn" style="float:right" onclick="saveNoteToMaterials(this)" title="Сохранить в «Мои материалы»">К себе</button>
</div>
<div class="lh-note-text">${LS.escapeHtml(content)}</div>
</div>`;
} catch {
container.innerHTML = `<div class="ml-empty"><p>Ошибка загрузки заметок</p></div>`;
}
}
/* ─── 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('ml-list-panel').classList.remove('hidden-mobile');
});
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();
});
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>