26ba289019
- css/ls.css: --text-3 #8898AA → #56687A (5.1:1 contrast), min-height 44px on .btn-primary/.btn-ghost/.sb-link, new .icon-btn utility (44×44px) - js/api.js: lsConfirm — role=dialog, aria-modal, aria-labelledby, Tab focus trap, restore focus on close; lsToast — aria-live=polite on container, role=alert on errors; live quiz — role=dialog, role=radiogroup, role=radio, aria-checked, keyboard support - test-run.html: q-opt divs — role=radio/checkbox, aria-checked, tabindex, keyboard enter/space; confirm modal — role=dialog, aria-modal; btn-flag — aria-pressed; dots — aria-label, aria-current; touch targets 44px - board.html: btn-del-ann — aria-label; reaction buttons — aria-label, aria-pressed - All 18 HTML files: replace hardcoded color:#8898AA with color:var(--text-3) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
829 lines
45 KiB
HTML
829 lines
45 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" />
|
||
<style>
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
:root {
|
||
--violet: #9B5DE5; --cyan: #06D6E0; --bg: #F8F9FB; --text: #0F172A;
|
||
--text-2: #475569; --text-3: #56687A; --border: #E8EBF0;
|
||
--card: #fff; --shadow: 0 2px 16px rgba(15,23,42,0.06);
|
||
--green: #22c55e; --amber: #f59e0b; --red: #ef4444; --blue: #3b82f6;
|
||
}
|
||
body {
|
||
font-family: 'Manrope', system-ui, sans-serif;
|
||
background: var(--bg); color: var(--text); min-height: 100vh;
|
||
background-image: radial-gradient(circle, rgba(155,93,229,0.03) 1px, transparent 1px);
|
||
background-size: 24px 24px;
|
||
}
|
||
.ic { display:inline-block; width:1em; height:1em; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; vertical-align:middle; }
|
||
|
||
/* ── Animations ── */
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
@keyframes fadeUp {
|
||
from { opacity: 0; transform: translateY(20px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
@keyframes pulseRing {
|
||
0%,100% { box-shadow: 0 0 0 0 rgba(155,93,229,0.3); }
|
||
50% { box-shadow: 0 0 0 8px rgba(155,93,229,0); }
|
||
}
|
||
.anim { opacity: 0; animation: fadeUp .5s cubic-bezier(.4,0,.2,1) forwards; }
|
||
|
||
/* ── Header ── */
|
||
.ph {
|
||
position: sticky; top: 0; z-index: 100;
|
||
background: rgba(255,255,255,0.88); backdrop-filter: blur(16px);
|
||
border-bottom: 1px solid var(--border);
|
||
padding: 0 24px; height: 60px;
|
||
display: flex; align-items: center; gap: 14px;
|
||
}
|
||
.ph::after {
|
||
content: ''; position: absolute; bottom: -1px; left: 0; right: 0; height: 2px;
|
||
background: linear-gradient(90deg, var(--violet), var(--cyan), var(--violet));
|
||
background-size: 200% 100%;
|
||
animation: shimmerLine 3s linear infinite;
|
||
}
|
||
@keyframes shimmerLine { to { background-position: -200% 0; } }
|
||
.ph-logo {
|
||
font-family: 'Unbounded', sans-serif; font-size: 0.9rem; font-weight: 800;
|
||
color: var(--violet); text-decoration: none; flex-shrink: 0;
|
||
}
|
||
.ph-logo span { color: var(--cyan); }
|
||
.ph-sep { width: 1px; height: 24px; background: var(--border); flex-shrink: 0; }
|
||
.ph-title {
|
||
flex: 1; font-size: 0.82rem; font-weight: 600; color: var(--text-2);
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.ph-refresh {
|
||
width: 36px; height: 36px; border-radius: 10px;
|
||
border: 1.5px solid var(--border); background: #fff; cursor: pointer;
|
||
display: flex; align-items: center; justify-content: center;
|
||
color: var(--text-3); transition: all .2s;
|
||
}
|
||
.ph-refresh:hover { border-color: var(--cyan); color: var(--cyan); transform: rotate(90deg); }
|
||
.ph-notif {
|
||
position: relative; width: 36px; height: 36px; border-radius: 10px;
|
||
border: 1.5px solid var(--border); background: #fff; cursor: pointer;
|
||
display: flex; align-items: center; justify-content: center;
|
||
color: var(--text-3); transition: all .2s;
|
||
}
|
||
.ph-notif:hover { border-color: var(--violet); color: var(--violet); }
|
||
.ph-notif-badge {
|
||
position: absolute; top: -5px; right: -5px;
|
||
min-width: 18px; height: 18px; border-radius: 99px; padding: 0 5px;
|
||
background: linear-gradient(135deg, var(--red), #dc2626); color: #fff;
|
||
font-size: 0.6rem; font-weight: 800;
|
||
display: none; align-items: center; justify-content: center;
|
||
font-family: 'Unbounded', sans-serif; border: 2px solid #fff;
|
||
}
|
||
.ph-logout {
|
||
padding: 7px 16px; border: 1.5px solid var(--border); border-radius: 10px;
|
||
background: #fff; font-family: 'Manrope', sans-serif; font-size: 0.78rem;
|
||
font-weight: 600; color: var(--text-3); cursor: pointer; transition: all .2s;
|
||
}
|
||
.ph-logout:hover { border-color: var(--red); color: var(--red); background: rgba(239,68,68,0.04); }
|
||
|
||
/* ── Main ── */
|
||
.pm { max-width: 1100px; margin: 0 auto; padding: 24px 24px 80px; }
|
||
|
||
/* ══ Student Hero Card ══ */
|
||
.ps-card {
|
||
background: linear-gradient(135deg, #0d0b28 0%, #1a1248 40%, #0f1635 100%);
|
||
border-radius: 24px; padding: 32px 28px; margin-bottom: 24px;
|
||
position: relative; overflow: hidden; color: #fff;
|
||
}
|
||
.ps-card::before {
|
||
content: ''; position: absolute; inset: 0; pointer-events: none;
|
||
background-image: radial-gradient(circle, rgba(255,255,255,0.03) 1px, transparent 1px);
|
||
background-size: 20px 20px;
|
||
}
|
||
.ps-blob1 {
|
||
position: absolute; width: 280px; height: 280px; border-radius: 50%;
|
||
background: radial-gradient(circle, rgba(155,93,229,0.3) 0%, transparent 70%);
|
||
top: -100px; right: -60px; pointer-events: none;
|
||
}
|
||
.ps-blob2 {
|
||
position: absolute; width: 180px; height: 180px; border-radius: 50%;
|
||
background: radial-gradient(circle, rgba(6,214,224,0.15) 0%, transparent 70%);
|
||
bottom: -50px; left: -30px; pointer-events: none;
|
||
}
|
||
.ps-blob3 {
|
||
position: absolute; width: 100px; height: 100px; border-radius: 50%;
|
||
background: radial-gradient(circle, rgba(255,209,102,0.1) 0%, transparent 70%);
|
||
top: 20px; left: 40%; pointer-events: none;
|
||
}
|
||
.ps-inner { position: relative; z-index: 1; }
|
||
.ps-top { display: flex; align-items: center; gap: 20px; margin-bottom: 20px; }
|
||
.ps-avatar-wrap { position: relative; width: 72px; height: 72px; flex-shrink: 0; }
|
||
.ps-avatar-ring {
|
||
position: absolute; inset: -4px; border-radius: 50%;
|
||
background: conic-gradient(var(--violet) 0%, var(--cyan) 50%, var(--violet) 100%);
|
||
animation: pulseRing 2.5s ease infinite;
|
||
}
|
||
.ps-avatar-ring-inner {
|
||
position: absolute; inset: 2px; border-radius: 50%; background: #1a1248;
|
||
}
|
||
.ps-avatar {
|
||
position: absolute; inset: 4px; border-radius: 50%;
|
||
background: linear-gradient(135deg, rgba(155,93,229,0.6), rgba(6,214,224,0.4));
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-family: 'Unbounded', sans-serif; font-size: 1.2rem; font-weight: 800;
|
||
}
|
||
.ps-info { flex: 1; min-width: 0; }
|
||
.ps-name {
|
||
font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 800;
|
||
margin-bottom: 6px; line-height: 1.2;
|
||
}
|
||
.ps-meta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||
.ps-level-badge {
|
||
display: inline-flex; align-items: center; gap: 5px;
|
||
padding: 4px 12px; border-radius: 99px;
|
||
background: linear-gradient(135deg, rgba(155,93,229,0.3), rgba(6,214,224,0.15));
|
||
font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.9);
|
||
letter-spacing: 0.04em; border: 1px solid rgba(155,93,229,0.3);
|
||
}
|
||
.ps-activity-tag {
|
||
font-size: 0.7rem; color: rgba(255,255,255,0.5); font-weight: 600;
|
||
}
|
||
.ps-activity-tag b { color: rgba(255,255,255,0.85); }
|
||
|
||
/* XP bar */
|
||
.ps-xp { margin-bottom: 20px; }
|
||
.ps-xp-top { display: flex; justify-content: space-between; margin-bottom: 6px; }
|
||
.ps-xp-lbl { font-size: 0.65rem; font-weight: 700; color: rgba(255,255,255,0.4); text-transform: uppercase; letter-spacing: 0.06em; }
|
||
.ps-xp-val { font-size: 0.65rem; font-weight: 800; font-family: 'Unbounded', sans-serif; color: rgba(255,255,255,0.7); }
|
||
.ps-xp-bar { height: 6px; background: rgba(255,255,255,0.08); border-radius: 99px; overflow: hidden; }
|
||
.ps-xp-fill {
|
||
height: 100%; border-radius: 99px;
|
||
background: linear-gradient(90deg, var(--violet), var(--cyan));
|
||
transition: width 1s cubic-bezier(.4,0,.2,1);
|
||
}
|
||
|
||
/* Chips row */
|
||
.ps-chips { display: flex; gap: 10px; flex-wrap: wrap; }
|
||
.ps-chip {
|
||
flex: 1; min-width: 80px; padding: 12px 14px; border-radius: 14px;
|
||
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08);
|
||
display: flex; align-items: center; gap: 10px;
|
||
transition: background .2s;
|
||
}
|
||
.ps-chip:hover { background: rgba(255,255,255,0.1); }
|
||
.ps-chip-ic {
|
||
width: 32px; height: 32px; border-radius: 9px; flex-shrink: 0;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 0.85rem;
|
||
}
|
||
.ps-chip-ic.xp { background: rgba(155,93,229,0.2); color: #c4a5f0; }
|
||
.ps-chip-ic.str { background: rgba(255,152,0,0.15); color: #ffb74d; }
|
||
.ps-chip-ic.coin { background: rgba(255,209,102,0.15); color: #FFD166; }
|
||
.ps-chip-ic.sess { background: rgba(6,214,224,0.15); color: #06D6E0; }
|
||
.ps-chip-body {}
|
||
.ps-chip-val {
|
||
font-family: 'Unbounded', sans-serif; font-size: 0.95rem; font-weight: 800; line-height: 1;
|
||
}
|
||
.ps-chip-lbl { font-size: 0.58rem; color: rgba(255,255,255,0.45); margin-top: 3px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
|
||
|
||
/* ══ Alerts ══ */
|
||
.pa-alerts { display: flex; flex-direction: column; gap: 10px; margin-bottom: 24px; }
|
||
.pa-alert {
|
||
display: flex; align-items: center; gap: 14px;
|
||
padding: 16px 20px; border-radius: 16px; font-size: 0.85rem; font-weight: 600;
|
||
backdrop-filter: blur(4px);
|
||
}
|
||
.pa-alert-ic {
|
||
width: 36px; height: 36px; border-radius: 10px; flex-shrink: 0;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.pa-alert.warn { background: rgba(245,158,11,0.06); color: #92400e; border: 1px solid rgba(245,158,11,0.15); }
|
||
.pa-alert.warn .pa-alert-ic { background: rgba(245,158,11,0.12); color: var(--amber); }
|
||
.pa-alert.danger { background: rgba(239,68,68,0.06); color: #991b1b; border: 1px solid rgba(239,68,68,0.15); }
|
||
.pa-alert.danger .pa-alert-ic { background: rgba(239,68,68,0.12); color: var(--red); }
|
||
|
||
/* ══ Stats Chips ══ */
|
||
.pg-chips { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 24px; }
|
||
.pg-chip {
|
||
background: var(--card); border: 1.5px solid var(--border);
|
||
border-radius: 18px; padding: 20px 18px;
|
||
box-shadow: var(--shadow); transition: all .25s;
|
||
position: relative; overflow: hidden;
|
||
}
|
||
.pg-chip::before {
|
||
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
|
||
border-radius: 18px 18px 0 0;
|
||
}
|
||
.pg-chip:nth-child(1)::before { background: linear-gradient(90deg, var(--violet), #c084fc); }
|
||
.pg-chip:nth-child(2)::before { background: linear-gradient(90deg, var(--green), #86efac); }
|
||
.pg-chip:nth-child(3)::before { background: linear-gradient(90deg, var(--amber), #fcd34d); }
|
||
.pg-chip:nth-child(4)::before { background: linear-gradient(90deg, var(--cyan), #67e8f9); }
|
||
.pg-chip:hover { transform: translateY(-3px); box-shadow: 0 8px 30px rgba(15,23,42,0.1); }
|
||
.pg-chip-top { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
||
.pg-chip-ic {
|
||
width: 36px; height: 36px; border-radius: 10px; flex-shrink: 0;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.pg-chip:nth-child(1) .pg-chip-ic { background: rgba(155,93,229,0.1); color: var(--violet); }
|
||
.pg-chip:nth-child(2) .pg-chip-ic { background: rgba(34,197,94,0.1); color: var(--green); }
|
||
.pg-chip:nth-child(3) .pg-chip-ic { background: rgba(245,158,11,0.1); color: var(--amber); }
|
||
.pg-chip:nth-child(4) .pg-chip-ic { background: rgba(6,214,224,0.1); color: var(--cyan); }
|
||
.pg-chip-val {
|
||
font-family: 'Unbounded', sans-serif; font-size: 1.5rem; font-weight: 800;
|
||
color: var(--text); line-height: 1;
|
||
}
|
||
.pg-chip-lbl { font-size: 0.7rem; color: var(--text-3); font-weight: 600; }
|
||
|
||
/* ══ Grid ══ */
|
||
.pg-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-bottom: 24px; }
|
||
|
||
/* ══ Widget ══ */
|
||
.pw {
|
||
background: var(--card); border: 1.5px solid var(--border);
|
||
border-radius: 20px; padding: 22px 24px;
|
||
box-shadow: var(--shadow); transition: box-shadow .2s;
|
||
}
|
||
.pw:hover { box-shadow: 0 6px 28px rgba(15,23,42,0.08); }
|
||
.pw-head { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; }
|
||
.pw-icon {
|
||
width: 34px; height: 34px; border-radius: 10px; flex-shrink: 0;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.pw-title {
|
||
font-family: 'Unbounded', sans-serif; font-size: 0.72rem; font-weight: 800;
|
||
color: var(--text); letter-spacing: 0.03em;
|
||
}
|
||
.pw-subtitle { font-size: 0.7rem; color: var(--text-3); margin-top: 1px; }
|
||
.pw-empty { text-align: center; padding: 28px 16px; color: var(--text-3); font-size: 0.85rem; font-weight: 600; }
|
||
|
||
/* Widget icon colors */
|
||
.pw-ic-violet { background: linear-gradient(135deg, rgba(155,93,229,0.12), rgba(155,93,229,0.06)); color: var(--violet); }
|
||
.pw-ic-red { background: linear-gradient(135deg, rgba(239,68,68,0.12), rgba(239,68,68,0.06)); color: var(--red); }
|
||
.pw-ic-blue { background: linear-gradient(135deg, rgba(59,130,246,0.12), rgba(59,130,246,0.06)); color: var(--blue); }
|
||
.pw-ic-green { background: linear-gradient(135deg, rgba(34,197,94,0.12), rgba(34,197,94,0.06)); color: var(--green); }
|
||
.pw-ic-amber { background: linear-gradient(135deg, rgba(245,158,11,0.12), rgba(245,158,11,0.06)); color: var(--amber); }
|
||
.pw-ic-cyan { background: linear-gradient(135deg, rgba(6,214,224,0.12), rgba(6,214,224,0.06)); color: var(--cyan); }
|
||
|
||
/* ── Subject bars ── */
|
||
.psb-row { margin-bottom: 14px; }
|
||
.psb-row:last-child { margin-bottom: 0; }
|
||
.psb-top { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px; }
|
||
.psb-name { font-size: 0.8rem; font-weight: 600; color: var(--text-2); }
|
||
.psb-pct { font-size: 0.8rem; font-weight: 800; font-family: 'Unbounded', sans-serif; }
|
||
.psb-bar { height: 8px; background: rgba(15,23,42,0.05); border-radius: 99px; overflow: hidden; }
|
||
.psb-fill { height: 100%; border-radius: 99px; transition: width .8s cubic-bezier(.4,0,.2,1); }
|
||
|
||
/* ── Weak topics ── */
|
||
.pwt-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid rgba(15,23,42,0.05); }
|
||
.pwt-row:last-child { border: none; }
|
||
.pwt-num {
|
||
width: 24px; height: 24px; border-radius: 7px; flex-shrink: 0;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 0.65rem; font-weight: 800; color: #fff;
|
||
font-family: 'Unbounded', sans-serif;
|
||
}
|
||
.pwt-info { flex: 1; }
|
||
.pwt-name { font-size: 0.82rem; font-weight: 600; color: var(--text); }
|
||
.pwt-subj { font-size: 0.68rem; color: var(--text-3); margin-top: 1px; }
|
||
.pwt-pct { font-size: 0.82rem; font-weight: 800; font-family: 'Unbounded', sans-serif; }
|
||
|
||
/* ── Deadlines ── */
|
||
.pd-row { display: flex; align-items: center; gap: 12px; padding: 11px 0; border-bottom: 1px solid rgba(15,23,42,0.05); }
|
||
.pd-row:last-child { border: none; }
|
||
.pd-icon-wrap {
|
||
width: 32px; height: 32px; border-radius: 9px; flex-shrink: 0;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.pd-icon-wrap.done { background: rgba(34,197,94,0.1); color: var(--green); }
|
||
.pd-icon-wrap.missed { background: rgba(239,68,68,0.1); color: var(--red); }
|
||
.pd-icon-wrap.pending { background: rgba(59,130,246,0.1); color: var(--blue); }
|
||
.pd-info { flex: 1; }
|
||
.pd-title { font-size: 0.82rem; font-weight: 600; color: var(--text); }
|
||
.pd-date { font-size: 0.7rem; color: var(--text-3); margin-top: 2px; }
|
||
|
||
/* ── Submissions ── */
|
||
.psu-row { display: flex; align-items: center; gap: 12px; padding: 11px 0; border-bottom: 1px solid rgba(15,23,42,0.05); }
|
||
.psu-row:last-child { border: none; }
|
||
.psu-info { flex: 1; min-width: 0; }
|
||
.psu-title { font-size: 0.82rem; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.psu-date { font-size: 0.7rem; color: var(--text-3); margin-top: 2px; }
|
||
.psu-badge {
|
||
padding: 4px 11px; border-radius: 99px; font-size: 0.63rem; font-weight: 700;
|
||
text-transform: uppercase; letter-spacing: 0.05em; flex-shrink: 0;
|
||
}
|
||
.psu-badge.new { background: rgba(59,130,246,0.08); color: var(--blue); }
|
||
.psu-badge.resubmitted{ background: rgba(245,158,11,0.08); color: var(--amber); }
|
||
.psu-badge.reviewed { background: rgba(155,93,229,0.08); color: var(--violet); }
|
||
.psu-badge.accepted { background: rgba(34,197,94,0.08); color: var(--green); }
|
||
.psu-badge.revision { background: rgba(239,68,68,0.08); color: var(--red); }
|
||
.psu-grade {
|
||
font-family: 'Unbounded', sans-serif; font-size: 0.9rem; font-weight: 800; flex-shrink: 0;
|
||
width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center;
|
||
}
|
||
|
||
/* ── Course progress ── */
|
||
.pcp-row { margin-bottom: 14px; }
|
||
.pcp-row:last-child { margin-bottom: 0; }
|
||
.pcp-top { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px; }
|
||
.pcp-name { font-size: 0.8rem; font-weight: 600; color: var(--text-2); }
|
||
.pcp-pct { font-size: 0.72rem; font-weight: 700; color: var(--text-3); }
|
||
.pcp-bar { height: 8px; background: rgba(15,23,42,0.05); border-radius: 99px; overflow: hidden; }
|
||
.pcp-fill { height: 100%; border-radius: 99px; background: linear-gradient(90deg, var(--violet), var(--cyan)); transition: width .8s; }
|
||
|
||
/* ── Heatmap ── */
|
||
.phm { display: flex; flex-wrap: wrap; gap: 3px; }
|
||
.phm-cell {
|
||
width: 14px; height: 14px; border-radius: 3px;
|
||
background: rgba(155,93,229,0.06); transition: all .15s;
|
||
}
|
||
.phm-cell:hover { transform: scale(1.4); }
|
||
.phm-cell.l1 { background: rgba(155,93,229,0.15); }
|
||
.phm-cell.l2 { background: rgba(155,93,229,0.3); }
|
||
.phm-cell.l3 { background: rgba(155,93,229,0.5); }
|
||
.phm-cell.l4 { background: rgba(155,93,229,0.75); }
|
||
.phm-legend { display: flex; align-items: center; gap: 6px; margin-top: 10px; font-size: 0.65rem; color: var(--text-3); }
|
||
.phm-legend-cell { width: 12px; height: 12px; border-radius: 2px; }
|
||
|
||
/* ── Weekly chart ── */
|
||
.pw-chart { width: 100%; height: 200px; }
|
||
|
||
/* ── Notifications ── */
|
||
.pn-drop {
|
||
position: fixed; top: 56px; right: 24px; width: 360px; max-height: 440px;
|
||
background: #fff; border: 1.5px solid var(--border); border-radius: 18px;
|
||
box-shadow: 0 16px 48px rgba(15,23,42,0.18); overflow: hidden;
|
||
display: none; z-index: 200; flex-direction: column;
|
||
}
|
||
.pn-drop.open { display: flex; }
|
||
.pn-drop-head {
|
||
padding: 16px 20px; font-family: 'Unbounded', sans-serif; font-size: 0.75rem;
|
||
font-weight: 800; border-bottom: 1px solid var(--border);
|
||
display: flex; align-items: center; gap: 8px;
|
||
}
|
||
.pn-drop-list { overflow-y: auto; flex: 1; max-height: 380px; }
|
||
.pn-item {
|
||
display: flex; align-items: flex-start; gap: 12px; padding: 14px 20px;
|
||
border-bottom: 1px solid rgba(15,23,42,0.04); cursor: pointer; transition: background .15s;
|
||
}
|
||
.pn-item:hover { background: rgba(155,93,229,0.03); }
|
||
.pn-item.unread { background: rgba(155,93,229,0.04); }
|
||
.pn-item-dot {
|
||
width: 8px; height: 8px; border-radius: 50%; margin-top: 5px; flex-shrink: 0;
|
||
background: linear-gradient(135deg, var(--violet), var(--cyan));
|
||
}
|
||
.pn-item.unread .pn-item-dot { display: block; }
|
||
.pn-item:not(.unread) .pn-item-dot { display: none; }
|
||
.pn-item-msg { font-size: 0.82rem; color: var(--text); line-height: 1.45; }
|
||
.pn-item-time { font-size: 0.68rem; color: var(--text-3); margin-top: 4px; }
|
||
.pn-empty { padding: 40px 20px; text-align: center; color: var(--text-3); font-size: 0.85rem; }
|
||
|
||
/* ── Loading / Error ── */
|
||
.pm-loading { text-align: center; padding: 100px 20px; }
|
||
.pm-loading-spinner {
|
||
width: 48px; height: 48px; border: 3px solid rgba(155,93,229,0.15);
|
||
border-top-color: var(--violet); border-radius: 50%;
|
||
animation: spin .8s linear infinite; margin: 0 auto 16px;
|
||
}
|
||
.pm-error { text-align: center; padding: 100px 20px; }
|
||
.pm-error-icon {
|
||
width: 64px; height: 64px; border-radius: 16px;
|
||
background: linear-gradient(135deg, rgba(155,93,229,0.1), rgba(6,214,224,0.06));
|
||
display: flex; align-items: center; justify-content: center;
|
||
margin: 0 auto 16px; color: var(--violet);
|
||
}
|
||
.pm-error-title {
|
||
font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800;
|
||
color: var(--text); margin-bottom: 8px;
|
||
}
|
||
.pm-error-sub { font-size: 0.85rem; color: var(--text-3); line-height: 1.7; max-width: 380px; margin: 0 auto; }
|
||
|
||
/* ── Responsive ── */
|
||
@media (max-width: 768px) {
|
||
.pg-chips { grid-template-columns: 1fr 1fr; }
|
||
.pg-grid { grid-template-columns: 1fr; }
|
||
.ps-top { flex-direction: column; align-items: flex-start; }
|
||
.ps-chips { width: 100%; }
|
||
.ps-chip { min-width: 0; }
|
||
.pn-drop { right: 10px; left: 10px; width: auto; top: 60px; }
|
||
.phm-cell { width: 11px; height: 11px; }
|
||
}
|
||
@media (max-width: 480px) {
|
||
.pm { padding: 16px 14px 80px; }
|
||
.ph { padding: 0 16px; }
|
||
.ps-card { padding: 22px 18px; border-radius: 20px; }
|
||
.pg-chip { padding: 16px 14px; }
|
||
.pg-chip-val { font-size: 1.2rem; }
|
||
.ps-chips { gap: 8px; }
|
||
.ps-chip { padding: 10px 12px; }
|
||
}
|
||
</style>
|
||
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||
</head>
|
||
<body>
|
||
|
||
<header class="ph">
|
||
<div class="ph-logo">Learn<span>Space</span></div>
|
||
<div class="ph-sep"></div>
|
||
<div class="ph-title" id="ph-title">Родительский кабинет</div>
|
||
<button class="ph-refresh" onclick="location.reload()" title="Обновить данные">
|
||
<svg class="ic" viewBox="0 0 24 24" style="width:16px;height:16px"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||
</button>
|
||
<button class="ph-notif" id="btn-notif" onclick="toggleNotifs()">
|
||
<svg class="ic" viewBox="0 0 24 24" style="width:17px;height:17px"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
|
||
<span class="ph-notif-badge" id="notif-badge">0</span>
|
||
</button>
|
||
<button class="ph-logout" onclick="parentLogout()">Выйти</button>
|
||
</header>
|
||
|
||
<div class="pn-drop" id="pn-drop">
|
||
<div class="pn-drop-head">
|
||
<svg class="ic" viewBox="0 0 24 24" style="width:15px;height:15px;color:var(--violet)"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
|
||
Уведомления
|
||
</div>
|
||
<div class="pn-drop-list" id="pn-list"></div>
|
||
</div>
|
||
|
||
<main class="pm" id="main-content">
|
||
<div class="pm-loading">
|
||
<div class="pm-loading-spinner"></div>
|
||
<div style="font-size:0.88rem;color:var(--text-3);font-weight:600">Загрузка данных...</div>
|
||
</div>
|
||
</main>
|
||
|
||
<script>
|
||
(async function() {
|
||
const API = window.location.origin + '/api';
|
||
let _delay = 0;
|
||
function ad() { return `animation-delay:${(_delay++) * 60}ms`; }
|
||
|
||
/* ── Auth ── */
|
||
const params = new URLSearchParams(location.search);
|
||
const linkToken = params.get('t');
|
||
if (linkToken) {
|
||
try {
|
||
const res = await fetch(API + '/parent/auth', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ token: linkToken }),
|
||
});
|
||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'err');
|
||
const d = await res.json();
|
||
localStorage.setItem('ls_parent_token', d.jwt);
|
||
localStorage.setItem('ls_parent_student', JSON.stringify(d.student));
|
||
history.replaceState(null, '', '/parent');
|
||
} catch { showError('Ссылка недействительна', 'Возможно, она была отключена или удалена учеником. Попросите новую ссылку.'); return; }
|
||
}
|
||
|
||
const jwt = localStorage.getItem('ls_parent_token');
|
||
if (!jwt) { showError('Нет доступа', 'Для просмотра прогресса ученика откройте ссылку, полученную от ребёнка.'); return; }
|
||
|
||
async function preq(path) {
|
||
const res = await fetch(API + path, { headers: { 'Authorization': 'Bearer ' + jwt } });
|
||
if (res.status === 401) { localStorage.removeItem('ls_parent_token'); localStorage.removeItem('ls_parent_student'); showError('Сессия истекла', 'Откройте ссылку от ученика заново.'); return null; }
|
||
if (!res.ok) throw new Error('fail');
|
||
return res.json();
|
||
}
|
||
|
||
let data;
|
||
try { data = await preq('/parent/dashboard'); if (!data) return; }
|
||
catch { showError('Ошибка загрузки', 'Не удалось получить данные. Попробуйте обновить страницу.'); return; }
|
||
|
||
document.getElementById('ph-title').textContent = 'Прогресс: ' + esc(data.student.name);
|
||
if (data.unreadNotifs > 0) { const b = document.getElementById('notif-badge'); b.textContent = data.unreadNotifs; b.style.display = 'flex'; }
|
||
|
||
const main = document.getElementById('main-content');
|
||
main.innerHTML = '';
|
||
const s = data.student;
|
||
const t = data.totals;
|
||
|
||
/* ── Student Hero ── */
|
||
const initials = (s.name||'LS').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'LS';
|
||
const lvl = s.level || 1;
|
||
const xpMin = (lvl-1)*(lvl-1)*100;
|
||
const xpMax = lvl*lvl*100;
|
||
const xpPct = xpMax > xpMin ? Math.min(100, Math.round((s.xp - xpMin) / (xpMax - xpMin) * 100)) : 0;
|
||
const lastAct = data.recentActivity?.lastSessionDate;
|
||
const lastActStr = lastAct ? timeAgo(lastAct) : 'нет данных';
|
||
|
||
main.innerHTML += `
|
||
<div class="ps-card anim" style="${ad()}">
|
||
<div class="ps-blob1"></div><div class="ps-blob2"></div><div class="ps-blob3"></div>
|
||
<div class="ps-inner">
|
||
<div class="ps-top">
|
||
<div class="ps-avatar-wrap">
|
||
<div class="ps-avatar-ring"></div><div class="ps-avatar-ring-inner"></div>
|
||
<div class="ps-avatar">${esc(initials)}</div>
|
||
</div>
|
||
<div class="ps-info">
|
||
<div class="ps-name">${esc(s.name)}</div>
|
||
<div class="ps-meta">
|
||
<span class="ps-level-badge">
|
||
<svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
|
||
Уровень ${lvl}
|
||
</span>
|
||
<span class="ps-activity-tag">Активность: <b>${lastActStr}</b></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="ps-xp">
|
||
<div class="ps-xp-top">
|
||
<span class="ps-xp-lbl">Опыт</span>
|
||
<span class="ps-xp-val">${s.xp} / ${xpMax} XP</span>
|
||
</div>
|
||
<div class="ps-xp-bar"><div class="ps-xp-fill" style="width:${xpPct}%"></div></div>
|
||
</div>
|
||
<div class="ps-chips">
|
||
<div class="ps-chip">
|
||
<div class="ps-chip-ic xp"><svg class="ic" viewBox="0 0 24 24" style="width:16px;height:16px"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></div>
|
||
<div class="ps-chip-body"><div class="ps-chip-val">${s.xp||0}</div><div class="ps-chip-lbl">XP</div></div>
|
||
</div>
|
||
<div class="ps-chip">
|
||
<div class="ps-chip-ic str"><svg class="ic" viewBox="0 0 24 24" style="width:16px;height:16px"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/></svg></div>
|
||
<div class="ps-chip-body"><div class="ps-chip-val">${s.streak_current||0}</div><div class="ps-chip-lbl">Стрик</div></div>
|
||
</div>
|
||
<div class="ps-chip">
|
||
<div class="ps-chip-ic coin"><svg class="ic" viewBox="0 0 24 24" style="width:16px;height:16px"><circle cx="12" cy="12" r="8"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg></div>
|
||
<div class="ps-chip-body"><div class="ps-chip-val">${s.coins||0}</div><div class="ps-chip-lbl">Монет</div></div>
|
||
</div>
|
||
<div class="ps-chip">
|
||
<div class="ps-chip-ic sess"><svg class="ic" viewBox="0 0 24 24" style="width:16px;height:16px"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></div>
|
||
<div class="ps-chip-body"><div class="ps-chip-val">${data.recentActivity?.sessionsThisWeek||0}</div><div class="ps-chip-lbl">За неделю</div></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
/* ── Alerts ── */
|
||
if (data.alerts?.length) {
|
||
let h = '<div class="pa-alerts">';
|
||
for (const a of data.alerts) {
|
||
const cls = a.type === 'deadline_missed' ? 'danger' : 'warn';
|
||
const ic = a.icon === 'clock'
|
||
? '<svg class="ic" viewBox="0 0 24 24" style="width:17px;height:17px"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>'
|
||
: '<svg class="ic" viewBox="0 0 24 24" style="width:17px;height:17px"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
|
||
h += `<div class="pa-alert ${cls} anim" style="${ad()}"><div class="pa-alert-ic">${ic}</div> ${esc(a.message)}</div>`;
|
||
}
|
||
main.innerHTML += h + '</div>';
|
||
}
|
||
|
||
/* ── Stats chips ── */
|
||
main.innerHTML += `
|
||
<div class="pg-chips">
|
||
<div class="pg-chip anim" style="${ad()}">
|
||
<div class="pg-chip-top"><div class="pg-chip-ic"><svg class="ic" viewBox="0 0 24 24" style="width:17px;height:17px"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></div></div>
|
||
<div class="pg-chip-val">${t.sessions}</div><div class="pg-chip-lbl">Тестов пройдено</div>
|
||
</div>
|
||
<div class="pg-chip anim" style="${ad()}">
|
||
<div class="pg-chip-top"><div class="pg-chip-ic"><svg class="ic" viewBox="0 0 24 24" style="width:17px;height:17px"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg></div></div>
|
||
<div class="pg-chip-val">${t.avgPct}%</div><div class="pg-chip-lbl">Средний балл</div>
|
||
</div>
|
||
<div class="pg-chip anim" style="${ad()}">
|
||
<div class="pg-chip-top"><div class="pg-chip-ic"><svg class="ic" viewBox="0 0 24 24" style="width:17px;height:17px"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/></svg></div></div>
|
||
<div class="pg-chip-val">${s.streak_current}</div><div class="pg-chip-lbl">Текущий стрик</div>
|
||
</div>
|
||
<div class="pg-chip anim" style="${ad()}">
|
||
<div class="pg-chip-top"><div class="pg-chip-ic"><svg class="ic" viewBox="0 0 24 24" style="width:17px;height:17px"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></div></div>
|
||
<div class="pg-chip-val">${t.questions}</div><div class="pg-chip-lbl">Вопросов</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
/* ── Grid ── */
|
||
const SUBJ_COLORS = { bio:'#22c55e', chem:'#f59e0b', math:'#3b82f6', phys:'#9B5DE5', other:'#8898AA' };
|
||
let gridHtml = '<div class="pg-grid">';
|
||
|
||
// Subjects
|
||
if (data.bySubject?.length) {
|
||
let h = `<div class="pw anim" style="${ad()}"><div class="pw-head"><div class="pw-icon pw-ic-violet"><svg class="ic" viewBox="0 0 24 24" style="width:16px;height:16px"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg></div><div><div class="pw-title">Предметы</div><div class="pw-subtitle">Средний балл по предметам</div></div></div>`;
|
||
const mx = Math.max(...data.bySubject.map(s=>s.avgPct),1);
|
||
for (const subj of data.bySubject) {
|
||
const col = SUBJ_COLORS[subj.slug]||'#9B5DE5';
|
||
h += `<div class="psb-row"><div class="psb-top"><span class="psb-name">${esc(subj.name)}</span><span class="psb-pct" style="color:${col}">${subj.avgPct}%</span></div><div class="psb-bar"><div class="psb-fill" style="width:${Math.round(subj.avgPct/mx*100)}%;background:${col}"></div></div></div>`;
|
||
}
|
||
gridHtml += h + '</div>';
|
||
}
|
||
|
||
// Weak topics
|
||
if (data.weakTopics?.length) {
|
||
let h = `<div class="pw anim" style="${ad()}"><div class="pw-head"><div class="pw-icon pw-ic-red"><svg class="ic" viewBox="0 0 24 24" style="width:16px;height:16px"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></div><div><div class="pw-title">Слабые темы</div><div class="pw-subtitle">Темы с наибольшим % ошибок</div></div></div>`;
|
||
data.weakTopics.forEach((wt,i)=>{
|
||
const p = wt.errorPct;
|
||
const col = p>=70?'var(--red)':p>=40?'var(--amber)':'var(--green)';
|
||
h += `<div class="pwt-row"><div class="pwt-num" style="background:${col}">${i+1}</div><div class="pwt-info"><div class="pwt-name">${esc(wt.topic)}</div><div class="pwt-subj">${esc(wt.subject||'')}</div></div><div class="pwt-pct" style="color:${col}">${p}%</div></div>`;
|
||
});
|
||
gridHtml += h + '</div>';
|
||
}
|
||
|
||
// Deadlines
|
||
if (data.deadlines?.length) {
|
||
let h = `<div class="pw anim" style="${ad()}"><div class="pw-head"><div class="pw-icon pw-ic-blue"><svg class="ic" viewBox="0 0 24 24" style="width:16px;height:16px"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></div><div><div class="pw-title">Задания</div><div class="pw-subtitle">Ближайшие дедлайны</div></div></div>`;
|
||
for (const d of data.deadlines) {
|
||
const past = new Date(d.deadline)<new Date();
|
||
const cls = d.done?'done':(past?'missed':'pending');
|
||
const ic = d.done
|
||
? '<svg class="ic" viewBox="0 0 24 24" style="width:15px;height:15px"><polyline points="20 6 9 17 4 12"/></svg>'
|
||
: '<svg class="ic" viewBox="0 0 24 24" style="width:15px;height:15px"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
|
||
const dl = new Date(d.deadline);
|
||
const dlStr = dl.toLocaleDateString('ru',{day:'numeric',month:'short'});
|
||
h += `<div class="pd-row"><div class="pd-icon-wrap ${cls}">${ic}</div><div class="pd-info"><div class="pd-title">${esc(d.title)}</div><div class="pd-date">${d.done?'Выполнено':(past?'Просрочено':'До '+dlStr)}</div></div></div>`;
|
||
}
|
||
gridHtml += h + '</div>';
|
||
}
|
||
|
||
// Submissions
|
||
if (data.submissions?.length) {
|
||
const SL = {new:'Новая',resubmitted:'Повторная',reviewed:'Проверена',accepted:'Принята',revision:'Доработка'};
|
||
let h = `<div class="pw anim" style="${ad()}"><div class="pw-head"><div class="pw-icon pw-ic-amber"><svg class="ic" viewBox="0 0 24 24" style="width:16px;height:16px"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></div><div><div class="pw-title">Работы</div><div class="pw-subtitle">Последние сданные работы</div></div></div>`;
|
||
for (const sub of data.submissions) {
|
||
const bc = sub.status||'new';
|
||
const title = sub.assignment||sub.name||'';
|
||
const ds = new Date(sub.date).toLocaleDateString('ru',{day:'numeric',month:'short'});
|
||
let gradeHtml = '';
|
||
if (sub.grade!=null && sub.grade!==undefined) {
|
||
const gc = sub.grade>=70?'var(--green)':sub.grade>=40?'var(--amber)':'var(--red)';
|
||
const gbg = sub.grade>=70?'rgba(34,197,94,0.08)':sub.grade>=40?'rgba(245,158,11,0.08)':'rgba(239,68,68,0.08)';
|
||
gradeHtml = `<span class="psu-grade" style="color:${gc};background:${gbg}">${sub.grade}</span>`;
|
||
}
|
||
h += `<div class="psu-row"><div class="psu-info"><div class="psu-title">${esc(title)}</div><div class="psu-date">${ds}</div></div><span class="psu-badge ${bc}">${SL[sub.status]||sub.status}</span>${gradeHtml}</div>`;
|
||
}
|
||
gridHtml += h + '</div>';
|
||
}
|
||
|
||
gridHtml += '</div>';
|
||
main.innerHTML += gridHtml;
|
||
|
||
// Full-width row: courses + heatmap
|
||
let fullRow = '<div class="pg-grid">';
|
||
|
||
// Courses
|
||
if (data.courseProgress?.length) {
|
||
let h = `<div class="pw anim" style="${ad()}"><div class="pw-head"><div class="pw-icon pw-ic-green"><svg class="ic" viewBox="0 0 24 24" style="width:16px;height:16px"><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><div class="pw-title">Курсы</div><div class="pw-subtitle">Прогресс по курсам</div></div></div>`;
|
||
for (const c of data.courseProgress) {
|
||
h += `<div class="pcp-row"><div class="pcp-top"><span class="pcp-name">${esc(c.title)}</span><span class="pcp-pct">${c.pct}%</span></div><div class="pcp-bar"><div class="pcp-fill" style="width:${c.pct}%"></div></div></div>`;
|
||
}
|
||
fullRow += h + '</div>';
|
||
}
|
||
|
||
// Heatmap
|
||
if (data.heatmap?.length) {
|
||
let h = `<div class="pw anim" style="${ad()}"><div class="pw-head"><div class="pw-icon pw-ic-cyan"><svg class="ic" viewBox="0 0 24 24" style="width:16px;height:16px"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg></div><div><div class="pw-title">Активность</div><div class="pw-subtitle">Последние 90 дней</div></div></div>`;
|
||
const hmap = {};
|
||
for (const d of data.heatmap) hmap[d.day] = d.count;
|
||
h += '<div class="phm">';
|
||
const now = new Date();
|
||
for (let i = 89; i >= 0; i--) {
|
||
const d = new Date(now); d.setDate(d.getDate()-i);
|
||
const key = d.toISOString().slice(0,10);
|
||
const cnt = hmap[key]||0;
|
||
const lvl = cnt===0?'':cnt<=1?'l1':cnt<=2?'l2':cnt<=4?'l3':'l4';
|
||
h += `<div class="phm-cell ${lvl}" title="${key}: ${cnt} тестов"></div>`;
|
||
}
|
||
h += '</div>';
|
||
h += '<div class="phm-legend">Меньше <div class="phm-legend-cell" style="background:rgba(155,93,229,0.06)"></div><div class="phm-legend-cell" style="background:rgba(155,93,229,0.15)"></div><div class="phm-legend-cell" style="background:rgba(155,93,229,0.3)"></div><div class="phm-legend-cell" style="background:rgba(155,93,229,0.5)"></div><div class="phm-legend-cell" style="background:rgba(155,93,229,0.75)"></div> Больше</div>';
|
||
fullRow += h + '</div>';
|
||
}
|
||
|
||
fullRow += '</div>';
|
||
main.innerHTML += fullRow;
|
||
|
||
// Weekly chart
|
||
if (data.weeklyStats?.length > 1) {
|
||
main.innerHTML += `<div class="pw anim" style="${ad()};margin-bottom:24px"><div class="pw-head"><div class="pw-icon pw-ic-violet"><svg class="ic" viewBox="0 0 24 24" style="width:16px;height:16px"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg></div><div><div class="pw-title">Недельная динамика</div><div class="pw-subtitle">Средний балл по неделям</div></div></div><canvas class="pw-chart" id="weekly-chart"></canvas></div>`;
|
||
requestAnimationFrame(() => drawWeeklyChart(data.weeklyStats));
|
||
}
|
||
|
||
if (window.lucide) lucide.createIcons();
|
||
|
||
/* ── Chart ── */
|
||
function drawWeeklyChart(weeks) {
|
||
const canvas = document.getElementById('weekly-chart');
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
const dpr = window.devicePixelRatio||1;
|
||
const rect = canvas.getBoundingClientRect();
|
||
canvas.width = rect.width*dpr; canvas.height = rect.height*dpr;
|
||
ctx.scale(dpr,dpr);
|
||
const W=rect.width, H=rect.height;
|
||
const pad={t:14,r:14,b:32,l:44};
|
||
const cw=W-pad.l-pad.r, ch=H-pad.t-pad.b;
|
||
const n=weeks.length, barW=Math.min(44,cw/n*0.55);
|
||
const gap=(cw-barW*n)/(n+1);
|
||
const maxVal=Math.max(...weeks.map(w=>w.avgPct),10);
|
||
|
||
// Grid
|
||
ctx.strokeStyle='rgba(15,23,42,0.05)'; ctx.lineWidth=1;
|
||
for(let i=0;i<=4;i++){
|
||
const y=pad.t+ch-(ch*i/4);
|
||
ctx.beginPath(); ctx.moveTo(pad.l,y); ctx.lineTo(W-pad.r,y); ctx.stroke();
|
||
ctx.fillStyle='#94A3B8'; ctx.font='500 10px Manrope'; ctx.textAlign='right'; ctx.textBaseline='middle';
|
||
ctx.fillText(Math.round(maxVal*i/4)+'%',pad.l-8,y);
|
||
}
|
||
|
||
const grad=ctx.createLinearGradient(0,pad.t,0,H-pad.b);
|
||
grad.addColorStop(0,'#9B5DE5'); grad.addColorStop(1,'#06D6E0');
|
||
const gradShadow=ctx.createLinearGradient(0,pad.t,0,H-pad.b);
|
||
gradShadow.addColorStop(0,'rgba(155,93,229,0.15)'); gradShadow.addColorStop(1,'rgba(6,214,224,0.05)');
|
||
|
||
weeks.forEach((w,i)=>{
|
||
const x=pad.l+gap+i*(barW+gap);
|
||
const barH=(w.avgPct/maxVal)*ch;
|
||
const y=pad.t+ch-barH;
|
||
const r=Math.min(6,barW/2);
|
||
|
||
// Shadow
|
||
ctx.fillStyle=gradShadow;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x+2,y+r+2); ctx.arcTo(x+2,y+2,x+barW+2,y+2,r); ctx.arcTo(x+barW+2,y+2,x+barW+2,y+barH+2,r);
|
||
ctx.lineTo(x+barW+2,pad.t+ch+2); ctx.lineTo(x+2,pad.t+ch+2); ctx.closePath(); ctx.fill();
|
||
|
||
// Bar
|
||
ctx.fillStyle=grad;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x,y+r); ctx.arcTo(x,y,x+barW,y,r); ctx.arcTo(x+barW,y,x+barW,y+barH,r);
|
||
ctx.lineTo(x+barW,pad.t+ch); ctx.lineTo(x,pad.t+ch); ctx.closePath(); ctx.fill();
|
||
|
||
// Value on top
|
||
if (barH > 20) {
|
||
ctx.fillStyle='rgba(255,255,255,0.9)'; ctx.font='700 9px Unbounded';
|
||
ctx.textAlign='center'; ctx.textBaseline='bottom';
|
||
ctx.fillText(w.avgPct+'%',x+barW/2,y+14);
|
||
}
|
||
|
||
// Label
|
||
ctx.fillStyle='#94A3B8'; ctx.font='500 9px Manrope'; ctx.textAlign='center'; ctx.textBaseline='top';
|
||
ctx.fillText('Н'+(w.week?w.week.split('-')[1]||'':''),x+barW/2,pad.t+ch+10);
|
||
});
|
||
}
|
||
|
||
/* ── Notifications ── */
|
||
let _notifsLoaded = false;
|
||
window.toggleNotifs = async function() {
|
||
const drop = document.getElementById('pn-drop');
|
||
if (drop.classList.contains('open')) { drop.classList.remove('open'); return; }
|
||
drop.classList.add('open');
|
||
if (!_notifsLoaded) {
|
||
_notifsLoaded = true;
|
||
try {
|
||
const notifs = await preq('/parent/notifications');
|
||
if (!notifs) return;
|
||
const list = document.getElementById('pn-list');
|
||
if (!notifs.length) { list.innerHTML = '<div class="pn-empty">Уведомлений пока нет</div>'; return; }
|
||
list.innerHTML = notifs.map(n => {
|
||
const cls = n.is_read ? '' : ' unread';
|
||
const d = new Date(n.created_at);
|
||
const ds = d.toLocaleDateString('ru',{day:'numeric',month:'short',hour:'2-digit',minute:'2-digit'});
|
||
return `<div class="pn-item${cls}" onclick="markNotifRead(${n.id},this)"><div class="pn-item-dot"></div><div><div class="pn-item-msg">${esc(n.message)}</div><div class="pn-item-time">${ds}</div></div></div>`;
|
||
}).join('');
|
||
} catch { document.getElementById('pn-list').innerHTML = '<div class="pn-empty">Ошибка загрузки</div>'; }
|
||
}
|
||
};
|
||
|
||
window.markNotifRead = async function(id, el) {
|
||
if (!el.classList.contains('unread')) return;
|
||
try {
|
||
await fetch(API+'/parent/notifications/'+id+'/read',{method:'PATCH',headers:{'Authorization':'Bearer '+jwt}});
|
||
el.classList.remove('unread');
|
||
const b=document.getElementById('notif-badge'), cur=parseInt(b.textContent)||0;
|
||
if(cur>1){b.textContent=cur-1;}else{b.style.display='none';}
|
||
} catch {}
|
||
};
|
||
|
||
document.addEventListener('click',e=>{
|
||
const drop=document.getElementById('pn-drop'), btn=document.getElementById('btn-notif');
|
||
if(drop.classList.contains('open')&&!drop.contains(e.target)&&!btn.contains(e.target))drop.classList.remove('open');
|
||
});
|
||
})();
|
||
|
||
function esc(s){return s?String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'):'';}
|
||
|
||
function timeAgo(dateStr) {
|
||
const diff = Date.now() - new Date(dateStr).getTime();
|
||
const mins = Math.floor(diff/60000);
|
||
if (mins < 1) return 'только что';
|
||
if (mins < 60) return mins + ' мин назад';
|
||
const hrs = Math.floor(mins/60);
|
||
if (hrs < 24) return hrs + ' ч назад';
|
||
const days = Math.floor(hrs/24);
|
||
if (days === 1) return 'вчера';
|
||
if (days < 7) return days + ' дн назад';
|
||
return new Date(dateStr).toLocaleDateString('ru',{day:'numeric',month:'short'});
|
||
}
|
||
|
||
function showError(title, sub) {
|
||
document.getElementById('main-content').innerHTML = `
|
||
<div class="pm-error">
|
||
<div class="pm-error-icon"><svg class="ic" viewBox="0 0 24 24" style="width:28px;height:28px"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
|
||
<div class="pm-error-title">${esc(title)}</div>
|
||
<div class="pm-error-sub">${esc(sub)}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function parentLogout() {
|
||
localStorage.removeItem('ls_parent_token');
|
||
localStorage.removeItem('ls_parent_student');
|
||
location.reload();
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|