758e1bf6cb
На дашборде ученика/учителя — баннер активной classroom-сессии: заголовок урока, для учителя «N онлайн», для ученика «Присоединиться/Вернуться», ссылка на /classroom (там сессия подхватывается автоматически). Данные — LS.crGetMySession (учитель → своя сессия, ученик → сессия его класса/приглашения). Нет активной сессии → баннер скрыт. Доска работает по WebSocket, дашборд — по SSE, поэтому добавлен отдельный SSE-сигнал classroom_live (state started/ended) ученикам класса/приглашённым/учителю в createSession и endSession (аддитивно, в try/catch — не ломает создание/завершение сессии). Баннер живо появляется/исчезает по этому событию + обновляется при возврате на вкладку. Verified: рендер баннера 10/10 (ученик/учитель/нет сессии, online-счёт без вышедших, пустой title→«Онлайн-урок»); node --check sessions.js + инлайна dashboard; sse-путь резолвится. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4638 lines
243 KiB
HTML
4638 lines
243 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Личный кабинет — LearnSpace</title>
|
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
|
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
<link rel="stylesheet" href="/css/ls.css" />
|
|
<style>
|
|
.container { max-width: 1600px; margin: 0 auto; padding: 24px 32px 100px; }
|
|
|
|
/* ── working area bg ── */
|
|
.sb-content { background: #f4f5f8; }
|
|
|
|
/* ── ZONE 1: Compact Header (sticky) ── */
|
|
.dash-header {
|
|
display: flex; align-items: center; gap: 18px;
|
|
height: 68px; padding: 0 28px;
|
|
background: rgba(255,255,255,0.93); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
|
|
border-bottom: 1px solid rgba(155,93,229,.1);
|
|
box-shadow: 0 2px 14px rgba(15,23,42,.05);
|
|
position: sticky; top: 0; z-index: 90;
|
|
}
|
|
.dh-avatar {
|
|
width: 48px; height: 48px; border-radius: 50%;
|
|
background: var(--grad-1);
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 800; color: #fff;
|
|
flex-shrink: 0;
|
|
box-shadow: 0 4px 16px rgba(155,93,229,.4);
|
|
}
|
|
.dh-text { flex: 1; min-width: 0; }
|
|
.dh-greeting {
|
|
font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800;
|
|
color: var(--text); letter-spacing: -0.02em;
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
}
|
|
.dh-sub { font-size: 0.78rem; color: var(--text-3); font-weight: 500; margin-top: 2px; }
|
|
.dh-stats {
|
|
display: flex; gap: 0; flex-shrink: 0;
|
|
background: rgba(155,93,229,.05);
|
|
border: 1.5px solid rgba(155,93,229,.14);
|
|
border-radius: 16px; padding: 8px 4px;
|
|
}
|
|
.stat-ring {
|
|
display: flex; flex-direction: row; align-items: center; gap: 9px;
|
|
padding: 0 14px;
|
|
}
|
|
.stat-ring + .stat-ring { border-left: 1px solid rgba(155,93,229,.12); }
|
|
.stat-ring .sr-text { display: flex; flex-direction: column; gap: 1px; }
|
|
.stat-ring .sr-val { font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800; line-height: 1.1; }
|
|
.stat-ring .sr-label { font-size: 0.56rem; color: var(--text-3); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
|
|
/* ── ZONE 2: Action Banner + Cards ── */
|
|
.action-zone { margin-bottom: 22px; }
|
|
.action-banner {
|
|
border-radius: 16px; padding: 18px 24px;
|
|
display: flex; align-items: center; gap: 18px;
|
|
margin-bottom: 14px; color: #fff; text-decoration: none;
|
|
}
|
|
.action-banner.ab-urgent {
|
|
background: linear-gradient(135deg, #1a0a2e 0%, #5c1a3e 50%, #8b1a1a 100%);
|
|
}
|
|
.action-banner.ab-continue {
|
|
background: linear-gradient(135deg, #0a0818 0%, #1a1040 60%, #0d1f3c 100%);
|
|
}
|
|
.ab-icon { font-size: 1.6rem; flex-shrink: 0; }
|
|
.ab-body { flex: 1; min-width: 0; }
|
|
.ab-title { font-family: 'Unbounded', sans-serif; font-size: 0.88rem; font-weight: 800; margin-bottom: 4px; }
|
|
.ab-sub { font-size: 0.78rem; opacity: 0.7; }
|
|
.ab-countdown { text-align: right; flex-shrink: 0; }
|
|
.ab-countdown-val { font-family: 'Unbounded', sans-serif; font-size: 1.4rem; font-weight: 900; }
|
|
.ab-countdown-label { font-size: 0.68rem; opacity: 0.6; font-weight: 600; }
|
|
.ab-btn {
|
|
padding: 8px 22px; border: none; border-radius: 99px;
|
|
background: rgba(255,255,255,0.15); color: #fff; backdrop-filter: blur(8px);
|
|
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700;
|
|
cursor: pointer; transition: background 0.15s; white-space: nowrap; flex-shrink: 0;
|
|
text-decoration: none;
|
|
}
|
|
.ab-btn:hover { background: rgba(255,255,255,0.25); }
|
|
/* ── Hero cards row (Reading · Lab of day · Pet) ── */
|
|
.hero-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
|
|
|
|
/* ── Live online-lesson banner ── */
|
|
.live-lesson {
|
|
display: flex; align-items: center; gap: 14px; text-decoration: none;
|
|
background: linear-gradient(100deg, #059652, #06D6A0); color: #fff;
|
|
border-radius: 16px; padding: 14px 20px; margin-bottom: 18px;
|
|
box-shadow: 0 6px 22px rgba(5,150,82,0.28); transition: transform .15s, box-shadow .15s;
|
|
}
|
|
.live-lesson:hover { transform: translateY(-1px); box-shadow: 0 10px 28px rgba(5,150,82,0.34); }
|
|
.ll-dot { width: 12px; height: 12px; border-radius: 50%; background: #fff; flex-shrink: 0;
|
|
box-shadow: 0 0 0 0 rgba(255,255,255,0.7); animation: llPulse 1.6s infinite; }
|
|
@keyframes llPulse {
|
|
0% { box-shadow: 0 0 0 0 rgba(255,255,255,0.6); }
|
|
70% { box-shadow: 0 0 0 10px rgba(255,255,255,0); }
|
|
100% { box-shadow: 0 0 0 0 rgba(255,255,255,0); }
|
|
}
|
|
.ll-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
.ll-text b { font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 800;
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.ll-text span { font-size: 0.78rem; opacity: 0.92; }
|
|
.ll-cta { flex-shrink: 0; background: rgba(255,255,255,0.95); color: #059652;
|
|
font-weight: 800; font-size: 0.82rem; padding: 8px 16px; border-radius: 10px; white-space: nowrap; }
|
|
@media (max-width: 480px) {
|
|
.live-lesson { padding: 12px 14px; gap: 10px; }
|
|
.ll-cta { padding: 7px 12px; font-size: 0.78rem; }
|
|
}
|
|
.hero-card {
|
|
position: relative; border-radius: 18px; padding: 18px 20px;
|
|
display: flex; flex-direction: column; min-height: 196px;
|
|
text-decoration: none; color: inherit; overflow: hidden;
|
|
transition: transform 0.16s, box-shadow 0.16s;
|
|
}
|
|
.hero-card:hover { transform: translateY(-3px); box-shadow: 0 14px 34px rgba(15,23,42,0.16); }
|
|
.hc-tag {
|
|
display: inline-flex; align-items: center; gap: 7px;
|
|
font-size: 0.66rem; font-weight: 800; letter-spacing: .07em; text-transform: uppercase;
|
|
margin-bottom: 12px;
|
|
}
|
|
.hc-tag svg { width: 15px; height: 15px; }
|
|
.hc-h { font-family: 'Unbounded', sans-serif; font-size: 1.15rem; font-weight: 800; line-height: 1.15; }
|
|
.hc-p { font-size: 0.76rem; line-height: 1.45; margin-top: 7px; }
|
|
.hc-foot { margin-top: auto; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-top: 14px; }
|
|
.hc-meta { font-size: 0.7rem; font-weight: 600; }
|
|
.hc-btn {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
padding: 7px 15px; border-radius: 99px; flex-shrink: 0;
|
|
font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 700;
|
|
border: none; cursor: pointer; transition: filter 0.15s, transform 0.12s;
|
|
}
|
|
.hc-btn svg { width: 14px; height: 14px; }
|
|
.hc-btn:hover { filter: brightness(1.08); }
|
|
.hc-btn:active { transform: translateY(1px); }
|
|
.hc-progress { height: 6px; border-radius: 99px; margin-top: 12px; overflow: hidden; }
|
|
.hc-progress > i { display: block; height: 100%; border-radius: 99px; }
|
|
|
|
/* Card 1 — Reading (warm gradient) */
|
|
.hc-read {
|
|
background: linear-gradient(140deg, #e8803a 0%, #c25020 55%, #8b3010 100%);
|
|
color: #fff;
|
|
}
|
|
.hc-read .hc-read-bg {
|
|
position: absolute; inset: 0; z-index: 0; pointer-events: none;
|
|
display: flex; align-items: center; justify-content: flex-end; overflow: hidden;
|
|
}
|
|
.hc-read .hc-read-bg svg { width: 200px; height: 200px; flex-shrink: 0; }
|
|
.hc-read > *:not(.hc-read-bg) { position: relative; z-index: 1; }
|
|
.hc-read .hc-tag {
|
|
background: rgba(255,255,255,.15); backdrop-filter: blur(8px);
|
|
padding: 3px 11px 3px 7px; border-radius: 99px;
|
|
color: rgba(255,255,255,.95); align-self: flex-start;
|
|
}
|
|
.hc-read .hc-p { color: rgba(255,255,255,.8); }
|
|
.hc-read .hc-foot-left { display: flex; align-items: center; gap: 7px; min-width: 0; }
|
|
.hc-read .hc-meta { color: rgba(255,255,255,.75); }
|
|
.hc-read .hc-pct {
|
|
font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 0.78rem;
|
|
color: #fff; opacity: .9;
|
|
}
|
|
.hc-read .hc-progress { height: 7px; background: rgba(255,255,255,.22); }
|
|
.hc-read .hc-progress > i {
|
|
background: rgba(255,255,255,.96);
|
|
box-shadow: 0 0 10px rgba(255,255,255,.45);
|
|
}
|
|
.hc-read .hc-btn { background: #fff; color: #b3531a; box-shadow: 0 2px 14px rgba(0,0,0,.2); }
|
|
|
|
/* Card 2 — Lab of day (dark) */
|
|
.hc-lab { background: linear-gradient(150deg, #16131f 0%, #1d1830 100%); color: #fff; border: 1px solid rgba(155,93,229,.18); }
|
|
.hc-lab .hc-bg { position: absolute; inset: 0; opacity: .5; z-index: 0; }
|
|
.hc-lab .hc-bg svg { width: 100%; height: 100%; }
|
|
.hc-lab > *:not(.hc-bg) { position: relative; z-index: 1; }
|
|
.hc-lab .hc-tag { color: #06D6E0; }
|
|
.hc-lab .hc-tag svg { stroke: #06D6E0; }
|
|
.hc-lab .hc-p { color: rgba(255,255,255,.74); }
|
|
.hc-lab .hc-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
|
.hc-lab .hc-chip { font-size: 0.66rem; font-weight: 700; padding: 3px 9px; border-radius: 99px; background: rgba(255,255,255,.08); color: rgba(255,255,255,.82); }
|
|
.hc-lab .hc-chip.subj { background: rgba(6,214,224,.16); color: #06D6E0; }
|
|
.hc-lab .hc-meta { color: rgba(255,255,255,.6); }
|
|
.hc-lab .hc-btn { background: rgba(255,255,255,.12); color: #fff; backdrop-filter: blur(6px); }
|
|
|
|
/* Card 3 — Pet (warm cream, sparkle bg) */
|
|
.hc-pet {
|
|
background: linear-gradient(140deg, #fffdf7 0%, #fff8e7 100%);
|
|
border: 1.5px solid rgba(249,199,79,.28);
|
|
border-top: 4px solid #F9C74F;
|
|
}
|
|
.hc-pet .hc-pet-bg {
|
|
position: absolute; inset: 0; z-index: 0; pointer-events: none; overflow: hidden;
|
|
}
|
|
.hc-pet .hc-pet-bg svg { width: 100%; height: 100%; }
|
|
.hc-pet > *:not(.hc-pet-bg) { position: relative; z-index: 1; }
|
|
.hc-pet .hc-tag {
|
|
background: rgba(249,199,79,.18); padding: 3px 11px 3px 7px;
|
|
border-radius: 99px; color: #92400e; align-self: flex-start;
|
|
}
|
|
.hc-pet .hc-tag svg { stroke: #e08c1a; }
|
|
.hc-pet .hc-pet-top { display: flex; align-items: center; gap: 10px; }
|
|
.hc-pet .hc-pet-name { font-family: 'Unbounded', sans-serif; font-size: 1.15rem; font-weight: 800; }
|
|
.hc-pet .hc-pet-art {
|
|
width: 54px; height: 54px; margin-left: auto; flex-shrink: 0;
|
|
filter: drop-shadow(0 0 10px rgba(249,199,79,.7));
|
|
}
|
|
.hc-pet .hc-pet-art svg { width: 100%; height: 100%; }
|
|
.hc-pet .hc-xp-row {
|
|
display: flex; align-items: baseline; justify-content: space-between;
|
|
margin-top: 12px; font-size: 0.7rem; color: var(--text-3);
|
|
}
|
|
.hc-pet .hc-xp-row b { color: var(--text); font-weight: 800; }
|
|
.hc-pet .hc-progress { height: 7px; background: rgba(249,199,79,.2); }
|
|
.hc-pet .hc-progress > i {
|
|
background: linear-gradient(90deg, #F9C74F, #F98231);
|
|
box-shadow: 0 0 8px rgba(249,150,49,.45);
|
|
}
|
|
.hc-pet .hc-pet-chips { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin-top: 12px; }
|
|
.hc-pet .hc-pchip {
|
|
text-align: center; padding: 8px 4px; border-radius: 12px;
|
|
background: rgba(15,23,42,.04); border: 1px solid rgba(15,23,42,.06);
|
|
}
|
|
.hc-pet .hc-pchip b {
|
|
display: block; font-family: 'Unbounded', sans-serif;
|
|
font-size: 0.88rem; font-weight: 800; color: var(--text);
|
|
}
|
|
.hc-pet .hc-pchip span {
|
|
display: block; font-size: 0.58rem; font-weight: 700;
|
|
letter-spacing: .04em; text-transform: uppercase; color: var(--text-3); margin-top: 2px;
|
|
}
|
|
.hc-pet .hc-pchip.chip-streak { background: rgba(249,115,22,.09); border-color: rgba(249,115,22,.2); }
|
|
.hc-pet .hc-pchip.chip-streak b { color: #c2410c; }
|
|
.hc-pet .hc-pchip.chip-goal { background: rgba(16,185,129,.09); border-color: rgba(16,185,129,.2); }
|
|
.hc-pet .hc-pchip.chip-goal b { color: #047857; }
|
|
.hc-pet .hc-pchip.chip-mood { background: rgba(124,58,237,.09); border-color: rgba(124,58,237,.2); }
|
|
.hc-pet .hc-pchip.chip-mood b { color: #6d28d9; }
|
|
.hc-pet .hc-btn {
|
|
background: linear-gradient(135deg, #F9C74F 0%, #F98231 100%);
|
|
color: #7c2d00; box-shadow: 0 2px 12px rgba(249,150,49,.35);
|
|
margin-top: 12px; align-self: flex-start;
|
|
}
|
|
|
|
/* ── ZONE 3: Three-Column Grid ── */
|
|
.main-grid {
|
|
display: grid;
|
|
grid-template-columns: 1.3fr 0.8fr 1fr;
|
|
gap: 22px;
|
|
margin-bottom: 22px;
|
|
}
|
|
.full-row { margin-bottom: 22px; }
|
|
/* Bottom row: Activity · My submissions · Challenges side by side */
|
|
.bottom-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 22px;
|
|
margin-bottom: 22px;
|
|
align-items: stretch;
|
|
}
|
|
.bottom-grid > * { margin-bottom: 0; height: 100%; }
|
|
|
|
.qa-btn {
|
|
display: flex; align-items: center; gap: 8px;
|
|
padding: 12px 20px; border-radius: 14px;
|
|
border: 1.5px solid rgba(15,23,42,0.08);
|
|
background: #fff; color: var(--text);
|
|
font-family: 'Manrope', sans-serif; font-size: 0.84rem; font-weight: 700;
|
|
cursor: pointer; transition: all 0.18s; text-decoration: none;
|
|
box-shadow: 0 2px 8px rgba(15,23,42,0.04);
|
|
}
|
|
.qa-btn:hover { border-color: rgba(155,93,229,0.3); transform: translateY(-1px); box-shadow: 0 6px 20px rgba(15,23,42,0.1); }
|
|
|
|
.widget {
|
|
background: #fff;
|
|
border: 1.5px solid rgba(15,23,42,0.07);
|
|
border-radius: 20px;
|
|
padding: 20px;
|
|
box-shadow: 0 2px 8px rgba(15,23,42,0.04);
|
|
transition: box-shadow 0.2s;
|
|
}
|
|
.widget:hover { box-shadow: 0 6px 24px rgba(15,23,42,0.08); }
|
|
.w-head {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
margin-bottom: 16px;
|
|
}
|
|
.w-title {
|
|
font-family: 'Unbounded', sans-serif; font-size: 0.72rem; font-weight: 800;
|
|
color: var(--text-3); text-transform: uppercase; letter-spacing: 0.08em;
|
|
display: flex; align-items: center; gap: 8px;
|
|
}
|
|
.w-title::before {
|
|
content: ''; display: inline-block; width: 3px; height: 14px;
|
|
background: linear-gradient(180deg, #06D6E0, #9B5DE5);
|
|
border-radius: 99px; flex-shrink: 0;
|
|
}
|
|
.w-more {
|
|
font-size: 0.74rem; font-weight: 700; color: var(--violet);
|
|
text-decoration: none; cursor: pointer; background: none; border: none;
|
|
font-family: 'Manrope', sans-serif;
|
|
}
|
|
.w-more:hover { text-decoration: underline; }
|
|
|
|
/* ── last grades mini-list ── */
|
|
.grade-row {
|
|
display: flex; align-items: center; gap: 12px;
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid rgba(15,23,42,0.05);
|
|
}
|
|
.grade-row:last-child { border-bottom: none; }
|
|
.grade-pct { font-family: 'Unbounded', sans-serif; font-size: 0.88rem; font-weight: 900; min-width: 42px; text-align: center; }
|
|
.grade-body { flex: 1; min-width: 0; }
|
|
.grade-subj { font-size: 0.82rem; font-weight: 700; color: var(--text); }
|
|
.grade-date { font-size: 0.7rem; color: var(--text-3); }
|
|
|
|
/* ── activity widget tabs ── */
|
|
.act-tabs { display: flex; gap: 4px; }
|
|
.act-tab {
|
|
padding: 3px 12px; border-radius: 99px; border: none;
|
|
font-family: 'Manrope', sans-serif; font-size: 0.68rem; font-weight: 700;
|
|
color: var(--text-3); background: transparent; cursor: pointer; transition: all 0.15s;
|
|
}
|
|
.act-tab:hover { color: var(--violet); }
|
|
.act-tab.active { background: var(--text); color: #fff; }
|
|
.act-scale-btns { display: inline-flex; gap: 2px; padding: 3px; border-radius: 10px; background: rgba(15,23,42,0.05); }
|
|
.act-scale-btn {
|
|
padding: 3px 11px; border-radius: 7px; border: none;
|
|
font-family: 'Manrope', sans-serif; font-size: 0.64rem; font-weight: 700;
|
|
color: var(--text-3); background: transparent; cursor: pointer; transition: all 0.15s;
|
|
}
|
|
.act-scale-btn:hover { color: var(--violet); }
|
|
.act-scale-btn.active { background: #fff; color: var(--violet); box-shadow: 0 1px 4px rgba(15,23,42,0.1); }
|
|
.act-pane { display: none; }
|
|
.act-pane.visible { display: block; }
|
|
|
|
/* ── mini heatmap (redesigned) ── */
|
|
.hm-months { display: flex; margin-bottom: 2px; padding-left: 22px; }
|
|
.hm-month-label { font-size: 0.56rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.02em; overflow: hidden; white-space: nowrap; }
|
|
.hm-body { display: flex; gap: 0; }
|
|
.hm-weekdays { display: flex; flex-direction: column; gap: 2px; width: 22px; flex-shrink: 0; padding-top: 1px; }
|
|
.hm-wd { font-size: 0.5rem; font-weight: 700; color: var(--text-3); height: 14px; line-height: 14px; }
|
|
.mini-heatmap { display: grid; grid-template-rows: repeat(7, 14px); grid-auto-flow: column; grid-auto-columns: 14px; gap: 2px; }
|
|
/* Hero stat row */
|
|
.hm-hero { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 13px; }
|
|
.hm-hero-num-row { display: flex; align-items: baseline; gap: 7px; flex-wrap: wrap; }
|
|
.hm-hero-num { font-family: 'Unbounded', sans-serif; font-size: 1.72rem; font-weight: 800; color: var(--text); line-height: 1; }
|
|
.hm-hero-unit { font-size: 0.76rem; font-weight: 700; color: var(--text-3); }
|
|
.hm-hero-sub { font-size: 0.72rem; color: var(--text-3); font-weight: 600; margin-top: 6px; }
|
|
.hm-hero-sub strong { color: var(--text); font-weight: 800; }
|
|
.hm-trend-pill { display: inline-flex; align-items: center; gap: 3px; padding: 3px 9px; border-radius: 99px;
|
|
font-size: 0.66rem; font-weight: 800; font-family: 'Manrope', sans-serif; white-space: nowrap; }
|
|
.hm-trend-pill svg { width: 12px; height: 12px; stroke-width: 2.6; }
|
|
.hm-trend-pill.up { background: rgba(5,150,82,0.1); color: #059652; }
|
|
.hm-trend-pill.down { background: rgba(224,51,94,0.1); color: #E0335E; }
|
|
.hm-trend-pill.flat { background: rgba(15,23,42,0.05); color: var(--text-3); }
|
|
.hm-empty { padding: 28px 16px; text-align: center; color: var(--text-3); }
|
|
.hm-empty-ic { color: var(--violet); opacity: .85; margin-bottom: 8px; }
|
|
.hm-empty-t { font-weight: 700; color: var(--text); font-size: 0.92rem; margin-bottom: 4px; }
|
|
.hm-empty-s { font-size: 0.78rem; line-height: 1.5; max-width: 320px; margin: 0 auto 12px; }
|
|
.hm-empty-cta { display: inline-block; padding: 8px 16px; border-radius: 9px; background: var(--violet); color: #fff; font-weight: 700; font-size: 0.82rem; text-decoration: none; }
|
|
.mhm-cell {
|
|
width: 14px; height: 14px; border-radius: 4px; background: rgba(15,23,42,0.05);
|
|
cursor: pointer; transition: transform 0.12s ease, box-shadow 0.12s ease;
|
|
animation: hmCellIn 0.3s ease both;
|
|
}
|
|
.mhm-cell.has-data { box-shadow: inset 0 0 0 0.5px rgba(15,23,42,0.06); }
|
|
.mhm-cell:hover { transform: scale(1.34); z-index: 2; position: relative; border-radius: 5px; }
|
|
.mhm-cell.has-data:hover { box-shadow: 0 3px 12px var(--cell-color, rgba(155,93,229,0.5)); }
|
|
@keyframes hmCellIn { from { opacity: 0; transform: scale(0.3); } to { opacity: 1; transform: scale(1); } }
|
|
|
|
/* Subject-colored cells */
|
|
.mhm-cell.s-bio { --cell-color: rgba(155,93,229,0.85); }
|
|
.mhm-cell.s-chem { --cell-color: rgba(6,214,160,0.85); }
|
|
.mhm-cell.s-math { --cell-color: rgba(6,182,212,0.85); }
|
|
.mhm-cell.s-phys { --cell-color: rgba(245,158,11,0.85); }
|
|
.mhm-cell.s-mix { --cell-color: rgba(155,93,229,0.6); }
|
|
|
|
/* Heatmap legend */
|
|
.hm-legend-row {
|
|
display: flex; align-items: center; flex-wrap: wrap; gap: 6px;
|
|
margin-top: 13px; padding-top: 11px; border-top: 1px solid rgba(15,23,42,0.06);
|
|
}
|
|
.hm-legend { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
|
|
.hm-legend-chip {
|
|
display: inline-flex; align-items: center; gap: 5px; padding: 2px 9px;
|
|
border-radius: 99px; background: rgba(15,23,42,0.04);
|
|
font-size: 0.6rem; font-weight: 700; color: var(--text-3);
|
|
}
|
|
.hm-legend-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
|
|
|
/* Day popup */
|
|
.hm-day-popup {
|
|
position: fixed; z-index: 250; pointer-events: auto;
|
|
background: #fff; border: 1.5px solid rgba(15,23,42,0.1);
|
|
border-radius: 14px; padding: 14px 16px; min-width: 200px;
|
|
box-shadow: 0 12px 40px rgba(15,23,42,0.15);
|
|
font-family: 'Manrope', sans-serif;
|
|
animation: fadeIn 0.2s ease;
|
|
}
|
|
.hm-day-popup .hdp-date { font-size: 0.72rem; font-weight: 800; color: var(--text); margin-bottom: 8px; }
|
|
.hm-day-popup .hdp-row {
|
|
display: flex; align-items: center; gap: 8px;
|
|
padding: 5px 0; border-bottom: 1px solid rgba(15,23,42,0.04);
|
|
font-size: 0.75rem;
|
|
}
|
|
.hm-day-popup .hdp-row:last-child { border-bottom: none; }
|
|
.hm-day-popup .hdp-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
.hm-day-popup .hdp-subj { flex: 1; font-weight: 600; color: var(--text); }
|
|
.hm-day-popup .hdp-score { font-family: 'Unbounded', sans-serif; font-size: 0.72rem; font-weight: 900; }
|
|
.hm-day-popup .hdp-mode { font-size: 0.66rem; color: var(--text-3); }
|
|
.hm-day-popup .hdp-empty { font-size: 0.75rem; color: var(--text-3); padding: 4px 0; }
|
|
|
|
/* Dark mode */
|
|
.app-layout.dark .act-tab.active { background: #E8ECF2; color: var(--text); }
|
|
.app-layout.dark .act-scale-btns { background: rgba(255,255,255,0.05); }
|
|
.app-layout.dark .act-scale-btn { color: #6B7A8E; }
|
|
.app-layout.dark .act-scale-btn.active { background: rgba(255,255,255,0.1); color: var(--violet); box-shadow: none; }
|
|
.app-layout.dark .mhm-cell { background: rgba(255,255,255,0.04); }
|
|
.app-layout.dark .mhm-cell.has-data { box-shadow: inset 0 0 0 0.5px rgba(255,255,255,0.07); }
|
|
.app-layout.dark .hm-hero-num, .app-layout.dark .hm-hero-sub strong { color: #E8ECF2; }
|
|
.app-layout.dark .hm-legend-row { border-color: rgba(255,255,255,0.07); }
|
|
.app-layout.dark .hm-legend-chip { background: rgba(255,255,255,0.05); }
|
|
.app-layout.dark .hm-day-popup { background: #1A1D27; border-color: rgba(255,255,255,0.08); box-shadow: 0 12px 40px rgba(0,0,0,0.4); }
|
|
.app-layout.dark .hm-day-popup .hdp-date, .app-layout.dark .hm-day-popup .hdp-subj { color: #E8ECF2; }
|
|
|
|
/* ── continue reading banner ── */
|
|
.continue-card {
|
|
display: flex; align-items: center; gap: 14px;
|
|
padding: 14px 16px; border-radius: 14px;
|
|
background: linear-gradient(135deg, rgba(155,93,229,0.05), rgba(6,214,224,0.04));
|
|
border: 1px solid rgba(155,93,229,0.12); cursor: pointer;
|
|
transition: all 0.15s; text-decoration: none; color: inherit;
|
|
}
|
|
.continue-card:hover { border-color: rgba(155,93,229,0.3); transform: translateX(3px); }
|
|
.cont-emoji { font-size: 1.5rem; flex-shrink: 0; }
|
|
.cont-info { flex: 1; min-width: 0; }
|
|
.cont-title { font-size: 0.85rem; font-weight: 700; color: var(--text); }
|
|
.cont-sub { font-size: 0.72rem; color: var(--text-3); margin-top: 2px; }
|
|
.cont-pct { font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800; color: var(--violet); }
|
|
|
|
/* ── flashcard review widget ── */
|
|
.fcw-card { perspective: 1000px; cursor: pointer; }
|
|
.fcw-inner {
|
|
position: relative; transform-style: preserve-3d;
|
|
transition: transform 0.5s cubic-bezier(.34,1.1,.64,1); min-height: 118px;
|
|
}
|
|
.fcw-card.flipped .fcw-inner { transform: rotateY(180deg); }
|
|
.fcw-face {
|
|
position: absolute; inset: 0; backface-visibility: hidden; -webkit-backface-visibility: hidden;
|
|
border-radius: 14px; padding: 14px 16px; display: flex; flex-direction: column; gap: 6px;
|
|
border: 1.5px solid rgba(15,23,42,0.08); box-sizing: border-box;
|
|
}
|
|
.fcw-front { background: linear-gradient(135deg, rgba(155,93,229,0.06), rgba(6,214,224,0.05)); }
|
|
.fcw-back { background: linear-gradient(135deg, rgba(6,214,100,0.07), rgba(6,214,224,0.05)); transform: rotateY(180deg); }
|
|
.fcw-deck { font-size: 0.66rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.04em; color: var(--violet); }
|
|
.fcw-back .fcw-deck { color: #059652; }
|
|
.fcw-text { flex: 1; font-size: 0.92rem; font-weight: 600; color: var(--text); line-height: 1.35;
|
|
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
|
.fcw-hint { font-size: 0.68rem; color: var(--text-3); display: flex; align-items: center; gap: 5px; }
|
|
.fcw-hint svg { width: 12px; height: 12px; }
|
|
.fcw-actions { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; }
|
|
.fcw-count { font-size: 0.72rem; color: var(--text-3); font-weight: 600; }
|
|
.fcw-btn {
|
|
display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: 99px;
|
|
border: 1.5px solid rgba(155,93,229,0.3); background: rgba(155,93,229,0.06); color: var(--violet);
|
|
font-family: 'Manrope', sans-serif; font-size: 0.76rem; font-weight: 700; cursor: pointer;
|
|
transition: all 0.15s; text-decoration: none;
|
|
}
|
|
.fcw-btn:hover { background: rgba(155,93,229,0.14); border-color: var(--violet); }
|
|
.fcw-btn svg { width: 13px; height: 13px; stroke: currentColor; }
|
|
.fcw-empty { text-align: center; padding: 16px 12px; color: var(--text-3); }
|
|
.fcw-empty p { font-size: 0.82rem; margin-bottom: 10px; }
|
|
/* картинка на карточке виджета */
|
|
.fcw-inner.has-img { min-height: 172px; }
|
|
.fcw-img { align-self: center; max-width: 100%; max-height: 96px; object-fit: contain;
|
|
border-radius: 8px; box-shadow: 0 1px 5px rgba(15,23,42,0.12); }
|
|
.has-img .fcw-text { -webkit-line-clamp: 2; }
|
|
|
|
/* ── subjects progress bars ── */
|
|
.subj-prog-row {
|
|
display: flex; align-items: center; gap: 14px;
|
|
padding: 10px 0; border-bottom: 1px solid rgba(15,23,42,0.05);
|
|
}
|
|
.subj-prog-row:last-child { border-bottom: none; }
|
|
.sp-name { font-size: 0.82rem; font-weight: 700; min-width: 90px; color: var(--text); }
|
|
.sp-bar { flex: 1; height: 8px; border-radius: 99px; background: rgba(15,23,42,0.06); overflow: hidden; }
|
|
.sp-fill { height: 100%; border-radius: 99px; transition: width 0.8s cubic-bezier(.34,1.2,.64,1); }
|
|
.sp-pct { font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800; min-width: 40px; text-align: right; }
|
|
|
|
/* ── compact test cards (Column 2) ── */
|
|
.subj-mini-grid { display: flex; flex-direction: column; gap: 8px; }
|
|
.subj-mini-card {
|
|
display: flex; align-items: center; gap: 12px;
|
|
padding: 12px 14px; border-radius: 14px;
|
|
border: 1.5px solid rgba(15,23,42,0.07); background: #fff;
|
|
cursor: pointer; transition: all 0.18s;
|
|
box-shadow: 0 1px 4px rgba(15,23,42,0.04);
|
|
}
|
|
.subj-mini-card:hover { border-color: rgba(155,93,229,0.25); transform: translateX(3px); box-shadow: 0 6px 20px rgba(15,23,42,0.08); }
|
|
.smc-icon {
|
|
width: 38px; height: 38px; border-radius: 10px;
|
|
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
|
color: #fff;
|
|
}
|
|
.smc-icon svg, .smc-icon i { width: 20px; height: 20px; stroke: #fff; stroke-width: 1.8; }
|
|
.smc-body { flex: 1; min-width: 0; }
|
|
.smc-name { font-size: 0.86rem; font-weight: 700; color: var(--text); }
|
|
.smc-meta { font-size: 0.72rem; color: var(--text-3); margin-top: 2px; }
|
|
.smc-arrow { width: 18px; height: 18px; color: var(--text-3); flex-shrink: 0; }
|
|
|
|
/* ── animations ── */
|
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
|
@keyframes fadeUp { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } }
|
|
|
|
/* B4: Animated stat ring fill */
|
|
@keyframes ringFill { from { stroke-dasharray: 0 200; } }
|
|
.stat-ring svg circle:nth-child(2) { animation: ringFill 0.9s cubic-bezier(.34,1.2,.64,1) both; }
|
|
.stat-ring { animation: fadeIn 0.5s ease both; }
|
|
.stat-ring:nth-child(1) { animation-delay: 0.1s; }
|
|
.stat-ring:nth-child(2) { animation-delay: 0.2s; }
|
|
.stat-ring:nth-child(3) { animation-delay: 0.3s; }
|
|
.stat-ring:nth-child(4) { animation-delay: 0.4s; }
|
|
|
|
/* A4: Deadline gradient bar on assignment rows */
|
|
.asgn-row.urgent::after, .asgn-row.over::after {
|
|
content: ''; position: absolute; bottom: 0; left: 0; right: 0; height: 3px;
|
|
border-radius: 0 0 14px 14px;
|
|
}
|
|
.asgn-row { position: relative; overflow: hidden; }
|
|
.asgn-row.urgent::after { background: linear-gradient(90deg, #F59E0B, #E83A1E); }
|
|
.asgn-row.over::after { background: linear-gradient(90deg, #E83A1E, #9B1B30); }
|
|
|
|
/* A2: Skeleton shimmer for widgets */
|
|
.widget-skeleton { padding: 20px; }
|
|
.sk-line { height: 12px; border-radius: 6px; background: linear-gradient(90deg, rgba(15,23,42,0.06) 25%, rgba(15,23,42,0.1) 50%, rgba(15,23,42,0.06) 75%); background-size: 200% 100%; animation: skShimmer 1.5s ease infinite; margin-bottom: 10px; }
|
|
.sk-line:nth-child(1) { width: 65%; }
|
|
.sk-line:nth-child(2) { width: 85%; }
|
|
.sk-line:nth-child(3) { width: 50%; }
|
|
.sk-line.sk-tall { height: 40px; width: 100%; border-radius: 10px; }
|
|
@keyframes skShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
|
|
|
/* B3: Heatmap tooltip */
|
|
.hm-tip {
|
|
position: fixed; z-index: 200; pointer-events: none;
|
|
background: var(--text); color: #fff; padding: 6px 12px; border-radius: 8px;
|
|
font-size: 0.72rem; font-weight: 600; font-family: 'Manrope', sans-serif;
|
|
box-shadow: 0 8px 24px rgba(0,0,0,0.25); white-space: nowrap;
|
|
opacity: 0; transition: opacity 0.15s;
|
|
}
|
|
.hm-tip.visible { opacity: 1; }
|
|
|
|
/* B2: Search assignments */
|
|
.assign-search {
|
|
width: 100%; padding: 8px 14px 8px 34px; border: 1.5px solid rgba(15,23,42,0.08);
|
|
border-radius: 10px; font-family: 'Manrope', sans-serif; font-size: 0.82rem;
|
|
color: var(--text); background: #f8f9fc; transition: border-color 0.2s;
|
|
box-sizing: border-box; margin-bottom: 10px;
|
|
}
|
|
.assign-search:focus { outline: none; border-color: var(--violet); background: #fff; }
|
|
.assign-search-wrap { position: relative; }
|
|
.assign-search-wrap::before {
|
|
content: ''; position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
|
|
width: 14px; height: 14px;
|
|
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%238898AA' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'/%3E%3C/svg%3E") center/contain no-repeat;
|
|
}
|
|
|
|
/* B5: Deadline urgent toast */
|
|
.deadline-toast {
|
|
position: fixed; top: 12px; right: 12px; z-index: 300;
|
|
display: flex; align-items: center; gap: 12px;
|
|
padding: 14px 20px; border-radius: 14px;
|
|
background: linear-gradient(135deg, #2d0a0a, #4a1020);
|
|
color: #fff; box-shadow: 0 12px 40px rgba(0,0,0,0.3);
|
|
font-family: 'Manrope', sans-serif; font-size: 0.84rem; font-weight: 600;
|
|
animation: toastSlide 0.4s ease, toastSlide 0.4s ease 8s reverse forwards;
|
|
cursor: pointer;
|
|
}
|
|
.deadline-toast:hover { opacity: 0.9; }
|
|
@keyframes toastSlide { from { transform: translateX(120%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
|
.dt-icon { font-size: 1.3rem; }
|
|
.dt-text { flex: 1; }
|
|
.dt-time { font-family: 'Unbounded', sans-serif; font-weight: 900; }
|
|
.dt-close { background: none; border: none; color: rgba(255,255,255,0.5); cursor: pointer; font-size: 1.2rem; padding: 0 0 0 8px; }
|
|
|
|
/* C2: Streak calendar */
|
|
.streak-cal { display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px; }
|
|
.sc-day {
|
|
aspect-ratio: 1; border-radius: 8px; display: flex; align-items: center; justify-content: center;
|
|
font-size: 0.62rem; font-weight: 700; color: var(--text-3); background: rgba(15,23,42,0.03);
|
|
transition: transform 0.12s, box-shadow 0.12s;
|
|
}
|
|
.sc-day.today { box-shadow: inset 0 0 0 1.5px var(--violet); color: var(--violet); font-weight: 800; }
|
|
.sc-day.active { background: rgba(155,93,229,0.18); color: #7c3aed; }
|
|
.sc-day.active.today { background: var(--violet); color: #fff; box-shadow: 0 3px 10px rgba(155,93,229,0.35); }
|
|
.sc-day.future { opacity: 0.3; }
|
|
.sc-day:hover { transform: scale(1.15); box-shadow: 0 4px 12px rgba(15,23,42,0.12); }
|
|
.sc-day.pulse { animation: scPulse 2s ease-in-out infinite; }
|
|
@keyframes scPulse {
|
|
0%, 100% { box-shadow: 0 0 0 0 rgba(155,93,229,0.3); }
|
|
50% { box-shadow: 0 0 0 5px rgba(155,93,229,0); }
|
|
}
|
|
.sc-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
|
.sc-month { font-family: 'Unbounded', sans-serif; font-size: 0.7rem; font-weight: 800; color: var(--text); }
|
|
.sc-streak-badge { font-family: 'Unbounded', sans-serif; font-size: 0.68rem; font-weight: 800; color: #E8890B; display: inline-flex; align-items: center; gap: 4px; padding: 3px 10px; border-radius: 99px; background: rgba(245,158,11,0.13); }
|
|
.sc-weekdays { display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px; margin-bottom: 3px; }
|
|
.sc-wd { font-size: 0.56rem; color: var(--text-3); font-weight: 700; text-align: center; text-transform: uppercase; }
|
|
|
|
/* C4: Keyboard shortcuts hint */
|
|
.kb-hint {
|
|
position: fixed; bottom: 16px; right: 16px; z-index: 100;
|
|
background: rgba(15,23,42,0.9); color: #fff; padding: 8px 14px;
|
|
border-radius: 10px; font-size: 0.72rem; font-family: 'Manrope', sans-serif;
|
|
backdrop-filter: blur(8px); display: none; gap: 12px;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
|
}
|
|
.kb-hint.visible { display: flex; }
|
|
.kb-key { background: rgba(255,255,255,0.15); padding: 2px 7px; border-radius: 4px; font-family: monospace; font-weight: 700; }
|
|
|
|
/* B1: Quick-start test modal */
|
|
.qs-subjects { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
|
|
.qs-subj-btn {
|
|
display: flex; align-items: center; gap: 8px;
|
|
padding: 10px 16px; border-radius: 12px;
|
|
border: 1.5px solid rgba(15,23,42,0.1); background: #fff;
|
|
cursor: pointer; transition: all 0.15s; flex: 1; min-width: 100px;
|
|
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700; color: var(--text);
|
|
}
|
|
.qs-subj-btn:hover { border-color: rgba(155,93,229,0.3); }
|
|
.qs-subj-btn.active { border-color: var(--violet); background: rgba(155,93,229,0.06); color: var(--violet); }
|
|
.qs-subj-icon { width: 28px; height: 28px; border-radius: 8px; display: flex; align-items: center; justify-content: center; }
|
|
.qs-subj-icon svg { width: 14px; height: 14px; stroke: #fff; stroke-width: 2; }
|
|
.qs-options { display: flex; flex-direction: column; gap: 12px; }
|
|
.qs-row { display: flex; align-items: center; gap: 12px; }
|
|
.qs-label { font-size: 0.78rem; font-weight: 700; color: var(--text-3); min-width: 80px; }
|
|
.qs-select, .qs-input {
|
|
flex: 1; padding: 8px 12px; border: 1.5px solid rgba(15,23,42,0.1);
|
|
border-radius: 8px; font-family: 'Manrope', sans-serif; font-size: 0.82rem;
|
|
color: var(--text); background: #f8f9fc;
|
|
}
|
|
.qs-select:focus, .qs-input:focus { outline: none; border-color: var(--violet); }
|
|
|
|
/* ── Admin/Teacher compact layout ── */
|
|
.admin-grid {
|
|
display: grid;
|
|
grid-template-columns: 1.3fr 1fr 1fr;
|
|
gap: 18px;
|
|
margin-bottom: 18px;
|
|
}
|
|
.admin-grid .widget { padding: 18px; border-radius: 16px; }
|
|
.admin-grid .w-head { margin-bottom: 10px; }
|
|
|
|
/* Quick-action buttons grid — grouped layout */
|
|
.adm-actions { display: grid; grid-template-columns: 2fr 2fr 1fr; gap: 16px; }
|
|
.adm-act-group { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; }
|
|
@media (max-width: 900px) { .adm-actions { grid-template-columns: 1fr 1fr; } }
|
|
@media (max-width: 640px) { .adm-actions { grid-template-columns: 1fr; } }
|
|
.adm-act {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 12px 14px; border-radius: 12px;
|
|
border: 1.5px solid rgba(15,23,42,0.07); background: #fff;
|
|
font-family: 'Manrope', sans-serif; font-size: 0.8rem; font-weight: 700;
|
|
color: var(--text); cursor: pointer; transition: all 0.18s;
|
|
text-decoration: none; position: relative;
|
|
}
|
|
.adm-act:hover { border-color: rgba(155,93,229,0.25); transform: translateY(-2px) scale(1.01); box-shadow: 0 6px 20px rgba(15,23,42,0.10); }
|
|
.adm-act-icon {
|
|
width: 36px; height: 36px; border-radius: 9px;
|
|
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
|
}
|
|
.adm-act-icon svg, .adm-act-icon i { width: 20px; height: 20px; stroke: #fff; stroke-width: 2; }
|
|
.adm-act-badge {
|
|
position: absolute; top: 6px; right: 8px;
|
|
background: #E0335E; color: #fff; font-size: 0.62rem; font-weight: 800;
|
|
padding: 1px 5px; border-radius: 999px; font-family: 'Unbounded', sans-serif;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
/* Compact stat chips row for admin header */
|
|
.adm-stat-chips { display: flex; gap: 10px; flex-shrink: 0; flex-wrap: wrap; }
|
|
.adm-stat-chip {
|
|
display: flex; align-items: center; gap: 8px;
|
|
padding: 6px 14px; border-radius: 10px;
|
|
background: rgba(15,23,42,0.03); border: 1px solid rgba(15,23,42,0.06);
|
|
font-family: 'Manrope', sans-serif; font-size: 0.75rem; font-weight: 600; color: var(--text-3);
|
|
}
|
|
.adm-stat-val {
|
|
font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 900;
|
|
}
|
|
|
|
/* Admin compact assignment row */
|
|
.admin-grid .asgn-row { height: 52px; padding: 0 12px 0 10px; border-radius: 10px; }
|
|
.admin-grid .ar-icon { width: 30px; height: 30px; border-radius: 8px; }
|
|
.admin-grid .ar-icon svg { width: 14px; height: 14px; }
|
|
.admin-grid .ar-title { font-size: 0.82rem; }
|
|
.admin-grid .ar-meta { font-size: 0.68rem; }
|
|
.admin-grid .ar-prog-text { font-size: 0.68rem; }
|
|
.admin-grid .ar-btn-ghost { font-size: 0.72rem; padding: 4px 12px; }
|
|
.admin-grid .asgn-expand { padding: 8px 12px 10px 50px; border-radius: 0 0 10px 10px; }
|
|
|
|
/* Recent sessions mini table */
|
|
.adm-sessions { display: flex; flex-direction: column; gap: 4px; }
|
|
.adm-sess-row {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 6px 0; border-bottom: 1px solid rgba(15,23,42,0.04);
|
|
font-size: 0.76rem;
|
|
}
|
|
.adm-sess-row:last-child { border-bottom: none; }
|
|
.adm-sess-name { flex: 1; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.adm-sess-subj { color: var(--text-3); font-weight: 600; min-width: 60px; }
|
|
.adm-sess-pct { font-family: 'Unbounded', sans-serif; font-weight: 900; font-size: 0.74rem; min-width: 36px; text-align: right; }
|
|
/* Colored chip for session % */
|
|
.adm-sess-chip {
|
|
display: inline-flex; padding: 3px 9px; border-radius: 999px;
|
|
font-size: 0.74rem; font-weight: 700; min-width: 42px; justify-content: center;
|
|
font-family: 'Unbounded', sans-serif;
|
|
}
|
|
.adm-sess-ago { color: var(--text-3); font-size: 0.7rem; flex-shrink: 0; }
|
|
/* Hover on rows in admin-grid */
|
|
.admin-grid .adm-sess-row:hover,
|
|
.admin-grid .asgn-row:hover {
|
|
background: rgba(155,93,229,0.04);
|
|
cursor: pointer;
|
|
}
|
|
/* Thick colored progress bar */
|
|
.admin-grid .ar-prog-bar { height: 8px !important; border-radius: 4px; overflow: hidden; background: rgba(15,23,42,0.06); }
|
|
.admin-grid .ar-prog-fill { height: 100%; border-radius: 4px; }
|
|
/* Empty states */
|
|
.adm-empty { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 24px 12px; text-align: center; color: var(--text-3); }
|
|
.adm-empty i { display: block; }
|
|
.adm-empty-text { font-size: 0.82rem; }
|
|
.adm-empty-cta {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
padding: 7px 16px; border-radius: 9px; font-size: 0.78rem; font-weight: 700;
|
|
background: var(--violet); color: #fff; text-decoration: none;
|
|
transition: opacity 0.15s;
|
|
}
|
|
.adm-empty-cta:hover { opacity: 0.85; }
|
|
/* Class badge on assignment row */
|
|
.asgn-class-badge {
|
|
display: inline-flex; padding: 1px 6px; border-radius: 6px;
|
|
font-size: 0.68rem; font-weight: 700;
|
|
background: rgba(155,93,229,0.08); color: #7c3aed;
|
|
margin-left: 4px; vertical-align: middle;
|
|
}
|
|
/* Urgency highlight */
|
|
.admin-grid .asgn-urgent { border-left: 3px solid var(--pink, #F15BB5); padding-left: 9px !important; }
|
|
.asgn-fire {
|
|
background: rgba(241,91,181,0.12); color: var(--pink, #F15BB5);
|
|
padding: 1px 7px; border-radius: 999px; font-size: 0.68rem; font-weight: 700; margin-left: 6px;
|
|
}
|
|
/* KPI row */
|
|
.dh-kpi-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 6px; }
|
|
.dh-kpi { font-size: 0.84rem; color: var(--text-3); }
|
|
.dh-kpi strong { color: var(--text); font-weight: 700; }
|
|
.dh-kpi.warn strong { color: var(--amber, #F59E0B); }
|
|
/* Admin assignment search */
|
|
.adm-asgn-search {
|
|
width: 100%; padding: 7px 10px; border: 1px solid var(--border);
|
|
border-radius: 8px; font: inherit; font-size: 0.82rem;
|
|
margin-bottom: 10px; background: var(--bg, #fff); color: var(--text);
|
|
box-sizing: border-box;
|
|
}
|
|
.adm-asgn-search:focus { outline: none; border-color: var(--violet); }
|
|
|
|
/* Dark mode for admin */
|
|
.app-layout.dark .adm-act { background: #1A1D27; border-color: rgba(255,255,255,0.06); color: #E8ECF2; }
|
|
.app-layout.dark .adm-stat-chip { background: rgba(255,255,255,0.04); border-color: rgba(255,255,255,0.06); }
|
|
.app-layout.dark .adm-sess-name { color: #E8ECF2; }
|
|
.app-layout.dark .adm-asgn-search { background: #1A1D27; color: #E8ECF2; border-color: rgba(255,255,255,0.1); }
|
|
.app-layout.dark .adm-empty-text { color: var(--text-3); }
|
|
.app-layout.dark .asgn-class-badge { background: rgba(155,93,229,0.15); color: #b07de0; }
|
|
|
|
/* C1: Teacher class summary widget */
|
|
.class-summary { display: flex; flex-direction: column; gap: 12px; }
|
|
.cs-card {
|
|
display: flex; align-items: center; gap: 14px;
|
|
padding: 14px 16px; border-radius: 14px;
|
|
background: rgba(155,93,229,0.04); border: 1px solid rgba(155,93,229,0.1);
|
|
}
|
|
.cs-avatar { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 1.1rem; flex-shrink: 0; }
|
|
.cs-body { flex: 1; min-width: 0; }
|
|
.cs-name { font-size: 0.84rem; font-weight: 700; color: var(--text); }
|
|
.cs-stats { font-size: 0.72rem; color: var(--text-3); margin-top: 2px; display: flex; gap: 10px; }
|
|
.cs-stats span { display: flex; align-items: center; gap: 3px; }
|
|
.cs-bar { width: 60px; height: 5px; border-radius: 99px; background: rgba(15,23,42,0.07); overflow: hidden; flex-shrink: 0; }
|
|
.cs-fill { height: 100%; border-radius: 99px; background: var(--violet); }
|
|
|
|
/* ── section header ── */
|
|
.section-header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
margin-bottom: 18px;
|
|
}
|
|
.section-title {
|
|
font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800;
|
|
color: var(--text-3); text-transform: uppercase; letter-spacing: 0.09em;
|
|
display: flex; align-items: center; gap: 8px;
|
|
}
|
|
.section-title::before {
|
|
content: '';
|
|
display: inline-block; width: 3px; height: 14px;
|
|
background: linear-gradient(180deg, #06D6E0, #9B5DE5);
|
|
border-radius: 99px; flex-shrink: 0;
|
|
}
|
|
.section-mb { margin-bottom: 40px; }
|
|
|
|
/* ── assign toolbar ── */
|
|
.assign-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 10px; }
|
|
.assign-chips { display: flex; gap: 6px; }
|
|
.assign-chip {
|
|
padding: 5px 16px; border-radius: 99px; border: 1.5px solid rgba(15,23,42,0.1);
|
|
background: #fff; color: #6B7A8E;
|
|
font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 700;
|
|
cursor: pointer; transition: all 0.16s;
|
|
}
|
|
.assign-chip:hover { border-color: rgba(155,93,229,0.3); color: var(--violet); }
|
|
.assign-chip.active { background: var(--text); color: #fff; border-color: var(--text); }
|
|
.assign-stats { font-size: 0.75rem; color: var(--text-3); font-weight: 600; display: flex; gap: 14px; flex-wrap: wrap; }
|
|
.assign-stats .s-active { color: var(--violet); }
|
|
.assign-stats .s-overdue { color: var(--pink); }
|
|
.assign-stats .s-done { color: #059652; }
|
|
|
|
/* ── subject filter chips ── */
|
|
.subj-filter-row { display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 14px; }
|
|
.sf-chip {
|
|
padding: 3px 11px 3px 8px; border-radius: 99px; border: 1.5px solid rgba(15,23,42,0.09);
|
|
background: #fff; color: var(--text-3);
|
|
font-family: 'Manrope', sans-serif; font-size: 0.72rem; font-weight: 700;
|
|
cursor: pointer; transition: all 0.15s; display: inline-flex; align-items: center; gap: 5px;
|
|
white-space: nowrap;
|
|
}
|
|
.sf-chip svg { width: 13px; height: 13px; stroke: currentColor; stroke-width: 1.8; }
|
|
.sf-chip:hover { color: var(--violet); border-color: rgba(155,93,229,0.35); }
|
|
.sf-chip.active { color: #fff; border-color: transparent; }
|
|
|
|
/* ── group headers ── */
|
|
.assign-group-hdr {
|
|
display: flex; align-items: center; gap: 10px;
|
|
font-family: 'Unbounded', sans-serif; font-size: 0.72rem; font-weight: 800;
|
|
color: var(--text-3); text-transform: uppercase; letter-spacing: 0.08em;
|
|
margin: 8px 0 4px; cursor: pointer; user-select: none;
|
|
}
|
|
.assign-group-hdr::before { content: ''; display: inline-block; width: 3px; height: 13px; border-radius: 99px; flex-shrink: 0; }
|
|
.assign-group-hdr.grp-todo::before { background: linear-gradient(180deg, #FF4C29, #F15BB5); }
|
|
.assign-group-hdr.grp-done::before { background: linear-gradient(180deg, #06D664, #06D6E0); }
|
|
.assign-group-hdr .grp-count { background: rgba(15,23,42,0.07); border-radius: 99px; padding: 1px 8px; font-size: 0.68rem; }
|
|
.assign-group-hdr .grp-arrow { margin-left: auto; transition: transform 0.2s; font-size: 0.65rem; }
|
|
.assign-group-hdr.collapsed .grp-arrow { transform: rotate(-90deg); }
|
|
.assign-group-body.collapsed { display: none; }
|
|
.assign-group-body { margin-top: 6px; }
|
|
|
|
/* ── expand panel ── */
|
|
.asgn-wrap { }
|
|
.asgn-row.expanded { border-radius: 14px 14px 0 0; border-bottom-color: rgba(15,23,42,0.04); transform: none !important; box-shadow: none !important; }
|
|
.asgn-row.spotlight { background: linear-gradient(90deg, rgba(155,93,229,0.04) 0%, #fff 60%); }
|
|
.asgn-expand {
|
|
background: #f8f9fc;
|
|
border: 1.5px solid rgba(15,23,42,0.07); border-top: none;
|
|
border-radius: 0 0 14px 14px;
|
|
padding: 10px 14px 13px 62px;
|
|
display: none; flex-direction: column; gap: 10px;
|
|
margin-bottom: 0;
|
|
}
|
|
.asgn-expand.open { display: flex; }
|
|
.ae-row { display: flex; align-items: center; gap: 12px; }
|
|
.ae-pills { display: flex; gap: 6px; flex-wrap: wrap; flex: 1; }
|
|
.ae-pill { padding: 3px 10px; border-radius: 99px; background: rgba(15,23,42,0.06); color: #6B7A8E; font-size: 0.72rem; font-weight: 600; }
|
|
.ae-dl { flex: 1; }
|
|
.ae-dl-label { font-size: 0.70rem; color: var(--text-3); margin-bottom: 5px; display: flex; justify-content: space-between; }
|
|
.ae-dl-bar { height: 4px; border-radius: 99px; background: rgba(15,23,42,0.08); overflow: hidden; }
|
|
.ae-dl-fill { height: 100%; border-radius: 99px; transition: width 0.4s; }
|
|
.ae-btn {
|
|
padding: 8px 24px; border: none; border-radius: 99px;
|
|
background: var(--text); color: #fff;
|
|
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700;
|
|
cursor: pointer; transition: background 0.15s; white-space: nowrap; flex-shrink: 0;
|
|
}
|
|
.ae-btn:hover { background: #1E2D4A; }
|
|
.ae-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
.ae-btn-result {
|
|
padding: 8px 24px; border: 1.5px solid #059652; border-radius: 99px;
|
|
background: rgba(6,214,100,0.07); color: #059652;
|
|
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700;
|
|
text-decoration: none; display: inline-flex; align-items: center;
|
|
white-space: nowrap; flex-shrink: 0; transition: background 0.15s;
|
|
}
|
|
.ae-btn-result:hover { background: rgba(6,214,100,0.14); }
|
|
|
|
/* ── assignment list ── */
|
|
.tests-list { display: flex; flex-direction: column; gap: 6px; }
|
|
/* ── compact assignment rows ── */
|
|
.asgn-row {
|
|
background: #fff;
|
|
border: 1.5px solid rgba(15,23,42,0.07);
|
|
border-left: 4px solid var(--ac, #9B5DE5);
|
|
border-radius: 14px;
|
|
padding: 0 14px 0 12px;
|
|
height: 62px;
|
|
display: flex; align-items: center; gap: 12px;
|
|
transition: transform 0.15s, box-shadow 0.15s;
|
|
}
|
|
.asgn-row:hover { transform: translateX(3px); box-shadow: 0 4px 20px rgba(15,23,42,0.09); }
|
|
.asgn-row.over { --ac: var(--pink); background: rgba(241,91,181,0.02); }
|
|
.asgn-row.urgent { --ac: #FF4C29; box-shadow: 0 0 0 2px rgba(255,76,41,0.08); }
|
|
.asgn-row.done { --ac: var(--green); }
|
|
|
|
.ar-icon {
|
|
width: 36px; height: 36px; border-radius: 10px;
|
|
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
|
}
|
|
.ar-icon svg { width: 18px; height: 18px; stroke: currentColor; stroke-width: 1.8; }
|
|
|
|
.ar-body { flex: 1; min-width: 0; }
|
|
.ar-title {
|
|
font-size: 0.88rem; font-weight: 700; color: var(--text);
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.2;
|
|
}
|
|
.ar-meta {
|
|
font-size: 0.71rem; color: var(--text-3); margin-top: 3px;
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
}
|
|
.ar-meta .ar-tag-urgent { color: #E83A1E; font-weight: 700; }
|
|
.ar-meta .ar-tag-over { color: var(--pink); font-weight: 700; }
|
|
.ar-meta .ar-tag-hw { color: #7c3aed; font-weight: 700; }
|
|
|
|
.ar-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
|
.ar-score { font-family: 'Unbounded', sans-serif; font-size: 0.85rem; font-weight: 900; min-width: 36px; text-align: right; }
|
|
.ar-score.hi { color: #059652; }
|
|
.ar-score.mid { color: var(--amber); }
|
|
.ar-score.lo { color: var(--pink); }
|
|
|
|
.ar-progress { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
|
.ar-prog-bar { width: 72px; height: 4px; border-radius: 99px; background: rgba(15,23,42,0.08); overflow: hidden; }
|
|
.ar-prog-fill { height: 100%; border-radius: 99px; }
|
|
.ar-prog-text { font-size: 0.71rem; color: var(--text-3); white-space: nowrap; min-width: 36px; }
|
|
|
|
.ar-btn {
|
|
padding: 6px 16px; border: none; border-radius: 99px;
|
|
background: var(--text); color: #fff;
|
|
font-family: 'Manrope', sans-serif; font-size: 0.76rem; font-weight: 700;
|
|
cursor: pointer; transition: background 0.15s; white-space: nowrap;
|
|
}
|
|
.ar-btn:hover { background: #1E2D4A; }
|
|
.ar-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
.ar-btn-result {
|
|
padding: 6px 16px; border: 1.5px solid #059652; border-radius: 99px;
|
|
background: rgba(6,214,100,0.06); color: #059652;
|
|
font-family: 'Manrope', sans-serif; font-size: 0.76rem; font-weight: 700;
|
|
text-decoration: none; display: inline-flex; align-items: center;
|
|
white-space: nowrap; transition: background 0.15s;
|
|
}
|
|
.ar-btn-result:hover { background: rgba(6,214,100,0.14); }
|
|
.ar-btn-ghost {
|
|
padding: 6px 16px; border: 1.5px solid rgba(15,23,42,0.12); border-radius: 99px;
|
|
background: transparent; color: var(--text-3);
|
|
font-family: 'Manrope', sans-serif; font-size: 0.76rem; font-weight: 600;
|
|
text-decoration: none; display: inline-flex; align-items: center;
|
|
white-space: nowrap; transition: all 0.15s;
|
|
}
|
|
.ar-btn-ghost:hover { border-color: var(--violet); color: var(--violet); }
|
|
|
|
/* ── history ── */
|
|
.history-list { display: flex; flex-direction: column; gap: 8px; }
|
|
.hist-item {
|
|
background: #fff;
|
|
border: 1.5px solid rgba(15,23,42,0.07);
|
|
border-radius: 16px; padding: 14px 20px;
|
|
display: flex; align-items: center; gap: 16px;
|
|
transition: all 0.18s ease;
|
|
text-decoration: none; color: inherit;
|
|
box-shadow: 0 1px 4px rgba(15,23,42,0.04);
|
|
}
|
|
.hist-item:hover {
|
|
border-color: rgba(15,23,42,0.12);
|
|
box-shadow: 0 6px 24px rgba(15,23,42,0.08);
|
|
transform: translateX(4px);
|
|
}
|
|
.hist-pct { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 900; min-width: 52px; text-align: center; }
|
|
.hist-pct.hi { color: #059652; }
|
|
.hist-pct.mid { color: var(--amber); }
|
|
.hist-pct.lo { color: var(--pink); }
|
|
.hist-info { flex: 1; }
|
|
.hist-subj { font-size: 0.88rem; font-weight: 700; margin-bottom: 2px; color: var(--text); }
|
|
.hist-meta { font-size: 0.75rem; color: var(--text-3); }
|
|
.hist-score { font-size: 0.8rem; color: var(--text-2); font-weight: 600; white-space: nowrap; }
|
|
|
|
/* ── progress tab ── */
|
|
.chart-section-title {
|
|
font-family: 'Unbounded', sans-serif; font-size: 0.76rem; font-weight: 800;
|
|
color: var(--text-3); text-transform: uppercase; letter-spacing: 0.09em;
|
|
margin: 36px 0 16px;
|
|
display: flex; align-items: center; gap: 8px;
|
|
}
|
|
.chart-section-title::before {
|
|
content: ''; display: inline-block; width: 3px; height: 14px;
|
|
background: linear-gradient(180deg, #06D6E0, #9B5DE5);
|
|
border-radius: 99px;
|
|
}
|
|
.chart-section-title:first-child { margin-top: 0; }
|
|
.trend-wrap {
|
|
background: #fff; border: 1.5px solid rgba(15,23,42,0.08);
|
|
border-radius: 20px; padding: 24px 24px 16px;
|
|
box-shadow: 0 2px 8px rgba(15,23,42,0.05);
|
|
}
|
|
.trend-canvas-wrap { position: relative; height: 230px; }
|
|
.subj-charts-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); gap: 14px; }
|
|
.subj-chart-card {
|
|
background: #fff; border: 1.5px solid rgba(15,23,42,0.08);
|
|
border-radius: 20px; padding: 22px 18px;
|
|
text-align: center; display: flex; flex-direction: column; align-items: center; gap: 6px;
|
|
box-shadow: 0 2px 8px rgba(15,23,42,0.05);
|
|
transition: all 0.2s;
|
|
}
|
|
.subj-chart-card:hover { border-color: rgba(15,23,42,0.14); transform: translateY(-3px); box-shadow: 0 10px 32px rgba(15,23,42,0.1); }
|
|
.subj-chart-name { font-family: 'Unbounded', sans-serif; font-size: 0.76rem; font-weight: 800; color: var(--text); }
|
|
.subj-chart-sessions { font-size: 0.72rem; color: var(--text-3); }
|
|
.canvas-wrap { position: relative; width: 130px; height: 130px; margin: 4px 0; }
|
|
|
|
/* ── weak topics ── */
|
|
.weak-list { display: flex; flex-direction: column; gap: 10px; }
|
|
.weak-item {
|
|
background: #fff; border: 1.5px solid rgba(15,23,42,0.07);
|
|
border-radius: 16px; padding: 16px 20px;
|
|
display: flex; align-items: center; gap: 18px;
|
|
cursor: pointer; transition: all 0.18s ease;
|
|
box-shadow: 0 1px 4px rgba(15,23,42,0.04);
|
|
}
|
|
.weak-item:hover { border-color: rgba(15,23,42,0.12); transform: translateX(4px); box-shadow: 0 6px 24px rgba(15,23,42,0.08); }
|
|
.weak-bar-wrap { flex: 1; }
|
|
.weak-name { font-size: 0.88rem; font-weight: 700; margin-bottom: 2px; color: var(--text); }
|
|
.weak-meta { font-size: 0.75rem; color: var(--text-3); }
|
|
.weak-bar { height: 7px; background: rgba(15,23,42,0.07); border-radius: 99px; overflow: hidden; margin-top: 8px; }
|
|
.weak-fill { height: 100%; border-radius: 99px; background: linear-gradient(90deg, #FF9F1C, var(--pink)); transition: width 0.8s cubic-bezier(0.34,1.2,0.64,1); }
|
|
.weak-pct { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 900; color: #E0335E; min-width: 48px; text-align: right; }
|
|
|
|
/* ── submission strip in expand ── */
|
|
.ae-submit-row {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding-top: 8px; border-top: 1px solid rgba(15,23,42,0.06); flex-wrap: wrap;
|
|
}
|
|
.ae-submit-label { font-size: 0.72rem; color: var(--text-3); font-weight: 600; flex-shrink: 0; }
|
|
.ae-submit-status {
|
|
display: inline-flex; align-items: center; gap: 5px;
|
|
padding: 3px 10px; border-radius: 99px; font-size: 0.72rem; font-weight: 700;
|
|
}
|
|
.ae-submit-status.new { background: rgba(6,214,224,0.1); color: #06aab3; }
|
|
.ae-submit-status.resubmitted { background: rgba(6,214,224,0.1); color: #06aab3; }
|
|
.ae-submit-status.reviewed { background: rgba(5,150,82,0.1); color: #059652; }
|
|
.ae-submit-status.accepted { background: rgba(5,150,82,0.12); color: #059652; }
|
|
.ae-submit-status.revision { background: rgba(241,91,181,0.1); color: #c0306a; }
|
|
|
|
/* mini status chip on collapsed card */
|
|
.ar-sub-chip {
|
|
display: inline-flex; align-items: center; gap: 4px;
|
|
padding: 2px 8px; border-radius: 99px; font-size: 0.65rem; font-weight: 700;
|
|
flex-shrink: 0;
|
|
}
|
|
.ar-sub-chip.s-new, .ar-sub-chip.s-resubmitted { background: rgba(6,214,224,0.1); color: #06aab3; }
|
|
.ar-sub-chip.s-reviewed { background: rgba(5,150,82,0.1); color: #059652; }
|
|
.ar-sub-chip.s-accepted { background: rgba(5,150,82,0.12); color: #059652; }
|
|
.ar-sub-chip.s-revision { background: rgba(241,91,181,0.1); color: #c0306a; }
|
|
.ar-sub-chip.s-none { background: rgba(155,93,229,0.08); color: var(--violet); }
|
|
|
|
/* ── My submissions widget ── */
|
|
.my-subs-list { display: flex; flex-direction: column; gap: 6px; }
|
|
.ms-row {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 10px 14px; background: #fff;
|
|
border: 1.5px solid rgba(15,23,42,0.07); border-radius: 12px;
|
|
transition: transform 0.15s, box-shadow 0.15s;
|
|
}
|
|
.ms-row:hover { transform: translateX(3px); box-shadow: 0 4px 16px rgba(15,23,42,0.07); }
|
|
.ms-title { flex: 1; font-size: 0.82rem; font-weight: 600; color: var(--text);
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.ms-file { font-size: 0.7rem; color: var(--text-3); flex-shrink: 0; max-width: 120px;
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.ms-grade { font-weight: 800; font-size: 0.78rem; flex-shrink: 0; }
|
|
.ms-more { display: inline-block; margin-top: 8px; font-size: 0.78rem; font-weight: 700; color: var(--violet); text-decoration: none; }
|
|
.ms-more:hover { text-decoration: underline; }
|
|
.ae-submit-note { font-size: 0.75rem; color: var(--text-2); flex: 1; font-style: italic; }
|
|
.ae-grade-badge {
|
|
display: inline-flex; align-items: center; gap: 3px;
|
|
padding: 3px 9px; border-radius: 99px; font-size: 0.72rem; font-weight: 800;
|
|
background: rgba(155,93,229,0.1); color: var(--violet); flex-shrink: 0;
|
|
}
|
|
.ae-grade-badge.high { background: rgba(5,150,82,0.12); color: #059652; }
|
|
.ae-grade-badge.mid { background: rgba(255,193,7,0.14); color: #c07c00; }
|
|
.ae-grade-badge.low { background: rgba(241,91,181,0.12); color: #c0306a; }
|
|
.ae-btn-submit {
|
|
padding: 5px 14px; border: 1.5px solid rgba(155,93,229,0.3); border-radius: 99px;
|
|
background: rgba(155,93,229,0.06); color: var(--violet);
|
|
font-family: 'Manrope', sans-serif; font-size: 0.74rem; font-weight: 700;
|
|
cursor: pointer; transition: all 0.15s; white-space: nowrap; flex-shrink: 0;
|
|
}
|
|
.ae-btn-submit:hover { background: rgba(155,93,229,0.14); border-color: var(--violet); }
|
|
.ae-btn-delete-sub {
|
|
padding: 5px 10px; border: 1.5px solid rgba(241,91,181,0.25); border-radius: 99px;
|
|
background: transparent; color: #c0306a;
|
|
font-family: 'Manrope', sans-serif; font-size: 0.70rem; font-weight: 600;
|
|
cursor: pointer; transition: all 0.15s; white-space: nowrap; flex-shrink: 0;
|
|
}
|
|
.ae-btn-delete-sub:hover { background: rgba(241,91,181,0.08); border-color: var(--pink); }
|
|
|
|
/* ── spinner / empty ── */
|
|
.spinner { width: 36px; height: 36px; border: 3px solid rgba(15,23,42,0.1); border-top-color: var(--violet); border-radius: 50%; animation: spin 0.75s linear infinite; margin: 48px auto; display: block; }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.empty { text-align: center; padding: 36px; color: var(--text-3); font-size: 0.88rem; }
|
|
|
|
/* ── empty CTA ── */
|
|
.empty-cta {
|
|
display: flex; flex-direction: column; align-items: center; gap: 16px;
|
|
padding: 60px 28px; text-align: center;
|
|
background: #fff;
|
|
border: 2px dashed rgba(155,93,229,0.2);
|
|
border-radius: 24px;
|
|
box-shadow: 0 2px 8px rgba(15,23,42,0.04);
|
|
}
|
|
.empty-cta-icon { font-size: 3.4rem; line-height: 1; filter: drop-shadow(0 4px 16px rgba(0,0,0,0.12)); }
|
|
.empty-cta-title { font-family: 'Unbounded', sans-serif; font-size: 1.05rem; font-weight: 800; color: var(--text); }
|
|
.empty-cta-desc { font-size: 0.88rem; color: #6B7A8E; max-width: 300px; line-height: 1.7; }
|
|
|
|
/* ── nav active ── */
|
|
.nav-active { background: rgba(155,93,229,0.08) !important; border-color: var(--violet) !important; color: var(--violet) !important; cursor: default; pointer-events: none; }
|
|
|
|
/* ── notification ── */
|
|
.notif-bell { position: relative; }
|
|
.notif-badge { position: absolute; top: -4px; right: -4px; min-width: 18px; height: 18px; padding: 0 4px; background: var(--pink); color: #fff; border-radius: 99px; font-size: 0.65rem; font-weight: 700; display: flex; align-items: center; justify-content: center; line-height: 1; }
|
|
.notif-drop-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 10px; border-bottom: 1px solid var(--border); }
|
|
.notif-drop-title { font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800; }
|
|
.notif-read-all { background: none; border: none; font-size: 0.74rem; color: var(--violet); cursor: pointer; font-family: 'Manrope', sans-serif; font-weight: 600; }
|
|
.notif-item { display: flex; gap: 10px; padding: 11px 16px; border-bottom: 1px solid var(--border); cursor: pointer; transition: background var(--tr); text-decoration: none; color: inherit; }
|
|
.notif-item:last-child { border-bottom: none; }
|
|
.notif-item:hover { background: rgba(155,93,229,0.04); }
|
|
.notif-item.unread { background: rgba(155,93,229,0.05); }
|
|
.notif-item.unread:hover { background: rgba(155,93,229,0.09); }
|
|
.notif-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--violet); flex-shrink: 0; margin-top: 5px; }
|
|
.notif-dot.read { background: transparent; border: 1.5px solid var(--border-h); }
|
|
.notif-msg { font-size: 0.80rem; line-height: 1.45; flex: 1; }
|
|
.notif-time { font-size: 0.70rem; color: var(--text-3); margin-top: 2px; }
|
|
.notif-empty { padding: 28px 16px; text-align: center; color: var(--text-3); font-size: 0.84rem; }
|
|
|
|
/* ── join modal ── */
|
|
.modal-overlay { position: fixed; inset: 0; background: rgba(15,23,42,0.4); backdrop-filter: blur(6px); z-index: 200; display: none; align-items: center; justify-content: center; padding: 20px; }
|
|
.modal-overlay.open { display: flex; }
|
|
.modal { background: #fff; border-radius: 24px; padding: 36px; width: 100%; max-width: 420px; box-shadow: 0 32px 80px rgba(15,23,42,0.22); }
|
|
.modal-title { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800; margin-bottom: 22px; }
|
|
.form-input { width: 100%; padding: 12px 16px; border: 1.5px solid rgba(15,23,42,0.15); border-radius: 12px; font-family: 'Manrope', sans-serif; font-size: 0.95rem; color: var(--text); background: #f8f9fc; transition: border-color 0.2s; box-sizing: border-box; }
|
|
.form-input:focus { outline: none; border-color: var(--violet); background: #fff; }
|
|
.modal-footer { display: flex; gap: 10px; justify-content: flex-end; margin-top: 22px; }
|
|
.btn-cancel { padding: 10px 22px; border: 1.5px solid rgba(15,23,42,0.15); border-radius: 999px; background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all 0.18s; }
|
|
.btn-cancel:hover { border-color: rgba(15,23,42,0.3); color: var(--text); }
|
|
.btn-join { padding: 10px 28px; border: none; border-radius: 999px; background: var(--text); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 700; cursor: pointer; box-shadow: 0 2px 10px rgba(15,23,42,0.2); transition: all 0.18s; }
|
|
.btn-join:hover { background: #1E2D4A; box-shadow: 0 6px 20px rgba(15,23,42,0.25); }
|
|
.btn-join:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none; }
|
|
|
|
|
|
/* ── history timeline ── */
|
|
.hist-date-sep {
|
|
font-family: 'Unbounded', sans-serif; font-size: 0.68rem; font-weight: 800;
|
|
color: var(--text-3); text-transform: uppercase; letter-spacing: 0.08em;
|
|
margin: 22px 0 10px; display: flex; align-items: center; gap: 12px;
|
|
}
|
|
.hist-date-sep:first-child { margin-top: 0; }
|
|
.hist-date-sep::after { content: ''; flex: 1; height: 1px; background: rgba(15,23,42,0.08); }
|
|
.hist-ring { flex-shrink: 0; }
|
|
|
|
/* ── stats charts ── */
|
|
.stats-section { margin-bottom: 18px; }
|
|
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
|
@media (max-width: 768px) { .stats-grid { grid-template-columns: 1fr; } }
|
|
.stats-chart-card {
|
|
background: #fff; border: 1.5px solid rgba(15,23,42,0.07);
|
|
border-radius: 18px; padding: 18px 20px;
|
|
}
|
|
.stats-chart-title {
|
|
font-size: 0.72rem; font-weight: 700; color: var(--text-3);
|
|
text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 12px;
|
|
}
|
|
.stats-bar-chart { display: flex; align-items: flex-end; gap: 4px; height: 100px; }
|
|
.stats-bar {
|
|
flex: 1; border-radius: 4px 4px 0 0; min-width: 6px; position: relative;
|
|
transition: height 0.3s; cursor: default;
|
|
}
|
|
.stats-bar:hover::after {
|
|
content: attr(data-tip); position: absolute; bottom: 100%; left: 50%;
|
|
transform: translateX(-50%); padding: 3px 8px; border-radius: 6px;
|
|
background: var(--text); color: #fff; font-size: 0.65rem; font-weight: 600;
|
|
white-space: nowrap; pointer-events: none;
|
|
}
|
|
.stats-bar-labels { display: flex; gap: 4px; margin-top: 4px; }
|
|
.stats-bar-labels span {
|
|
flex: 1; font-size: 0.55rem; color: #B0BEC5; text-align: center;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.stats-line-chart { position: relative; height: 100px; }
|
|
.stats-line-chart canvas { width: 100% !important; height: 100% !important; }
|
|
.stats-summary-chips {
|
|
display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px;
|
|
}
|
|
.stats-chip {
|
|
flex: 1; min-width: 80px; padding: 12px 14px; border-radius: 14px;
|
|
background: rgba(255,255,255,0.7); border: 1px solid rgba(15,23,42,0.06);
|
|
text-align: center;
|
|
}
|
|
.stats-chip-val {
|
|
font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 800;
|
|
color: var(--text); line-height: 1;
|
|
}
|
|
.stats-chip-lbl { font-size: 0.65rem; color: var(--text-3); margin-top: 3px; font-weight: 600; }
|
|
|
|
/* ── activity heatmap ── */
|
|
.heatmap-section {
|
|
background: #fff; border: 1.5px solid rgba(15,23,42,0.08);
|
|
border-radius: 20px; padding: 24px 28px; overflow-x: auto;
|
|
box-shadow: 0 2px 8px rgba(15,23,42,0.05);
|
|
}
|
|
.heatmap-grid { display: grid; grid-template-columns: repeat(52, 14px); grid-template-rows: repeat(7, 14px); gap: 3px; min-width: 780px; }
|
|
.hm-cell { width: 14px; height: 14px; border-radius: 3px; background: rgba(15,23,42,0.06); transition: transform 0.12s; cursor: default; }
|
|
.hm-cell[data-n="1"] { background: rgba(155,93,229,0.2); }
|
|
.hm-cell[data-n="2"] { background: rgba(155,93,229,0.42); }
|
|
.hm-cell[data-n="3"] { background: rgba(155,93,229,0.65); }
|
|
.hm-cell[data-n="4"] { background: rgba(155,93,229,0.88); }
|
|
.hm-cell:hover { transform: scale(1.5); z-index: 5; position: relative; }
|
|
.heatmap-legend { display: flex; align-items: center; gap: 5px; margin-top: 14px; font-size: 0.72rem; color: var(--text-3); }
|
|
.hm-leg { width: 14px; height: 14px; border-radius: 3px; }
|
|
|
|
/* ── subject card grid (legacy, kept for compatibility) ── */
|
|
.subj-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 18px; }
|
|
|
|
/* ── theory progress ── */
|
|
.theory-courses-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px,1fr)); gap: 14px; margin-bottom: 8px; }
|
|
.theory-course-card {
|
|
background: #fff; border: 1.5px solid rgba(15,23,42,0.07);
|
|
border-radius: 16px; padding: 16px 18px;
|
|
text-decoration: none; color: inherit;
|
|
display: flex; flex-direction: column; gap: 10px;
|
|
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
|
|
box-shadow: 0 1px 4px rgba(15,23,42,0.04);
|
|
}
|
|
.theory-course-card:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(15,23,42,0.1); border-color: rgba(155,93,229,0.2); }
|
|
.tc-header { display: flex; align-items: center; gap: 10px; }
|
|
.tc-emoji { font-size: 1.6rem; line-height: 1; }
|
|
.tc-info { flex: 1; }
|
|
.tc-title { font-size: 0.88rem; font-weight: 700; color: var(--text); line-height: 1.35; }
|
|
.tc-subj { font-size: 0.7rem; font-weight: 700; color: var(--text-3); margin-top: 2px; text-transform: uppercase; letter-spacing: 0.06em; }
|
|
.tc-progress { display: flex; align-items: center; gap: 8px; }
|
|
.tc-bar { flex: 1; height: 5px; border-radius: 99px; background: rgba(15,23,42,0.07); }
|
|
.tc-fill { height: 100%; border-radius: 99px; background: var(--violet); }
|
|
.tc-pct { font-size: 0.72rem; font-weight: 700; color: var(--violet); flex-shrink: 0; }
|
|
.tc-meta { font-size: 0.74rem; color: var(--text-3); }
|
|
|
|
/* ── Leaderboard widget ── */
|
|
.lb-widget {
|
|
background: #fff; border: 1.5px solid rgba(15,23,42,0.08);
|
|
border-radius: 18px; padding: 20px 22px; margin-bottom: 22px;
|
|
box-shadow: 0 2px 8px rgba(15,23,42,0.04);
|
|
}
|
|
.lb-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
|
.lb-title { font-family: 'Unbounded', sans-serif; font-size: 0.84rem; font-weight: 800; color: var(--text); }
|
|
.lb-tabs { display: flex; gap: 4px; }
|
|
.lb-tab {
|
|
padding: 5px 12px; border-radius: 99px; border: none;
|
|
font-family: 'Manrope', sans-serif; font-size: 0.72rem; font-weight: 700;
|
|
background: transparent; color: var(--text-3); cursor: pointer; transition: all 0.15s;
|
|
}
|
|
.lb-tab.active { background: linear-gradient(135deg, rgba(155,93,229,0.1), rgba(6,214,224,0.08)); color: var(--violet); }
|
|
.lb-list { display: flex; flex-direction: column; gap: 4px; }
|
|
.lb-row {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 8px 10px; border-radius: 12px; transition: background 0.12s;
|
|
}
|
|
.lb-row:hover { background: rgba(155,93,229,0.04); }
|
|
.lb-row.me { background: linear-gradient(135deg, rgba(155,93,229,0.06), rgba(6,214,224,0.04)); border: 1px solid rgba(155,93,229,0.12); }
|
|
.lb-pos {
|
|
width: 24px; height: 24px; border-radius: 50%; flex-shrink: 0;
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-family: 'Unbounded', sans-serif; font-size: 0.68rem; font-weight: 900;
|
|
background: rgba(15,23,42,0.06); color: var(--text-3);
|
|
}
|
|
.lb-pos.gold { background: linear-gradient(135deg, #FFD700, #FFA500); color: #fff; }
|
|
.lb-pos.silver { background: linear-gradient(135deg, #C0C0C0, #A0A0A0); color: #fff; }
|
|
.lb-pos.bronze { background: linear-gradient(135deg, #CD7F32, #B87333); color: #fff; }
|
|
.lb-avatar {
|
|
width: 32px; height: 32px; border-radius: 50%; flex-shrink: 0;
|
|
background: var(--grad-1);
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-family: 'Unbounded', sans-serif; font-size: 0.6rem; font-weight: 800; color: #fff;
|
|
}
|
|
.lb-info { flex: 1; min-width: 0; }
|
|
.lb-name { font-size: 0.82rem; font-weight: 700; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.lb-rank { font-size: 0.64rem; color: var(--text-3); font-weight: 600; }
|
|
.lb-xp { font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800; color: var(--violet); flex-shrink: 0; }
|
|
.lb-empty { text-align: center; padding: 20px; color: var(--text-3); font-size: 0.82rem; }
|
|
.lb-class-sel {
|
|
padding: 5px 10px; border-radius: 10px; border: 1.5px solid rgba(15,23,42,0.1);
|
|
background: #fff; font-family: 'Manrope', sans-serif; font-size: 0.72rem; font-weight: 600;
|
|
color: var(--text); cursor: pointer; outline: none;
|
|
}
|
|
|
|
/* ── Challenges widget ── */
|
|
.ch-widget { margin-bottom: 22px; }
|
|
.ch-list { display: flex; flex-direction: column; gap: 8px; }
|
|
.ch-item {
|
|
display: flex; align-items: center; gap: 14px;
|
|
padding: 14px 16px; border-radius: 14px;
|
|
background: #fff; border: 1.5px solid rgba(15,23,42,0.07);
|
|
transition: all 0.15s;
|
|
}
|
|
.ch-item.done { border-color: rgba(6,214,100,0.25); background: rgba(6,214,100,0.03); }
|
|
.ch-item.claimed { opacity: 0.55; }
|
|
.ch-icon { font-size: 1.3rem; flex-shrink: 0; }
|
|
.ch-body { flex: 1; min-width: 0; }
|
|
.ch-title { font-size: 0.84rem; font-weight: 700; color: var(--text); }
|
|
.ch-desc { font-size: 0.72rem; color: var(--text-3); margin-top: 2px; }
|
|
.ch-prog { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
|
|
.ch-bar { flex: 1; height: 5px; border-radius: 99px; background: rgba(15,23,42,0.07); overflow: hidden; }
|
|
.ch-fill { height: 100%; border-radius: 99px; background: linear-gradient(90deg, #9B5DE5, #06D6E0); transition: width 0.4s; }
|
|
.ch-pct { font-size: 0.68rem; font-weight: 700; color: var(--violet); flex-shrink: 0; }
|
|
.ch-reward { flex-shrink: 0; text-align: right; }
|
|
.ch-xp { font-family: 'Unbounded', sans-serif; font-size: 0.72rem; font-weight: 800; color: var(--violet); }
|
|
.ch-claim {
|
|
margin-top: 4px; padding: 4px 12px; border: none; border-radius: 99px;
|
|
background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif;
|
|
font-size: 0.68rem; font-weight: 700; cursor: pointer; transition: transform 0.15s;
|
|
}
|
|
.ch-claim:hover { transform: translateY(-1px); }
|
|
.ch-claimed-badge { font-size: 0.68rem; color: #059652; font-weight: 700; margin-top: 4px; }
|
|
|
|
/* ── Gamification bar ── */
|
|
.gam-bar {
|
|
display: flex; align-items: center; gap: 18px;
|
|
padding: 16px 22px; border-radius: 18px; margin-bottom: 16px;
|
|
background:
|
|
radial-gradient(ellipse at 6% 50%, rgba(155,93,229,.14) 0%, transparent 38%),
|
|
linear-gradient(135deg, rgba(155,93,229,.07) 0%, rgba(6,214,224,.04) 100%);
|
|
border: 1.5px solid rgba(155,93,229,.18);
|
|
box-shadow: inset 0 1px 0 rgba(255,255,255,.85), 0 2px 14px rgba(155,93,229,.07);
|
|
}
|
|
.gam-level {
|
|
width: 56px; height: 56px; border-radius: 50%; flex-shrink: 0;
|
|
background: var(--grad-1);
|
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
color: #fff; line-height: 1;
|
|
box-shadow: 0 4px 18px rgba(155,93,229,.45), 0 0 0 3px rgba(155,93,229,.18);
|
|
}
|
|
.gam-level-num { font-family: 'Unbounded', sans-serif; font-size: 1.2rem; font-weight: 900; }
|
|
.gam-level-lbl { font-size: 0.5rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.82; }
|
|
.gam-main { flex: 1; min-width: 0; }
|
|
.gam-top { display: flex; align-items: baseline; gap: 8px; margin-bottom: 7px; }
|
|
.gam-rank { font-family: 'Unbounded', sans-serif; font-size: 0.84rem; font-weight: 800; color: var(--text); }
|
|
.gam-xp-text { font-size: 0.72rem; color: var(--text-3); font-weight: 600; }
|
|
.gam-progress { height: 10px; border-radius: 99px; background: rgba(155,93,229,.1); overflow: hidden; }
|
|
.gam-fill {
|
|
height: 100%; border-radius: 99px;
|
|
background: linear-gradient(90deg, #9B5DE5 0%, #4DC8D4 60%, #06D6E0 100%);
|
|
transition: width 0.6s ease;
|
|
box-shadow: 0 0 10px rgba(155,93,229,.35);
|
|
}
|
|
.gam-chips {
|
|
display: flex; gap: 10px; flex-shrink: 0;
|
|
padding-left: 18px;
|
|
border-left: 1.5px solid rgba(155,93,229,.14);
|
|
}
|
|
.gam-chip {
|
|
display: flex; flex-direction: column; align-items: center; gap: 3px;
|
|
padding: 8px 14px; border-radius: 14px;
|
|
background: rgba(255,255,255,.92); border: 1px solid rgba(155,93,229,.12);
|
|
min-width: 66px;
|
|
box-shadow: 0 2px 8px rgba(15,23,42,.05);
|
|
}
|
|
.gam-chip-icon { line-height: 1; }
|
|
.gam-chip-val { font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800; color: var(--text); }
|
|
.gam-chip-lbl { font-size: 0.56rem; color: var(--text-3); font-weight: 700; text-transform: uppercase; letter-spacing: .04em; }
|
|
.gam-goal-ring { position: relative; width: 40px; height: 40px; }
|
|
.gam-goal-ring svg { width: 40px; height: 40px; }
|
|
|
|
/* ── Mobile responsive ── */
|
|
@media (max-width: 768px) {
|
|
/* Layout */
|
|
.container { padding: 20px 14px 80px; }
|
|
.widget { padding: 16px; }
|
|
.lb-widget { padding: 16px; }
|
|
|
|
/* Dash header */
|
|
.dash-header { flex-wrap: wrap; height: auto; padding: 14px 16px; gap: 12px; }
|
|
.dh-stats { flex-wrap: wrap; gap: 10px; width: 100%; justify-content: center; }
|
|
.dh-greeting { font-size: 0.88rem; }
|
|
|
|
/* Grids <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> single column */
|
|
.main-grid { grid-template-columns: 1fr; }
|
|
.action-cards { grid-template-columns: 1fr; }
|
|
.hero-row { grid-template-columns: 1fr; }
|
|
.admin-grid { grid-template-columns: 1fr; gap: 14px; }
|
|
.adm-actions { grid-template-columns: 1fr; gap: 10px; }
|
|
.adm-act-group { grid-template-columns: 1fr 1fr; }
|
|
/* KPI chips wrap to 2 cols */
|
|
.dh-kpi-row { gap: 8px; }
|
|
/* Session rows: wrap on small screens */
|
|
.adm-sess-row { flex-wrap: wrap; }
|
|
.adm-sess-subj { min-width: unset; }
|
|
/* Assignment title: allow wrapping */
|
|
.admin-grid .ar-title { white-space: normal; }
|
|
.theory-courses-grid { grid-template-columns: 1fr; }
|
|
.subj-charts-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
|
|
|
/* Gamification bar */
|
|
.gam-bar { flex-wrap: wrap; gap: 12px; }
|
|
.gam-chips { width: 100%; justify-content: center; }
|
|
|
|
/* Action banner */
|
|
.action-banner { flex-wrap: wrap; padding: 14px 16px; gap: 10px; }
|
|
.ab-countdown { text-align: left; }
|
|
.ab-countdown-val { font-size: 1.1rem; }
|
|
.ab-btn { width: 100%; text-align: center; justify-content: center; }
|
|
|
|
/* Assignment rows: wrap right-side, hide progress bar */
|
|
.asgn-row { height: auto; min-height: 54px; padding: 8px 10px; gap: 8px; flex-wrap: wrap; }
|
|
.ar-progress { display: none; }
|
|
.ar-right { gap: 6px; }
|
|
.ar-btn, .ar-btn-ghost, .ar-btn-result { font-size: 0.72rem; padding: 5px 12px; }
|
|
/* Expand panel: remove deep left indent */
|
|
.asgn-expand { padding: 10px 12px 12px; }
|
|
|
|
/* History + weak topics */
|
|
.hist-item { padding: 12px 14px; gap: 10px; }
|
|
.hist-pct { min-width: 44px; font-size: 0.88rem; }
|
|
.weak-item { padding: 12px 14px; gap: 12px; }
|
|
.weak-pct { min-width: 36px; font-size: 0.88rem; }
|
|
|
|
/* Empty CTA */
|
|
.empty-cta { padding: 40px 20px; }
|
|
.empty-cta-icon { font-size: 2.5rem; }
|
|
.empty-cta-title { font-size: 0.9rem; }
|
|
|
|
/* Trend chart */
|
|
.trend-wrap { padding: 16px 12px 12px; }
|
|
|
|
/* Heatmap popup: prevent off-screen overflow */
|
|
.hm-day-popup { min-width: 0; max-width: 90vw; }
|
|
|
|
/* Modal <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> bottom sheet */
|
|
.modal-overlay { align-items: flex-end; padding: 0; }
|
|
.modal { border-radius: 22px 22px 0 0; padding: 28px 20px 36px; max-height: 90vh; overflow-y: auto; -webkit-overflow-scrolling: touch; }
|
|
.modal-footer { flex-wrap: wrap; }
|
|
.modal-footer .btn-cancel,
|
|
.modal-footer .btn-primary { flex: 1; justify-content: center; }
|
|
|
|
/* Quick-start modal row label */
|
|
.qs-label { min-width: 60px; }
|
|
|
|
/* Leaderboard */
|
|
.lb-head { flex-direction: column; gap: 10px; align-items: flex-start; }
|
|
.lb-name { font-size: 0.76rem; }
|
|
|
|
/* Section headers */
|
|
.section-title { font-size: 0.74rem; }
|
|
|
|
/* Heatmap section */
|
|
.heatmap-section { padding: 18px 12px; }
|
|
|
|
/* Keyboard hints: hide on mobile */
|
|
.kb-hint { display: none !important; }
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
.container { padding: 16px 10px 80px; }
|
|
.widget { padding: 14px; }
|
|
.lb-widget { padding: 14px; }
|
|
|
|
/* Header stats */
|
|
.dh-stats { gap: 0; flex-wrap: wrap; padding: 6px 4px; }
|
|
.stat-ring { padding: 4px 10px; gap: 7px; }
|
|
.dh-greeting { font-size: 0.82rem; }
|
|
|
|
/* Admin quick actions: narrow */
|
|
.adm-actions { gap: 8px; }
|
|
.adm-act-group { grid-template-columns: 1fr; }
|
|
.adm-act { padding: 10px 10px; font-size: 0.76rem; gap: 8px; }
|
|
|
|
/* Assignment rows tighter */
|
|
.asgn-row { gap: 6px; padding: 6px 8px; }
|
|
.ar-icon { width: 30px; height: 30px; }
|
|
.ar-icon svg { width: 14px; height: 14px; }
|
|
|
|
/* Stats chips min-width */
|
|
.stats-chip { min-width: 60px; }
|
|
|
|
/* Heatmap popup always right-anchored */
|
|
.hm-day-popup { right: 10px !important; left: auto !important; top: auto !important; }
|
|
}
|
|
/* ── Widget configurator FAB ── */
|
|
.dash-cfg-fab {
|
|
display: none; /* shown for students via JS */
|
|
position: fixed; bottom: 88px; right: 20px; z-index: 80;
|
|
width: 44px; height: 44px; border-radius: 50%;
|
|
background: var(--violet); border: none; cursor: pointer;
|
|
box-shadow: 0 4px 16px rgba(155,93,229,0.45);
|
|
align-items: center; justify-content: center;
|
|
transition: transform .15s, box-shadow .15s;
|
|
}
|
|
.dash-cfg-fab:hover { transform: scale(1.08); box-shadow: 0 6px 24px rgba(155,93,229,0.55); }
|
|
.dash-cfg-fab svg { width: 20px; height: 20px; color: #fff; flex-shrink: 0; }
|
|
.dash-cfg-panel {
|
|
display: none; position: fixed; bottom: 140px; right: 20px;
|
|
background: #1e1b2e; border: 1px solid rgba(155,93,229,0.3);
|
|
border-radius: 14px; padding: 14px; min-width: 220px; z-index: 81;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,.45);
|
|
}
|
|
.dash-cfg-panel.open { display: block; }
|
|
.dash-cfg-title {
|
|
font-size: 0.72rem; font-weight: 700; color: var(--violet);
|
|
text-transform: uppercase; letter-spacing: .5px; margin-bottom: 10px;
|
|
}
|
|
.dash-cfg-row {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 7px 4px; border-radius: 6px; cursor: pointer;
|
|
}
|
|
.dash-cfg-row:hover { background: rgba(255,255,255,.05); }
|
|
.dash-cfg-row label { font-size: 0.83rem; color: #e2e8f0; cursor: pointer; flex: 1; }
|
|
.dash-cfg-row input[type=checkbox] { accent-color: var(--violet); width: 15px; height: 15px; cursor: pointer; }
|
|
.dash-cfg-wrap { display: contents; }
|
|
</style>
|
|
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
|
</head>
|
|
<body>
|
|
<div class="app-layout">
|
|
<aside class="sidebar" id="app-sidebar"></aside>
|
|
<div class="notif-drop" id="notif-drop"></div>
|
|
<div class="sb-content">
|
|
|
|
<!-- ZONE 1: Compact Header -->
|
|
<div class="dash-header">
|
|
<div class="dh-avatar" id="dh-avatar">LS</div>
|
|
<div class="dh-text">
|
|
<div class="dh-greeting" id="dh-greeting">Привет, <span id="user-name">—</span></div>
|
|
<div class="dh-sub" id="dh-sub">Выбери тест и начни</div>
|
|
<div class="dh-kpi-row" id="dh-kpi-row" style="display:none">
|
|
<span class="dh-kpi"><strong id="kpi-classes">…</strong> классов</span>
|
|
<span class="dh-kpi"><strong id="kpi-students">…</strong> учеников</span>
|
|
<span class="dh-kpi"><strong id="kpi-active-asgn">…</strong> активных заданий</span>
|
|
<span class="dh-kpi warn" id="kpi-pending-wrap" style="display:none"><strong id="kpi-pending">…</strong> требуют внимания</span>
|
|
</div>
|
|
</div>
|
|
<div class="dh-stats" id="dh-stats" style="display:none">
|
|
<div class="stat-ring" id="sr-sessions"></div>
|
|
<div class="stat-ring" id="sr-avg"></div>
|
|
<div class="stat-ring" id="sr-streak"></div>
|
|
<div class="stat-ring" id="sr-pending"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Widget configurator FAB (fixed, outside header flow) -->
|
|
<button class="dash-cfg-fab" id="dash-cfg-btn" onclick="toggleDashCfg(event)" title="Настроить виджеты">
|
|
<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="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
|
</button>
|
|
<div class="dash-cfg-panel" id="dash-cfg-panel">
|
|
<div class="dash-cfg-title">Показывать виджеты</div>
|
|
<div class="dash-cfg-row" onclick="toggleDashWidget('ch-section',this)"><label>Испытания недели</label><input type="checkbox" data-widget="ch-section" checked></div>
|
|
<div class="dash-cfg-row" onclick="toggleDashWidget('stats-section',this)"><label>Статистика</label><input type="checkbox" data-widget="stats-section" checked></div>
|
|
<div class="dash-cfg-row" onclick="toggleDashWidget('w-my-subs',this)"><label>Мои сдачи</label><input type="checkbox" data-widget="w-my-subs" checked></div>
|
|
</div>
|
|
|
|
<div class="container">
|
|
|
|
<!-- Live online-lesson status (student/teacher) -->
|
|
<a class="live-lesson" id="live-lesson-banner" href="/classroom" style="display:none">
|
|
<span class="ll-dot"></span>
|
|
<span class="ll-text"><b id="ll-title">Идёт онлайн-урок</b><span id="ll-sub"></span></span>
|
|
<span class="ll-cta" id="ll-cta">Присоединиться</span>
|
|
</a>
|
|
|
|
<!-- Gamification Bar (students only) -->
|
|
<div class="gam-bar" id="gam-bar" style="display:none">
|
|
<div class="gam-level">
|
|
<div class="gam-level-num" id="gam-lvl">1</div>
|
|
<div class="gam-level-lbl">уровень</div>
|
|
</div>
|
|
<div class="gam-main">
|
|
<div class="gam-top">
|
|
<div class="gam-rank" id="gam-rank">Новичок</div>
|
|
<div class="gam-xp-text" id="gam-xp-text">0 / 100 XP</div>
|
|
</div>
|
|
<div class="gam-progress"><div class="gam-fill" id="gam-fill" style="width:0%"></div></div>
|
|
</div>
|
|
<div class="gam-chips">
|
|
<div class="gam-chip">
|
|
<div class="gam-chip-icon" id="gam-streak-icon"></div>
|
|
<div class="gam-chip-val" id="gam-streak">0</div>
|
|
<div class="gam-chip-lbl">стрик</div>
|
|
</div>
|
|
<div class="gam-chip" style="cursor:pointer" onclick="cycleGoalTier()" title="Нажмите для смены сложности">
|
|
<div class="gam-goal-ring" id="gam-goal-ring"></div>
|
|
<div class="gam-chip-lbl" id="gam-goal-label">цель дня</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ZONE 2: Action Banner + Cards -->
|
|
<div class="action-zone">
|
|
<div class="action-banner" id="action-banner" style="display:none">
|
|
<!-- populated by JS -->
|
|
</div>
|
|
<div class="hero-row" id="hero-row" style="display:none">
|
|
|
|
<!-- Card 1 — Continue / start reading -->
|
|
<a class="hero-card hc-read" id="hc-read" href="/textbooks">
|
|
<div class="hc-read-bg" aria-hidden="true">
|
|
<svg viewBox="0 0 200 160" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M100 16 C82 12 44 16 20 26 L20 138 C44 128 82 124 100 128Z" fill="white" opacity="0.55"/>
|
|
<path d="M100 16 C118 12 156 16 180 26 L180 138 C156 128 118 124 100 128Z" fill="white" opacity="0.55"/>
|
|
<path d="M100 16 L100 128" stroke="rgba(0,0,0,0.08)" stroke-width="3"/>
|
|
<path d="M28 42 Q62 39 94 42" stroke="rgba(0,0,0,0.18)" stroke-width="2.5" stroke-linecap="round"/>
|
|
<path d="M28 55 Q62 52 94 55" stroke="rgba(0,0,0,0.14)" stroke-width="2" stroke-linecap="round"/>
|
|
<path d="M28 67 Q62 64 94 67" stroke="rgba(0,0,0,0.14)" stroke-width="2" stroke-linecap="round"/>
|
|
<path d="M28 79 Q62 76 94 79" stroke="rgba(0,0,0,0.14)" stroke-width="2" stroke-linecap="round"/>
|
|
<path d="M28 91 Q52 88 80 91" stroke="rgba(0,0,0,0.14)" stroke-width="2" stroke-linecap="round"/>
|
|
<path d="M106 42 Q140 39 172 42" stroke="rgba(0,0,0,0.18)" stroke-width="2.5" stroke-linecap="round"/>
|
|
<path d="M106 55 Q140 52 172 55" stroke="rgba(0,0,0,0.14)" stroke-width="2" stroke-linecap="round"/>
|
|
<path d="M106 67 Q140 64 172 67" stroke="rgba(0,0,0,0.14)" stroke-width="2" stroke-linecap="round"/>
|
|
<path d="M106 79 Q140 76 172 79" stroke="rgba(0,0,0,0.14)" stroke-width="2" stroke-linecap="round"/>
|
|
<path d="M120 91 Q140 88 172 91" stroke="rgba(0,0,0,0.14)" stroke-width="2" stroke-linecap="round"/>
|
|
</svg>
|
|
</div>
|
|
<span class="hc-tag">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5a2 2 0 0 1 2-2h6v17H6a2 2 0 0 0-2 2z"/><path d="M20 5a2 2 0 0 0-2-2h-6v17h6a2 2 0 0 1 2 2z"/></svg>
|
|
<span id="hc-read-tag">Начать чтение</span>
|
|
</span>
|
|
<div class="hc-h" id="hc-read-title">Учебники</div>
|
|
<div class="hc-p" id="hc-read-sub">Открой учебник и продолжи курс с того места, где остановился.</div>
|
|
<div class="hc-progress" id="hc-read-prog-wrap" style="display:none"><i id="hc-read-prog" style="width:0%"></i></div>
|
|
<div class="hc-foot">
|
|
<span class="hc-foot-left">
|
|
<span class="hc-meta" id="hc-read-meta">новый учебник</span>
|
|
<span class="hc-pct" id="hc-read-pct" style="display:none">0%</span>
|
|
</span>
|
|
<span class="hc-btn">Начать <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
|
|
</div>
|
|
</a>
|
|
|
|
<!-- Card 2 — Lab of the day -->
|
|
<a class="hero-card hc-lab" id="hc-lab" href="/lab">
|
|
<div class="hc-bg" id="hc-lab-bg"></div>
|
|
<span class="hc-tag">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6M10 3v6l-5.4 9.3A1.5 1.5 0 0 0 5.9 21h12.2a1.5 1.5 0 0 0 1.3-2.3L14 9V3"/><path d="M7.5 15h9"/></svg>
|
|
Лаборатория дня
|
|
</span>
|
|
<div class="hc-h" id="hc-lab-title">Газовые законы</div>
|
|
<div class="hc-p" id="hc-lab-sub">Давление, объём и температура газа.</div>
|
|
<div class="hc-chips" id="hc-lab-chips">
|
|
<span class="hc-chip subj" id="hc-lab-subj">Физика</span>
|
|
<span class="hc-chip" id="hc-lab-time">~10 мин</span>
|
|
<span class="hc-chip" id="hc-lab-level">средне</span>
|
|
</div>
|
|
<div class="hc-foot">
|
|
<span class="hc-meta" id="hc-lab-meta">Освой: уравнение состояния газа</span>
|
|
<span class="hc-btn">Открыть <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
|
|
</div>
|
|
</a>
|
|
|
|
<!-- Card 3 — Pet (synced with /pet module) -->
|
|
<a class="hero-card hc-pet" id="hc-pet" href="/pet">
|
|
<div class="hc-pet-bg" aria-hidden="true">
|
|
<svg viewBox="0 0 300 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M256 28 L259 16 L262 28 L274 31 L262 34 L259 46 L256 34 L244 31Z" fill="#F9C74F" opacity="0.32"/>
|
|
<path d="M282 148 L284 141 L286 148 L293 150 L286 152 L284 159 L282 152 L275 150Z" fill="#F98231" opacity="0.28"/>
|
|
<path d="M20 36 L22 29 L24 36 L31 38 L24 40 L22 47 L20 40 L13 38Z" fill="#F9C74F" opacity="0.22"/>
|
|
<path d="M12 118 L13.5 112 L15 118 L21 119.5 L15 121 L13.5 127 L12 121 L6 119.5Z" fill="#F9C74F" opacity="0.18"/>
|
|
<path d="M78 163 V170 M74.5 166.5 H81.5" stroke="#F98231" stroke-width="2.5" stroke-linecap="round" opacity="0.22"/>
|
|
<path d="M268 78 V84 M265 81 H271" stroke="#F9C74F" stroke-width="2" stroke-linecap="round" opacity="0.28"/>
|
|
<path d="M148 8 V14 M145 11 H151" stroke="#F9C74F" stroke-width="2" stroke-linecap="round" opacity="0.18"/>
|
|
<circle cx="238" cy="172" r="4" fill="#F9C74F" opacity="0.22"/>
|
|
<circle cx="52" cy="172" r="3" fill="#F98231" opacity="0.18"/>
|
|
<circle cx="295" cy="48" r="3" fill="#F9C74F" opacity="0.18"/>
|
|
<circle cx="8" cy="172" r="2.5" fill="#F9C74F" opacity="0.16"/>
|
|
<circle cx="128" cy="192" r="2" fill="#F98231" opacity="0.16"/>
|
|
</svg>
|
|
</div>
|
|
<span class="hc-tag">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="5.5" cy="11" r="2"/><circle cx="9.5" cy="6.5" r="2"/><circle cx="14.5" cy="6.5" r="2"/><circle cx="18.5" cy="11" r="2"/><path d="M8.2 16.4C8.2 14.3 9.9 13 12 13s3.8 1.3 3.8 3.4c0 1.7-1.3 2.8-2.6 3.2-.8.2-1.6.2-2.4 0-1.3-.4-2.6-1.5-2.6-3.2z"/></svg>
|
|
Питомец
|
|
</span>
|
|
<div class="hc-pet-top">
|
|
<div class="hc-pet-name" id="hc-pet-name">Квантик</div>
|
|
<div class="hc-pet-art" id="hc-pet-art"></div>
|
|
</div>
|
|
<div class="hc-xp-row"><span>Ур. <b id="hc-pet-lvl">1</b></span><span><b id="hc-pet-xp">0</b> / <span id="hc-pet-xpmax">500</span> XP</span></div>
|
|
<div class="hc-progress"><i id="hc-pet-prog" style="width:0%"></i></div>
|
|
<div class="hc-pet-chips">
|
|
<div class="hc-pchip chip-streak"><b id="hc-pet-streak">0</b><span>стрик</span></div>
|
|
<div class="hc-pchip chip-goal"><b id="hc-pet-goal">0/2</b><span>цель дня</span></div>
|
|
<div class="hc-pchip chip-mood"><b id="hc-pet-mood">бодр</b><span>настроение</span></div>
|
|
</div>
|
|
<span class="hc-btn">Ухаживать <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
|
|
</a>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ADMIN ZONE: Quick actions (hidden for students) -->
|
|
<div id="admin-actions-zone" style="display:none;margin-bottom:18px">
|
|
<div class="adm-actions" id="adm-actions-grid">
|
|
<div class="adm-act-group">
|
|
<a class="adm-act" href="/classes">
|
|
<div class="adm-act-icon" style="background:#9B5DE5"><i data-lucide="graduation-cap"></i></div>
|
|
Мои классы
|
|
</a>
|
|
<a class="adm-act" href="/homework">
|
|
<div class="adm-act-icon" style="background:#F15BB5"><i data-lucide="file-check"></i></div>
|
|
Работы
|
|
</a>
|
|
</div>
|
|
<div class="adm-act-group">
|
|
<a class="adm-act" href="/board">
|
|
<div class="adm-act-icon" style="background:#06D6A0"><i data-lucide="layout-dashboard"></i></div>
|
|
Доска
|
|
</a>
|
|
<a class="adm-act" href="/library">
|
|
<div class="adm-act-icon" style="background:#F59E0B"><i data-lucide="book-open"></i></div>
|
|
Библиотека
|
|
</a>
|
|
</div>
|
|
<div class="adm-act-group">
|
|
<a class="adm-act" href="/admin">
|
|
<div class="adm-act-icon" style="background:#06B6D4"><i data-lucide="settings"></i></div>
|
|
Управление
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ADMIN GRID: 3-column compact layout (hidden for students) -->
|
|
<div class="admin-grid" id="admin-grid" style="display:none">
|
|
<!-- Admin Col 1: Assignments (compact) -->
|
|
<div class="widget" id="w-admin-assignments">
|
|
<div class="w-head">
|
|
<div class="w-title">Задания</div>
|
|
<a class="w-more" href="/classes">Все классы <i data-lucide="chevron-right" style="width:13px;height:13px;vertical-align:-2px"></i></a>
|
|
</div>
|
|
<div id="admin-assignments-list"><div id="admin-assignments-sk"></div></div>
|
|
</div>
|
|
<!-- Admin Col 2: Classes -->
|
|
<div class="widget" id="w-admin-classes">
|
|
<div class="w-head">
|
|
<div class="w-title">Классы</div>
|
|
<a class="w-more" href="/classes">Все <i data-lucide="chevron-right" style="width:13px;height:13px;vertical-align:-2px"></i></a>
|
|
</div>
|
|
<div class="class-summary" id="admin-classes-body"></div>
|
|
</div>
|
|
<!-- Admin Col 3: Recent sessions -->
|
|
<div class="widget" id="w-admin-sessions">
|
|
<div class="w-head">
|
|
<div class="w-title">Последние сессии</div>
|
|
<a class="w-more" href="/admin">Все <i data-lucide="chevron-right" style="width:13px;height:13px;vertical-align:-2px"></i></a>
|
|
</div>
|
|
<div class="adm-sessions" id="admin-sessions-body"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ADMIN COMMAND CENTER: full redesign overview (admin only) -->
|
|
<div id="admin-command-center" style="display:none"></div>
|
|
|
|
<!-- ZONE 3: Three-Column Grid -->
|
|
<div class="main-grid">
|
|
<!-- Col 1: Assignments -->
|
|
<div class="widget" id="w-assignments">
|
|
<div class="w-head">
|
|
<div class="w-title">Задания <span class="tab-badge" id="assignments-badge" style="display:none"></span></div>
|
|
<div class="assign-chips" id="assign-chips-wrap" style="display:none">
|
|
<button class="assign-chip active" onclick="setAssignFilter('all')">Все</button>
|
|
<button class="assign-chip" onclick="setAssignFilter('active')">Активные</button>
|
|
<button class="assign-chip" onclick="setAssignFilter('done')">Сдано</button>
|
|
</div>
|
|
</div>
|
|
<div class="assign-search-wrap">
|
|
<input class="assign-search" id="assign-search" type="text" placeholder="Поиск заданий…" oninput="filterAssignments(this.value)">
|
|
</div>
|
|
<div class="assign-stats" id="assign-stats" style="margin-bottom:8px"></div>
|
|
<div class="subj-filter-row" id="subj-filter-row" style="display:none"></div>
|
|
<div id="assignments-list"><div id="assignments-sk"></div></div>
|
|
</div>
|
|
|
|
<!-- Col 2: Tests / Teacher class summary -->
|
|
<div class="widget" id="w-teacher-summary" style="display:none">
|
|
<div class="w-head"><div class="w-title">Мои классы</div></div>
|
|
<div class="class-summary" id="teacher-summary-body"></div>
|
|
</div>
|
|
<div class="widget" id="w-tests">
|
|
<div class="w-head"><div class="w-title">Тесты</div></div>
|
|
<div class="subj-mini-grid" id="subjects-list"><div id="subjects-sk"></div></div>
|
|
<!-- Витрина: тесты, открытые учителем/админом ученикам -->
|
|
<div id="avail-tests-wrap" style="display:none;margin-top:14px">
|
|
<div style="font-size:.74rem;font-weight:800;letter-spacing:.04em;text-transform:uppercase;color:var(--text-3);margin:0 0 8px 2px">Доступные тесты</div>
|
|
<div class="subj-mini-grid" id="available-tests-list"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Col 3: Progress -->
|
|
<div class="widget" id="w-progress-col">
|
|
<!-- Flashcard review (random card from pool) -->
|
|
<div id="w-flashcard" style="display:none;margin-bottom:18px">
|
|
<div class="w-head">
|
|
<div class="w-title">Повтори карточку</div>
|
|
<a class="w-more" href="/flashcards">Все карточки</a>
|
|
</div>
|
|
<div id="fcw-body"></div>
|
|
</div>
|
|
<!-- Day popup (floating) -->
|
|
<div class="hm-day-popup" id="hm-day-popup" style="display:none"></div>
|
|
<!-- Subject progress bars -->
|
|
<div id="w-subj-progress" style="display:none;margin-top:18px">
|
|
<div class="w-head"><div class="w-title">По предметам</div></div>
|
|
<div id="subj-progress-bars"></div>
|
|
</div>
|
|
<!-- Last results -->
|
|
<div id="w-last-results" style="display:none;margin-top:18px">
|
|
<div class="w-head">
|
|
<div class="w-title">Результаты</div>
|
|
<button class="w-more" onclick="toggleFullHistory()">Все</button>
|
|
</div>
|
|
<div id="last-results-list"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ZONE 4: Bottom row — Activity · My submissions · Challenges -->
|
|
<div class="bottom-grid">
|
|
|
|
<!-- Combined Activity Widget (heatmap + streak calendar) -->
|
|
<div class="widget" id="w-activity" style="display:none">
|
|
<div class="w-head">
|
|
<div class="w-title">Активность</div>
|
|
<div class="act-tabs">
|
|
<button class="act-tab active" onclick="switchActTab('heatmap',this)">Карта</button>
|
|
<button class="act-tab" onclick="switchActTab('calendar',this)">Месяц</button>
|
|
</div>
|
|
</div>
|
|
<!-- Pane 1: Heatmap -->
|
|
<div class="act-pane visible" id="act-heatmap-pane">
|
|
<div id="activity-heatmap"></div>
|
|
</div>
|
|
<!-- Pane 2: Streak calendar -->
|
|
<div class="act-pane" id="act-cal-pane">
|
|
<div id="streak-cal-body"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- My submissions compact widget (student only) -->
|
|
<div class="widget" id="w-my-subs" style="display:none">
|
|
<div class="w-head">
|
|
<div class="w-title">Мои сдачи</div>
|
|
<a class="ae-btn-submit" href="/homework" style="text-decoration:none">Загрузить работу</a>
|
|
</div>
|
|
<div class="my-subs-list" id="my-subs-list"></div>
|
|
</div>
|
|
|
|
<!-- Challenges (students only) -->
|
|
<div class="widget ch-widget" id="ch-section" style="display:none">
|
|
<div class="w-head"><div class="w-title" id="ch-title">Испытания недели</div></div>
|
|
<div class="ch-list" id="ch-list"></div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- Statistics Charts (students only) -->
|
|
<div class="full-row stats-section" id="stats-section" style="display:none">
|
|
<div class="widget">
|
|
<div class="w-head"><div class="w-title">Статистика</div></div>
|
|
<div class="stats-summary-chips" id="stats-chips"></div>
|
|
<div class="stats-grid">
|
|
<div class="stats-chart-card">
|
|
<div class="stats-chart-title">Средний балл по неделям</div>
|
|
<div class="stats-bar-chart" id="stats-weekly-chart"></div>
|
|
<div class="stats-bar-labels" id="stats-weekly-labels"></div>
|
|
</div>
|
|
<div class="stats-chart-card">
|
|
<div class="stats-chart-title">Тренд последних сессий</div>
|
|
<div class="stats-bar-chart" id="stats-trend-chart"></div>
|
|
</div>
|
|
<div class="stats-chart-card">
|
|
<div class="stats-chart-title">По предметам</div>
|
|
<div id="stats-subjects-chart"></div>
|
|
</div>
|
|
<div class="stats-chart-card">
|
|
<div class="stats-chart-title">Прогресс курсов</div>
|
|
<div id="stats-courses-chart"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Full width: Full history (hidden) -->
|
|
<div class="full-row">
|
|
<div class="widget" id="w-full-history" style="display:none">
|
|
<div class="w-head">
|
|
<div class="w-title">История сессий</div>
|
|
<button class="w-more" onclick="toggleFullHistory()">Свернуть</button>
|
|
</div>
|
|
<div id="history-wrap"><div id="history-sk"></div></div>
|
|
<div id="history-more-wrap" style="text-align:center;margin-top:16px;display:none">
|
|
<button class="qa-btn" id="history-more-btn" onclick="loadMoreHistory()" style="margin:0 auto">Загрузить ещё</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Full width: Full progress (hidden) -->
|
|
<div class="full-row">
|
|
<div class="widget" id="w-full-progress" style="display:none">
|
|
<div class="w-head"><div class="w-title">Детальный прогресс</div></div>
|
|
<div id="progress-wrap"></div>
|
|
</div>
|
|
</div>
|
|
|
|
</div><!-- /.container -->
|
|
|
|
<!-- Heatmap tooltip (floating) -->
|
|
<div class="hm-tip" id="hm-tip"></div>
|
|
|
|
<!-- Keyboard hints (floating) -->
|
|
<div class="kb-hint" id="kb-hint">
|
|
<span><span class="kb-key">N</span> Новый тест</span>
|
|
<span><span class="kb-key">T</span> Библиотека</span>
|
|
<span><span class="kb-key">B</span> Доска</span>
|
|
<span><span class="kb-key">?</span> Подсказки</span>
|
|
</div>
|
|
|
|
<!-- Join modal -->
|
|
<!-- Quick-start test modal -->
|
|
<script src="/js/api.js"></script>
|
|
<script src="/js/assignment-utils.js"></script>
|
|
<script src="/js/sound.js"></script>
|
|
<script src="/js/sidebar.js"></script>
|
|
<script src="/js/notifications.js"></script>
|
|
<script src="/js/pet-sprite.js"></script>
|
|
<script src="/js/lab-previews.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
|
<script src="/js/dashboard-admin-center.js"></script>
|
|
<script>
|
|
const { user, isTeacher, isAdmin } = LS.initPage();
|
|
if (!user) throw new Error('Not logged in');
|
|
|
|
document.getElementById('user-name').textContent = user?.name?.split(' ')[0] || 'Студент';
|
|
// Hero-аватар: загруженная картинка (avatar_url) или инициалы — как в сайдбаре
|
|
LS.renderNavAvatar(document.getElementById('dh-avatar'), user);
|
|
LS.showBoardIfAllowed();
|
|
if (isTeacher) {
|
|
document.getElementById('btn-admin').style.display = '';
|
|
document.getElementById('btn-classes').style.display = '';
|
|
const h = new Date().getHours();
|
|
const gr = h < 6 ? 'Доброй ночи' : h < 12 ? 'Доброе утро' : h < 18 ? 'Добрый день' : 'Добрый вечер';
|
|
document.getElementById('dh-greeting').innerHTML = `${gr}, <span>${esc(user?.name?.split(' ')[0] || 'Администратор')}</span>`;
|
|
document.getElementById('dh-sub').textContent = user?.role === 'admin' ? 'Панель администратора' : 'Панель учителя';
|
|
// teacher/admin: hide student-only widgets, show admin compact layout
|
|
document.querySelectorAll('.action-zone,.main-grid,.bottom-grid,.full-row').forEach(el => { if (el) el.style.display = 'none'; });
|
|
if (isAdmin) {
|
|
// admin: full command center (redesign) instead of compact layout
|
|
const dh = document.querySelector('.dash-header'); if (dh) dh.style.display = 'none';
|
|
document.getElementById('admin-actions-zone').style.display = 'none';
|
|
document.getElementById('admin-grid').style.display = 'none';
|
|
document.getElementById('admin-command-center').style.display = '';
|
|
} else {
|
|
document.getElementById('admin-actions-zone').style.display = '';
|
|
document.getElementById('admin-grid').style.display = '';
|
|
}
|
|
} else {
|
|
// Приветствие по времени суток
|
|
const h = new Date().getHours();
|
|
const gr = h < 6 ? 'Доброй ночи' : h < 12 ? 'Доброе утро' : h < 18 ? 'Добрый день' : 'Добрый вечер';
|
|
document.getElementById('dh-greeting').innerHTML =
|
|
`${gr}, <span id="user-name2">${esc(user?.name?.split(' ')[0] || 'Студент')}</span>`;
|
|
reIcons();
|
|
}
|
|
|
|
// Auto-join from invite link (?join=CODE)
|
|
const joinCode = new URLSearchParams(location.search).get('join');
|
|
if (joinCode) { history.replaceState({}, '', location.pathname); openJoinModal(joinCode); }
|
|
|
|
const SUBJ = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика', other:'Задание' };
|
|
const ICONS = { bio:'dna', chem:'flask-conical', math:'calculator', phys:'zap', other:'file-check' };
|
|
const MODES = { exam:'Экзамен', practice:'Тренировка', repeat:'Обычный', ct:'ЦТ/ЦЭ', topic:'По теме', random:'Случайный' };
|
|
const SUBJ_COLORS = { bio:'#9B5DE5', chem:'#06D6A0', math:'#06B6D4', phys:'#F59E0B', other:'#7c3aed' };
|
|
|
|
// helper: inline Lucide icon for dynamic HTML (requires lucide.createIcons() after render)
|
|
function lci(name, style = '') {
|
|
return `<i data-lucide="${name}"${style ? ` style="${style}"` : ''}></i>`;
|
|
}
|
|
function reIcons() { if (window.lucide) lucide.createIcons(); }
|
|
|
|
|
|
/* ══ TOGGLE FULL HISTORY ═══════════════════════════════════════════ */
|
|
let _fullHistoryLoaded = false;
|
|
function toggleFullHistory() {
|
|
const full = document.getElementById('w-full-history');
|
|
const mini = document.getElementById('w-last-results');
|
|
const isOpen = full.style.display !== 'none';
|
|
full.style.display = isOpen ? 'none' : '';
|
|
mini.style.display = isOpen ? '' : 'none';
|
|
if (!isOpen && !_fullHistoryLoaded) {
|
|
_fullHistoryLoaded = true;
|
|
loadHistory();
|
|
}
|
|
}
|
|
|
|
/* ══ GAMIFICATION BAR ═════════════════════════════════════════════════ */
|
|
async function loadGamification() {
|
|
if (isTeacher) return;
|
|
try {
|
|
const g = await LS.getGamificationMe();
|
|
document.getElementById('gam-bar').style.display = '';
|
|
document.getElementById('gam-lvl').textContent = g.level || 1;
|
|
document.getElementById('gam-rank').textContent = g.rank || 'Новичок';
|
|
const xp = g.xp || 0, min = g.levelMin || 0, max = g.levelMax || 100;
|
|
const pct = max > min ? Math.round((xp - min) / (max - min) * 100) : 0;
|
|
// Animated XP count-up
|
|
const xpText = document.getElementById('gam-xp-text');
|
|
const xpTarget = xp - min, xpMax = max - min;
|
|
const fillEl = document.getElementById('gam-fill');
|
|
fillEl.style.width = '0%';
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
fillEl.style.width = Math.min(100, pct) + '%';
|
|
});
|
|
});
|
|
// Count-up animation for XP text
|
|
const countDur = 800;
|
|
const countStart = performance.now();
|
|
function countTick(now) {
|
|
const t = Math.min(1, (now - countStart) / countDur);
|
|
const ease = 1 - Math.pow(1 - t, 3);
|
|
const cur = Math.round(xpTarget * ease);
|
|
xpText.textContent = `${cur} / ${xpMax} XP`;
|
|
if (t < 1) requestAnimationFrame(countTick);
|
|
}
|
|
requestAnimationFrame(countTick);
|
|
document.getElementById('gam-streak').textContent = g.streak || 0;
|
|
document.getElementById('gam-streak-icon').innerHTML = lci('flame', 20);
|
|
// Daily goal ring
|
|
const dg = g.dailyGoal;
|
|
if (dg) {
|
|
const testsPct = dg.tests_target > 0 ? Math.min(100, Math.round(dg.tests_done / dg.tests_target * 100)) : 0;
|
|
const xpPct = dg.xp_target > 0 ? Math.min(100, Math.round(dg.xp_earned / dg.xp_target * 100)) : 0;
|
|
const avgPct = Math.round((testsPct + xpPct) / 2);
|
|
const r = 15, circ = 2 * Math.PI * r, dash = (avgPct / 100 * circ).toFixed(1);
|
|
const color = avgPct >= 100 ? '#059652' : 'var(--violet)';
|
|
document.getElementById('gam-goal-ring').innerHTML = `
|
|
<svg width="40" height="40" viewBox="0 0 40 40">
|
|
<circle cx="20" cy="20" r="${r}" fill="none" stroke="rgba(15,23,42,0.07)" stroke-width="4"/>
|
|
<circle cx="20" cy="20" r="${r}" fill="none" stroke="${color}" stroke-width="4"
|
|
stroke-dasharray="${dash} ${circ.toFixed(1)}" stroke-linecap="round" transform="rotate(-90 20 20)"/>
|
|
<text x="20" y="24" text-anchor="middle" font-family="Unbounded,sans-serif" font-size="8" font-weight="800" fill="${color}">${avgPct}%</text>
|
|
</svg>`;
|
|
// Show tier label
|
|
const tierLabels = { easy: 'Лёгкая', medium: 'Средняя', hard: 'Тяжёлая' };
|
|
document.getElementById('gam-goal-label').textContent = tierLabels[g.goalTier] || 'цель дня';
|
|
window._currentGoalTier = g.goalTier || 'medium';
|
|
}
|
|
// Apply avatar frame
|
|
if (g.avatarFrame && g.avatarFrame.css) {
|
|
const dhAv = document.getElementById('dh-avatar');
|
|
if (dhAv) dhAv.style.cssText += ';' + g.avatarFrame.css;
|
|
const navAv = document.getElementById('nav-avatar');
|
|
if (navAv) navAv.style.cssText += ';' + g.avatarFrame.css;
|
|
}
|
|
} catch { /* silent */ }
|
|
}
|
|
|
|
const _tierCycle = ['easy', 'medium', 'hard'];
|
|
const _tierLabels = { easy: 'Лёгкая', medium: 'Средняя', hard: 'Тяжёлая' };
|
|
let _cyclingGoal = false;
|
|
async function cycleGoalTier() {
|
|
if (_cyclingGoal) return;
|
|
_cyclingGoal = true;
|
|
const cur = window._currentGoalTier || 'medium';
|
|
const idx = _tierCycle.indexOf(cur);
|
|
const next = _tierCycle[(idx + 1) % _tierCycle.length];
|
|
try {
|
|
await LS.setGoalTier(next);
|
|
window._currentGoalTier = next;
|
|
document.getElementById('gam-goal-label').textContent = _tierLabels[next];
|
|
LS.toast(`Цель дня: ${_tierLabels[next]}`, 'info', 2000);
|
|
// Refresh gam bar to show updated targets (will apply from tomorrow)
|
|
} catch (e) { LS.toast(e.message, 'error'); }
|
|
finally { _cyclingGoal = false; }
|
|
}
|
|
|
|
/* ══ LEADERBOARD ═══════════════════════════════════════════════════ */
|
|
let _lbPeriod = 'week';
|
|
function setLbPeriod(p, btn) {
|
|
_lbPeriod = p;
|
|
btn.parentElement.querySelectorAll('.lb-tab').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
loadLeaderboard();
|
|
}
|
|
async function loadLeaderboard() {
|
|
if (isTeacher) return;
|
|
try {
|
|
const sel = document.getElementById('lb-class-sel');
|
|
const classId = sel.value || undefined;
|
|
const resp = await LS.getLeaderboard({ period: _lbPeriod, class_id: classId });
|
|
const data = Array.isArray(resp) ? resp : (resp.rows || []);
|
|
const list = document.getElementById('lb-list');
|
|
const me = LS.getUser();
|
|
if (!data.length) {
|
|
list.innerHTML = '<div class="lb-empty">Пока нет данных</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = data.slice(0, 10).map((u, i) => {
|
|
const pos = i + 1;
|
|
const posClass = pos === 1 ? 'gold' : pos === 2 ? 'silver' : pos === 3 ? 'bronze' : '';
|
|
const initials = (u.name || 'U').split(' ').slice(0, 2).map(w => (w[0] || '').toUpperCase()).join('') || 'U';
|
|
const isMe = me && (u.user_id || u.id) === me.id;
|
|
return `<div class="lb-row${isMe ? ' me' : ''}">
|
|
<div class="lb-pos ${posClass}">${pos}</div>
|
|
<div class="lb-avatar">${esc(initials)}</div>
|
|
<div class="lb-info">
|
|
<div class="lb-name">${esc(u.name)}${isMe ? ' (вы)' : ''}</div>
|
|
<div class="lb-rank">Ур. ${u.level || 1}</div>
|
|
</div>
|
|
<div class="lb-xp">${u.sort_xp || u.xp || 0} XP</div>
|
|
</div>`;
|
|
}).join('');
|
|
showWidget('lb-section');
|
|
document.getElementById('lb-title').innerHTML = lci('trophy', 18) + ' Рейтинг';
|
|
} catch { /* silent */ }
|
|
}
|
|
async function _populateLbClasses() {
|
|
try {
|
|
const classes = await LS.myClasses();
|
|
if (classes && classes.length) {
|
|
const sel = document.getElementById('lb-class-sel');
|
|
classes.forEach(c => {
|
|
const opt = document.createElement('option');
|
|
opt.value = c.id;
|
|
opt.textContent = c.name;
|
|
sel.appendChild(opt);
|
|
});
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
/* ══ CHALLENGES ════════════════════════════════════════════════════ */
|
|
async function loadChallenges() {
|
|
if (isTeacher) return;
|
|
try {
|
|
const data = await LS.getChallenges();
|
|
if (!data || !data.length) return;
|
|
const list = document.getElementById('ch-list');
|
|
list.innerHTML = data.map(c => {
|
|
const pct = c.target > 0 ? Math.min(100, Math.round(c.progress / c.target * 100)) : 0;
|
|
const done = c.completed;
|
|
const claimed = c.claimed;
|
|
const icon = done ? lci('check-circle', 20) : c.type === 'topic_tests' ? lci('book-open', 20) : c.type === 'high_score' ? lci('target', 20) : c.type === 'perfect' ? lci('diamond', 20) : lci('footprints', 20);
|
|
return `<div class="ch-item${done ? ' done' : ''}${claimed ? ' claimed' : ''}">
|
|
<div class="ch-icon">${icon}</div>
|
|
<div class="ch-body">
|
|
<div class="ch-title">${esc(c.title)}</div>
|
|
<div class="ch-desc">${esc(c.description)}</div>
|
|
<div class="ch-prog">
|
|
<div class="ch-bar"><div class="ch-fill" style="width:${pct}%"></div></div>
|
|
<div class="ch-pct">${c.progress}/${c.target}</div>
|
|
</div>
|
|
</div>
|
|
<div class="ch-reward">
|
|
<div class="ch-xp">+${c.xp_reward} XP</div>
|
|
${done && !claimed ? `<button class="ch-claim" onclick="claimCh(${c.id},this)">Забрать</button>` : ''}
|
|
${claimed ? '<div class="ch-claimed-badge">Получено</div>' : ''}
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
showWidget('ch-section');
|
|
document.getElementById('ch-title').innerHTML = lci('target', 18) + ' Испытания недели';
|
|
} catch {}
|
|
}
|
|
async function claimCh(id, btn) {
|
|
btn.disabled = true;
|
|
btn.textContent = '...';
|
|
try {
|
|
const r = await LS.claimChallenge(id);
|
|
LS.toast(`+${r.xp} XP за испытание!`, 'success');
|
|
loadChallenges();
|
|
loadGamification();
|
|
} catch (e) { LS.toast(e.message, 'error'); btn.disabled = false; btn.textContent = 'Забрать'; }
|
|
}
|
|
|
|
/* ══ ANIMATED COUNTER ════════════════════════════════════════════════ */
|
|
function animateCounter(el, target, suffix = '') {
|
|
if (!el) return;
|
|
const duration = 700;
|
|
const start = performance.now();
|
|
const step = t => {
|
|
const p = Math.min((t - start) / duration, 1);
|
|
const e = 1 - Math.pow(1 - p, 3);
|
|
el.textContent = Math.round(target * e) + suffix;
|
|
if (p < 1) requestAnimationFrame(step);
|
|
};
|
|
requestAnimationFrame(step);
|
|
}
|
|
|
|
/* ══ STAT RING SVG HELPER ════════════════════════════════════════════ */
|
|
function statRingSvg(value, max, color, label) {
|
|
const pct = max > 0 ? Math.min(100, Math.round(value / max * 100)) : 0;
|
|
const r = 13, circ = 2 * Math.PI * r;
|
|
const dash = (pct / 100 * circ).toFixed(1);
|
|
return `<svg width="36" height="36" viewBox="0 0 36 36">
|
|
<circle cx="18" cy="18" r="${r}" fill="none" stroke="rgba(15,23,42,0.08)" stroke-width="4"/>
|
|
<circle cx="18" cy="18" r="${r}" fill="none" stroke="${color}" stroke-width="4"
|
|
stroke-dasharray="${dash} ${circ.toFixed(1)}" stroke-linecap="round"
|
|
transform="rotate(-90 18 18)"/>
|
|
</svg>
|
|
<span class="sr-text">
|
|
<span class="sr-val" style="color:${color}">${value}</span>
|
|
<span class="sr-label">${label}</span>
|
|
</span>`;
|
|
}
|
|
|
|
/* ══ STATS STRIP ═════════════════════════════════════════════════════ */
|
|
async function loadStats() {
|
|
if (isTeacher) return;
|
|
document.getElementById('dh-stats').style.display = '';
|
|
try {
|
|
const data = await LS.getHistory(1, 60);
|
|
const rows = data.rows || [];
|
|
|
|
// Total sessions
|
|
const totalSessions = data.total || 0;
|
|
|
|
// Average %
|
|
const done = rows.filter(r => r.score !== null && r.total > 0);
|
|
const avg = done.length
|
|
? Math.round(done.reduce((s, r) => s + r.score / r.total * 100, 0) / done.length)
|
|
: 0;
|
|
const avgColor = avg >= 75 ? '#059652' : avg >= 50 ? '#F59E0B' : '#E0335E';
|
|
|
|
// Streak — consecutive days going back from today
|
|
const dayKeys = new Set(rows.map(r => {
|
|
const d = parseDate(r.started_at);
|
|
return d.getFullYear() + '-' + d.getMonth() + '-' + d.getDate();
|
|
}));
|
|
let streak = 0;
|
|
const now = new Date();
|
|
for (let i = 0; i <= 60; i++) {
|
|
const d = new Date(now);
|
|
d.setDate(d.getDate() - i);
|
|
const k = d.getFullYear() + '-' + d.getMonth() + '-' + d.getDate();
|
|
if (dayKeys.has(k)) { streak++; }
|
|
else if (i > 1) break;
|
|
}
|
|
|
|
document.getElementById('sr-sessions').innerHTML = statRingSvg(totalSessions, Math.max(totalSessions, 1), 'var(--violet)', 'сессий');
|
|
document.getElementById('sr-avg').innerHTML = statRingSvg(avg, 100, avgColor, 'средний %');
|
|
document.getElementById('sr-streak').innerHTML = statRingSvg(streak, Math.max(streak, 7), '#F59E0B', 'подряд');
|
|
document.getElementById('sr-pending').innerHTML = statRingSvg(0, 1, 'var(--cyan)', 'ожидают');
|
|
} catch { /* тихо */ }
|
|
}
|
|
|
|
function updatePendingStat(list) {
|
|
if (isTeacher) return;
|
|
const pending = (list || []).filter(a =>
|
|
!a.session_status && (!a.deadline || new Date(a.deadline) > new Date())
|
|
).length;
|
|
const el = document.getElementById('sr-pending');
|
|
if (el) el.innerHTML = statRingSvg(pending, Math.max(pending, 1), 'var(--cyan)', 'ожидают');
|
|
}
|
|
|
|
/* ══ ПРЕДМЕТЫ ════════════════════════════════════════════════════════ */
|
|
async function loadSubjects() {
|
|
const list = document.getElementById('subjects-list');
|
|
try {
|
|
const SUBJ_MODE_LABELS = { exam:'Экзамен', practice:'Пробный тест' };
|
|
// Прячем предметы, по которым нечего запустить (нет вопросов в банке и нет фикс-теста).
|
|
const subjects = (await LS.getSubjects())
|
|
.filter(s => (s.question_count || 0) > 0 || s.default_test_id);
|
|
if (!subjects.length) { list.innerHTML = '<div class="empty">Тесты пока недоступны</div>'; return; }
|
|
list.innerHTML = subjects.map((s, si) => {
|
|
let mode = s.default_mode || 'exam';
|
|
if (mode !== 'exam' && mode !== 'practice') mode = 'practice'; // старые topic/random → practice (старт сессии их не принимает)
|
|
const count = s.default_count || 25;
|
|
const testId = s.default_test_id || null;
|
|
const modeLabel = SUBJ_MODE_LABELS[mode] || mode;
|
|
const color = SUBJ_COLORS[s.slug] || '#9B5DE5';
|
|
const iconName = ICONS[s.slug] || 'book-open';
|
|
const srcLabel = testId ? 'Фиксированный тест' : `${count} вопросов`;
|
|
return `<div class="subj-mini-card stagger-item" style="--i:${si}" onclick="startSubjectTest('${s.slug}','${mode}',${count},${testId||'null'})">
|
|
<div class="smc-icon" style="background:${color}">${lci(iconName)}</div>
|
|
<div class="smc-body">
|
|
<div class="smc-name">${esc(s.name)}</div>
|
|
<div class="smc-meta">${modeLabel} · ${srcLabel}</div>
|
|
</div>
|
|
<i data-lucide="chevron-right" class="smc-arrow"></i>
|
|
</div>`;
|
|
}).join('');
|
|
reIcons();
|
|
} catch (e) {
|
|
list.innerHTML = `<div class="empty">Ошибка: ${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
function startSubjectTest(slug, mode = 'exam', count = 25, testId = null) {
|
|
let url = `/test-run?subject=${slug}&mode=${mode}&count=${count}`;
|
|
if (testId) url += `&test=${testId}`;
|
|
window.location.href = url;
|
|
}
|
|
|
|
/* Витрина доступных тестов (бэкенд ученику отдаёт только помеченные доступными). */
|
|
async function loadAvailableTests() {
|
|
const wrap = document.getElementById('avail-tests-wrap');
|
|
const list = document.getElementById('available-tests-list');
|
|
if (!wrap || !list) return;
|
|
try {
|
|
const tests = await LS.getTests();
|
|
if (!tests || !tests.length) { wrap.style.display = 'none'; return; }
|
|
const SUBJ_N = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
|
|
list.innerHTML = tests.map((t, i) => {
|
|
const color = SUBJ_COLORS[t.subject_slug] || '#9B5DE5';
|
|
const iconName = ICONS[t.subject_slug] || 'book-open';
|
|
return `<div class="subj-mini-card stagger-item" style="--i:${i}" onclick="startSubjectTest('${t.subject_slug}','exam',25,${t.id})">
|
|
<div class="smc-icon" style="background:${color}">${lci(iconName)}</div>
|
|
<div class="smc-body">
|
|
<div class="smc-name">${esc(t.title)}</div>
|
|
<div class="smc-meta">${SUBJ_N[t.subject_slug] || t.subject_slug} · ${t.question_count} вопр.</div>
|
|
</div>
|
|
<i data-lucide="chevron-right" class="smc-arrow"></i>
|
|
</div>`;
|
|
}).join('');
|
|
wrap.style.display = '';
|
|
reIcons();
|
|
} catch { wrap.style.display = 'none'; }
|
|
}
|
|
|
|
/* ══ ЗАДАНИЯ ══════════════════════════════════════════════════════════ */
|
|
async function loadAssignments() {
|
|
try {
|
|
const [list, myClassesList] = isTeacher
|
|
? [await LS.teacherAssignments(), []]
|
|
: await Promise.all([LS.myAssignments(), LS.myClasses().catch(() => [])]);
|
|
|
|
if (!isTeacher) await loadMySubmissions();
|
|
|
|
const alreadyInClass = !isTeacher && myClassesList.length > 0;
|
|
|
|
if (!isTeacher) {
|
|
document.getElementById('btn-join').style.display = alreadyInClass ? 'none' : '';
|
|
}
|
|
|
|
if (!list.length) {
|
|
if (!isTeacher) {
|
|
document.getElementById('assignments-list').innerHTML = alreadyInClass
|
|
? `<div class="rich-empty">
|
|
<svg class="rich-empty-svg" width="110" height="84" viewBox="0 0 110 84" fill="none">
|
|
<rect x="22" y="18" width="66" height="54" rx="9" fill="rgba(155,93,229,0.07)" stroke="#9B5DE5" stroke-width="1.8"/>
|
|
<rect x="38" y="10" width="34" height="16" rx="5" fill="white" stroke="#9B5DE5" stroke-width="1.8"/>
|
|
<line x1="36" y1="40" x2="74" y2="40" stroke="#9B5DE5" stroke-width="2" stroke-linecap="round"/>
|
|
<line x1="36" y1="51" x2="68" y2="51" stroke="#06D6E0" stroke-width="2" stroke-linecap="round"/>
|
|
<line x1="36" y1="62" x2="58" y2="62" stroke="#06D6E0" stroke-width="2" stroke-linecap="round"/>
|
|
</svg>
|
|
<div class="rich-empty-title">Заданий пока нет</div>
|
|
<div class="rich-empty-sub">Учитель ещё не назначил задания. Они появятся здесь автоматически.</div>
|
|
</div>`
|
|
: `<div class="rich-empty">
|
|
<svg class="rich-empty-svg" width="110" height="84" viewBox="0 0 110 84" fill="none">
|
|
<circle cx="55" cy="42" r="28" fill="rgba(155,93,229,0.07)" stroke="#9B5DE5" stroke-width="1.8"/>
|
|
<path d="M44 42 L52 50 L66 34" stroke="#06D6E0" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
<circle cx="55" cy="42" r="18" fill="none" stroke="rgba(155,93,229,0.25)" stroke-width="1" stroke-dasharray="4 3"/>
|
|
</svg>
|
|
<div class="rich-empty-title">Вы ещё не в классе</div>
|
|
<div class="rich-empty-sub">Попросите учителя поделиться кодом приглашения и вступите в класс, чтобы получать задания</div>
|
|
<button class="rich-empty-btn" onclick="openJoinModal()">Вступить в класс</button>
|
|
</div>`;
|
|
} else {
|
|
document.getElementById('assignments-list').innerHTML = `<div class="empty" style="padding:20px 0">Заданий пока нет.</div>`;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// ── badge на кнопке таба ──
|
|
if (!isTeacher) {
|
|
const lsKey = 'ls_assign_seen_' + (LS.getUser()?.id || '');
|
|
const lastSeen = localStorage.getItem(lsKey);
|
|
const newCount = lastSeen
|
|
? list.filter(a => !a.session_status && parseDate(a.created_at) > parseDate(lastSeen)).length
|
|
: 0;
|
|
const badge = document.getElementById('assignments-badge');
|
|
if (newCount > 0) { badge.textContent = newCount; badge.style.display = ''; }
|
|
else badge.style.display = 'none';
|
|
localStorage.setItem(lsKey, new Date().toISOString());
|
|
updatePendingStat(list);
|
|
|
|
// show toolbar + subject chips for students
|
|
document.getElementById('assign-chips-wrap').style.display = '';
|
|
buildSubjChips(list);
|
|
}
|
|
|
|
_allAssignmentsList = list;
|
|
renderAssignmentsList();
|
|
if (!isTeacher) {
|
|
loadActionBanner(list);
|
|
showDeadlineToast(list);
|
|
}
|
|
|
|
} catch (e) {
|
|
console.error('Assignments error:', e.message);
|
|
document.getElementById('assignments-list').innerHTML =
|
|
`<div class="empty">Не удалось загрузить задания</div>`;
|
|
}
|
|
}
|
|
|
|
let _allAssignmentsList = [];
|
|
let _assignFilter = 'all';
|
|
|
|
function setAssignFilter(f) {
|
|
_assignFilter = f;
|
|
document.querySelectorAll('.assign-chip').forEach(c => c.classList.toggle('active', c.textContent.trim() === {all:'Все',active:'Активные',done:'Сдано'}[f]));
|
|
renderAssignmentsList();
|
|
}
|
|
|
|
function toggleAssignGroup(grpId) {
|
|
const hdr = document.getElementById('grp-hdr-' + grpId);
|
|
const body = document.getElementById('grp-body-' + grpId);
|
|
hdr .classList.toggle('collapsed');
|
|
body.classList.toggle('collapsed');
|
|
}
|
|
|
|
/* ── Urgency sort score (lower = shown first) — общий модуль ── */
|
|
function urgencyScore(a) { return AssignmentUtils.urgencyScore(a); }
|
|
|
|
/* ── Is assignment urgent for teacher (within 48h) ── */
|
|
function isTeacherUrgent(a) {
|
|
if (!a.deadline) return false;
|
|
const ms = parseDate(a.deadline).getTime() - Date.now();
|
|
return ms > 0 && ms < 48 * 3600 * 1000;
|
|
}
|
|
|
|
/* ── Relative timestamp helper ("12 мин назад", "сегодня", "вчера") ── */
|
|
function relativeAgo(dateStr) {
|
|
if (!dateStr) return '';
|
|
const d = parseDate(dateStr);
|
|
const diffMs = Date.now() - d.getTime();
|
|
const diffMin = Math.floor(diffMs / 60000);
|
|
if (diffMin < 1) return 'только что';
|
|
if (diffMin < 60) return `${diffMin} мин назад`;
|
|
const diffH = Math.floor(diffMin / 60);
|
|
if (diffH < 24) return `${diffH} ч назад`;
|
|
const diffD = Math.floor(diffH / 24);
|
|
if (diffD === 1) return 'вчера';
|
|
if (diffD < 7) return `${diffD} дн назад`;
|
|
return d.toLocaleDateString('ru', { day: 'numeric', month: 'short' });
|
|
}
|
|
|
|
function buildAssignCard(a, idx, isFirst = false) {
|
|
const dl = a.deadline ? new Date(a.deadline).toLocaleDateString('ru',{day:'numeric',month:'short'}) : null;
|
|
const dlMs = a.deadline ? new Date(a.deadline) - Date.now() : Infinity;
|
|
const isUrgent = !a.session_status && dlMs > 0 && dlMs < 24 * 3600 * 1000;
|
|
const hoursLeft = isUrgent ? Math.ceil(dlMs / 3600000) : 0;
|
|
const sColor = SUBJ_COLORS[a.subject_slug] || '#9B5DE5';
|
|
const iconName = ICONS[a.subject_slug] || 'file-text';
|
|
const classStr = a.class_id ? esc(a.class_name) : 'Личное';
|
|
|
|
/* ── Teacher view ── */
|
|
if (isTeacher) {
|
|
const done = a.completed_count || 0;
|
|
const total = a.total_members || 0;
|
|
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
const barColor = pct >= 75 ? '#059652' : pct >= 50 ? '#F59E0B' : 'var(--pink)';
|
|
const urgent = isTeacherUrgent(a);
|
|
const titleHtml = esc(a.title)
|
|
+ (a.class_name && a.class_name !== 'Личное задание' ? `<span class="asgn-class-badge">${esc(a.class_name)}</span>` : '')
|
|
+ (urgent ? `<span class="asgn-fire">срочно</span>` : '');
|
|
const meta = [SUBJ[a.subject_slug] || a.subject_slug, dl ? `до ${dl}` : null].filter(Boolean).join(' · ');
|
|
const urgentCls = urgent ? ' asgn-urgent' : '';
|
|
return `<div class="asgn-wrap"><div class="asgn-row stagger-item${urgentCls}" style="--i:${idx};--ac:${sColor}" id="asgn-row-${a.id}" onclick="toggleAsgn(${a.id},event)">
|
|
<div class="ar-icon" style="background:${sColor}18;color:${sColor}">${lci(iconName)}</div>
|
|
<div class="ar-body"><div class="ar-title">${titleHtml}</div><div class="ar-meta">${meta}</div></div>
|
|
<div class="ar-progress">
|
|
<div class="ar-prog-bar"><div class="ar-prog-fill" style="width:${pct}%;background:${barColor}"></div></div>
|
|
<span class="ar-prog-text">${done} / ${total}</span>
|
|
</div>
|
|
<a class="ar-btn-ghost" href="/classes" onclick="event.stopPropagation()">Подробнее</a>
|
|
</div>
|
|
<div class="asgn-expand" id="asgn-exp-${a.id}">
|
|
<div class="ae-row">
|
|
<div class="ae-pills">
|
|
<span class="ae-pill">${lci('layers')} ${MODES[a.mode]||a.mode}</span>
|
|
<span class="ae-pill">${lci('list')} ${a.count} вопросов</span>
|
|
${a.is_homework ? `<span class="ae-pill">${lci('book-open')} Домашнее задание</span>` : ''}
|
|
</div>
|
|
<a class="ae-btn-result" href="/classes" onclick="event.stopPropagation()">Открыть результаты</a>
|
|
</div>
|
|
</div></div>`;
|
|
}
|
|
|
|
/* ── Upload-only homework (no test, no file) ── */
|
|
if (AssignmentUtils.type(a) === 'upload') {
|
|
const over = a.deadline && new Date(a.deadline) < new Date();
|
|
const sub = _mySubmissions.get(a.id);
|
|
const metaParts = [classStr, dl ? `до ${dl}` : null,
|
|
isUrgent ? `<span class="ar-tag-urgent"><i data-lucide="zap" style="width:10px;height:10px;vertical-align:-1px"></i> ${hoursLeft} ч</span>` : null].filter(Boolean);
|
|
const submitBtn = sub
|
|
? `<span class="ar-sub-chip s-${(SUB_STATUS[sub.status]||SUB_STATUS.new).cls}">${lci((SUB_STATUS[sub.status]||SUB_STATUS.new).icon,'width:11px;height:11px')} ${(SUB_STATUS[sub.status]||SUB_STATUS.new).label}</span>`
|
|
: `<a class="ar-btn" href="/homework" onclick="event.stopPropagation()" style="background:var(--violet);text-decoration:none;color:#fff">Сдать работу</a>`;
|
|
return `<div class="asgn-wrap"><div class="asgn-row stagger-item${over?' over':isUrgent?' urgent':''}" style="--i:${idx};--ac:#7c3aed" id="asgn-row-${a.id}" onclick="toggleAsgn(${a.id},event)">
|
|
<div class="ar-icon" style="background:rgba(124,58,237,0.1);color:#7c3aed">${lci('upload')}</div>
|
|
<div class="ar-body"><div class="ar-title">${esc(a.title)}</div><div class="ar-meta">${metaParts.join(' · ')}</div></div>
|
|
<div class="ar-right">${submitBtn}</div>
|
|
</div>
|
|
<div class="asgn-expand" id="asgn-exp-${a.id}">
|
|
<div class="ae-row">
|
|
<div class="ae-pills">
|
|
<span class="ae-pill">${lci('upload')} Загрузить работу</span>
|
|
${a.is_homework ? `<span class="ae-pill">${lci('book-open')} Домашнее задание</span>` : ''}
|
|
</div>
|
|
${!sub ? `<a class="ae-btn" href="/homework" onclick="event.stopPropagation()" style="background:var(--violet);text-decoration:none;color:#fff">Прикрепить файл</a>` : ''}
|
|
</div>
|
|
${buildSubmitStrip(a)}
|
|
</div></div>`;
|
|
}
|
|
|
|
/* ── File assignment ── */
|
|
if (a.file_id) {
|
|
const over = a.deadline && new Date(a.deadline) < new Date();
|
|
const metaParts = [classStr, SUBJ[a.subject_slug]||a.subject_slug, dl ? `до ${dl}` : null,
|
|
isUrgent ? `<span class="ar-tag-urgent"><i data-lucide="zap" style="width:10px;height:10px;vertical-align:-1px"></i> ${hoursLeft} ч</span>` : null].filter(Boolean);
|
|
return `<div class="asgn-wrap"><div class="asgn-row stagger-item${over?' over':isUrgent?' urgent':''}" style="--i:${idx};--ac:#05aab3" id="asgn-row-${a.id}" onclick="toggleAsgn(${a.id},event)">
|
|
<div class="ar-icon" style="background:rgba(6,214,224,0.1);color:#05aab3">${lci('paperclip')}</div>
|
|
<div class="ar-body"><div class="ar-title">${esc(a.title)}</div><div class="ar-meta">${metaParts.join(' · ')}</div></div>
|
|
<div class="ar-right">${buildSubChip(a)}<a class="ar-btn" href="${LS.downloadFileUrl(a.file_id)}" target="_blank" download onclick="event.stopPropagation()">Скачать</a></div>
|
|
</div>
|
|
<div class="asgn-expand" id="asgn-exp-${a.id}">
|
|
<div class="ae-row">
|
|
<div class="ae-pills">
|
|
<span class="ae-pill">${lci('file')} Файл для скачивания</span>
|
|
${a.file_title ? `<span class="ae-pill">${esc(a.file_title)}</span>` : ''}
|
|
</div>
|
|
<a class="ae-btn" href="${LS.downloadFileUrl(a.file_id)}" target="_blank" download onclick="event.stopPropagation()">Скачать файл</a>
|
|
</div>
|
|
${buildSubmitStrip(a)}
|
|
</div></div>`;
|
|
}
|
|
|
|
/* ── Textbook reading assignment ── */
|
|
if (a.textbook_id) {
|
|
const reqCount = a.textbook_required_count || 0;
|
|
const readCount = a.textbook_read_count || 0;
|
|
const allRead = !!a.textbook_all_read || !!a.completed_at;
|
|
const tbPct = reqCount > 0 ? Math.round(100 * readCount / reqCount) : 0;
|
|
const tbColorMap = { amber:'#d97706', blue:'#2563eb', green:'#059669', violet:'#7c3aed', pink:'#db2777' };
|
|
const tbColor = tbColorMap[a.textbook_color] || '#7c3aed';
|
|
const over = !allRead && a.deadline && new Date(a.deadline) < new Date();
|
|
const cardCls = allRead ? 'done' : over ? 'over' : isUrgent ? 'urgent' : '';
|
|
const parasText = a.textbook_paragraphs ? `§${a.textbook_paragraphs}` : 'весь учебник';
|
|
const metaParts = [
|
|
classStr,
|
|
parasText,
|
|
a.is_homework ? `<span class="ar-tag-hw">ДЗ</span>` : null,
|
|
dl ? `до ${dl}` : null,
|
|
isUrgent ? `<span class="ar-tag-urgent"><i data-lucide="zap" style="width:10px;height:10px;vertical-align:-1px"></i> ${hoursLeft} ч</span>` : null,
|
|
over ? `<span class="ar-tag-over">просрочено</span>` : null,
|
|
].filter(Boolean);
|
|
|
|
// Find first required paragraph for deep-link
|
|
let firstHash = '';
|
|
if (a.textbook_paragraphs) {
|
|
const m = String(a.textbook_paragraphs).match(/^\s*(\d+)/);
|
|
if (m) firstHash = '#p' + m[1];
|
|
}
|
|
const openHref = `/textbook/${a.textbook_slug}${firstHash}`;
|
|
|
|
const actionBtn = allRead
|
|
? `<span class="ar-score hi" style="background:#06D6A018;color:#059669">${lci('check')} Прочитано</span>`
|
|
: `<a class="ar-btn" href="${openHref}" onclick="event.stopPropagation()" style="background:${tbColor};color:#fff;text-decoration:none">${readCount > 0 ? 'Продолжить' : 'Открыть'}</a>`;
|
|
|
|
return `<div class="asgn-wrap"><div class="asgn-row stagger-item ${cardCls}${isFirst && !allRead ? ' spotlight' : ''}" style="--i:${idx};--ac:${tbColor}" id="asgn-row-${a.id}" onclick="toggleAsgn(${a.id},event)">
|
|
<div class="ar-icon" style="background:${tbColor}18;color:${tbColor}">${lci('book-open-text')}</div>
|
|
<div class="ar-body"><div class="ar-title">${esc(a.title)}</div><div class="ar-meta">${metaParts.join(' · ')}</div></div>
|
|
<div class="ar-progress">
|
|
<div class="ar-prog-bar"><div class="ar-prog-fill" style="width:${tbPct}%;background:${tbColor}"></div></div>
|
|
<span class="ar-prog-text">${readCount} / ${reqCount} §</span>
|
|
</div>
|
|
<div class="ar-right">${actionBtn}</div>
|
|
</div>
|
|
<div class="asgn-expand" id="asgn-exp-${a.id}">
|
|
<div class="ae-row">
|
|
<div class="ae-pills">
|
|
<span class="ae-pill">${lci('book-open')} ${esc(a.textbook_title || 'Учебник')}</span>
|
|
<span class="ae-pill">${lci('layers')} ${parasText}</span>
|
|
${a.is_homework ? `<span class="ae-pill">${lci('book-open')} Домашнее задание</span>` : ''}
|
|
${allRead ? `<span class="ae-pill" style="color:#059669">${lci('check')} Завершено</span>` : ''}
|
|
</div>
|
|
<a class="ae-btn" href="${openHref}" onclick="event.stopPropagation()" style="background:${tbColor}">Открыть учебник</a>
|
|
</div>
|
|
</div></div>`;
|
|
}
|
|
|
|
/* ── Test assignment ── */
|
|
const isDone = a.session_status === 'completed';
|
|
const inProgress = a.session_status === 'in_progress';
|
|
const isRepeat = a.mode === 'repeat';
|
|
const maxAtt = a.max_attempts || 0;
|
|
const usedAtt = a.attempts_used ?? 0;
|
|
const attExhausted = maxAtt > 0 && usedAtt >= maxAtt;
|
|
const over = !isDone && !inProgress && a.deadline && new Date(a.deadline) < new Date();
|
|
const cardCls = attExhausted ? 'done' : (isDone && !isRepeat) ? 'done' : over ? 'over' : isUrgent ? 'urgent' : '';
|
|
const pct = a.percent;
|
|
const pctCls = pct >= 75 ? 'hi' : pct >= 50 ? 'mid' : 'lo';
|
|
|
|
const metaParts = [
|
|
classStr,
|
|
SUBJ[a.subject_slug] || a.subject_slug,
|
|
a.is_homework ? `<span class="ar-tag-hw">ДЗ</span>` : null,
|
|
dl ? `до ${dl}` : null,
|
|
isUrgent ? `<span class="ar-tag-urgent"><i data-lucide="zap" style="width:10px;height:10px;vertical-align:-1px"></i> ${hoursLeft} ч</span>` : null,
|
|
over && !isDone ? `<span class="ar-tag-over">просрочено</span>` : null
|
|
].filter(Boolean);
|
|
|
|
const scoreEl = (isDone && !isRepeat && pct !== null) ? `<span class="ar-score ${pctCls}">${pct}%</span>` : '';
|
|
const actionBtn = attExhausted
|
|
? `<span class="ar-tag-over" style="font-size:.73rem">Попытки исчерпаны</span>`
|
|
: (isDone && !isRepeat)
|
|
? `<a class="ar-btn-result" href="/test-result?session=${a.session_id}" onclick="event.stopPropagation()">Результат</a>`
|
|
: `<button class="ar-btn" onclick="startAssignment(event,${a.id},'${a.mode}')">${inProgress ? 'Продолжить' : isDone ? 'Повторить' : 'Начать'}</button>`;
|
|
|
|
/* Deadline urgency bar */
|
|
let dlBarHtml = '';
|
|
if (a.deadline && !isDone) {
|
|
const refMs = 7 * 24 * 3600 * 1000;
|
|
const fillPct = Math.min(100, Math.max(0, (1 - dlMs / refMs) * 100));
|
|
const barClr = fillPct >= 85 ? '#E83A1E' : fillPct >= 60 ? '#F59E0B' : '#06D6A0';
|
|
const daysLeft = dlMs > 0 ? Math.ceil(dlMs / 86400000) : null;
|
|
const dlText = over ? 'Просрочено' : daysLeft === 1 ? 'Остался 1 день' : daysLeft ? `${daysLeft} дн. до дедлайна` : 'Сегодня дедлайн';
|
|
dlBarHtml = `<div class="ae-dl">
|
|
<div class="ae-dl-label"><span>${dlText}</span><span>${dl}</span></div>
|
|
<div class="ae-dl-bar"><div class="ae-dl-fill" style="width:${fillPct}%;background:${barClr}"></div></div>
|
|
</div>`;
|
|
}
|
|
|
|
const expandActionBtn = attExhausted
|
|
? `<span style="font-size:.8rem;color:var(--text-3)">Лимит попыток исчерпан</span>`
|
|
: (isDone && !isRepeat)
|
|
? `<a class="ae-btn-result" href="/test-result?session=${a.session_id}" onclick="event.stopPropagation()">Смотреть результат</a>`
|
|
: `<button class="ae-btn" onclick="startAssignment(event,${a.id},'${a.mode}')">${inProgress ? 'Продолжить тест' : isDone ? 'Пройти снова' : 'Начать тест'}</button>`;
|
|
|
|
return `<div class="asgn-wrap"><div class="asgn-row stagger-item ${cardCls}${isFirst && !isDone ? ' spotlight' : ''}" style="--i:${idx};--ac:${sColor}" id="asgn-row-${a.id}" onclick="toggleAsgn(${a.id},event)">
|
|
<div class="ar-icon" style="background:${sColor}18;color:${sColor}">${lci(a.is_homework ? 'book-open' : iconName)}</div>
|
|
<div class="ar-body"><div class="ar-title">${esc(a.title)}</div><div class="ar-meta">${metaParts.join(' · ')}</div></div>
|
|
<div class="ar-right">${buildSubChip(a)}${scoreEl}${actionBtn}</div>
|
|
</div>
|
|
<div class="asgn-expand" id="asgn-exp-${a.id}">
|
|
<div class="ae-row">
|
|
<div class="ae-pills">
|
|
<span class="ae-pill">${lci('layers')} ${MODES[a.mode]||a.mode}</span>
|
|
<span class="ae-pill">${lci('list')} ${a.count} вопросов</span>
|
|
${a.is_homework ? `<span class="ae-pill">${lci('book-open')} Домашнее задание</span>` : ''}
|
|
${inProgress ? `<span class="ae-pill" style="color:#059652">${lci('play')} Начат</span>` : ''}
|
|
${maxAtt > 0 ? `<span class="ae-pill" style="color:${attExhausted?'#E83A1E':'var(--text-2)'}">${lci('repeat')} ${usedAtt}/${maxAtt} поп.</span>` : (usedAtt > 0 ? `<span class="ae-pill">${lci('repeat')} ${usedAtt} поп.</span>` : '')}
|
|
</div>
|
|
${expandActionBtn}
|
|
</div>
|
|
${dlBarHtml}
|
|
${buildSubmitStrip(a)}
|
|
</div></div>`;
|
|
}
|
|
|
|
function toggleAsgn(id, e) {
|
|
if (e.target.closest('a,button')) return;
|
|
const row = document.getElementById('asgn-row-' + id);
|
|
const exp = document.getElementById('asgn-exp-' + id);
|
|
if (!row || !exp) return;
|
|
const isOpen = exp.classList.contains('open');
|
|
document.querySelectorAll('.asgn-expand.open').forEach(el => {
|
|
el.classList.remove('open');
|
|
el.previousElementSibling?.classList.remove('expanded');
|
|
});
|
|
if (!isOpen) { exp.classList.add('open'); row.classList.add('expanded'); }
|
|
}
|
|
|
|
let _subjFilter = '';
|
|
|
|
function setSubjFilter(slug) {
|
|
_subjFilter = _subjFilter === slug ? '' : slug;
|
|
document.querySelectorAll('.sf-chip').forEach(c => c.classList.toggle('active', c.dataset.slug === _subjFilter));
|
|
if (_subjFilter) {
|
|
const chip = document.querySelector(`.sf-chip[data-slug="${_subjFilter}"]`);
|
|
if (chip) {
|
|
const color = SUBJ_COLORS[_subjFilter] || '#9B5DE5';
|
|
document.querySelectorAll('.sf-chip').forEach(c => c.style.background = '');
|
|
chip.style.background = color;
|
|
}
|
|
} else {
|
|
document.querySelectorAll('.sf-chip').forEach(c => c.style.background = '');
|
|
}
|
|
renderAssignmentsList();
|
|
}
|
|
|
|
function buildSubjChips(list) {
|
|
const slugs = [...new Set(list.map(a => a.subject_slug).filter(Boolean))];
|
|
const row = document.getElementById('subj-filter-row');
|
|
if (slugs.length < 2) { row.style.display = 'none'; return; }
|
|
row.style.display = 'flex';
|
|
row.innerHTML = slugs.map(slug => {
|
|
const color = SUBJ_COLORS[slug] || '#9B5DE5';
|
|
return `<button class="sf-chip" data-slug="${slug}" onclick="setSubjFilter('${slug}')" style="color:${color};border-color:${color}33">
|
|
${lci(ICONS[slug] || 'book')} ${SUBJ[slug] || slug}
|
|
</button>`;
|
|
}).join('');
|
|
reIcons();
|
|
}
|
|
|
|
function renderAssignmentsList() {
|
|
const now = new Date();
|
|
let list = _allAssignmentsList;
|
|
|
|
// Subject filter
|
|
if (_subjFilter) list = list.filter(a => a.subject_slug === _subjFilter);
|
|
|
|
// B2: Live search filter
|
|
if (_searchQuery) list = list.filter(a =>
|
|
(a.title || '').toLowerCase().includes(_searchQuery) ||
|
|
(SUBJ[a.subject_slug] || '').toLowerCase().includes(_searchQuery) ||
|
|
(a.class_name || '').toLowerCase().includes(_searchQuery)
|
|
);
|
|
|
|
// For teacher: sorted by deadline, flat
|
|
if (isTeacher) {
|
|
const sorted = [...list].sort((a, b) => urgencyScore(a) - urgencyScore(b));
|
|
document.getElementById('assignments-list').innerHTML =
|
|
`<div class="tests-list">${sorted.map((a, i) => buildAssignCard(a, i)).join('')}</div>`;
|
|
reIcons(); return;
|
|
}
|
|
|
|
// Classify (active/overdue/done) — тип и «сдано» из общего модуля AssignmentUtils.
|
|
function classify(a) {
|
|
const t = AssignmentUtils.type(a);
|
|
if (t === 'textbook') {
|
|
if (AssignmentUtils.isDone(a)) return 'done';
|
|
if (a.deadline && new Date(a.deadline) < now) return 'overdue';
|
|
return 'active';
|
|
}
|
|
if (t === 'test') {
|
|
if (AssignmentUtils.isDone(a)) return 'done';
|
|
if (a.deadline && new Date(a.deadline) < now && a.session_status !== 'in_progress') return 'overdue';
|
|
return 'active';
|
|
}
|
|
// upload / file: «сдано» по сабмишену здесь не считаем (как и раньше — статус
|
|
// показывает чип сдачи в карточке); upload просрочивается по дедлайну, file — всегда активен.
|
|
if (t === 'upload' && a.deadline && new Date(a.deadline) < now) return 'overdue';
|
|
return 'active';
|
|
}
|
|
|
|
const cActive = list.filter(a => classify(a) === 'active').sort((a,b) => urgencyScore(a) - urgencyScore(b));
|
|
const cOverdue = list.filter(a => classify(a) === 'overdue');
|
|
const cDone = list.filter(a => classify(a) === 'done');
|
|
|
|
// Stats
|
|
const statParts = [];
|
|
if (cActive.length) statParts.push(`<span class="s-active">${cActive.length} активных</span>`);
|
|
if (cOverdue.length) statParts.push(`<span class="s-overdue">${cOverdue.length} просрочено</span>`);
|
|
if (cDone.length) statParts.push(`<span class="s-done">${cDone.length} сдано</span>`);
|
|
document.getElementById('assign-stats').innerHTML = statParts.join('<span style="color:#DDE2EC"> · </span>');
|
|
|
|
// Status filter
|
|
let filtered;
|
|
if (_assignFilter === 'active') filtered = [...cActive, ...cOverdue];
|
|
else if (_assignFilter === 'done') filtered = cDone;
|
|
else filtered = null; // grouped
|
|
|
|
if (filtered !== null) {
|
|
if (!filtered.length) {
|
|
document.getElementById('assignments-list').innerHTML = `<div class="empty" style="padding:20px 0">Нет заданий в этой категории.</div>`;
|
|
return;
|
|
}
|
|
document.getElementById('assignments-list').innerHTML =
|
|
`<div class="tests-list">${filtered.map((a, i) => buildAssignCard(a, i, i === 0)).join('')}</div>`;
|
|
reIcons(); return;
|
|
}
|
|
|
|
// "Все": grouped view
|
|
const todoList = [...cActive, ...cOverdue];
|
|
const doneList = cDone;
|
|
let html = '';
|
|
|
|
if (todoList.length) {
|
|
html += `<div class="assign-group-hdr grp-todo" id="grp-hdr-todo" onclick="toggleAssignGroup('todo')">
|
|
Нужно выполнить <span class="grp-count">${todoList.length}</span>
|
|
<span class="grp-arrow"><svg class="ic" viewBox="0 0 24 24"><polyline points="2 9 12 21 22 9"/></svg></span>
|
|
</div>
|
|
<div class="tests-list assign-group-body" id="grp-body-todo">
|
|
${todoList.map((a, i) => buildAssignCard(a, i, i === 0)).join('')}
|
|
</div>`;
|
|
}
|
|
|
|
if (doneList.length) {
|
|
const startCollapsed = todoList.length > 0;
|
|
html += `<div class="assign-group-hdr grp-done${startCollapsed?' collapsed':''}" id="grp-hdr-done" onclick="toggleAssignGroup('done')" style="margin-top:${todoList.length?18:0}px">
|
|
Выполнено <span class="grp-count">${doneList.length}</span>
|
|
<span class="grp-arrow"><svg class="ic" viewBox="0 0 24 24"><polyline points="2 9 12 21 22 9"/></svg></span>
|
|
</div>
|
|
<div class="tests-list assign-group-body${startCollapsed?' collapsed':''}" id="grp-body-done">
|
|
${doneList.map((a, i) => buildAssignCard(a, todoList.length + i)).join('')}
|
|
</div>`;
|
|
}
|
|
|
|
if (!html) html = `<div class="empty" style="padding:20px 0">Заданий нет.</div>`;
|
|
document.getElementById('assignments-list').innerHTML = html;
|
|
reIcons();
|
|
}
|
|
|
|
async function startAssignment(e, id, mode) {
|
|
const btn = e.currentTarget;
|
|
btn.disabled = true; btn.textContent = '…';
|
|
try {
|
|
const r = await LS.startAssignment(id);
|
|
if (r.error && r.max_attempts) {
|
|
LS.toast(`Исчерпан лимит попыток (${r.attempts_used}/${r.max_attempts})`, 'warn');
|
|
btn.disabled = false; btn.textContent = 'Начать';
|
|
return;
|
|
}
|
|
const aMode = r.assignment_mode || mode || 'exam';
|
|
if (r.status === 'completed' && aMode !== 'repeat') {
|
|
window.location.href = `/test-result?session=${r.session_id}`;
|
|
} else {
|
|
window.location.href = `/test-run?session=${r.session_id}&assignment_mode=${aMode}`;
|
|
}
|
|
} catch (err) {
|
|
if (err.message?.includes('лимит попыток') || err.message?.includes('Исчерпан')) {
|
|
LS.toast(err.message, 'warn');
|
|
} else {
|
|
LS.toast('Ошибка: ' + err.message, 'error');
|
|
}
|
|
btn.disabled = false; btn.textContent = 'Начать';
|
|
}
|
|
}
|
|
|
|
/* ══ ИСТОРИЯ ══════════════════════════════════════════════════════════ */
|
|
let _histPage = 1, _histTotal = 0, _histList = null;
|
|
const HIST_LIMIT = 15;
|
|
|
|
function histRingSvg(pct, pc) {
|
|
const colorMap = { hi: 'var(--green)', mid: 'var(--amber)', lo: 'var(--pink)' };
|
|
const color = colorMap[pc] || 'var(--text-3)';
|
|
const circ = 106.8;
|
|
const dash = (pct / 100 * circ).toFixed(1);
|
|
return `<svg class="hist-ring" width="48" height="48" viewBox="0 0 48 48">
|
|
<circle cx="24" cy="24" r="17" fill="none" stroke="rgba(15,23,42,0.08)" stroke-width="4"/>
|
|
<circle cx="24" cy="24" r="17" fill="none" stroke="${color}" stroke-width="4"
|
|
stroke-dasharray="${dash} ${circ}" stroke-dashoffset="26.7" stroke-linecap="round"
|
|
transform="rotate(-90 24 24)"/>
|
|
<text x="24" y="28" text-anchor="middle" font-family="Unbounded,sans-serif" font-size="8" font-weight="800" fill="${color}">${pct}%</text>
|
|
</svg>`;
|
|
}
|
|
|
|
function histItemHtml(h, i = 0) {
|
|
const pct = h.score !== null ? Math.round((h.score / h.total) * 100) : null;
|
|
const pc = pct === null ? '' : pct >= 75 ? 'hi' : pct >= 50 ? 'mid' : 'lo';
|
|
const href = h.status === 'completed' ? `/test-result?session=${h.id}` : '#';
|
|
const ring = pct !== null
|
|
? histRingSvg(pct, pc)
|
|
: `<div class="hist-pct" style="width:48px;text-align:center;color:var(--text-3)">—</div>`;
|
|
return `<a class="hist-item stagger-item" style="--i:${i}" href="${href}">
|
|
${ring}
|
|
<div class="hist-info">
|
|
<div class="hist-subj">${esc(h.subject_name || 'Тест')}</div>
|
|
<div class="hist-meta">${MODES[h.mode] || h.mode}</div>
|
|
</div>
|
|
<div class="hist-score">${h.score ?? '—'} / ${h.total}</div>
|
|
</a>`;
|
|
}
|
|
|
|
function histGroupedHtml(rows) {
|
|
const groups = {};
|
|
rows.forEach(h => {
|
|
const key = parseDate(h.started_at).toLocaleDateString('ru', { day:'numeric', month:'long', year:'numeric' });
|
|
(groups[key] = groups[key] || []).push(h);
|
|
});
|
|
let idx = 0;
|
|
return Object.entries(groups).map(([date, items]) =>
|
|
`<div class="hist-date-sep">${date}</div>` + items.map(h => histItemHtml(h, idx++)).join('')
|
|
).join('');
|
|
}
|
|
|
|
async function loadHistory() {
|
|
const wrap = document.getElementById('history-wrap');
|
|
try {
|
|
const data = await LS.getHistory(1, HIST_LIMIT);
|
|
_histPage = 1;
|
|
_histTotal = data.total;
|
|
if (!data.rows.length) {
|
|
wrap.innerHTML = `<div class="rich-empty">
|
|
<svg class="rich-empty-svg" width="110" height="84" viewBox="0 0 110 84" fill="none">
|
|
<circle cx="55" cy="40" r="24" fill="rgba(155,93,229,0.07)" stroke="#9B5DE5" stroke-width="1.8"/>
|
|
<line x1="55" y1="40" x2="55" y2="23" stroke="#9B5DE5" stroke-width="2.5" stroke-linecap="round"/>
|
|
<line x1="55" y1="40" x2="67" y2="47" stroke="#06D6E0" stroke-width="2.5" stroke-linecap="round"/>
|
|
<circle cx="55" cy="40" r="3" fill="#9B5DE5"/>
|
|
<circle cx="55" cy="40" r="8" fill="none" stroke="rgba(155,93,229,0.2)" stroke-width="1"/>
|
|
</svg>
|
|
<div class="rich-empty-title">История пуста</div>
|
|
<div class="rich-empty-sub">Пройди первый тест, и здесь появятся твои результаты</div>
|
|
</div>`;
|
|
return;
|
|
}
|
|
_histList = document.createElement('div');
|
|
_histList.className = 'history-list';
|
|
_histList.innerHTML = histGroupedHtml(data.rows);
|
|
wrap.innerHTML = '';
|
|
wrap.appendChild(_histList);
|
|
document.getElementById('history-more-wrap').style.display =
|
|
data.rows.length < data.total ? '' : 'none';
|
|
} catch { wrap.innerHTML = '<div class="empty">Не удалось загрузить историю</div>'; }
|
|
}
|
|
|
|
async function loadMoreHistory() {
|
|
const btn = document.getElementById('history-more-btn');
|
|
btn.disabled = true; btn.textContent = 'Загрузка…';
|
|
try {
|
|
const data = await LS.getHistory(_histPage + 1, HIST_LIMIT);
|
|
_histPage++;
|
|
const frag = document.createRange().createContextualFragment(histGroupedHtml(data.rows));
|
|
_histList.appendChild(frag);
|
|
const shown = _histPage * HIST_LIMIT;
|
|
document.getElementById('history-more-wrap').style.display = shown < _histTotal ? '' : 'none';
|
|
} catch { LS.toast('Не удалось загрузить историю', 'error'); }
|
|
finally { btn.disabled = false; btn.textContent = 'Загрузить ещё'; }
|
|
}
|
|
|
|
/* ══ ПРОГРЕСС TAB ════════════════════════════════════════════════════ */
|
|
|
|
// Chart.js: text in center of doughnut
|
|
const centerLabelPlugin = {
|
|
id: 'centerLabel',
|
|
beforeDraw(chart) {
|
|
const opts = chart.config.options.plugins.centerLabel;
|
|
if (!opts?.text) return;
|
|
const { ctx, chartArea: { left, right, top, bottom } } = chart;
|
|
const cx = (left + right) / 2, cy = (top + bottom) / 2;
|
|
ctx.save();
|
|
ctx.font = `900 ${opts.size || 18}px 'Unbounded', sans-serif`;
|
|
ctx.fillStyle = opts.color || '#0F172A';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(opts.text, cx, cy);
|
|
ctx.restore();
|
|
}
|
|
};
|
|
if (typeof Chart !== 'undefined') Chart.register(centerLabelPlugin);
|
|
|
|
async function loadProgress() {
|
|
const wrap = document.getElementById('progress-wrap');
|
|
wrap.innerHTML = '<div class="spinner"></div>';
|
|
try {
|
|
const [histData, weakTopics, courses] = await Promise.all([
|
|
LS.getHistory(1, 200),
|
|
LS.getWeakTopics(),
|
|
LS.api('/api/courses').catch(() => []),
|
|
]);
|
|
const rows = histData.rows || [];
|
|
wrap.innerHTML = '';
|
|
|
|
// Theory progress section
|
|
const activeCourses = (Array.isArray(courses) ? courses : [])
|
|
.filter(c => c.lessonCount > 0 && c.doneCount < c.lessonCount);
|
|
if (activeCourses.length > 0) {
|
|
renderTheoryProgress(wrap, activeCourses);
|
|
}
|
|
|
|
if (!rows.length) {
|
|
wrap.innerHTML += `<div class="rich-empty" style="margin-top:20px">
|
|
<svg class="rich-empty-svg" width="120" height="90" viewBox="0 0 120 90" fill="none">
|
|
<rect x="16" y="56" width="16" height="20" rx="3" fill="rgba(155,93,229,0.15)" stroke="#9B5DE5" stroke-width="1.5"/>
|
|
<rect x="40" y="40" width="16" height="36" rx="3" fill="rgba(155,93,229,0.25)" stroke="#9B5DE5" stroke-width="1.5"/>
|
|
<rect x="64" y="28" width="16" height="48" rx="3" fill="rgba(155,93,229,0.4)" stroke="#9B5DE5" stroke-width="1.5"/>
|
|
<rect x="88" y="16" width="16" height="60" rx="3" fill="rgba(155,93,229,0.6)" stroke="#9B5DE5" stroke-width="1.5"/>
|
|
<line x1="10" y1="76" x2="115" y2="76" stroke="rgba(15,23,42,0.18)" stroke-width="1.5" stroke-linecap="round"/>
|
|
</svg>
|
|
<div class="rich-empty-title">Нет данных по тестам</div>
|
|
<div class="rich-empty-sub">Пройди несколько тестов — и здесь появится твоя статистика прогресса</div>
|
|
</div>`;
|
|
if (!activeCourses.length) return;
|
|
reIcons();
|
|
return;
|
|
}
|
|
renderHeatmap(wrap, rows);
|
|
renderTrendChart(wrap, rows);
|
|
renderSubjectCharts(wrap, rows);
|
|
renderWeakInProgress(wrap, weakTopics);
|
|
} catch (e) {
|
|
wrap.innerHTML = `<div class="empty">Ошибка загрузки: ${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
function renderTheoryProgress(container, courses) {
|
|
const SUBJ_LABEL = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика', other:'Задание' };
|
|
const div = document.createElement('div');
|
|
div.innerHTML = `
|
|
<div class="chart-section-title">Теория — в процессе</div>
|
|
<div class="theory-courses-grid">
|
|
${courses.slice(0, 6).map(c => {
|
|
const pct = c.lessonCount > 0 ? Math.round(c.doneCount / c.lessonCount * 100) : 0;
|
|
const subj = SUBJ_LABEL[c.subjectSlug] || c.subjectSlug || '';
|
|
return `<a class="theory-course-card" href="/course?id=${c.id}">
|
|
<div class="tc-header">
|
|
<span class="tc-emoji">${c.coverEmoji || LS.icon('book-open',20)}</span>
|
|
<div class="tc-info">
|
|
<div class="tc-title">${esc(c.title)}</div>
|
|
${subj ? `<div class="tc-subj">${esc(subj)}</div>` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="tc-progress">
|
|
<div class="tc-bar"><div class="tc-fill" style="width:${pct}%"></div></div>
|
|
<span class="tc-pct">${pct}%</span>
|
|
</div>
|
|
<div class="tc-meta">${c.doneCount} / ${c.lessonCount} уроков</div>
|
|
</a>`;
|
|
}).join('')}
|
|
</div>`;
|
|
container.appendChild(div);
|
|
reIcons();
|
|
}
|
|
|
|
function renderHeatmap(container, rows) {
|
|
const title = document.createElement('div');
|
|
title.className = 'chart-section-title';
|
|
title.textContent = 'Активность';
|
|
container.appendChild(title);
|
|
|
|
const section = document.createElement('div');
|
|
section.className = 'heatmap-section';
|
|
|
|
// Build day-count map for past 364 days (52 weeks)
|
|
const today = new Date(); today.setHours(0,0,0,0);
|
|
const dayCounts = {};
|
|
rows.forEach(r => {
|
|
const d = parseDate(r.started_at); d.setHours(0,0,0,0);
|
|
const key = d.toISOString().slice(0,10);
|
|
dayCounts[key] = (dayCounts[key] || 0) + 1;
|
|
});
|
|
|
|
// Start from 52 weeks ago, aligned to Sunday of that week
|
|
const startDay = new Date(today);
|
|
startDay.setDate(today.getDate() - 364);
|
|
// align to nearest past Sunday
|
|
startDay.setDate(startDay.getDate() - startDay.getDay());
|
|
|
|
const grid = document.createElement('div');
|
|
grid.className = 'heatmap-grid';
|
|
|
|
for (let col = 0; col < 52; col++) {
|
|
for (let row = 0; row < 7; row++) {
|
|
const d = new Date(startDay);
|
|
d.setDate(startDay.getDate() + col * 7 + row);
|
|
const key = d.toISOString().slice(0,10);
|
|
const n = dayCounts[key] || 0;
|
|
const lvl = n === 0 ? 0 : n === 1 ? 1 : n <= 3 ? 2 : n <= 5 ? 3 : 4;
|
|
const cell = document.createElement('div');
|
|
cell.className = 'hm-cell';
|
|
if (lvl > 0) cell.dataset.n = lvl;
|
|
cell.title = `${d.toLocaleDateString('ru',{day:'numeric',month:'short'})}: ${n} ${n===1?'тест':'тестов'}`;
|
|
grid.appendChild(cell);
|
|
}
|
|
}
|
|
section.appendChild(grid);
|
|
|
|
const legend = document.createElement('div');
|
|
legend.className = 'heatmap-legend';
|
|
legend.innerHTML = `<span>Меньше</span>
|
|
<div class="hm-leg" style="background:rgba(15,23,42,0.06)"></div>
|
|
<div class="hm-leg" data-n="1"></div>
|
|
<div class="hm-leg" data-n="2"></div>
|
|
<div class="hm-leg" data-n="3"></div>
|
|
<div class="hm-leg" data-n="4"></div>
|
|
<span>Больше</span>`;
|
|
legend.querySelectorAll('[data-n]').forEach(el => {
|
|
const n = el.dataset.n;
|
|
el.style.background = `rgba(155,93,229,${n*0.22})`;
|
|
});
|
|
section.appendChild(legend);
|
|
container.appendChild(section);
|
|
}
|
|
|
|
function renderTrendChart(container, rows) {
|
|
const completed = rows
|
|
.filter(r => r.score !== null && r.total > 0)
|
|
.slice(0, 20)
|
|
.reverse();
|
|
if (!completed.length) return;
|
|
|
|
const title = document.createElement('div');
|
|
title.className = 'chart-section-title';
|
|
title.textContent = 'Динамика результатов';
|
|
container.appendChild(title);
|
|
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'trend-wrap';
|
|
const cWrap = document.createElement('div');
|
|
cWrap.className = 'trend-canvas-wrap';
|
|
const canvas = document.createElement('canvas');
|
|
cWrap.appendChild(canvas);
|
|
wrap.appendChild(cWrap);
|
|
container.appendChild(wrap);
|
|
|
|
new Chart(canvas, {
|
|
type: 'line',
|
|
data: {
|
|
labels: completed.map(r =>
|
|
parseDate(r.started_at).toLocaleDateString('ru', { day: 'numeric', month: 'short' })
|
|
),
|
|
datasets: [{
|
|
label: '%',
|
|
data: completed.map(r => Math.round(r.score / r.total * 100)),
|
|
borderColor: '#9B5DE5',
|
|
backgroundColor: 'rgba(155,93,229,0.07)',
|
|
borderWidth: 2.5,
|
|
pointRadius: 5,
|
|
pointHoverRadius: 7,
|
|
pointBackgroundColor: '#9B5DE5',
|
|
pointBorderColor: '#fff',
|
|
pointBorderWidth: 2,
|
|
fill: true,
|
|
tension: 0.4,
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: {
|
|
callbacks: { label: ctx => ` ${ctx.raw}%` },
|
|
bodyFont: { family: 'Manrope' },
|
|
titleFont: { family: 'Manrope' },
|
|
}
|
|
},
|
|
scales: {
|
|
y: {
|
|
min: 0, max: 100,
|
|
grid: { color: 'rgba(15,23,42,0.05)' },
|
|
ticks: { callback: v => v + '%', font: { family: 'Manrope', size: 11 }, color: '#56687A' }
|
|
},
|
|
x: {
|
|
grid: { display: false },
|
|
ticks: { font: { family: 'Manrope', size: 11 }, color: '#56687A' }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderSubjectCharts(container, rows) {
|
|
const bySubj = {};
|
|
rows.forEach(r => {
|
|
if (!r.subject_slug) return;
|
|
if (!bySubj[r.subject_slug]) bySubj[r.subject_slug] = { name: r.subject_name, rows: [] };
|
|
bySubj[r.subject_slug].rows.push(r);
|
|
});
|
|
if (!Object.keys(bySubj).length) return;
|
|
|
|
const title = document.createElement('div');
|
|
title.className = 'chart-section-title';
|
|
title.textContent = 'По предметам';
|
|
container.appendChild(title);
|
|
|
|
const grid = document.createElement('div');
|
|
grid.className = 'subj-charts-grid';
|
|
container.appendChild(grid);
|
|
|
|
Object.entries(bySubj).forEach(([slug, data]) => {
|
|
const done = data.rows.filter(r => r.score !== null && r.total > 0);
|
|
const avg = done.length
|
|
? Math.round(done.reduce((s, r) => s + r.score / r.total * 100, 0) / done.length)
|
|
: 0;
|
|
const color = SUBJ_COLORS[slug] || '#9B5DE5';
|
|
const avgCls = avg >= 75 ? 'c-green' : avg >= 50 ? 'c-amber' : 'c-pink';
|
|
|
|
const card = document.createElement('div');
|
|
card.className = 'subj-chart-card';
|
|
card.innerHTML = `
|
|
<div class="subj-chart-name">${esc(data.name || SUBJ[slug] || slug)}</div>
|
|
<div class="canvas-wrap"><canvas id="chart-${slug}"></canvas></div>
|
|
<div class="subj-chart-sessions">${done.length} сессий</div>`;
|
|
grid.appendChild(card);
|
|
|
|
const canvas = card.querySelector('canvas');
|
|
new Chart(canvas, {
|
|
type: 'doughnut',
|
|
data: {
|
|
datasets: [{
|
|
data: [avg, 100 - avg],
|
|
backgroundColor: [color, 'rgba(15,23,42,0.06)'],
|
|
borderWidth: 0,
|
|
borderRadius: 6,
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
cutout: '70%',
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: { enabled: false },
|
|
centerLabel: { text: avg + '%', size: 16, color: color }
|
|
},
|
|
animation: { duration: 900, easing: 'easeInOutQuart' }
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderWeakInProgress(container, topics) {
|
|
const title = document.createElement('div');
|
|
title.className = 'chart-section-title';
|
|
title.textContent = 'Темы, требующие внимания';
|
|
container.appendChild(title);
|
|
|
|
if (!topics.length) {
|
|
const el = document.createElement('div');
|
|
el.className = 'empty';
|
|
el.style.padding = '20px 0';
|
|
el.innerHTML = 'Слабых тем не обнаружено <i data-lucide="party-popper" style="width:15px;height:15px;vertical-align:-3px"></i>';
|
|
container.appendChild(el);
|
|
return;
|
|
}
|
|
|
|
const list = document.createElement('div');
|
|
list.className = 'weak-list';
|
|
list.innerHTML = topics.map(t => `
|
|
<div class="weak-item" onclick="window.location.href='/test-run?subject=${t.subject_slug}&mode=exam&count=15&topic=${t.topic_id}'">
|
|
<div class="weak-bar-wrap">
|
|
<div class="weak-name">${esc(t.topic)}</div>
|
|
<div class="weak-meta">${esc(t.subject_name)} · ${t.wrong} из ${t.total} неверно</div>
|
|
<div class="weak-bar"><div class="weak-fill" style="width:${t.error_pct}%"></div></div>
|
|
</div>
|
|
<div class="weak-pct">${t.error_pct}%</div>
|
|
</div>`).join('');
|
|
container.appendChild(list);
|
|
}
|
|
|
|
/* ══ УВЕДОМЛЕНИЯ — handled by notifications.js ══ */
|
|
|
|
/* ══ JOIN MODAL ════════════════════════════════════════════════════════ */
|
|
let _joinModal = null;
|
|
function openJoinModal(code) {
|
|
const body = `
|
|
<input class="form-input" id="join-code" placeholder="Код приглашения" value="${LS.esc(code || '')}" />
|
|
<div style="font-size:0.78rem;color:var(--text-3);margin-top:8px">Попросите учителя поделиться кодом или ссылкой</div>`;
|
|
_joinModal = LS.modal({
|
|
title: 'Вступить в класс', content: body, size: 'sm',
|
|
actions: [
|
|
{ label: 'Отмена', onClick: () => _joinModal.close() },
|
|
{ label: 'Вступить', primary: true, id: 'btn-do-join', onClick: doJoin },
|
|
],
|
|
});
|
|
}
|
|
async function doJoin() {
|
|
const code = document.getElementById('join-code').value.trim();
|
|
if (!code) return;
|
|
const btn = document.getElementById('btn-do-join');
|
|
btn.disabled = true;
|
|
try {
|
|
const r = await LS.joinClass(code);
|
|
_joinModal?.close();
|
|
LS.toast(`Вы вступили в класс «${r.class_name}»!`, 'success');
|
|
loadAssignments();
|
|
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); btn.disabled = false; }
|
|
}
|
|
|
|
/* ══ SUBMISSIONS (student) ════════════════════════════════════════════ */
|
|
let _mySubmissions = new Map(); // assignment_id -> latest submission
|
|
let _allSubmissions = []; // full list for "My submissions" widget
|
|
|
|
const SUB_STATUS = {
|
|
new: { label: 'На проверке', icon: 'clock', cls: 'new' },
|
|
resubmitted: { label: 'Повторно', icon: 'refresh-cw', cls: 'resubmitted' },
|
|
reviewed: { label: 'Проверено', icon: 'check-circle', cls: 'reviewed' },
|
|
accepted: { label: 'Принято', icon: 'check-circle', cls: 'accepted' },
|
|
revision: { label: 'На доработке', icon: 'alert-circle', cls: 'revision' },
|
|
};
|
|
|
|
async function loadMySubmissions() {
|
|
if (isTeacher) return;
|
|
try {
|
|
const rows = await LS.getMySubmissions();
|
|
_allSubmissions = rows;
|
|
_mySubmissions.clear();
|
|
rows.forEach(r => { if (r.assignment_id) _mySubmissions.set(r.assignment_id, r); });
|
|
renderMySubsWidget();
|
|
} catch (e) { console.error('[submissions]', e.message); renderMySubsWidget(); }
|
|
}
|
|
|
|
/* Mini status chip for collapsed assignment card */
|
|
function buildSubChip(a) {
|
|
if (isTeacher || !a.class_id) return '';
|
|
const sub = _mySubmissions.get(a.id);
|
|
if (sub) {
|
|
const st = SUB_STATUS[sub.status] || SUB_STATUS.new;
|
|
const gradeStr = sub.grade != null ? ` ${sub.grade}` : '';
|
|
return `<span class="ar-sub-chip s-${st.cls}">${lci(st.icon,'width:11px;height:11px')} ${st.label}${gradeStr}</span>`;
|
|
}
|
|
if (a.is_homework) return `<span class="ar-sub-chip s-none">${lci('upload','width:11px;height:11px')} Сдать</span>`;
|
|
return '';
|
|
}
|
|
|
|
function buildSubmitStrip(a) {
|
|
if (isTeacher || !a.class_id) return '';
|
|
const sub = _mySubmissions.get(a.id);
|
|
if (sub) {
|
|
const st = SUB_STATUS[sub.status] || SUB_STATUS.new;
|
|
const noteHtml = sub.teacher_note
|
|
? `<span class="ae-submit-note">"${esc(sub.teacher_note)}"</span>`
|
|
: '';
|
|
const gradeHtml = sub.grade != null ? (() => {
|
|
const gcls = sub.grade >= 80 ? 'high' : sub.grade >= 50 ? 'mid' : 'low';
|
|
const letter = sub.grade>=90?'A':sub.grade>=75?'B':sub.grade>=60?'C':sub.grade>=40?'D':'F';
|
|
return `<span class="ae-grade-badge ${gcls}">${sub.grade} <span style="opacity:.7">${letter}</span></span>`;
|
|
})() : '';
|
|
const canDelete = !['reviewed','accepted'].includes(sub.status);
|
|
const delBtn = canDelete
|
|
? `<button class="ae-btn-delete-sub" onclick="doDeleteSubmission(event,${sub.id},${a.id})">Отозвать</button>`
|
|
: '';
|
|
const resubBtn = sub.status === 'revision'
|
|
? `<a class="ae-btn-submit" href="/homework" onclick="event.stopPropagation()" style="text-decoration:none">Пересдать</a>`
|
|
: '';
|
|
return `<div class="ae-submit-row">
|
|
<span class="ae-submit-label">Работа:</span>
|
|
<span class="ae-submit-status ${st.cls}">${lci(st.icon,'width:14px;height:14px')} ${st.label}</span>
|
|
${gradeHtml}
|
|
<span style="font-size:0.72rem;color:var(--text-3);flex:1">${esc(sub.original_name)}</span>
|
|
${noteHtml}
|
|
${resubBtn}
|
|
${delBtn}
|
|
</div>`;
|
|
}
|
|
return `<div class="ae-submit-row">
|
|
<span class="ae-submit-label">Сдача работы:</span>
|
|
<a class="ae-btn-submit" href="/homework" onclick="event.stopPropagation()" style="text-decoration:none">
|
|
Прикрепить файл
|
|
</a>
|
|
</div>`;
|
|
}
|
|
|
|
/* Compact "My submissions" widget */
|
|
function renderMySubsWidget() {
|
|
const wrap = document.getElementById('w-my-subs');
|
|
const list = document.getElementById('my-subs-list');
|
|
if (!wrap || !list || isTeacher) return;
|
|
|
|
if (!_allSubmissions.length) {
|
|
list.innerHTML = `<div style="padding:18px 0;text-align:center;color:var(--text-3);font-size:0.82rem">
|
|
${lci('file-plus','width:24px;height:24px;opacity:0.4;display:block;margin:0 auto 8px')}
|
|
Сданных работ пока нет.<br>
|
|
<span style="font-size:0.74rem">Когда учитель назначит задание с прикреплением файла, вы сможете сдать его прямо здесь.</span>
|
|
</div>`;
|
|
showWidget('w-my-subs');
|
|
reIcons();
|
|
return;
|
|
}
|
|
|
|
const recent = _allSubmissions.slice(0, 5);
|
|
list.innerHTML = recent.map(s => {
|
|
const st = SUB_STATUS[s.status] || SUB_STATUS.new;
|
|
const gradeHtml = s.grade != null ? (() => {
|
|
const gcls = s.grade >= 80 ? 'high' : s.grade >= 50 ? 'mid' : 'low';
|
|
return `<span class="ms-grade" style="color:${gcls==='high'?'#059652':gcls==='mid'?'#c07c00':'#c0306a'}">${s.grade}</span>`;
|
|
})() : '';
|
|
return `<div class="ms-row">
|
|
<span class="ae-submit-status ${st.cls}" style="flex-shrink:0">${lci(st.icon,'width:12px;height:12px')} ${st.label}</span>
|
|
<span class="ms-title">${esc(s.assignment_title || s.original_name)}</span>
|
|
<span class="ms-file">${esc(s.original_name)}</span>
|
|
${gradeHtml}
|
|
</div>`;
|
|
}).join('');
|
|
|
|
showWidget('w-my-subs');
|
|
reIcons();
|
|
}
|
|
|
|
async function doDeleteSubmission(e, subId, assignId) {
|
|
e.stopPropagation();
|
|
const ok = await LS.confirm('Отозвать отправленную работу?', { confirmText: 'Отозвать', danger: true });
|
|
if (!ok) return;
|
|
try {
|
|
await LS.deleteSubmission(subId);
|
|
_mySubmissions.delete(assignId);
|
|
LS.toast('Работа отозвана', 'info');
|
|
renderAssignmentsList();
|
|
} catch (err) {
|
|
LS.toast('Ошибка: ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
/* ══ ACTION BANNER (replaces loadDeadlineWidget) ══════════════════ */
|
|
function loadActionBanner(assignments) {
|
|
const banner = document.getElementById('action-banner');
|
|
if (!banner) return;
|
|
const threeDays = 3 * 24 * 3600 * 1000;
|
|
const upcoming = (assignments || [])
|
|
.filter(a => a.deadline && !a.session_status && new Date(a.deadline) > new Date())
|
|
.sort((a, b) => new Date(a.deadline) - new Date(b.deadline));
|
|
const urgent = upcoming.filter(a => (new Date(a.deadline) - Date.now()) < threeDays);
|
|
if (urgent.length) {
|
|
const a = urgent[0];
|
|
const dl = new Date(a.deadline);
|
|
const diffMs = dl - Date.now();
|
|
const days = Math.floor(diffMs / 86400000);
|
|
const hours = Math.floor((diffMs % 86400000) / 3600000);
|
|
const countdownText = days > 0 ? days : hours;
|
|
const countdownSub = days > 0 ? (days === 1 ? 'день' : days < 5 ? 'дня' : 'дней') : (hours === 1 ? 'час' : hours < 5 ? 'часа' : 'часов');
|
|
banner.className = 'action-banner ab-urgent';
|
|
banner.style.display = '';
|
|
banner.innerHTML = `
|
|
<span class="ab-icon"><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2 2"/><path d="M5 3 2 6m20 0-3-3"/></svg></span>
|
|
<div class="ab-body">
|
|
<div class="ab-title">${esc(a.title)}</div>
|
|
<div class="ab-sub">${SUBJ[a.subject_slug] || ''} · до ${dl.toLocaleDateString('ru',{day:'numeric',month:'short'})}</div>
|
|
</div>
|
|
<div class="ab-countdown">
|
|
<div class="ab-countdown-val">${countdownText}</div>
|
|
<div class="ab-countdown-label">${countdownSub}</div>
|
|
</div>`;
|
|
} else {
|
|
banner.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
/* ══ HERO: Reading card — данные и цвет из блока «Учебники» ═══════ */
|
|
// Палитра обложек учебников (зеркало .tb-cover из textbooks.html)
|
|
const TB_COVER = {
|
|
amber:'linear-gradient(135deg,#b45309 0%,#d97706 60%,#f59e0b 100%)',
|
|
blue:'linear-gradient(135deg,#1e40af 0%,#2563eb 60%,#3b82f6 100%)',
|
|
green:'linear-gradient(135deg,#047857 0%,#059669 60%,#10b981 100%)',
|
|
violet:'linear-gradient(135deg,#6d28d9 0%,#7c3aed 60%,#9333ea 100%)',
|
|
pink:'linear-gradient(135deg,#be185d 0%,#db2777 60%,#ec4899 100%)',
|
|
indigo:'linear-gradient(135deg,#3730a3 0%,#4f46e5 60%,#818cf8 100%)',
|
|
rose:'linear-gradient(135deg,#9f1239 0%,#e11d48 60%,#fb7185 100%)',
|
|
teal:'linear-gradient(135deg,#134e4a 0%,#0d9488 60%,#14b8a6 100%)',
|
|
cyan:'linear-gradient(135deg,#164e63 0%,#0891b2 60%,#22d3ee 100%)',
|
|
emerald:'linear-gradient(135deg,#064e3b 0%,#059669 60%,#34d399 100%)',
|
|
'amber-light':'linear-gradient(135deg,#92400e 0%,#d97706 60%,#fbbf24 100%)',
|
|
sky:'linear-gradient(135deg,#0c4a6e 0%,#0284c7 60%,#7dd3fc 100%)',
|
|
red:'linear-gradient(135deg,#7f1d1d 0%,#dc2626 60%,#f87171 100%)',
|
|
orange:'linear-gradient(135deg,#9a3412 0%,#ea580c 60%,#fb923c 100%)',
|
|
yellow:'linear-gradient(135deg,#854d0e 0%,#ca8a04 60%,#fde047 100%)',
|
|
};
|
|
function _renderReadCard(o) {
|
|
const card = document.getElementById('hc-read');
|
|
if (!card) return;
|
|
if (o.href) card.href = o.href;
|
|
if (TB_COVER[o.color]) card.style.background = TB_COVER[o.color];
|
|
document.getElementById('hc-read-tag').textContent = o.tag;
|
|
document.getElementById('hc-read-title').textContent = o.title;
|
|
document.getElementById('hc-read-sub').textContent = o.sub || '';
|
|
document.getElementById('hc-read-meta').textContent = o.meta || '';
|
|
const pw = document.getElementById('hc-read-prog-wrap');
|
|
const pe = document.getElementById('hc-read-pct');
|
|
if (o.showProg) {
|
|
if (pw) { pw.style.display = ''; document.getElementById('hc-read-prog').style.width = (o.pct || 0) + '%'; }
|
|
if (pe) { pe.style.display = ''; pe.textContent = '· ' + (o.pct || 0) + '%'; }
|
|
} else {
|
|
if (pw) pw.style.display = 'none';
|
|
if (pe) pe.style.display = 'none';
|
|
}
|
|
}
|
|
async function loadContinueWidget() {
|
|
const card = document.getElementById('hc-read');
|
|
if (!card) return;
|
|
let books;
|
|
try {
|
|
const r = await LS.api('/api/textbooks');
|
|
books = r && r.textbooks ? r.textbooks : [];
|
|
} catch { return; } // нет доступа/ошибка — оставляем дефолт «Учебники»
|
|
if (!books.length) return;
|
|
|
|
// Выбор учебника: тот, что в процессе (есть прочитанные §), приоритет
|
|
// последнему открытому; иначе — первый из каталога как рекомендованный.
|
|
const withProgress = books
|
|
.filter(b => (b.progress && (b.progress.last_para || (b.progress.read || []).length)))
|
|
.sort((a, b) => ((b.progress.read || []).length) - ((a.progress.read || []).length));
|
|
const inProgress = withProgress[0];
|
|
const b = inProgress || books[0];
|
|
const readCount = (b.progress && b.progress.read ? b.progress.read.length : 0);
|
|
const pct = b.para_count ? Math.round(100 * readCount / b.para_count) : 0;
|
|
const href = (b.progress && b.progress.last_para)
|
|
? `/textbook/${b.slug}#${b.progress.last_para}`
|
|
: `/textbook/${b.slug}`;
|
|
|
|
if (inProgress) {
|
|
_renderReadCard({
|
|
tag: 'Продолжить чтение', href, color: b.color,
|
|
title: b.title,
|
|
sub: b.description || `${b.grade} класс`,
|
|
meta: `${readCount} из ${b.para_count} § прочитано`,
|
|
pct, showProg: true,
|
|
});
|
|
} else {
|
|
_renderReadCard({
|
|
tag: 'Начать чтение', href, color: b.color,
|
|
title: b.title,
|
|
sub: b.description || `${b.grade} класс`,
|
|
meta: `${b.para_count} § · новый учебник`,
|
|
pct: 0, showProg: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
/* ══ HERO: Lab of the day (deterministic daily pick) ═════════════ */
|
|
const LAB_OF_DAY = [
|
|
{ key:'isoprocess', href:'/lab?sim=molphys', title:'Газовые законы', sub:'Давление, объём и температура газа.', subj:'Физика', time:'~10 мин', level:'средне', goal:'уравнение состояния газа' },
|
|
{ key:'opticsbench', href:'/lab?sim=opticsbench', title:'Оптическая скамья', sub:'Собери систему линз и проследи ход лучей.', subj:'Физика', time:'~12 мин', level:'средне', goal:'построение изображения в линзе' },
|
|
{ key:'circuit', href:'/lab?sim=circuit', title:'Электрическая цепь', sub:'Закон Ома: ток, напряжение и сопротивление.', subj:'Физика', time:'~8 мин', level:'легко', goal:'расчёт цепи по закону Ома' },
|
|
{ key:'pendulum', href:'/lab?sim=pendulum', title:'Математический маятник', sub:'Период колебаний и зависимость от длины.', subj:'Физика', time:'~9 мин', level:'легко', goal:'формула периода маятника' },
|
|
{ key:'waves', href:'/lab?sim=waves', title:'Волны и колебания', sub:'Длина волны, частота и стоячие волны.', subj:'Физика', time:'~11 мин', level:'средне', goal:'связь v = λf' },
|
|
{ key:'stereo', href:'/lab?sim=stereo', title:'Стереометрия 3D', sub:'Сечения и объёмы пространственных фигур.', subj:'Геометрия', time:'~10 мин', level:'сложно', goal:'построение сечений' },
|
|
];
|
|
// Метаданные карточки (время/уровень/цель/подпись) — их нет в каталоге БД,
|
|
// поэтому держим curated-карту по id (источник — LAB_OF_DAY выше).
|
|
const LAB_META = {};
|
|
LAB_OF_DAY.forEach(l => { LAB_META[l.key] = l; });
|
|
const CAT_LABEL = { math: 'Математика', phys: 'Физика', chem: 'Химия', bio: 'Биология', game: 'Игра' };
|
|
|
|
// Синхронизировано с каталогом лаборатории (/api/lab/sims): крутим только
|
|
// среди ВКЛЮЧЁННЫХ симуляций, у которых есть превью; title/категория — из БД,
|
|
// поэтому переименования/выключения в админке отражаются здесь автоматически.
|
|
async function loadLabOfDay() {
|
|
const card = document.getElementById('hc-lab');
|
|
if (!card) return;
|
|
|
|
let pick = null;
|
|
try {
|
|
const r = await LS.api('/api/lab/sims');
|
|
const sims = (r && r.sims) || [];
|
|
const hasPreview = id => window.LabPreviews && LabPreviews[id];
|
|
let pool = sims.filter(s => s.enabled && hasPreview(s.id));
|
|
const feat = pool.filter(s => s.featured);
|
|
if (feat.length >= 2) pool = feat; // приоритет «рекомендуемых», если их достаточно
|
|
pool.sort((a, b) => (a.sort - b.sort) || (a.id < b.id ? -1 : 1)); // стабильный порядок для детерминизма
|
|
if (pool.length) {
|
|
const s = pool[Math.floor(Date.now() / 86400000) % pool.length];
|
|
const m = LAB_META[s.id] || {};
|
|
pick = {
|
|
href: '/lab?sim=' + s.id,
|
|
key: s.id,
|
|
title: s.title || m.title || s.id, // title из каталога (источник истины)
|
|
sub: m.sub || 'Открой симуляцию и поэкспериментируй.',
|
|
subj: m.subj || CAT_LABEL[s.cat] || 'Лаборатория',
|
|
time: m.time || '~10 мин',
|
|
level: m.level || 'средне',
|
|
goal: m.goal || (s.title || ''),
|
|
};
|
|
}
|
|
} catch (_) { /* каталог недоступен — упадём на статичный список ниже */ }
|
|
|
|
if (!pick) { // фолбэк: прежний детерминированный выбор из захардкоженного списка
|
|
pick = LAB_OF_DAY[Math.floor(Date.now() / 86400000) % LAB_OF_DAY.length];
|
|
}
|
|
|
|
card.href = pick.href;
|
|
const bg = document.getElementById('hc-lab-bg');
|
|
if (bg && window.LabPreviews && LabPreviews[pick.key]) bg.innerHTML = LabPreviews[pick.key];
|
|
document.getElementById('hc-lab-title').textContent = pick.title;
|
|
document.getElementById('hc-lab-sub').textContent = pick.sub;
|
|
document.getElementById('hc-lab-subj').textContent = pick.subj;
|
|
document.getElementById('hc-lab-time').textContent = pick.time;
|
|
document.getElementById('hc-lab-level').textContent = pick.level;
|
|
document.getElementById('hc-lab-meta').textContent = 'Освой: ' + pick.goal;
|
|
}
|
|
|
|
/* ══ HERO: Pet (synced with /pet module via /api/pet + PetSprite) ═ */
|
|
async function loadPetHero() {
|
|
const card = document.getElementById('hc-pet');
|
|
if (!card) return;
|
|
let d;
|
|
try { d = await LS.api('/api/pet'); }
|
|
catch { card.style.display = 'none'; return; } // фича питомца выключена
|
|
if (!d) { card.style.display = 'none'; return; }
|
|
|
|
// Sprite — единый рендер из pet-sprite.js (как на /pet)
|
|
const art = document.getElementById('hc-pet-art');
|
|
if (art && window.PetSprite) {
|
|
art.innerHTML = PetSprite.render(d.petLevel || 1, d.mood || 'neutral', d.accessories || [], d.petColor || 'purple', d.streakCurrent || 0);
|
|
}
|
|
document.getElementById('hc-pet-name').textContent = d.petName || 'Квантик';
|
|
document.getElementById('hc-pet-lvl').textContent = d.level || 1;
|
|
|
|
// XP — как в модуле /pet: полный XP / абсолютный порог следующего уровня
|
|
const cur = d.xpForCurrLevel || 0;
|
|
const next = d.xpForNextLevel; // null = макс
|
|
const pct = next ? Math.min(100, ((d.xp - cur) / (next - cur)) * 100) : 100;
|
|
document.getElementById('hc-pet-xp').textContent = (d.xp || 0).toLocaleString();
|
|
document.getElementById('hc-pet-xpmax').textContent = next ? next.toLocaleString() : 'MAX';
|
|
document.getElementById('hc-pet-prog').style.width = pct + '%';
|
|
|
|
// Стрик / цель дня / настроение
|
|
document.getElementById('hc-pet-streak').textContent = d.streakCurrent || 0;
|
|
const quests = d.quests || [];
|
|
const doneCnt = quests.filter(q => q.done).length;
|
|
document.getElementById('hc-pet-goal').textContent = quests.length ? `${doneCnt}/${quests.length}` : '—';
|
|
document.getElementById('hc-pet-mood').textContent = window.PetSprite ? PetSprite.moodLabel(d.mood) : (d.mood || 'бодр');
|
|
}
|
|
|
|
/* ══ ACTIVITY: data structure ══════════════════════════════════════ */
|
|
let _activityDays = {}; // { 'YYYY-MM-DD': { test, exam, cards, lesson, live, homework } }
|
|
let _hmScale = 12; // weeks to show
|
|
const ACT_TYPES = {
|
|
test: { label: 'Тесты', color: '#06B6D4' },
|
|
exam: { label: 'Экзамен', color: '#9B5DE5' },
|
|
cards: { label: 'Карты', color: '#06D6A0' },
|
|
lesson: { label: 'Уроки', color: '#F59E0B' },
|
|
live: { label: 'Онлайн', color: '#EC4899' },
|
|
homework: { label: 'Домашка', color: '#22C55E' },
|
|
};
|
|
const ACT_ORDER = ['test', 'exam', 'cards', 'lesson', 'live', 'homework'];
|
|
function _dayTotal(types) { let s = 0; for (const k in (types || {})) s += types[k] || 0; return s; }
|
|
function _domType(types) { let best = null, bn = -1; for (const k in (types || {})) { if (types[k] > bn) { bn = types[k]; best = k; } } return best; }
|
|
function _hexA(h, a) { const n = parseInt(String(h).slice(1), 16); return `rgba(${(n>>16)&255},${(n>>8)&255},${n&255},${a})`; }
|
|
const ICN_TREND_UP = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 17 9 11 13 15 21 7"/><polyline points="15 7 21 7 21 13"/></svg>';
|
|
const ICN_TREND_DOWN = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 7 9 13 13 9 21 17"/><polyline points="15 17 21 17 21 11"/></svg>';
|
|
const ICN_TREND_FLAT = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="12" x2="20" y2="12"/></svg>';
|
|
|
|
/* ══ WIDGET: Activity heatmap (all study types) ══════════════════════ */
|
|
async function loadActivityWidget() {
|
|
const w = document.getElementById('w-activity');
|
|
if (!w) return;
|
|
showWidget('w-activity');
|
|
try { const data = await LS.getActivity(); _activityDays = data.days || {}; }
|
|
catch (e) { _activityDays = {}; }
|
|
renderHeatmap();
|
|
renderStreakCalendar();
|
|
}
|
|
|
|
function renderHeatmap() {
|
|
const byDay = _activityDays;
|
|
const weeks = _hmScale;
|
|
const today = new Date(); today.setHours(0,0,0,0);
|
|
const host = document.getElementById('activity-heatmap');
|
|
if (!host) return;
|
|
|
|
// Empty state (new users / no activity yet)
|
|
if (!byDay || !Object.keys(byDay).length) {
|
|
host.innerHTML = `<div class="hm-empty">
|
|
<div class="hm-empty-ic">${lci('sparkles', 26)}</div>
|
|
<div class="hm-empty-t">Здесь появится твоя активность</div>
|
|
<div class="hm-empty-s">Читай учебники, решай тесты и экзамен, проходи карточки и уроки — карта заполнится.</div>
|
|
<a class="hm-empty-cta" href="/exam-prep/math9">Начать заниматься</a>
|
|
</div>`;
|
|
if (window.lucide) lucide.createIcons();
|
|
return;
|
|
}
|
|
|
|
const totalDays = weeks * 7;
|
|
const startDay = new Date(today);
|
|
startDay.setDate(today.getDate() - totalDays + 1);
|
|
const startDow = (startDay.getDay() + 6) % 7; // align to Monday
|
|
startDay.setDate(startDay.getDate() - startDow);
|
|
|
|
const mNames = ['Янв','Фев','Мар','Апр','Май','Июн','Июл','Авг','Сен','Окт','Ноя','Дек'];
|
|
const months = []; let lastMonth = -1;
|
|
for (let col = 0; col < weeks; col++) {
|
|
const d = new Date(startDay); d.setDate(startDay.getDate() + col * 7);
|
|
if (d.getMonth() !== lastMonth) { months.push({ col, label: mNames[d.getMonth()] }); lastMonth = d.getMonth(); }
|
|
}
|
|
let monthHtml = '';
|
|
months.forEach((m, i) => {
|
|
const nextCol = i < months.length - 1 ? months[i + 1].col : weeks;
|
|
monthHtml += `<span class="hm-month-label" style="width:${(nextCol - m.col) * 16}px">${m.label}</span>`;
|
|
});
|
|
|
|
const wdNames = ['Пн','','Ср','','Пт','',''];
|
|
|
|
let cellsHtml = '', cellIdx = 0, totalAll = 0, thisWeek = 0, lastWeek = 0;
|
|
const wkAgo = new Date(today); wkAgo.setDate(today.getDate() - 6);
|
|
const twoWkAgo = new Date(today); twoWkAgo.setDate(today.getDate() - 13);
|
|
for (let col = 0; col < weeks; col++) {
|
|
for (let row = 0; row < 7; row++) {
|
|
const d = new Date(startDay); d.setDate(startDay.getDate() + col * 7 + row);
|
|
const key = d.toISOString().slice(0,10);
|
|
const types = byDay[key];
|
|
const n = types ? _dayTotal(types) : 0;
|
|
const isFuture = d > today;
|
|
if (n && !isFuture) {
|
|
totalAll += n;
|
|
if (d >= wkAgo) thisWeek += n;
|
|
else if (d >= twoWkAgo) lastWeek += n;
|
|
}
|
|
let style;
|
|
if (n && !isFuture) {
|
|
const color = (ACT_TYPES[_domType(types)] || {}).color || '#9B5DE5';
|
|
const alpha = Math.min(1, 0.42 + Math.log2(n + 1) * 0.2);
|
|
style = ` style="background:${_hexA(color, alpha.toFixed(2))};--cell-color:${_hexA(color, 0.55)};animation-delay:${cellIdx * 4}ms"`;
|
|
} else if (isFuture) {
|
|
style = ` style="opacity:0.15;animation-delay:${cellIdx * 4}ms"`;
|
|
} else {
|
|
style = ` style="animation-delay:${cellIdx * 4}ms"`;
|
|
}
|
|
cellsHtml += `<div class="mhm-cell${n && !isFuture ? ' has-data' : ''}" data-key="${key}"${style}></div>`;
|
|
cellIdx++;
|
|
}
|
|
}
|
|
|
|
// Hero: total + weekly trend pill + scale control
|
|
const diff = thisWeek - lastWeek;
|
|
let trendCls, trendTxt, trendIcon;
|
|
if (diff > 0) { trendCls = 'up'; trendTxt = `+${diff}`; trendIcon = ICN_TREND_UP; }
|
|
else if (diff < 0) { trendCls = 'down'; trendTxt = `${diff}`; trendIcon = ICN_TREND_DOWN; }
|
|
else { trendCls = 'flat'; trendTxt = 'без изменений'; trendIcon = ICN_TREND_FLAT; }
|
|
const wkWord = weeks === 26 ? '6 мес.' : `${weeks} нед.`;
|
|
const scaleHtml = [[6,'6н'],[12,'12н'],[26,'6м']]
|
|
.map(([w,l]) => `<button class="act-scale-btn${w === weeks ? ' active' : ''}" onclick="setHmScale(${w},this)">${l}</button>`).join('');
|
|
|
|
const heroHtml =
|
|
`<div class="hm-hero"><div>` +
|
|
`<div class="hm-hero-num-row">` +
|
|
`<span class="hm-hero-num">${totalAll}</span>` +
|
|
`<span class="hm-hero-unit">занятий за ${wkWord}</span>` +
|
|
`<span class="hm-trend-pill ${trendCls}">${trendIcon}${trendTxt}</span>` +
|
|
`</div>` +
|
|
`<div class="hm-hero-sub">на этой неделе <strong>${thisWeek}</strong>, на прошлой <strong>${lastWeek}</strong></div>` +
|
|
`</div><div class="act-scale-btns">${scaleHtml}</div></div>`;
|
|
|
|
const legendHtml = `<div class="hm-legend-row"><div class="hm-legend">` +
|
|
ACT_ORDER.map(k => `<span class="hm-legend-chip"><span class="hm-legend-dot" style="background:${ACT_TYPES[k].color}"></span>${ACT_TYPES[k].label}</span>`).join('') +
|
|
`</div></div>`;
|
|
|
|
host.innerHTML =
|
|
heroHtml +
|
|
`<div class="hm-months">${monthHtml}</div>` +
|
|
`<div class="hm-body">` +
|
|
`<div class="hm-weekdays">${wdNames.map(w => `<div class="hm-wd">${w}</div>`).join('')}</div>` +
|
|
`<div class="mini-heatmap">${cellsHtml}</div>` +
|
|
`</div>` +
|
|
legendHtml;
|
|
|
|
setTimeout(() => { initHeatmapTooltip(); initHeatmapClick(); }, 50);
|
|
}
|
|
|
|
/* ══ Heatmap scale switch ══════════════════════════════════════════ */
|
|
function setHmScale(weeks) {
|
|
_hmScale = weeks;
|
|
renderHeatmap(); // rebuilds the scale control with the active state
|
|
}
|
|
|
|
/* ══ Activity tab switch ═══════════════════════════════════════════ */
|
|
function switchActTab(tab, btn) {
|
|
document.querySelectorAll('.act-tab').forEach(t => t.classList.remove('active'));
|
|
if (btn) btn.classList.add('active');
|
|
document.getElementById('act-heatmap-pane').classList.toggle('visible', tab === 'heatmap');
|
|
document.getElementById('act-cal-pane').classList.toggle('visible', tab === 'calendar');
|
|
}
|
|
|
|
/* Колонка прогресса (#w-progress-col) — это один .widget-бокс с тремя секциями
|
|
(карточка / по предметам / результаты). Если все секции скрыты (напр. флешкарты
|
|
отключены и нет данных) — прячем сам бокс, иначе висит пустая рамка. */
|
|
function syncProgressCol() {
|
|
const col = document.getElementById('w-progress-col');
|
|
if (!col) return;
|
|
const any = ['w-flashcard', 'w-subj-progress', 'w-last-results'].some(id => {
|
|
const e = document.getElementById(id);
|
|
return e && getComputedStyle(e).display !== 'none';
|
|
});
|
|
col.style.display = any ? '' : 'none';
|
|
}
|
|
|
|
/* Hero-ряд (чтение/лаборатория/питомец): карточки скрываются по фиче (через CSS).
|
|
Подгоняем число колонок под видимые карточки и прячем весь ряд, если пусто. */
|
|
function syncHeroRow() {
|
|
const row = document.getElementById('hero-row');
|
|
if (!row) return;
|
|
const vis = [...row.querySelectorAll('.hero-card')]
|
|
.filter(c => getComputedStyle(c).display !== 'none');
|
|
row.style.display = vis.length ? '' : 'none';
|
|
// ширину колонок под число карточек делает CSS (auto-fit), мобайл не трогаем.
|
|
}
|
|
|
|
/* ══ WIDGET: Last results (compact, 5 items) ══════════════════════ */
|
|
function loadLastResultsWidget(rows) {
|
|
const w = document.getElementById('w-last-results');
|
|
if (!w) return;
|
|
const completed = (rows || []).filter(r => r.score !== null && r.total > 0).slice(0, 5);
|
|
if (!completed.length) { w.style.display = 'none'; syncProgressCol(); return; }
|
|
w.style.display = '';
|
|
document.getElementById('last-results-list').innerHTML = completed.map(h => {
|
|
const pct = Math.round(h.score / h.total * 100);
|
|
const pc = pct >= 75 ? 'hi' : pct >= 50 ? 'mid' : 'lo';
|
|
const colorMap = { hi: '#059652', mid: '#F59E0B', lo: '#E0335E' };
|
|
return `<div class="grade-row">
|
|
<span class="grade-pct" style="color:${colorMap[pc]}">${pct}%</span>
|
|
<div class="grade-body">
|
|
<div class="grade-subj">${esc(h.subject_name || 'Тест')}</div>
|
|
<div class="grade-date">${parseDate(h.started_at).toLocaleDateString('ru',{day:'numeric',month:'short'})} · ${MODES[h.mode] || h.mode}</div>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
syncProgressCol();
|
|
}
|
|
|
|
/* ══ WIDGET: Subject progress bars ════════════════════════════════ */
|
|
function loadSubjProgressWidget(rows) {
|
|
const w = document.getElementById('w-subj-progress');
|
|
if (!w) return;
|
|
const bySubj = {};
|
|
(rows || []).forEach(r => {
|
|
if (!r.subject_slug || r.score === null || r.total <= 0) return;
|
|
if (!bySubj[r.subject_slug]) bySubj[r.subject_slug] = { name: r.subject_name, scores: [] };
|
|
bySubj[r.subject_slug].scores.push(Math.round(r.score / r.total * 100));
|
|
});
|
|
const entries = Object.entries(bySubj);
|
|
if (!entries.length) { w.style.display = 'none'; syncProgressCol(); return; }
|
|
w.style.display = '';
|
|
document.getElementById('subj-progress-bars').innerHTML = entries.map(([slug, d]) => {
|
|
const avg = Math.round(d.scores.reduce((a, b) => a + b, 0) / d.scores.length);
|
|
const color = SUBJ_COLORS[slug] || '#9B5DE5';
|
|
return `<div class="subj-prog-row">
|
|
<span class="sp-name">${esc(d.name || SUBJ[slug] || slug)}</span>
|
|
<div class="sp-bar"><div class="sp-fill" style="width:${avg}%;background:${color}"></div></div>
|
|
<span class="sp-pct" style="color:${color}">${avg}%</span>
|
|
</div>`;
|
|
}).join('');
|
|
syncProgressCol();
|
|
}
|
|
|
|
/* ══ WIDGET: Theory progress ══════════════════════════════════════ */
|
|
async function loadTheoryWidget() {
|
|
const w = document.getElementById('w-theory-progress');
|
|
if (!w) return;
|
|
try {
|
|
const courses = await LS.api('/api/courses');
|
|
const active = (Array.isArray(courses) ? courses : [])
|
|
.filter(c => c.lessonCount > 0 && c.doneCount < c.lessonCount);
|
|
if (!active.length) { w.style.display = 'none'; return; }
|
|
showWidget('w-theory-progress');
|
|
const SUBJ_LABEL = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика', other:'Задание' };
|
|
document.getElementById('theory-progress-grid').innerHTML = active.slice(0, 6).map(c => {
|
|
const pct = Math.round(c.doneCount / c.lessonCount * 100);
|
|
const subj = SUBJ_LABEL[c.subjectSlug] || c.subjectSlug || '';
|
|
return `<a class="theory-course-card" href="/course?id=${c.id}">
|
|
<div class="tc-header">
|
|
<span class="tc-emoji">${c.coverEmoji || LS.icon('book-open',20)}</span>
|
|
<div class="tc-info">
|
|
<div class="tc-title">${esc(c.title)}</div>
|
|
${subj ? `<div class="tc-subj">${esc(subj)}</div>` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="tc-progress">
|
|
<div class="tc-bar"><div class="tc-fill" style="width:${pct}%"></div></div>
|
|
<span class="tc-pct">${pct}%</span>
|
|
</div>
|
|
<div class="tc-meta">${c.doneCount} / ${c.lessonCount} уроков</div>
|
|
</a>`;
|
|
}).join('');
|
|
} catch { w.style.display = 'none'; }
|
|
}
|
|
|
|
/* ══ B2: LIVE SEARCH ASSIGNMENTS ═══════════════════════════════════ */
|
|
let _searchQuery = '';
|
|
function filterAssignments(q) {
|
|
_searchQuery = (q || '').toLowerCase().trim();
|
|
renderAssignmentsList();
|
|
}
|
|
|
|
/* ══ HEATMAP TOOLTIP (rich) ════════════════════════════════════════ */
|
|
function initHeatmapTooltip() {
|
|
const container = document.getElementById('activity-heatmap');
|
|
const tip = document.getElementById('hm-tip');
|
|
if (!container || !tip) return;
|
|
// Remove old listeners by cloning
|
|
const fresh = container.cloneNode(true);
|
|
container.parentNode.replaceChild(fresh, container);
|
|
|
|
fresh.addEventListener('mouseover', e => {
|
|
const cell = e.target.closest('.mhm-cell');
|
|
if (!cell) { tip.classList.remove('visible'); return; }
|
|
const key = cell.dataset.key;
|
|
const types = _activityDays[key];
|
|
const dateLabel = new Date(key + 'T00:00:00').toLocaleDateString('ru', {weekday:'short', day:'numeric', month:'long'});
|
|
if (_dayTotal(types)) {
|
|
const parts = ACT_ORDER.filter(k => types[k]).map(k => `${ACT_TYPES[k].label} ${types[k]}`);
|
|
tip.textContent = `${dateLabel} — ${parts.join(', ')}`;
|
|
} else {
|
|
tip.textContent = `${dateLabel} — нет активности`;
|
|
}
|
|
const r = cell.getBoundingClientRect();
|
|
tip.style.left = Math.min(r.left + r.width / 2, window.innerWidth - 180) + 'px';
|
|
tip.style.top = (r.top - 34) + 'px';
|
|
tip.classList.add('visible');
|
|
});
|
|
fresh.addEventListener('mouseleave', () => tip.classList.remove('visible'));
|
|
}
|
|
|
|
/* ══ HEATMAP CLICK <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> DAY POPUP ════════════════════════════════════ */
|
|
function initHeatmapClick() {
|
|
const container = document.getElementById('activity-heatmap');
|
|
if (!container) return;
|
|
container.addEventListener('click', e => {
|
|
const cell = e.target.closest('.mhm-cell');
|
|
const popup = document.getElementById('hm-day-popup');
|
|
if (!cell || !cell.classList.contains('has-data')) {
|
|
if (popup) popup.style.display = 'none';
|
|
return;
|
|
}
|
|
const key = cell.dataset.key;
|
|
const types = _activityDays[key];
|
|
if (!_dayTotal(types)) { popup.style.display = 'none'; return; }
|
|
|
|
const dateLabel = new Date(key + 'T00:00:00').toLocaleDateString('ru', {weekday:'long', day:'numeric', month:'long'});
|
|
let html = `<div class="hdp-date">${dateLabel}</div>`;
|
|
ACT_ORDER.filter(k => types[k]).forEach(k => {
|
|
html += `<div class="hdp-row">
|
|
<span class="hdp-dot" style="background:${ACT_TYPES[k].color}"></span>
|
|
<span class="hdp-subj">${ACT_TYPES[k].label}</span>
|
|
<span class="hdp-score">${types[k]}</span>
|
|
</div>`;
|
|
});
|
|
popup.innerHTML = html;
|
|
popup.style.display = '';
|
|
|
|
const r = cell.getBoundingClientRect();
|
|
popup.style.left = Math.min(r.right + 8, window.innerWidth - 220) + 'px';
|
|
popup.style.top = Math.max(8, r.top - 20) + 'px';
|
|
});
|
|
// Close popup on outside click
|
|
document.addEventListener('click', e => {
|
|
const popup = document.getElementById('hm-day-popup');
|
|
if (popup && !popup.contains(e.target) && !e.target.closest('.mhm-cell')) {
|
|
popup.style.display = 'none';
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ══ B5: DEADLINE TOAST ════════════════════════════════════════════ */
|
|
function showDeadlineToast(assignments) {
|
|
const oneDay = 24 * 3600 * 1000;
|
|
const urgent = (assignments || []).filter(a =>
|
|
a.deadline && !a.session_status &&
|
|
new Date(a.deadline) > new Date() &&
|
|
(new Date(a.deadline) - Date.now()) < oneDay
|
|
);
|
|
if (!urgent.length) return;
|
|
const a = urgent[0];
|
|
const hours = Math.ceil((new Date(a.deadline) - Date.now()) / 3600000);
|
|
const toast = document.createElement('div');
|
|
toast.className = 'deadline-toast';
|
|
toast.innerHTML = `<span class="dt-icon">${LS.icon('flame',18)}</span>
|
|
<span class="dt-text"><strong>${esc(a.title)}</strong> — осталось <span class="dt-time">${hours}ч</span></span>
|
|
<button class="dt-close" onclick="this.parentElement.remove()"><svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>`;
|
|
toast.addEventListener('click', e => {
|
|
if (e.target.closest('.dt-close')) return;
|
|
toast.remove();
|
|
});
|
|
document.querySelector('.sb-content').appendChild(toast);
|
|
setTimeout(() => { if (toast.parentElement) toast.remove(); }, 9000);
|
|
}
|
|
|
|
/* ══ C2: STREAK CALENDAR ═══════════════════════════════════════════ */
|
|
function renderStreakCalendar() {
|
|
const body = document.getElementById('streak-cal-body');
|
|
if (!body) return;
|
|
const today = new Date(); today.setHours(0,0,0,0);
|
|
const activeDays = new Set();
|
|
for (const key in (_activityDays || {})) {
|
|
if (_dayTotal(_activityDays[key]) > 0) activeDays.add(key);
|
|
}
|
|
const year = today.getFullYear(), month = today.getMonth();
|
|
const firstDay = new Date(year, month, 1);
|
|
const lastDay = new Date(year, month + 1, 0);
|
|
const startDow = (firstDay.getDay() + 6) % 7;
|
|
const todayKey = today.toISOString().slice(0,10);
|
|
const todayHasSession = activeDays.has(todayKey);
|
|
|
|
// Current streak + best streak
|
|
let streak = 0, bestStreak = 0, cur = 0;
|
|
const allDays = [...activeDays].sort();
|
|
for (let i = 0; i <= 180; i++) {
|
|
const d = new Date(today); d.setDate(today.getDate() - i);
|
|
const k = d.toISOString().slice(0,10);
|
|
if (activeDays.has(k)) { if (i === 0 || streak > 0) streak++; }
|
|
else if (streak > 0) break;
|
|
}
|
|
// Best streak from all data
|
|
let tmpStreak = 0;
|
|
for (let i = 0; i <= 365; i++) {
|
|
const d = new Date(today); d.setDate(today.getDate() - i);
|
|
const k = d.toISOString().slice(0,10);
|
|
if (activeDays.has(k)) { tmpStreak++; bestStreak = Math.max(bestStreak, tmpStreak); }
|
|
else tmpStreak = 0;
|
|
}
|
|
|
|
const monthNames = ['Январь','Февраль','Март','Апрель','Май','Июнь','Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь'];
|
|
const wdNames = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
|
|
let html = `<div class="sc-header">
|
|
<span class="sc-month">${monthNames[month]}</span>
|
|
<span style="display:flex;gap:10px;align-items:center">
|
|
<span class="sc-streak-badge">${lci('flame', 16)} ${streak}</span>
|
|
${bestStreak > streak ? `<span style="font-size:0.6rem;color:var(--text-3);font-weight:600">рекорд ${bestStreak}</span>` : ''}
|
|
</span>
|
|
</div>`;
|
|
html += `<div class="sc-weekdays">${wdNames.map(d => `<span class="sc-wd">${d}</span>`).join('')}</div>`;
|
|
html += '<div class="streak-cal">';
|
|
for (let i = 0; i < startDow; i++) html += '<div class="sc-day" style="visibility:hidden"></div>';
|
|
for (let d = 1; d <= lastDay.getDate(); d++) {
|
|
const dt = new Date(year, month, d);
|
|
const key = dt.toISOString().slice(0,10);
|
|
const isToday = key === todayKey;
|
|
const isActive = activeDays.has(key);
|
|
const isFuture = dt > today;
|
|
let cls = 'sc-day';
|
|
if (isToday) cls += ' today';
|
|
if (isActive) cls += ' active';
|
|
if (isFuture) cls += ' future';
|
|
// Pulse today if no session yet
|
|
if (isToday && !todayHasSession) cls += ' pulse';
|
|
// Shade active (non-today) days by how much was done
|
|
let st = '';
|
|
if (isActive && !isToday) {
|
|
const a = Math.min(0.42, 0.16 + Math.log2(_dayTotal(_activityDays[key]) + 1) * 0.1);
|
|
st = ` style="background:${_hexA('#9B5DE5', a.toFixed(2))}"`;
|
|
}
|
|
html += `<div class="${cls}"${st}>${d}</div>`;
|
|
}
|
|
html += '</div>';
|
|
body.innerHTML = html;
|
|
}
|
|
|
|
/* ══ B1: QUICK-START MODAL ═════════════════════════════════════════ */
|
|
let _qsSlug = null;
|
|
let _qsModal = null;
|
|
async function openQuickStart(slug) {
|
|
_qsSlug = slug || null;
|
|
let subjectsHtml = '';
|
|
try {
|
|
const subjects = await LS.getSubjects();
|
|
subjectsHtml = subjects.map(s => {
|
|
const color = SUBJ_COLORS[s.slug] || '#9B5DE5';
|
|
const active = s.slug === _qsSlug ? ' active' : '';
|
|
return `<button class="qs-subj-btn${active}" data-slug="${s.slug}">
|
|
<div class="qs-subj-icon" style="background:${color}">${lci(ICONS[s.slug]||'book-open')}</div>
|
|
${esc(s.name)}
|
|
</button>`;
|
|
}).join('');
|
|
} catch {}
|
|
const body = `
|
|
<div class="qs-subjects" id="qs-subjects">${subjectsHtml}</div>
|
|
<div class="qs-options">
|
|
<div class="qs-row">
|
|
<span class="qs-label">Режим</span>
|
|
<select class="qs-select" id="qs-mode">
|
|
<option value="exam">Экзамен</option>
|
|
<option value="practice">Тренировка</option>
|
|
<option value="random">Случайный</option>
|
|
</select>
|
|
</div>
|
|
<div class="qs-row">
|
|
<span class="qs-label">Вопросов</span>
|
|
<input class="qs-input" id="qs-count" type="number" min="5" max="50" value="25">
|
|
</div>
|
|
</div>`;
|
|
_qsModal = LS.modal({
|
|
title: 'Быстрый тест', content: body, size: 'sm',
|
|
actions: [
|
|
{ label: 'Отмена', onClick: () => _qsModal.close() },
|
|
{ label: 'Начать', primary: true, onClick: doQuickStart },
|
|
],
|
|
});
|
|
reIcons();
|
|
// Wire subject selection within modal
|
|
_qsModal.body.querySelectorAll('.qs-subj-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
_qsSlug = btn.dataset.slug;
|
|
_qsModal.body.querySelectorAll('.qs-subj-btn').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
});
|
|
});
|
|
}
|
|
function doQuickStart() {
|
|
if (!_qsSlug) { LS.toast('Выберите предмет', 'warn'); return; }
|
|
const mode = document.getElementById('qs-mode').value;
|
|
const count = parseInt(document.getElementById('qs-count').value) || 25;
|
|
_qsModal?.close();
|
|
window.location.href = `/test-run?subject=${_qsSlug}&mode=${mode}&count=${count}`;
|
|
}
|
|
|
|
/* ══ C4: KEYBOARD SHORTCUTS ════════════════════════════════════════ */
|
|
(function initKeyboard() {
|
|
document.addEventListener('keydown', e => {
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
|
|
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
|
switch (e.key.toLowerCase()) {
|
|
case 'n': e.preventDefault(); openQuickStart(); break;
|
|
case 't': e.preventDefault(); window.location.href = '/library'; break;
|
|
case 'b': e.preventDefault(); window.location.href = '/board'; break;
|
|
case '?': e.preventDefault();
|
|
document.getElementById('kb-hint').classList.toggle('visible');
|
|
break;
|
|
}
|
|
});
|
|
})();
|
|
|
|
/* ══ ADMIN: Load stats into header ════════════════════════════════ */
|
|
async function loadAdminStats() {
|
|
if (!isTeacher) return;
|
|
try {
|
|
const data = await LS.api('/api/admin/stats');
|
|
const statsEl = document.getElementById('dh-stats');
|
|
statsEl.style.display = '';
|
|
statsEl.innerHTML = '';
|
|
// Show compact stat chips instead of rings for admin
|
|
const chips = [
|
|
{ val: data.totalUsers || 0, label: 'студентов', color: '#9B5DE5' },
|
|
{ val: data.totalTests || 0, label: 'тестов', color: '#06B6D4' },
|
|
{ val: data.avgScore != null ? data.avgScore + '%' : '—', label: 'средний', color: data.avgScore >= 75 ? '#059652' : data.avgScore >= 50 ? '#F59E0B' : '#E0335E' },
|
|
];
|
|
statsEl.innerHTML = chips.map(c =>
|
|
`<div class="adm-stat-chip"><span class="adm-stat-val" style="color:${c.color}">${c.val}</span>${c.label}</div>`
|
|
).join('');
|
|
} catch { /* silent */ }
|
|
}
|
|
|
|
/* ══ ADMIN: KPI chips in header ═════════════════════════════════════ */
|
|
async function loadTeacherKPIs() {
|
|
if (!isTeacher) return;
|
|
const kpiRow = document.getElementById('dh-kpi-row');
|
|
if (!kpiRow) return;
|
|
try {
|
|
const [classes, assignments] = await Promise.all([
|
|
LS.api('/api/classes'),
|
|
LS.teacherAssignments(),
|
|
]);
|
|
const classCount = (classes || []).length;
|
|
const studentCount = (classes || []).reduce((s, c) => s + (c.member_count || 0), 0);
|
|
const now = Date.now();
|
|
const activeAsgn = (assignments || []).filter(a =>
|
|
!a.deadline || parseDate(a.deadline).getTime() >= now
|
|
).length;
|
|
// Pending: overdue with submissions missing
|
|
const pending = (assignments || []).filter(a =>
|
|
a.deadline && parseDate(a.deadline).getTime() < now &&
|
|
(a.completed_count || 0) < (a.total_members || 0)
|
|
).length;
|
|
|
|
document.getElementById('kpi-classes').textContent = classCount;
|
|
document.getElementById('kpi-students').textContent = studentCount;
|
|
document.getElementById('kpi-active-asgn').textContent = activeAsgn;
|
|
kpiRow.style.display = '';
|
|
|
|
if (pending > 0) {
|
|
document.getElementById('kpi-pending').textContent = pending;
|
|
document.getElementById('kpi-pending-wrap').style.display = '';
|
|
}
|
|
|
|
// Add badge to «Мои классы» card
|
|
if (classCount > 0) {
|
|
const classCard = document.querySelector('.adm-act[href="/classes"]');
|
|
if (classCard && !classCard.querySelector('.adm-act-badge')) {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'adm-act-badge';
|
|
badge.textContent = classCount;
|
|
classCard.appendChild(badge);
|
|
}
|
|
}
|
|
// Add badge to «Работы» card if pending
|
|
if (pending > 0) {
|
|
const workCard = document.querySelector('.adm-act[href="/homework"]');
|
|
if (workCard && !workCard.querySelector('.adm-act-badge')) {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'adm-act-badge';
|
|
badge.textContent = pending;
|
|
workCard.appendChild(badge);
|
|
}
|
|
}
|
|
} catch { /* silent */ }
|
|
}
|
|
|
|
/* ══ ADMIN: Load classes into admin panel ═══════════════════════════ */
|
|
async function loadAdminClasses() {
|
|
if (!isTeacher) return;
|
|
const body = document.getElementById('admin-classes-body');
|
|
if (!body) return;
|
|
try {
|
|
const classes = await LS.api('/api/classes');
|
|
if (!classes || !classes.length) {
|
|
body.innerHTML = `<div class="adm-empty">
|
|
<div class="adm-empty-icon"><i data-lucide="users" style="width:32px;height:32px;color:var(--text-3)"></i></div>
|
|
<div class="adm-empty-text">Классов пока нет</div>
|
|
<a class="adm-empty-cta" href="/classes">+ Создать класс</a>
|
|
</div>`;
|
|
reIcons();
|
|
return;
|
|
}
|
|
const colors = ['#9B5DE5','#06D6A0','#F59E0B','#06B6D4','#E0335E'];
|
|
body.innerHTML = classes.slice(0, 5).map(c => {
|
|
const memberCount = c.member_count || c.members || 0;
|
|
const aCount = c.assignment_count || 0;
|
|
const bg = colors[(c.id || 0) % colors.length];
|
|
return `<a class="cs-card" href="/classes?id=${c.id}" style="text-decoration:none;color:inherit">
|
|
<div class="cs-avatar" style="${c.cover_emoji ? (()=>{const p=c.cover_emoji.split(':');const cl=p[1]||'#9B5DE5';return 'background:'+cl+'18;color:'+cl})() : 'background:'+bg}">${c.cover_emoji ? lci(c.cover_emoji.split(':')[0],'width:18px;height:18px') : (c.name||'?')[0]}</div>
|
|
<div class="cs-body">
|
|
<div class="cs-name">${esc(c.name)}</div>
|
|
<div class="cs-stats">
|
|
<span>${lci('users','width:12px;height:12px')} ${memberCount}</span>
|
|
<span>${lci('file-text','width:12px;height:12px')} ${aCount} заданий</span>
|
|
</div>
|
|
</div>
|
|
</a>`;
|
|
}).join('');
|
|
reIcons();
|
|
} catch {
|
|
body.innerHTML = '<div style="font-size:0.8rem;color:var(--text-3);padding:8px 0">Ошибка загрузки</div>';
|
|
}
|
|
}
|
|
|
|
/* ══ ADMIN: Load recent sessions ═══════════════════════════════════ */
|
|
async function loadAdminSessions() {
|
|
if (!isTeacher) return;
|
|
const body = document.getElementById('admin-sessions-body');
|
|
if (!body) return;
|
|
try {
|
|
const data = await LS.api('/api/admin/sessions?limit=8');
|
|
const rows = Array.isArray(data) ? data : (data.rows || []);
|
|
if (!rows.length) {
|
|
body.innerHTML = `<div class="adm-empty">
|
|
<div class="adm-empty-icon"><i data-lucide="clock" style="width:32px;height:32px;color:var(--text-3)"></i></div>
|
|
<div class="adm-empty-text">Сессий пока нет. Ученики не начинали тесты.</div>
|
|
</div>`;
|
|
reIcons();
|
|
return;
|
|
}
|
|
body.innerHTML = rows.slice(0, 8).map(s => {
|
|
const pct = s.total > 0 ? Math.round(s.score / s.total * 100) : 0;
|
|
const pc = pct >= 75 ? '#059652' : pct >= 50 ? '#F59E0B' : '#E0335E';
|
|
const ago = relativeAgo(s.started_at);
|
|
if (s.status !== 'completed') {
|
|
const chip = `<span class="adm-sess-chip" style="background:rgba(15,23,42,0.06);color:var(--text-3);border:1px solid rgba(15,23,42,0.10)">…</span>`;
|
|
return `<div class="adm-sess-row" onclick="window.location='/admin#sessions'" style="cursor:pointer">
|
|
<span class="adm-sess-name">${esc(s.user_name || s.email || '—')}</span>
|
|
<span class="adm-sess-subj">${esc(s.subject_name || '')}</span>
|
|
${ago ? `<span class="adm-sess-ago">${ago}</span>` : ''}
|
|
${chip}
|
|
</div>`;
|
|
}
|
|
const chip = `<span class="adm-sess-chip" style="background:${pc}22;color:${pc};border:1px solid ${pc}44">${pct}%</span>`;
|
|
return `<div class="adm-sess-row" onclick="window.location='/admin#sessions'" style="cursor:pointer">
|
|
<span class="adm-sess-name">${esc(s.user_name || s.email || '—')}</span>
|
|
<span class="adm-sess-subj">${esc(s.subject_name || '')}</span>
|
|
${ago ? `<span class="adm-sess-ago">${ago}</span>` : ''}
|
|
${chip}
|
|
</div>`;
|
|
}).join('');
|
|
} catch {
|
|
body.innerHTML = '<div style="font-size:0.8rem;color:var(--text-3);padding:8px 0">Ошибка</div>';
|
|
}
|
|
}
|
|
|
|
/* ══ ADMIN: Load assignments into admin compact panel ═══════════════ */
|
|
async function loadAdminAssignments() {
|
|
if (!isTeacher) return;
|
|
const listEl = document.getElementById('admin-assignments-list');
|
|
if (!listEl) return;
|
|
try {
|
|
const list = await LS.teacherAssignments();
|
|
if (!list.length) {
|
|
listEl.innerHTML = `<div class="adm-empty">
|
|
<div class="adm-empty-icon"><i data-lucide="inbox" style="width:32px;height:32px;color:var(--text-3)"></i></div>
|
|
<div class="adm-empty-text">Заданий пока нет</div>
|
|
<a class="adm-empty-cta" href="/classes">+ Создать первое задание</a>
|
|
</div>`;
|
|
reIcons();
|
|
return;
|
|
}
|
|
const sorted = [...list].sort((a, b) => urgencyScore(a) - urgencyScore(b));
|
|
// Save for search filtering
|
|
window._adminAssignmentsSorted = sorted;
|
|
listEl.innerHTML = `<input class="adm-asgn-search" id="adm-asgn-search" type="search" placeholder="Поиск задания..." oninput="filterAdminAssignments(this.value)"><div class="tests-list" id="admin-assignments-inner">${sorted.slice(0, 8).map((a, i) => buildAssignCard(a, i)).join('')}</div>`;
|
|
if (list.length > 8) {
|
|
listEl.innerHTML += `<div style="text-align:center;margin-top:8px"><a class="w-more" href="/classes">Ещё ${list.length - 8} заданий</a></div>`;
|
|
}
|
|
reIcons();
|
|
} catch {
|
|
listEl.innerHTML = '<div style="font-size:0.8rem;color:var(--text-3);padding:8px 0">Ошибка загрузки</div>';
|
|
}
|
|
}
|
|
|
|
/* ── Filter admin assignments by search query ── */
|
|
function filterAdminAssignments(q) {
|
|
const innerEl = document.getElementById('admin-assignments-inner');
|
|
if (!innerEl || !window._adminAssignmentsSorted) return;
|
|
const ql = q.toLowerCase().trim();
|
|
const filtered = ql
|
|
? window._adminAssignmentsSorted.filter(a =>
|
|
(a.title || '').toLowerCase().includes(ql) ||
|
|
(a.class_name || '').toLowerCase().includes(ql) ||
|
|
(SUBJ[a.subject_slug] || a.subject_slug || '').toLowerCase().includes(ql)
|
|
)
|
|
: window._adminAssignmentsSorted;
|
|
const show = filtered.slice(0, 8);
|
|
innerEl.innerHTML = show.length
|
|
? show.map((a, i) => buildAssignCard(a, i)).join('')
|
|
: '<div class="adm-empty"><div class="adm-empty-text">Ничего не найдено</div></div>';
|
|
reIcons();
|
|
}
|
|
|
|
/* ══ Load all student widgets ═════════════════════════════════════ */
|
|
/* Show a widget section, but respect the cfg-hidden flag */
|
|
function showWidget(id) {
|
|
const el = document.getElementById(id);
|
|
if (!el || el.dataset.cfgHidden) return;
|
|
el.style.display = '';
|
|
}
|
|
|
|
/* ── Dashboard widget visibility ──────────────────────────────────── */
|
|
const _DASH_WIDGETS = [
|
|
{ id: 'ch-section', label: 'Испытания недели' },
|
|
{ id: 'stats-section', label: 'Статистика' },
|
|
{ id: 'w-my-subs', label: 'Мои сдачи' },
|
|
{ id: 'w-activity', label: 'Активность' },
|
|
];
|
|
|
|
async function applyDashboardPrefs() {
|
|
if (isTeacher) return;
|
|
await LS.prefs.init();
|
|
const hidden = LS.prefs.get('dashboard.hidden', []);
|
|
// Show cfg button for students
|
|
const cfgBtn = document.getElementById('dash-cfg-btn');
|
|
if (cfgBtn) cfgBtn.style.display = 'flex';
|
|
// Sync checkboxes + visibility
|
|
_DASH_WIDGETS.forEach(w => {
|
|
const isHidden = hidden.includes(w.id);
|
|
// Update checkbox state
|
|
const cb = document.querySelector(`#dash-cfg-panel input[data-widget="${w.id}"]`);
|
|
if (cb) cb.checked = !isHidden;
|
|
// Apply visibility only if the element is already visible (don't override display:none set by data loaders)
|
|
if (isHidden) {
|
|
const el = document.getElementById(w.id);
|
|
if (el) el.dataset.cfgHidden = '1';
|
|
}
|
|
});
|
|
}
|
|
|
|
function _saveDashHidden() {
|
|
const hidden = _DASH_WIDGETS
|
|
.filter(w => document.querySelector(`#dash-cfg-panel input[data-widget="${w.id}"]`)?.checked === false)
|
|
.map(w => w.id);
|
|
LS.prefs.set('dashboard.hidden', hidden);
|
|
}
|
|
|
|
function toggleDashWidget(widgetId, row) {
|
|
// Don't let row click double-trigger when checkbox itself is clicked
|
|
const cb = row.querySelector('input[type=checkbox]');
|
|
if (!cb) return;
|
|
// If row was clicked (not the checkbox directly), toggle the checkbox
|
|
if (event && event.target !== cb) cb.checked = !cb.checked;
|
|
const el = document.getElementById(widgetId);
|
|
if (el) {
|
|
if (cb.checked) {
|
|
delete el.dataset.cfgHidden;
|
|
// Only show if data loader hasn't hidden it for another reason
|
|
if (!el.dataset.loaderHidden) el.style.display = '';
|
|
} else {
|
|
el.dataset.cfgHidden = '1';
|
|
el.style.display = 'none';
|
|
}
|
|
}
|
|
_saveDashHidden();
|
|
}
|
|
|
|
function toggleDashCfg(e) {
|
|
e.stopPropagation();
|
|
const panel = document.getElementById('dash-cfg-panel');
|
|
panel.classList.toggle('open');
|
|
if (panel.classList.contains('open')) {
|
|
setTimeout(() => document.addEventListener('click', _closeDashCfg, { once: true }), 0);
|
|
}
|
|
}
|
|
function _closeDashCfg() {
|
|
document.getElementById('dash-cfg-panel')?.classList.remove('open');
|
|
}
|
|
|
|
async function loadStudentWidgets() {
|
|
if (isTeacher) return;
|
|
loadActivityWidget(); // self-contained: own endpoint, renders heatmap + month calendar
|
|
try {
|
|
const histData = await LS.getHistory(1, 100);
|
|
const rows = histData.rows || [];
|
|
loadLastResultsWidget(rows);
|
|
loadSubjProgressWidget(rows);
|
|
} catch {}
|
|
const heroRow = document.getElementById('hero-row');
|
|
if (heroRow) heroRow.style.display = '';
|
|
loadContinueWidget();
|
|
loadLabOfDay();
|
|
loadPetHero();
|
|
loadFlashcardWidget();
|
|
syncHeroRow(); // спрятать карточки отключённых модулей и подогнать сетку
|
|
}
|
|
|
|
/* ══ WIDGET: Flashcard review (random card from pool) ════════════════ */
|
|
let _fcwTotal = 0;
|
|
async function loadFlashcardWidget() {
|
|
if (isTeacher) return;
|
|
const w = document.getElementById('w-flashcard');
|
|
if (!w || w.dataset.cfgHidden) return;
|
|
try {
|
|
const r = await LS.api('/api/flashcards/random');
|
|
renderFlashcardWidget(r);
|
|
w.style.display = '';
|
|
} catch { /* фича выключена или ошибка — оставляем скрытым */ }
|
|
syncProgressCol(); // если карточка скрыта и нет прогресса/результатов — спрятать бокс
|
|
}
|
|
|
|
function renderFlashcardWidget(r) {
|
|
const body = document.getElementById('fcw-body');
|
|
if (!body) return;
|
|
if (!r || !r.card) {
|
|
_fcwTotal = 0;
|
|
body.innerHTML = `<div class="fcw-empty">
|
|
<p>Пока нет карточек. Создавай их в любой точке системы кнопкой внизу справа или на странице карточек.</p>
|
|
<a class="fcw-btn" href="/flashcards">
|
|
${lci('plus','width:13px;height:13px')} Создать карточку
|
|
</a>
|
|
</div>`;
|
|
reIcons();
|
|
return;
|
|
}
|
|
_fcwTotal = r.total || 0;
|
|
const c = r.card;
|
|
const frontTxt = (c.front || '').trim() || (c.front_image ? '' : '(пусто)');
|
|
const back = (c.back || '').trim() || (c.back_image ? '' : '(ответ не заполнен)');
|
|
const col = c.deck_color || '#9B5DE5';
|
|
const hasImg = !!(c.front_image || c.back_image);
|
|
const fImg = c.front_image ? `<img class="fcw-img" src="${esc(c.front_image)}" alt="" />` : '';
|
|
const bImg = c.back_image ? `<img class="fcw-img" src="${esc(c.back_image)}" alt="" />` : '';
|
|
body.innerHTML = `
|
|
<div class="fcw-card" onclick="fcwFlip(this)">
|
|
<div class="fcw-inner${hasImg ? ' has-img' : ''}">
|
|
<div class="fcw-face fcw-front">
|
|
<div class="fcw-deck" style="color:${esc(col)}">${esc(c.deck_title || 'Карточка')}</div>
|
|
${fImg}
|
|
<div class="fcw-text">${esc(frontTxt)}</div>
|
|
<div class="fcw-hint">${lci('rotate-cw','width:12px;height:12px')} нажми, чтобы перевернуть</div>
|
|
</div>
|
|
<div class="fcw-face fcw-back">
|
|
<div class="fcw-deck">Ответ</div>
|
|
${bImg}
|
|
<div class="fcw-text">${esc(back)}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="fcw-actions">
|
|
<button class="fcw-btn" type="button" onclick="fcwNext(event)">
|
|
${lci('shuffle','width:13px;height:13px')} Другая
|
|
</button>
|
|
<span class="fcw-count">${_fcwTotal} ${_fcwTotal === 1 ? 'карточка' : (_fcwTotal % 10 >= 2 && _fcwTotal % 10 <= 4 && (_fcwTotal % 100 < 12 || _fcwTotal % 100 > 14) ? 'карточки' : 'карточек')} в пуле</span>
|
|
</div>`;
|
|
reIcons();
|
|
}
|
|
|
|
function fcwFlip(el) { el.classList.toggle('flipped'); }
|
|
|
|
async function fcwNext(e) {
|
|
e.stopPropagation();
|
|
try {
|
|
const r = await LS.api('/api/flashcards/random');
|
|
renderFlashcardWidget(r);
|
|
} catch {}
|
|
}
|
|
|
|
// Обновлять виджет, когда карточку добавили через глобальный FAB
|
|
window.addEventListener('flashcard:added', () => { if (!isTeacher) loadFlashcardWidget(); });
|
|
|
|
/* ══ INIT ═════════════════════════════════════════════════════════════ */
|
|
window.addEventListener('pageshow', e => { if (e.persisted) location.reload(); });
|
|
|
|
if (!isTeacher) {
|
|
document.getElementById('subjects-sk').innerHTML = LS.skeleton(4);
|
|
document.getElementById('assignments-sk').innerHTML = LS.skeleton(3);
|
|
}
|
|
|
|
/* ══ STATISTICS CHARTS ═══════════════════════════════════════════════ */
|
|
async function loadDashboardStats() {
|
|
try {
|
|
const data = await LS.getStudentStats();
|
|
if (!data || (!data.totals.sessions && !data.courseProgress.length)) return;
|
|
showWidget('stats-section');
|
|
|
|
// Summary chips
|
|
const chips = document.getElementById('stats-chips');
|
|
chips.innerHTML = `
|
|
<div class="stats-chip"><div class="stats-chip-val">${data.totals.sessions}</div><div class="stats-chip-lbl">Сессий</div></div>
|
|
<div class="stats-chip"><div class="stats-chip-val" style="color:${data.totals.avgPct >= 70 ? '#059652' : data.totals.avgPct >= 50 ? '#F59E0B' : '#EF476F'}">${data.totals.avgPct}%</div><div class="stats-chip-lbl">Ср. балл</div></div>
|
|
<div class="stats-chip"><div class="stats-chip-val">${data.totals.correct}/${data.totals.questions}</div><div class="stats-chip-lbl">Правильно</div></div>
|
|
<div class="stats-chip"><div class="stats-chip-val" style="color:#9B5DE5">${data.streak}</div><div class="stats-chip-lbl">Стрик (дн.)</div></div>
|
|
`;
|
|
|
|
// Weekly bar chart
|
|
renderWeeklyChart(data.weekly);
|
|
// Trend chart
|
|
renderTrendChart(data.trend);
|
|
// Subject chart
|
|
renderSubjectsChart(data.bySubject);
|
|
// Course progress
|
|
renderCourseProgressChart(data.courseProgress);
|
|
} catch (e) { console.warn('Stats error:', e); }
|
|
}
|
|
|
|
function renderWeeklyChart(weekly) {
|
|
const el = document.getElementById('stats-weekly-chart');
|
|
const labels = document.getElementById('stats-weekly-labels');
|
|
if (!weekly.length) { el.innerHTML = '<div style="color:#B0BEC5;font-size:.8rem;padding:20px;text-align:center">Нет данных</div>'; return; }
|
|
const max = Math.max(...weekly.map(w => w.avgPct), 10);
|
|
el.innerHTML = weekly.map(w => {
|
|
const h = Math.max(4, w.avgPct / max * 100);
|
|
const color = w.avgPct >= 70 ? '#06D664' : w.avgPct >= 50 ? '#FFB347' : '#EF476F';
|
|
return `<div class="stats-bar" style="height:${h}%;background:${color}" data-tip="${w.avgPct}% · ${w.sessions} сес."></div>`;
|
|
}).join('');
|
|
labels.innerHTML = weekly.map(w => {
|
|
const [yr, wk] = w.week.split('-').map(Number);
|
|
const jan4 = new Date(yr, 0, 4);
|
|
const mon = new Date(jan4);
|
|
mon.setDate(jan4.getDate() - ((jan4.getDay() || 7) - 1) + (wk - 1) * 7);
|
|
return `<span>${mon.toLocaleDateString('ru', { day:'numeric', month:'short' }).replace('.','')}</span>`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderTrendChart(trend) {
|
|
const el = document.getElementById('stats-trend-chart');
|
|
if (!trend.length) { el.innerHTML = '<div style="color:#B0BEC5;font-size:.8rem;padding:20px;text-align:center">Нет данных</div>'; return; }
|
|
const max = 100;
|
|
el.innerHTML = trend.map(t => {
|
|
const h = Math.max(4, t.pct);
|
|
const color = SUBJ_COLORS[t.subject] || '#9B5DE5';
|
|
return `<div class="stats-bar" style="height:${h}%;background:${color};opacity:0.7" data-tip="${t.pct}%"></div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderSubjectsChart(subjects) {
|
|
const el = document.getElementById('stats-subjects-chart');
|
|
if (!subjects.length) { el.innerHTML = '<div style="color:#B0BEC5;font-size:.8rem;padding:20px;text-align:center">Нет данных</div>'; return; }
|
|
el.innerHTML = subjects.map(s => {
|
|
const color = SUBJ_COLORS[s.slug] || '#9B5DE5';
|
|
const barW = Math.max(4, s.avgPct);
|
|
return `<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
|
|
<div style="width:80px;font-size:.75rem;font-weight:700;color:#3D4F6B;text-align:right;flex-shrink:0">${esc(s.name)}</div>
|
|
<div style="flex:1;height:20px;background:rgba(15,23,42,0.04);border-radius:6px;overflow:hidden;position:relative">
|
|
<div style="height:100%;width:${barW}%;background:${color};border-radius:6px;transition:width .5s"></div>
|
|
</div>
|
|
<div style="font-size:.75rem;font-weight:800;color:${color};min-width:36px">${s.avgPct}%</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderCourseProgressChart(courses) {
|
|
const el = document.getElementById('stats-courses-chart');
|
|
if (!courses.length) { el.innerHTML = '<div style="color:#B0BEC5;font-size:.8rem;padding:20px;text-align:center">Нет курсов в процессе</div>'; return; }
|
|
el.innerHTML = courses.slice(0, 6).map(c => {
|
|
const color = SUBJ_COLORS[c.subjectSlug] || '#9B5DE5';
|
|
return `<a href="/course?id=${c.id}" style="display:flex;align-items:center;gap:10px;margin-bottom:8px;text-decoration:none;color:inherit">
|
|
<div style="font-size:1.1rem;width:28px;text-align:center;flex-shrink:0">${c.emoji || '<svg class="ic" viewBox="0 0 24 24"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>'}</div>
|
|
<div style="flex:1;min-width:0">
|
|
<div style="font-size:.75rem;font-weight:700;color:#0F172A;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(c.title)}</div>
|
|
<div style="height:6px;background:rgba(15,23,42,0.06);border-radius:99px;margin-top:3px;overflow:hidden">
|
|
<div style="height:100%;width:${c.pct}%;background:${color};border-radius:99px"></div>
|
|
</div>
|
|
</div>
|
|
<div style="font-size:.72rem;font-weight:800;color:${color};flex-shrink:0">${c.pct}%</div>
|
|
</a>`;
|
|
}).join('');
|
|
}
|
|
|
|
if (isAdmin) {
|
|
// Admin: command center (redesign) on real /api/admin/overview data
|
|
const ccEl = document.getElementById('admin-command-center');
|
|
if (ccEl && window.DashAdminCenter) DashAdminCenter.mount(ccEl);
|
|
} else if (isTeacher) {
|
|
// Teacher: compact admin layout
|
|
loadAdminStats();
|
|
loadTeacherKPIs();
|
|
loadAdminAssignments();
|
|
loadAdminClasses();
|
|
loadAdminSessions();
|
|
} else {
|
|
// Student: full layout
|
|
loadSubjects();
|
|
loadAvailableTests();
|
|
loadAssignments();
|
|
loadStats();
|
|
loadGamification();
|
|
loadChallenges();
|
|
loadStudentWidgets();
|
|
loadDashboardStats();
|
|
applyDashboardPrefs();
|
|
}
|
|
loadLiveLesson();
|
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) loadLiveLesson(); });
|
|
LS.notif.init();
|
|
|
|
// Статус онлайн-урока: показываем баннер, если у ученика/учителя идёт активная сессия.
|
|
async function loadLiveLesson() {
|
|
const el = document.getElementById('live-lesson-banner');
|
|
if (!el) return;
|
|
let data;
|
|
try { data = await LS.crGetMySession(); } catch { el.style.display = 'none'; return; }
|
|
const s = data && data.session;
|
|
if (!s) { el.style.display = 'none'; return; }
|
|
const title = (s.title && s.title.trim()) ? s.title.trim() : 'Онлайн-урок';
|
|
document.getElementById('ll-title').textContent = (isTeacher ? 'Ваш урок идёт: ' : 'Идёт урок: ') + title;
|
|
let sub;
|
|
if (isTeacher) {
|
|
const online = Array.isArray(s.attendance) ? s.attendance.filter(a => !a.left_at).length : 0;
|
|
sub = online ? (online + ' онлайн') : 'ожидание учеников';
|
|
} else {
|
|
sub = data.wasJoined ? 'Вы участник — вернуться к доске' : 'Нажмите, чтобы присоединиться';
|
|
}
|
|
document.getElementById('ll-sub').textContent = sub;
|
|
document.getElementById('ll-cta').textContent = isTeacher
|
|
? 'Вернуться к доске'
|
|
: (data.wasJoined ? 'Вернуться' : 'Присоединиться');
|
|
el.style.display = '';
|
|
}
|
|
|
|
// Real-time SSE for page-specific events (notif handled by notifications.js)
|
|
LS.connectSSE(ev => {
|
|
if (ev.type === 'assignment') {
|
|
LS.toast(ev.message, 'info');
|
|
isTeacher ? loadAdminAssignments() : loadAssignments();
|
|
} else if (ev.type === 'classroom_live') {
|
|
loadLiveLesson();
|
|
if (ev.state === 'started' && !isTeacher && window.LS && LS.sfx) LS.sfx.play('user_joined');
|
|
} else if (ev.type === 'session') {
|
|
LS.toast(ev.message, 'info');
|
|
if (isTeacher) loadAdminSessions();
|
|
} else if (ev.type === 'achievement') {
|
|
if (window.LS && LS.sfx) LS.sfx.play('achievement');
|
|
} else if (ev.type === 'xp_update') {
|
|
if (window.LS && LS.sfx) {
|
|
if (ev.levelUp) LS.sfx.play('level_up');
|
|
else LS.sfx.play('xp_gain');
|
|
}
|
|
} else if (ev.type === 'coins') {
|
|
if (window.LS && LS.sfx) LS.sfx.play('coin');
|
|
}
|
|
});
|
|
|
|
if (window.lucide) lucide.createIcons();
|
|
</script>
|
|
</div>
|
|
</div>
|
|
<script src="/js/search.js"></script>
|
|
<script src="/js/mobile.js"></script>
|
|
</body>
|
|
</html>
|