Files
Maxim Dolgolyov 26ba289019 a11y: WCAG AA contrast + ARIA roles + focus management across all pages
- 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>
2026-04-16 11:42:38 +03:00

829 lines
45 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Родительский кабинет — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'):'';}
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>