LearnSpace: full-stack educational whiteboard platform

Node.js/Express backend + vanilla JS frontend.
Features: real-time collaborative whiteboard (SSE), multi-page support,
LaTeX formulas, shapes/connectors, coordinate systems, number lines,
compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with
rotation & resize controls, minimap navigation overlay, auto-measurements,
multi-page thumbnails sidebar, PNG export, page templates.
Student/teacher workflows: classes, assignments, library, dashboard.
Mobile responsive. SQLite (better-sqlite3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-12 10:10:37 +03:00
commit be4d43105e
204 changed files with 118117 additions and 0 deletions
+551
View File
@@ -0,0 +1,551 @@
<!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>
.sb-content { padding: 0; }
.col-wrap {
min-height: 100vh;
padding: 28px 24px 60px;
max-width: 1100px; margin: 0 auto;
}
/* Header */
.col-header { display:flex; align-items:center; gap:14px; margin-bottom:20px; }
.col-icon {
width:52px; height:52px; border-radius:14px; flex-shrink:0;
background: linear-gradient(135deg,rgba(249,199,79,.2),rgba(155,93,229,.15));
border:1.5px solid rgba(255,255,255,.1);
display:flex; align-items:center; justify-content:center;
}
.col-icon svg { width:26px; height:26px; stroke:#F9C74F; stroke-width:1.8; fill:none; }
.col-title { font-family:'Unbounded',sans-serif; font-size:1.3rem; font-weight:800; }
.col-sub { font-size:.82rem; color:var(--text-2); margin-top:2px; }
/* Summary bar */
.col-summary {
display:flex; gap:10px; flex-wrap:wrap; margin-bottom:20px;
}
.col-sum-card {
background:var(--surface); border:1.5px solid rgba(255,255,255,.08);
border-radius:14px; padding:12px 18px;
display:flex; align-items:center; gap:10px;
min-width:110px;
}
.col-sum-icon { width:32px; height:32px; border-radius:8px; display:flex; align-items:center; justify-content:center; flex-shrink:0; }
.col-sum-icon svg { width:16px; height:16px; stroke:currentColor; fill:none; stroke-width:2; }
.col-sum-val { font-family:'Unbounded',sans-serif; font-size:1rem; font-weight:800; }
.col-sum-label { font-size:.72rem; color:var(--text-2); margin-top:1px; }
/* Tier legend */
.tier-legend { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:16px; align-items:center; }
.tier-dot {
display:inline-flex; align-items:center; gap:5px;
font-size:.75rem; font-weight:600; color:var(--text-2); cursor:pointer;
padding:4px 10px; border-radius:99px; border:1.5px solid transparent; transition:all .15s;
}
.tier-dot.active { color:var(--text); }
.tier-dot-circle { width:10px; height:10px; border-radius:50%; flex-shrink:0; }
/* Filter */
.col-filter { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:18px; align-items:center; }
.col-pill {
padding:5px 14px; border-radius:99px; border:1.5px solid rgba(255,255,255,.12);
font-size:.78rem; font-weight:600; cursor:pointer; transition:all .15s;
background:transparent; color:var(--text-2);
}
.col-pill:hover { border-color:rgba(249,199,79,.4); color:#F9C74F; }
.col-pill.active { background:rgba(249,199,79,.12); border-color:#F9C74F; color:#F9C74F; }
/* Search */
.col-search-wrap { position:relative; margin-left:auto; }
.col-search {
padding:6px 12px 6px 34px; border-radius:99px;
border:1.5px solid rgba(255,255,255,.12);
background:rgba(255,255,255,.05); color:var(--text);
font-family:'Manrope',sans-serif; font-size:.82rem; outline:none; width:200px;
transition:border-color .2s;
}
.col-search:focus { border-color:rgba(249,199,79,.4); }
.col-search-icon {
position:absolute; left:10px; top:50%; transform:translateY(-50%);
color:var(--text-2); pointer-events:none;
}
.col-search-icon svg { width:14px; height:14px; stroke:currentColor; fill:none; stroke-width:2; }
/* Progress bar */
.col-progress-bar {
background:var(--surface); border:1.5px solid rgba(255,255,255,.08);
border-radius:14px; padding:14px 18px; margin-bottom:20px;
display:flex; align-items:center; gap:16px;
}
.col-prog-label { font-size:.82rem; color:var(--text-2); flex-shrink:0; }
.col-prog-bar { flex:1; height:8px; border-radius:99px; background:rgba(255,255,255,.08); overflow:hidden; }
.col-prog-fill { height:100%; border-radius:99px; background:linear-gradient(90deg,#9B5DE5,#F9C74F); transition:width .8s ease; }
.col-prog-pct { font-family:'Unbounded',sans-serif; font-size:.88rem; font-weight:800; color:#F9C74F; flex-shrink:0; }
/* Cards grid */
.col-grid {
display:grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap:12px;
}
/* Card */
.col-card {
position:relative; cursor:pointer;
perspective:800px;
height:200px;
}
.col-card-inner {
position:absolute; inset:0;
transition:transform .5s cubic-bezier(.4,0,.2,1);
transform-style:preserve-3d;
}
.col-card:hover .col-card-inner:not(.flipped) { transform:rotateY(10deg) scale(1.03); }
.col-card-inner.flipped { transform:rotateY(180deg); }
.col-card-front, .col-card-back {
position:absolute; inset:0;
border-radius:16px; padding:14px 12px;
backface-visibility:hidden;
display:flex; flex-direction:column; align-items:center;
justify-content:center; gap:6px;
}
.col-card-back { transform:rotateY(180deg); }
/* Tier styles */
.tier-locked .col-card-front {
background:rgba(255,255,255,.04); border:1.5px solid rgba(255,255,255,.08);
filter:grayscale(1);
}
.tier-bronze .col-card-front {
background:linear-gradient(145deg,rgba(180,80,20,.3),rgba(120,50,10,.2));
border:1.5px solid rgba(205,127,50,.35);
box-shadow:0 0 20px rgba(205,127,50,.15);
}
.tier-silver .col-card-front {
background:linear-gradient(145deg,rgba(160,160,170,.25),rgba(100,100,110,.2));
border:1.5px solid rgba(192,192,192,.4);
box-shadow:0 0 20px rgba(192,192,192,.15);
}
.tier-gold .col-card-front {
background:linear-gradient(145deg,rgba(249,199,79,.2),rgba(200,150,30,.15));
border:1.5px solid rgba(249,199,79,.45);
box-shadow:0 0 24px rgba(249,199,79,.2);
}
.tier-platinum .col-card-front {
background:linear-gradient(145deg,rgba(6,214,224,.2),rgba(155,93,229,.2));
border:1.5px solid rgba(6,214,224,.45);
box-shadow:0 0 28px rgba(6,214,224,.2);
}
/* Platinum shimmer */
.tier-platinum .col-card-front::after {
content:''; position:absolute; inset:0; border-radius:16px;
background:linear-gradient(135deg,transparent 30%,rgba(255,255,255,.06) 50%,transparent 70%);
animation:shimmer 2.5s linear infinite;
background-size:200% 200%;
}
@keyframes shimmer { 0%{background-position:200% 0} 100%{background-position:-200% 0} }
.col-card-tier-badge {
position:absolute; top:8px; right:8px;
font-size:.6rem; font-weight:800; padding:2px 7px;
border-radius:99px; text-transform:uppercase; letter-spacing:.05em;
}
.tier-locked .col-card-tier-badge { background:rgba(255,255,255,.08); color:var(--text-2); }
.tier-bronze .col-card-tier-badge { background:rgba(205,127,50,.3); color:#CD7F32; }
.tier-silver .col-card-tier-badge { background:rgba(192,192,192,.3); color:#C0C0C0; }
.tier-gold .col-card-tier-badge { background:rgba(249,199,79,.25); color:#F9C74F; }
.tier-platinum .col-card-tier-badge { background:rgba(6,214,224,.25); color:#06D6E0; }
.col-card-icon { font-size:2rem; line-height:1; }
.tier-locked .col-card-icon { opacity:.3; }
.col-card-name {
font-family:'Unbounded',sans-serif; font-size:.7rem; font-weight:800;
text-align:center; line-height:1.3; word-break:break-word;
}
.tier-locked .col-card-name { color:var(--text-2); }
.col-card-subj {
font-size:.68rem; color:var(--text-2); text-align:center;
}
/* Stars rating */
.col-card-stars { display:flex; gap:2px; }
.col-card-stars svg { width:12px; height:12px; }
/* Back face */
.col-card-back {
background:var(--surface); border:1.5px solid rgba(255,255,255,.12);
}
.col-card-back-title {
font-family:'Unbounded',sans-serif; font-size:.72rem; font-weight:800;
text-align:center; margin-bottom:6px;
}
.col-card-back-stat { font-size:.72rem; color:var(--text-2); text-align:center; }
.col-card-back-pct {
font-family:'Unbounded',sans-serif; font-size:1.3rem; font-weight:800;
color:#F9C74F;
}
.col-card-back-bar { width:80%; height:5px; border-radius:99px; background:rgba(255,255,255,.1); overflow:hidden; }
.col-card-back-fill { height:100%; border-radius:99px; }
/* Lock icon */
.col-lock-icon {
width:36px; height:36px; border-radius:50%;
background:rgba(255,255,255,.06); display:flex; align-items:center; justify-content:center;
}
.col-lock-icon svg { width:18px; height:18px; stroke:var(--text-2); fill:none; stroke-width:2; }
/* Empty state */
.col-empty {
grid-column:1/-1;
text-align:center; padding:60px; color:var(--text-2);
font-size:.88rem;
}
@media (max-width:768px) {
.col-grid { grid-template-columns:repeat(auto-fill, minmax(130px, 1fr)); }
.col-search { width:150px; }
}
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar">
<div class="sb-brand">
<a href="/dashboard" class="sb-logo"><span class="sb-lbl">Learn<span>Space</span></span></a>
<button class="sb-toggle" title="Свернуть меню"><i data-lucide="chevron-left" class="sb-icon"></i></button>
</div>
<nav class="sb-nav">
<button class="sb-link" onclick="lsSearchOpen()" title="Ctrl+K"><i data-lucide="search" class="sb-icon"></i><span class="sb-lbl">Поиск</span></button>
<a href="/dashboard" class="sb-link"><i data-lucide="home" class="sb-icon"></i><span class="sb-lbl">Дашборд</span></a>
<a href="/board" class="sb-link" id="sbl-board" style="display:none"><i data-lucide="layout-dashboard" class="sb-icon"></i><span class="sb-lbl">Доска</span></a>
<a href="/classes" class="sb-link" id="sbl-classes" style="display:none"><i data-lucide="graduation-cap" class="sb-icon"></i><span class="sb-lbl">Классы</span></a>
<a href="/library" class="sb-link"><i data-lucide="book-open" class="sb-icon"></i><span class="sb-lbl">Библиотека</span></a>
<a href="/theory" class="sb-link"><i data-lucide="brain" class="sb-icon"></i><span class="sb-lbl">Теория</span></a>
<a href="/lab" class="sb-link"><i data-lucide="atom" class="sb-icon"></i><span class="sb-lbl">Лаборатория</span></a>
<a href="/biochem" class="sb-link"><i data-lucide="flask-conical" class="sb-icon"></i><span class="sb-lbl">Биохимия</span></a>
<a href="/hangman" class="sb-link"><i data-lucide="gamepad-2" class="sb-icon"></i><span class="sb-lbl">Виселица</span></a>
<a href="/crossword" class="sb-link"><i data-lucide="grid-3x3" class="sb-icon"></i><span class="sb-lbl">Кроссворд</span></a>
<a href="/pet" class="sb-link"><i data-lucide="heart" class="sb-icon"></i><span class="sb-lbl">Питомец</span></a>
<span class="sb-link active"><i data-lucide="layers" class="sb-icon"></i><span class="sb-lbl">Коллекция</span></span>
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
<a href="/live-quiz" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="radio" class="sb-icon"></i><span class="sb-lbl">Live-квиз</span></a>
<a href="/gradebook" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="table" class="sb-icon"></i><span class="sb-lbl">Журнал</span></a>
<a href="/admin" class="sb-link" id="sbl-admin" style="display:none"><i data-lucide="settings" class="sb-icon"></i><span class="sb-lbl">Управление</span></a>
</nav>
<div style="padding:4px 2px">
<div id="notif-wrap">
<button class="sb-link" id="notif-btn" onclick="LS.notif.toggle()">
<i data-lucide="bell" class="sb-icon"></i><span class="sb-lbl">Уведомления</span>
<span class="sb-badge" id="notif-badge" style="display:none"></span>
</button>
</div>
</div>
<div class="sb-foot">
<a href="/profile" class="sb-user-row" style="text-decoration:none">
<div class="sb-avatar" id="nav-avatar">?</div>
<div class="sb-user-info">
<div class="sb-user-name" id="nav-user"></div>
<span class="sb-logout" style="pointer-events:none">Мой профиль</span>
</div>
</a>
</div>
</aside>
<div class="notif-drop" id="notif-drop"></div>
<div class="sb-content">
<div class="col-wrap">
<div class="col-header">
<div class="col-icon">
<svg viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
</div>
<div>
<div class="col-title">Коллекция карточек</div>
<div class="col-sub">Открывай карточки, прокачивая знания по темам</div>
</div>
</div>
<!-- Summary -->
<div class="col-summary" id="col-summary">
<div class="col-sum-card">
<div class="col-sum-icon" style="background:rgba(249,199,79,.12);color:#F9C74F">
<svg viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
</div>
<div>
<div class="col-sum-val" id="sum-total"></div>
<div class="col-sum-label">Всего тем</div>
</div>
</div>
<div class="col-sum-card">
<div class="col-sum-icon" style="background:rgba(56,217,90,.12);color:#38D95A">
<svg viewBox="0 0 24 24"><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="col-sum-val" id="sum-unlocked"></div>
<div class="col-sum-label">Открыто</div>
</div>
</div>
<div class="col-sum-card" style="cursor:pointer" onclick="setTier('platinum')">
<div class="col-sum-icon" style="background:rgba(6,214,224,.12);color:#06D6E0">
<svg viewBox="0 0 24 24"><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>
<div class="col-sum-val" id="sum-plat" style="color:#06D6E0"></div>
<div class="col-sum-label">Платина</div>
</div>
</div>
<div class="col-sum-card" style="cursor:pointer" onclick="setTier('gold')">
<div class="col-sum-icon" style="background:rgba(249,199,79,.12);color:#F9C74F">
<svg viewBox="0 0 24 24"><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>
<div class="col-sum-val" id="sum-gold" style="color:#F9C74F"></div>
<div class="col-sum-label">Золото</div>
</div>
</div>
<div class="col-sum-card" style="cursor:pointer" onclick="setTier('silver')">
<div class="col-sum-icon" style="background:rgba(192,192,192,.15);color:#C0C0C0">
<svg viewBox="0 0 24 24"><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>
<div class="col-sum-val" id="sum-silver" style="color:#C0C0C0"></div>
<div class="col-sum-label">Серебро</div>
</div>
</div>
<div class="col-sum-card" style="cursor:pointer" onclick="setTier('bronze')">
<div class="col-sum-icon" style="background:rgba(205,127,50,.15);color:#CD7F32">
<svg viewBox="0 0 24 24"><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>
<div class="col-sum-val" id="sum-bronze" style="color:#CD7F32"></div>
<div class="col-sum-label">Бронза</div>
</div>
</div>
</div>
<!-- Progress -->
<div class="col-progress-bar">
<div class="col-prog-label">Прогресс коллекции</div>
<div class="col-prog-bar"><div class="col-prog-fill" id="col-prog-fill" style="width:0%"></div></div>
<div class="col-prog-pct" id="col-prog-pct">0%</div>
</div>
<!-- Filters -->
<div class="col-filter">
<button class="col-pill active" data-slug="" onclick="setSubject(this,'')">Все</button>
<button class="col-pill" data-slug="bio" onclick="setSubject(this,'bio')">Биология</button>
<button class="col-pill" data-slug="chem" onclick="setSubject(this,'chem')">Химия</button>
<button class="col-pill" data-slug="math" onclick="setSubject(this,'math')">Математика</button>
<button class="col-pill" data-slug="phys" onclick="setSubject(this,'phys')">Физика</button>
<div class="col-search-wrap" style="margin-left:auto">
<div class="col-search-icon"><svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></div>
<input class="col-search" id="col-search" placeholder="Поиск темы…" oninput="renderCards()" />
</div>
</div>
<!-- Tier filter -->
<div class="tier-legend" id="tier-legend">
<div class="tier-dot active" data-tier="" onclick="setTier('')">
<div class="tier-dot-circle" style="background:var(--text-2)"></div>Все
</div>
<div class="tier-dot" data-tier="platinum" onclick="setTier('platinum')">
<div class="tier-dot-circle" style="background:#06D6E0"></div>Платина
</div>
<div class="tier-dot" data-tier="gold" onclick="setTier('gold')">
<div class="tier-dot-circle" style="background:#F9C74F"></div>Золото
</div>
<div class="tier-dot" data-tier="silver" onclick="setTier('silver')">
<div class="tier-dot-circle" style="background:#C0C0C0"></div>Серебро
</div>
<div class="tier-dot" data-tier="bronze" onclick="setTier('bronze')">
<div class="tier-dot-circle" style="background:#CD7F32"></div>Бронза
</div>
<div class="tier-dot" data-tier="locked" onclick="setTier('locked')">
<div class="tier-dot-circle" style="background:rgba(255,255,255,.2)"></div>Закрыто
</div>
</div>
<!-- Cards -->
<div class="col-grid" id="col-grid">
<div class="col-empty">
<div style="font-size:2rem;margin-bottom:8px"><svg class="ic" viewBox="0 0 24 24"><path d="M5 22h14M5 2h14M17 22v-4.17a2 2 0 0 0-.59-1.41L12 12l-4.41 4.42A2 2 0 0 0 7 17.83V22M7 2v4.17a2 2 0 0 0 .59 1.41L12 12l4.41-4.42A2 2 0 0 0 17 6.17V2"/></svg></div>
Загружаем коллекцию…
</div>
</div>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script>
(async () => {
if (!LS.requireAuth()) return;
const user = LS.getUser();
LS.applyRoleSidebar(user);
if (user) {
document.getElementById('nav-avatar').textContent =
(user.name||'LS').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'LS';
document.getElementById('nav-user').textContent = user.name || '—';
if (user.role === 'admin') document.getElementById('sbl-admin').style.display = '';
LS.showBoardIfAllowed();
if (user.role !== 'student') {
document.getElementById('sbl-classes').style.display = '';
}
}
LS.sidebar?.init();
lucide.createIcons();
await loadCollection();
})();
let _cards = [];
let _filterSubject = '';
let _filterTier = '';
/* ── Subject icons ── */
const SUBJ_ICONS = { bio:'<svg class="ic" viewBox="0 0 24 24"><path d="M2 15c6.667-6 13.333 0 20-6"/><path d="M9 22c1.798-2 2.518-4 2.807-6"/><path d="M15 2c-1.798 2-2.518 4-2.807 6"/><path d="m17 6-2.5-2.5M14 8 13 7M7 18l2.5 2.5M3.5 14.5l.5.5M20 9l.5.5M6.5 12.5l1 1M16.5 10.5l1 1M10 16l1.5 1.5"/></svg>', chem:'<svg class="ic" viewBox="0 0 24 24"><path d="M9 3h6m-4.5 0v5.5l-4 7.5a1 1 0 0 0 .9 1.5h8.2a1 1 0 0 0 .9-1.5l-4-7.5V3"/></svg>', math:'<svg class="ic" viewBox="0 0 24 24"><path d="m15 5 6.3 6.3a2.4 2.4 0 0 1 0 3.4L17 19"/><path d="M9.586 5.586A2 2 0 0 0 8.172 5H3a1 1 0 0 0-1 1v5.172a2 2 0 0 0 .586 1.414L8.29 18.29a2.426 2.426 0 0 0 3.42 0l3.58-3.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="6.5" cy="9.5" r=".5" fill="currentColor"/></svg>', phys:'<svg class="ic" viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>' };
/* ── Star SVG ── */
function starSVG(filled, color) {
return `<svg viewBox="0 0 24 24" fill="${filled ? color : 'none'}" stroke="${color}" stroke-width="2">
<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>`;
}
const TIER_COLOR = { platinum:'#06D6E0', gold:'#F9C74F', silver:'#C0C0C0', bronze:'#CD7F32', locked:'rgba(255,255,255,.2)' };
const TIER_LABEL = { platinum:'Платина', gold:'Золото', silver:'Серебро', bronze:'Бронза', locked:'Закрыто' };
const TIER_STARS = { platinum:5, gold:4, silver:3, bronze:1, locked:0 };
/* ── Load ── */
async function loadCollection() {
const data = await LS.api('/api/collection').catch(() => null);
if (!data) return;
_cards = data.cards;
document.getElementById('sum-total').textContent = data.totalTopics;
document.getElementById('sum-unlocked').textContent = data.unlockedTopics;
document.getElementById('sum-plat').textContent = data.platinumCount;
document.getElementById('sum-gold').textContent = data.goldCount;
document.getElementById('sum-silver').textContent = data.silverCount;
document.getElementById('sum-bronze').textContent = data.bronzeCount;
const pct = data.totalTopics > 0 ? Math.round(data.unlockedTopics / data.totalTopics * 100) : 0;
document.getElementById('col-prog-fill').style.width = pct + '%';
document.getElementById('col-prog-pct').textContent = pct + '%';
renderCards();
}
/* ── Filters ── */
function setSubject(el, slug) {
document.querySelectorAll('.col-pill').forEach(p => p.classList.remove('active'));
el.classList.add('active');
_filterSubject = slug;
renderCards();
}
function setTier(tier) {
document.querySelectorAll('.tier-dot').forEach(d => d.classList.remove('active'));
const el = document.querySelector(`.tier-dot[data-tier="${tier}"]`);
if (el) el.classList.add('active');
_filterTier = tier;
renderCards();
}
/* ── Render ── */
function renderCards() {
const q = document.getElementById('col-search').value.trim().toLowerCase();
let cards = _cards;
if (_filterSubject) cards = cards.filter(c => c.subjectSlug === _filterSubject);
if (_filterTier) cards = cards.filter(c => c.tier === _filterTier);
if (q) cards = cards.filter(c => c.topicName.toLowerCase().includes(q) || c.subjectName.toLowerCase().includes(q));
const grid = document.getElementById('col-grid');
if (!cards.length) {
grid.innerHTML = `<div class="col-empty"><div style="font-size:2rem;margin-bottom:8px"><svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></div>Ничего не найдено</div>`;
return;
}
// Sort: unlocked first, then by tier weight
const TIER_WEIGHT = { platinum:4, gold:3, silver:2, bronze:1, locked:0 };
cards = [...cards].sort((a,b) => (TIER_WEIGHT[b.tier]||0) - (TIER_WEIGHT[a.tier]||0));
grid.innerHTML = cards.map(card => buildCard(card)).join('');
}
function buildCard(c) {
const tierColor = TIER_COLOR[c.tier] || 'rgba(255,255,255,.2)';
const tierLbl = TIER_LABEL[c.tier] || '';
const stars = TIER_STARS[c.tier] || 0;
const icon = SUBJ_ICONS[c.subjectSlug] || '<svg class="ic" viewBox="0 0 24 24"><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>';
const starsHtml = [1,2,3,4,5].map(i =>
starSVG(i <= stars, tierColor)
).join('');
const frontContent = c.tier === 'locked'
? `<div class="col-lock-icon"><svg viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></div>
<div class="col-card-name">${escHtml(c.topicName)}</div>
<div class="col-card-subj">${escHtml(c.subjectName)}</div>`
: `<div class="col-card-icon">${icon}</div>
<div class="col-card-name">${escHtml(c.topicName)}</div>
<div class="col-card-subj">${escHtml(c.subjectName)}</div>
<div class="col-card-stars">${starsHtml}</div>`;
const backContent = c.tier === 'locked'
? `<div class="col-card-back-title">${escHtml(c.topicName)}</div>
<div class="col-card-back-stat">Нет правильных ответов</div>
<div class="col-card-back-stat" style="margin-top:6px">Проходи тесты,<br>чтобы открыть карточку</div>`
: `<div class="col-card-back-title">${escHtml(c.topicName)}</div>
<div class="col-card-back-pct">${c.masteryPct}%</div>
<div class="col-card-back-stat">мастерство</div>
<div class="col-card-back-bar" style="margin-top:6px">
<div class="col-card-back-fill" style="width:${c.masteryPct}%;background:${tierColor}"></div>
</div>
<div class="col-card-back-stat" style="margin-top:6px">${c.correctCount}/${c.totalAttempts} попыток</div>`;
return `<div class="col-card tier-${c.tier}" onclick="flipCard(this)">
<div class="col-card-inner">
<div class="col-card-front">
<span class="col-card-tier-badge">${tierLbl}</span>
${frontContent}
</div>
<div class="col-card-back">${backContent}</div>
</div>
</div>`;
}
function flipCard(el) {
el.querySelector('.col-card-inner').classList.toggle('flipped');
}
function escHtml(s) {
return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
LS.notif?.init();
</script>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
</body>
</html>