@bis-code/study-dash 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "study-dash",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Structured learning on any subject with dashboard, Q&A logging, visualizations, exercises",
5
5
  "author": {
6
6
  "name": "Ioan-Sorin Baicoianu",
package/hooks/hooks.json CHANGED
@@ -1,5 +1,12 @@
1
1
  {
2
2
  "hooks": {
3
+ "SessionStart": [{
4
+ "matcher": "",
5
+ "hooks": [{
6
+ "type": "command",
7
+ "command": "test -d \"${CLAUDE_PLUGIN_ROOT}/server/node_modules/better-sqlite3\" || npm install --prefix \"${CLAUDE_PLUGIN_ROOT}/server\" --silent 2>&1"
8
+ }]
9
+ }],
3
10
  "PostToolUse": [
4
11
  {
5
12
  "matcher": "learn_mark_done",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bis-code/study-dash",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Claude Code plugin for structured learning on any subject — dashboard, Q&A logging, visualizations, exercises",
5
5
  "license": "MIT",
6
6
  "author": "Ioan-Sorin Baicoianu <baicoianuioansorin@gmail.com>",
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import process$1 from 'node:process';
3
3
  import BetterSqlite3 from 'better-sqlite3';
4
- import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
5
- import { join } from 'node:path';
4
+ import fs, { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
5
+ import path, { join } from 'node:path';
6
6
  import { homedir } from 'node:os';
7
7
  import { execFile } from 'node:child_process';
8
8
  import { promisify } from 'node:util';
@@ -23127,6 +23127,9 @@ class ResourceService {
23127
23127
  constructor(db) {
23128
23128
  this.db = db;
23129
23129
  }
23130
+ getById(id) {
23131
+ return this.db.raw.prepare('SELECT * FROM resources WHERE id = ?').get(id);
23132
+ }
23130
23133
  addResource(topicId, title, url, source = 'manual') {
23131
23134
  const result = this.db.raw.prepare('INSERT INTO resources (topic_id, title, url, source) VALUES (?, ?, ?, ?)').run(topicId, title, url, source);
23132
23135
  return this.db.raw.prepare('SELECT * FROM resources WHERE id = ?').get(result.lastInsertRowid);
@@ -23676,12 +23679,56 @@ function handleSearch(qaSvc) {
23676
23679
  }
23677
23680
  };
23678
23681
  }
23682
+ function handleResourceFile(resourceSvc) {
23683
+ return (req, res) => {
23684
+ const id = extractId(req.url ?? '', '/api/resources/');
23685
+ if (id === null) {
23686
+ writeError(res, 400, 'Invalid resource ID');
23687
+ return;
23688
+ }
23689
+ const resource = resourceSvc.getById(id);
23690
+ if (!resource) {
23691
+ writeError(res, 404, 'Resource not found');
23692
+ return;
23693
+ }
23694
+ // Only serve file:// URLs — never proxy remote URLs
23695
+ if (!resource.url.startsWith('file://')) {
23696
+ writeError(res, 400, 'Resource is not a local file');
23697
+ return;
23698
+ }
23699
+ const filePath = decodeURIComponent(new URL(resource.url).pathname);
23700
+ const ext = path.extname(filePath).toLowerCase();
23701
+ const mimeTypes = {
23702
+ '.pdf': 'application/pdf',
23703
+ '.png': 'image/png',
23704
+ '.jpg': 'image/jpeg',
23705
+ '.jpeg': 'image/jpeg',
23706
+ };
23707
+ const contentType = mimeTypes[ext];
23708
+ if (!contentType) {
23709
+ writeError(res, 400, 'Unsupported file type');
23710
+ return;
23711
+ }
23712
+ try {
23713
+ const stat = fs.statSync(filePath);
23714
+ res.writeHead(200, {
23715
+ 'Content-Type': contentType,
23716
+ 'Content-Length': stat.size,
23717
+ 'Cache-Control': 'private, max-age=3600',
23718
+ });
23719
+ fs.createReadStream(filePath).pipe(res);
23720
+ }
23721
+ catch {
23722
+ writeError(res, 404, 'File not found on disk');
23723
+ }
23724
+ };
23725
+ }
23679
23726
 
23680
23727
  var indexHtml = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>StudyDash</title>\n <link rel=\"stylesheet\" href=\"styles.css\">\n <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css\">\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js\"></script>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/go.min.js\"></script>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/bash.min.js\"></script>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/sql.min.js\"></script>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/json.min.js\"></script>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/yaml.min.js\"></script>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/typescript.min.js\"></script>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/python.min.js\"></script>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/rust.min.js\"></script>\n</head>\n<body>\n\n <!-- Desktop sidebar (hidden on mobile) -->\n <div id=\"desktop-sidebar\">\n <div class=\"sidebar-inner\">\n <h1 style=\"font-size:18px;font-weight:700;color:var(--accent);margin-bottom:12px;\">StudyDash</h1>\n <div id=\"sidebar-subjects\" class=\"subject-switcher\"></div>\n <div class=\"progress-bar\" style=\"margin-bottom:14px;\">\n <div id=\"sidebar-progress-fill\" class=\"progress-fill\" style=\"width:0%\"></div>\n <span id=\"sidebar-progress-text\" class=\"progress-text\">0 / 0 topics</span>\n </div>\n <div id=\"sidebar-phases\"></div>\n </div>\n <div class=\"sidebar-footer\">\n <span class=\"sse-dot disconnected\" id=\"sse-dot\"></span>\n <kbd>Ctrl+K</kbd> Search\n </div>\n </div>\n\n <div class=\"page-container\">\n\n <!-- ==================== PAGE: HOME ==================== -->\n <div id=\"page-home\" class=\"page active\">\n <div class=\"page-header\">\n <h1>StudyDash</h1>\n </div>\n <div id=\"home-subjects\" class=\"subject-switcher\"></div>\n <div class=\"progress-bar\">\n <div id=\"home-progress-fill\" class=\"progress-fill\" style=\"width:0%\"></div>\n <span id=\"home-progress-text\" class=\"progress-text\">0 / 0 topics</span>\n </div>\n <div id=\"home-stats\" class=\"stats-grid\"></div>\n <div class=\"section-divider\">Recently Active</div>\n <div id=\"home-recent\"></div>\n </div>\n\n <!-- ==================== PAGE: TOPICS ==================== -->\n <div id=\"page-topics\" class=\"page\">\n <div class=\"page-header\">\n <h1>Topics</h1>\n </div>\n <div id=\"topics-subjects\" class=\"subject-switcher\"></div>\n <div id=\"topics-phases\"></div>\n </div>\n\n <!-- ==================== PAGE: TOPIC DETAIL ==================== -->\n <div id=\"page-topic\" class=\"page\">\n <button class=\"back-btn\" onclick=\"showPage('topics')\">&larr; Back to Topics</button>\n <div class=\"topic-title-row\">\n <h2 id=\"topic-name\"></h2>\n <span id=\"topic-status\" class=\"badge\"></span>\n </div>\n <p id=\"topic-desc\" class=\"topic-desc\"></p>\n <div class=\"tabs\">\n <button class=\"tab-btn active\" data-tab=\"qa\" onclick=\"switchTab('qa')\">Q&amp;A</button>\n <button class=\"tab-btn\" data-tab=\"viz\" onclick=\"switchTab('viz')\">Visualize</button>\n <button class=\"tab-btn\" data-tab=\"exercises\" onclick=\"switchTab('exercises')\">Exercises</button>\n <button class=\"tab-btn\" data-tab=\"resources\" onclick=\"switchTab('resources')\">Resources</button>\n </div>\n <div id=\"tab-qa\" class=\"tab-panel active\"></div>\n <div id=\"tab-viz\" class=\"tab-panel\"></div>\n <div id=\"tab-exercises\" class=\"tab-panel\"></div>\n <div id=\"tab-resources\" class=\"tab-panel\"></div>\n </div>\n\n <!-- ==================== PAGE: SEARCH ==================== -->\n <div id=\"page-search\" class=\"page\">\n <div class=\"page-header\">\n <h1>Search</h1>\n </div>\n <div class=\"search-bar\">\n <input type=\"text\" id=\"search-input\" placeholder=\"Search questions, answers, notes...\" autocomplete=\"off\">\n </div>\n <div id=\"search-results\"></div>\n </div>\n\n </div>\n\n <!-- Mobile bottom nav -->\n <nav class=\"mobile-nav\">\n <button class=\"nav-btn active\" data-page=\"home\" onclick=\"showPage('home')\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\"/><polyline points=\"9 22 9 12 15 12 15 22\"/></svg>\n Home\n </button>\n <button class=\"nav-btn\" data-page=\"topics\" onclick=\"showPage('topics')\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M4 19.5A2.5 2.5 0 0 1 6.5 17H20\"/><path d=\"M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z\"/></svg>\n Topics\n </button>\n <button class=\"nav-btn\" data-page=\"search\" onclick=\"showPage('search')\">\n <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><circle cx=\"11\" cy=\"11\" r=\"8\"/><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"/></svg>\n Search\n </button>\n </nav>\n\n <!-- Search modal (desktop Ctrl+K) -->\n <div id=\"search-modal\" class=\"modal hidden\">\n <div class=\"modal-backdrop\" onclick=\"closeSearchModal()\"></div>\n <div class=\"modal-content\">\n <input id=\"modal-search-input\" type=\"text\" placeholder=\"Search questions, answers, notes...\" autocomplete=\"off\">\n <div id=\"modal-search-results\" class=\"modal-results\"></div>\n </div>\n </div>\n\n <script src=\"app.js\"></script>\n</body>\n</html>\n";
23681
23728
 
23682
- var appJs = "// StudyDash — Dashboard Application\n// Connects to the learn-cc API and renders a responsive learning dashboard.\n\n// --- Markdown config ---\nmarked.setOptions({\n highlight: function(code, lang) {\n if (lang && hljs.getLanguage(lang)) {\n return hljs.highlight(code, { language: lang }).value;\n }\n return hljs.highlightAuto(code).value;\n },\n breaks: true,\n gfm: true,\n});\n\n// --- State ---\nconst state = {\n subjects: [],\n activeSubject: null,\n phases: [],\n activeTopic: null,\n activeTab: 'qa',\n topicData: null,\n topicViz: [],\n topicExercises: [],\n topicResources: [],\n searchTimeout: null,\n vizIndex: 0,\n vizStep: 0,\n};\n\n// --- API Helper ---\nasync function api(path) {\n const res = await fetch(path);\n if (!res.ok) throw new Error(`API error: ${res.status}`);\n return res.json();\n}\n\n// --- Sanitize viz HTML ---\nfunction sanitizeVizHtml(html) {\n const allowed = ['div', 'span', 'small', 'br', 'code', 'strong', 'em'];\n const tmp = document.createElement('div');\n tmp.innerHTML = html;\n // Remove all script tags\n tmp.querySelectorAll('script').forEach(el => el.remove());\n // Walk all elements, remove disallowed tags, strip non-class/style attributes\n const walk = (node) => {\n const children = [...node.children];\n for (const child of children) {\n if (!allowed.includes(child.tagName.toLowerCase())) {\n child.replaceWith(...child.childNodes);\n } else {\n [...child.attributes].forEach(attr => {\n if (attr.name !== 'class' && attr.name !== 'style') child.removeAttribute(attr.name);\n });\n walk(child);\n }\n }\n };\n walk(tmp);\n return tmp.innerHTML;\n}\n\n// --- Escape HTML for text content in templates ---\nfunction escapeHtml(str) {\n if (!str) return '';\n const div = document.createElement('div');\n div.textContent = str;\n return div.innerHTML;\n}\n\n// --- Format time ---\nfunction formatTime(iso) {\n if (!iso) return '';\n const d = new Date(iso);\n return d.toLocaleDateString('en-US', {\n month: 'short', day: 'numeric', year: 'numeric',\n hour: '2-digit', minute: '2-digit',\n });\n}\n\nfunction truncate(text, max) {\n if (!text) return '';\n if (text.length <= max) return escapeHtml(text);\n return escapeHtml(text.substring(0, max)) + '...';\n}\n\n// --- On load ---\ndocument.addEventListener('DOMContentLoaded', async () => {\n try {\n state.subjects = await api('/api/subjects');\n } catch {\n state.subjects = [];\n }\n\n if (state.subjects.length > 0) {\n state.activeSubject = state.subjects[0];\n await loadSubject();\n }\n\n renderAllSubjectSwitchers();\n renderHome();\n connectSSE();\n});\n\n// --- SSE ---\nfunction connectSSE() {\n const dot = document.getElementById('sse-dot');\n let evtSource;\n\n function connect() {\n evtSource = new EventSource('/api/events');\n\n evtSource.onopen = () => {\n if (dot) { dot.classList.remove('disconnected'); dot.classList.add('connected'); }\n };\n\n evtSource.onmessage = async (event) => {\n try {\n const data = JSON.parse(event.data);\n if (data.type === 'update') {\n await refresh();\n }\n } catch { /* ignore parse errors */ }\n };\n\n evtSource.onerror = () => {\n if (dot) { dot.classList.remove('connected'); dot.classList.add('disconnected'); }\n evtSource.close();\n setTimeout(connect, 3000);\n };\n }\n\n connect();\n}\n\nasync function refresh() {\n // Re-fetch subjects\n try {\n state.subjects = await api('/api/subjects');\n } catch { /* keep existing */ }\n\n if (state.activeSubject) {\n // Refresh the active subject from the new list\n const updated = state.subjects.find(s => s.id === state.activeSubject.id);\n if (updated) state.activeSubject = updated;\n await loadSubject();\n }\n\n renderAllSubjectSwitchers();\n\n // Re-render current view\n const activePage = document.querySelector('.page.active');\n if (activePage) {\n const pageId = activePage.id.replace('page-', '');\n if (pageId === 'home') renderHome();\n else if (pageId === 'topics') renderTopicsPage();\n else if (pageId === 'topic' && state.activeTopic) await selectTopic(state.activeTopic);\n }\n}\n\n// --- Subject management ---\nasync function loadSubject() {\n if (!state.activeSubject) return;\n try {\n state.phases = await api(`/api/subjects/${state.activeSubject.id}/phases`);\n } catch {\n state.phases = [];\n }\n renderSidebar();\n}\n\nasync function switchSubject(id) {\n const subject = state.subjects.find(s => s.id === id);\n if (!subject) return;\n state.activeSubject = subject;\n state.activeTopic = null;\n state.topicData = null;\n await loadSubject();\n renderAllSubjectSwitchers();\n renderHome();\n renderTopicsPage();\n // If on topic detail page, go back to topics\n const activePage = document.querySelector('.page.active');\n if (activePage && activePage.id === 'page-topic') {\n showPage('topics');\n }\n}\n\nfunction renderAllSubjectSwitchers() {\n const containers = ['home-subjects', 'topics-subjects', 'sidebar-subjects'];\n containers.forEach(id => {\n const el = document.getElementById(id);\n if (!el) return;\n if (state.subjects.length === 0) {\n el.innerHTML = '';\n return;\n }\n el.innerHTML = state.subjects.map(s =>\n `<button class=\"subject-btn ${state.activeSubject && state.activeSubject.id === s.id ? 'active' : ''}\"\n onclick=\"switchSubject(${s.id})\">${escapeHtml(s.name)}</button>`\n ).join('');\n });\n}\n\n// --- Progress ---\nfunction renderProgress() {\n if (!state.activeSubject) return;\n const p = state.activeSubject.progress || {};\n const total = p.total_topics || 0;\n const done = p.done || 0;\n const pct = total > 0 ? Math.round((done / total) * 100) : 0;\n const text = `${done} / ${total} topics`;\n\n // Home progress\n const hFill = document.getElementById('home-progress-fill');\n const hText = document.getElementById('home-progress-text');\n if (hFill) hFill.style.width = pct + '%';\n if (hText) hText.textContent = text;\n\n // Sidebar progress\n const sFill = document.getElementById('sidebar-progress-fill');\n const sText = document.getElementById('sidebar-progress-text');\n if (sFill) sFill.style.width = pct + '%';\n if (sText) sText.textContent = text;\n}\n\n// --- Sidebar ---\nfunction renderSidebar() {\n renderProgress();\n const container = document.getElementById('sidebar-phases');\n if (!container) return;\n\n if (state.phases.length === 0) {\n container.innerHTML = '<div class=\"empty-state\"><p>No phases yet</p></div>';\n return;\n }\n\n container.innerHTML = state.phases.map(phase => {\n const topics = phase.topics || [];\n const doneCount = topics.filter(t => t.status === 'done').length;\n return `\n <div class=\"phase-group\">\n <div class=\"phase-header\" onclick=\"togglePhase(this)\">\n ${escapeHtml(phase.name)}\n <span style=\"font-size:10px;color:var(--text-muted)\">${doneCount}/${topics.length}</span>\n <span class=\"chevron\">&#9660;</span>\n </div>\n <div class=\"phase-topics\">\n ${topics.map(t => `\n <div class=\"topic-item ${state.activeTopic === t.id ? 'active' : ''}\"\n onclick=\"selectTopic(${t.id})\"\n data-topic-id=\"${t.id}\">\n <span class=\"status-dot ${escapeHtml(t.status)}\"></span>\n <span>${escapeHtml(t.name)}</span>\n </div>\n `).join('')}\n </div>\n </div>`;\n }).join('');\n}\n\nfunction togglePhase(el) {\n el.classList.toggle('collapsed');\n el.nextElementSibling.classList.toggle('collapsed');\n}\n\n// --- Home page ---\nfunction renderHome() {\n renderProgress();\n\n const statsEl = document.getElementById('home-stats');\n const recentEl = document.getElementById('home-recent');\n\n if (state.subjects.length === 0) {\n if (statsEl) statsEl.innerHTML = '';\n if (recentEl) recentEl.innerHTML = `\n <div class=\"empty-state\">\n <p>Welcome to StudyDash!</p>\n <p>Start by creating a subject with <code>/learn</code></p>\n </div>`;\n return;\n }\n\n const p = state.activeSubject ? (state.activeSubject.progress || {}) : {};\n\n if (statsEl) {\n statsEl.innerHTML = `\n <div class=\"stat-card\">\n <div class=\"stat-value\">${p.done || 0}</div>\n <div class=\"stat-label\">Topics Done</div>\n </div>\n <div class=\"stat-card\">\n <div class=\"stat-value green\">${p.total_entries || 0}</div>\n <div class=\"stat-label\">Q&amp;A Entries</div>\n </div>\n <div class=\"stat-card\">\n <div class=\"stat-value yellow\">${p.total_exercises || 0}</div>\n <div class=\"stat-label\">Exercises</div>\n </div>\n <div class=\"stat-card\">\n <div class=\"stat-value purple\">${p.total_viz || 0}</div>\n <div class=\"stat-label\">Visualizations</div>\n </div>`;\n }\n\n // Show recently active topics (in_progress first, then by updated_at)\n if (recentEl) {\n const allTopics = state.phases.flatMap(ph => (ph.topics || []).map(t => ({ ...t, phaseName: ph.name })));\n const active = allTopics\n .filter(t => t.status !== 'todo')\n .sort((a, b) => {\n if (a.status === 'in_progress' && b.status !== 'in_progress') return -1;\n if (b.status === 'in_progress' && a.status !== 'in_progress') return 1;\n return new Date(b.updated_at || 0) - new Date(a.updated_at || 0);\n })\n .slice(0, 5);\n\n if (active.length === 0) {\n recentEl.innerHTML = `<div class=\"empty-state\"><p>No active topics yet. Import a curriculum with <code>/learn import</code></p></div>`;\n } else {\n recentEl.innerHTML = active.map(t => `\n <div class=\"exercise-card\" style=\"cursor:pointer\" onclick=\"selectTopic(${t.id})\">\n <div class=\"exercise-header\">\n <span class=\"exercise-title\">${escapeHtml(t.name)}</span>\n <span class=\"badge ${escapeHtml(t.status)}\">${escapeHtml(t.status.replace('_', ' '))}</span>\n </div>\n <div class=\"exercise-desc\">${escapeHtml(t.phaseName)}</div>\n </div>\n `).join('');\n }\n }\n}\n\n// --- Topics page ---\nfunction renderTopicsPage() {\n const container = document.getElementById('topics-phases');\n if (!container) return;\n\n if (state.phases.length === 0) {\n container.innerHTML = `<div class=\"empty-state\"><p>No topics yet.</p><p>Import a curriculum with <code>/learn import</code></p></div>`;\n return;\n }\n\n container.innerHTML = state.phases.map(phase => {\n const topics = phase.topics || [];\n return `\n <div class=\"phase-group\">\n <div class=\"phase-header\" onclick=\"togglePhase(this)\">\n ${escapeHtml(phase.name)}\n <span class=\"chevron\">&#9660;</span>\n </div>\n <div class=\"phase-topics\">\n ${topics.map(t => `\n <div class=\"topic-item ${state.activeTopic === t.id ? 'active' : ''}\"\n onclick=\"selectTopic(${t.id})\">\n <span class=\"status-dot ${escapeHtml(t.status)}\"></span>\n ${escapeHtml(t.name)}\n <span class=\"topic-count\"></span>\n </div>\n `).join('')}\n </div>\n </div>`;\n }).join('');\n}\n\n// --- Topic selection ---\nasync function selectTopic(id) {\n state.activeTopic = id;\n state.activeTab = 'qa';\n\n // Fetch topic detail, viz, exercises, and resources in parallel\n try {\n const [topicData, viz, exercises, resources] = await Promise.all([\n api(`/api/topics/${id}`),\n api(`/api/topics/${id}/viz`).catch(() => []),\n api(`/api/topics/${id}/exercises`).catch(() => []),\n api(`/api/topics/${id}/resources`).catch(() => []),\n ]);\n\n state.topicData = topicData;\n state.topicViz = viz || [];\n state.topicExercises = exercises || [];\n state.topicResources = resources || [];\n } catch {\n state.topicData = null;\n state.topicViz = [];\n state.topicExercises = [];\n state.topicResources = [];\n }\n\n // Update sidebar active state\n document.querySelectorAll('.topic-item').forEach(el => {\n el.classList.toggle('active', parseInt(el.dataset?.topicId) === id);\n });\n\n showPage('topic');\n renderTopicDetail();\n switchTab('qa');\n}\n\nfunction renderTopicDetail() {\n const data = state.topicData;\n if (!data) return;\n\n document.getElementById('topic-name').textContent = data.name || '';\n const statusEl = document.getElementById('topic-status');\n statusEl.textContent = (data.status || '').replace('_', ' ');\n statusEl.className = `badge ${data.status || ''}`;\n document.getElementById('topic-desc').textContent = data.description || '';\n}\n\n// --- Tab switching ---\nfunction switchTab(tab) {\n state.activeTab = tab;\n\n document.querySelectorAll('#page-topic .tab-btn').forEach(btn => {\n btn.classList.toggle('active', btn.dataset.tab === tab);\n });\n\n document.querySelectorAll('#page-topic .tab-panel').forEach(panel => {\n panel.classList.toggle('active', panel.id === `tab-${tab}`);\n });\n\n // Render tab content\n if (tab === 'qa') renderQATab();\n else if (tab === 'viz') renderVizTab();\n else if (tab === 'exercises') renderExercisesTab();\n else if (tab === 'resources') renderResourcesTab();\n}\n\n// --- Q&A Tab ---\nfunction renderQATab() {\n const container = document.getElementById('tab-qa');\n if (!container) return;\n\n const entries = state.topicData?.entries || [];\n\n if (entries.length === 0) {\n container.innerHTML = `<div class=\"empty-state\"><p>No Q&amp;A entries yet</p><p>Ask questions in Claude to see them here</p></div>`;\n return;\n }\n\n // Group entries into Q&A cards by question_id\n const questionMap = new Map();\n const groups = [];\n\n entries.forEach(e => {\n if (e.kind === 'question') {\n const group = { question: e, answers: [] };\n questionMap.set(e.id, group);\n groups.push(group);\n } else if (e.question_id && questionMap.has(e.question_id)) {\n questionMap.get(e.question_id).answers.push(e);\n } else {\n groups.push({ standalone: e });\n }\n });\n\n // marked.parse is used intentionally for markdown rendering (same as go-learn)\n container.innerHTML = groups.map(g => {\n if (g.standalone) {\n const e = g.standalone;\n return `\n <div class=\"entry-card\">\n <div class=\"entry-header\">\n <span class=\"entry-kind ${escapeHtml(e.kind)}\">${escapeHtml(e.kind)}</span>\n <span>${formatTime(e.created_at)}</span>\n </div>\n <div class=\"entry-body\">${marked.parse(e.content || '')}</div>\n </div>`;\n }\n\n const q = g.question;\n let html = `<div class=\"qa-card\">`;\n html += `\n <div class=\"qa-question\">\n <div class=\"entry-header\">\n <span class=\"entry-kind question\">question</span>\n <span>${formatTime(q.created_at)}</span>\n </div>\n <div class=\"entry-body\">${marked.parse(q.content || '')}</div>\n </div>`;\n\n g.answers.forEach(a => {\n html += `\n <div class=\"qa-answer\">\n <div class=\"entry-header\">\n <span class=\"entry-kind ${escapeHtml(a.kind)}\">${escapeHtml(a.kind)}</span>\n <span>${formatTime(a.created_at)}</span>\n </div>\n <div class=\"entry-body\">${marked.parse(a.content || '')}</div>\n </div>`;\n });\n\n html += `</div>`;\n return html;\n }).join('');\n}\n\n// --- Visualize Tab ---\nfunction renderVizTab() {\n const container = document.getElementById('tab-viz');\n if (!container) return;\n\n const vizList = state.topicViz;\n\n if (!vizList || vizList.length === 0) {\n container.innerHTML = `<div class=\"empty-state\"><p>No visualizations yet</p><p>Visualizations will appear here as you learn</p></div>`;\n return;\n }\n\n // Reset viz state\n state.vizIndex = 0;\n state.vizStep = 0;\n\n renderVizSelector(container);\n}\n\nfunction renderVizSelector(container) {\n if (!container) container = document.getElementById('tab-viz');\n if (!container) return;\n\n const vizList = state.topicViz;\n if (!vizList || vizList.length === 0) return;\n\n let html = `<div class=\"viz-selector\">`;\n vizList.forEach((v, i) => {\n html += `<button class=\"viz-select-btn ${i === state.vizIndex ? 'active' : ''}\" onclick=\"selectViz(${i})\">${escapeHtml(v.title)}</button>`;\n });\n html += `</div>`;\n html += `<div id=\"viz-stage-container\"></div>`;\n\n container.innerHTML = html;\n renderVizStage();\n}\n\nfunction selectViz(index) {\n state.vizIndex = index;\n state.vizStep = 0;\n\n // Update selector buttons\n document.querySelectorAll('.viz-select-btn').forEach((btn, i) => {\n btn.classList.toggle('active', i === index);\n });\n\n renderVizStage();\n}\n\nfunction renderVizStage() {\n const stageContainer = document.getElementById('viz-stage-container');\n if (!stageContainer) return;\n\n const viz = state.topicViz[state.vizIndex];\n if (!viz) return;\n\n let steps;\n try {\n steps = typeof viz.steps_json === 'string' ? JSON.parse(viz.steps_json) : viz.steps_json;\n } catch {\n stageContainer.innerHTML = `<div class=\"empty-state\"><p>Invalid visualization data</p></div>`;\n return;\n }\n\n if (!steps || steps.length === 0) {\n stageContainer.innerHTML = `<div class=\"empty-state\"><p>No steps in this visualization</p></div>`;\n return;\n }\n\n const step = steps[state.vizStep] || steps[0];\n const totalSteps = steps.length;\n\n // sanitizeVizHtml strips dangerous content, allowing only safe tags with class/style\n stageContainer.innerHTML = `\n <div class=\"viz-stage\">\n <div class=\"viz-canvas\">${sanitizeVizHtml(step.html || step.canvas || '')}</div>\n ${step.description || step.desc ? `<div class=\"viz-description\">${sanitizeVizHtml(step.description || step.desc || '')}</div>` : ''}\n <div class=\"viz-controls\">\n <button onclick=\"vizPrev()\" ${state.vizStep === 0 ? 'disabled' : ''}>Prev</button>\n <span class=\"viz-step-label\">Step ${state.vizStep + 1} / ${totalSteps}</span>\n <button onclick=\"vizNext()\" ${state.vizStep >= totalSteps - 1 ? 'disabled' : ''}>Next</button>\n </div>\n </div>`;\n}\n\nfunction vizPrev() {\n if (state.vizStep > 0) {\n state.vizStep--;\n renderVizStage();\n }\n}\n\nfunction vizNext() {\n const viz = state.topicViz[state.vizIndex];\n if (!viz) return;\n let steps;\n try {\n steps = typeof viz.steps_json === 'string' ? JSON.parse(viz.steps_json) : viz.steps_json;\n } catch { return; }\n if (state.vizStep < (steps?.length || 1) - 1) {\n state.vizStep++;\n renderVizStage();\n }\n}\n\n// --- Exercises Tab ---\nfunction renderExercisesTab() {\n const container = document.getElementById('tab-exercises');\n if (!container) return;\n\n const exercises = state.topicExercises;\n\n if (!exercises || exercises.length === 0) {\n container.innerHTML = `<div class=\"empty-state\"><p>No exercises yet</p><p>Exercises are generated when you complete topics</p></div>`;\n return;\n }\n\n container.innerHTML = exercises.map((ex, i) => {\n const results = ex.results || [];\n const passed = results.filter(r => r.passed).length;\n const total = results.length;\n const hasPassed = total > 0 && passed === total;\n\n let detailHtml = '';\n\n // Quiz type\n if (ex.type === 'quiz' && ex.quiz_json) {\n let quiz;\n try {\n quiz = typeof ex.quiz_json === 'string' ? JSON.parse(ex.quiz_json) : ex.quiz_json;\n } catch { quiz = null; }\n\n if (quiz && Array.isArray(quiz)) {\n detailHtml += `<h4>Questions</h4>`;\n detailHtml += quiz.map((q, qi) => `\n <div class=\"quiz-question\" data-exercise=\"${i}\" data-question=\"${qi}\">\n <p>${marked.parse(q.question || q.text || '')}</p>\n ${(q.options || q.choices || []).map((opt, oi) => `\n <div class=\"quiz-option\" data-exercise=\"${i}\" data-question=\"${qi}\" data-option=\"${oi}\" onclick=\"selectQuizOption(this)\">\n ${escapeHtml(opt)}\n </div>\n `).join('')}\n </div>\n `).join('');\n detailHtml += `\n <div class=\"exercise-actions\">\n <button class=\"exercise-action-btn btn-primary\" onclick=\"submitQuiz(${ex.id}, ${i})\">Submit Answers</button>\n </div>`;\n }\n }\n\n // Coding type — test cases\n if (ex.type === 'coding' || ex.type === 'project' || ex.type === 'assignment') {\n if (results.length > 0) {\n detailHtml += `<h4>Test Results</h4>`;\n detailHtml += results.map(r => `\n <div class=\"test-case\">\n <div class=\"test-case-header\">\n <span class=\"test-status ${r.passed ? 'pass' : 'fail'}\"></span>\n ${escapeHtml(r.test_name)}\n </div>\n ${r.output ? `<div class=\"test-case-body\">${truncate(r.output, 300)}</div>` : ''}\n </div>\n `).join('');\n\n detailHtml += `\n <div class=\"exercise-progress\">\n <span>${passed}/${total} tests</span>\n <div class=\"exercise-progress-bar\">\n <div class=\"exercise-progress-fill ${hasPassed ? 'green' : 'yellow'}\" style=\"width:${total > 0 ? Math.round(passed / total * 100) : 0}%\"></div>\n </div>\n </div>`;\n }\n\n detailHtml += `\n <div class=\"exercise-actions\">\n <button class=\"exercise-action-btn btn-primary\" onclick=\"runExercise(${ex.id}, ${i})\">Run Tests</button>\n </div>`;\n }\n\n return `\n <div class=\"exercise-card expandable\" id=\"exercise-${i}\">\n <div class=\"exercise-header\" onclick=\"toggleExercise(${i})\">\n <span class=\"exercise-title\">${escapeHtml(ex.title)}</span>\n <span class=\"exercise-type ${escapeHtml(ex.type)}\">${escapeHtml(ex.type)}</span>\n <span class=\"exercise-expand-icon\">&#9660;</span>\n </div>\n <div class=\"exercise-desc\">${escapeHtml(ex.description || '')}</div>\n <div class=\"exercise-meta\">\n ${ex.difficulty ? `<span>Difficulty: ${escapeHtml(ex.difficulty)}</span>` : ''}\n ${ex.est_minutes ? `<span>${ex.est_minutes} min</span>` : ''}\n ${ex.source ? `<span>Source: ${escapeHtml(ex.source)}</span>` : ''}\n ${ex.status ? `<span>Status: ${escapeHtml(ex.status)}</span>` : ''}\n </div>\n <div class=\"exercise-detail\" id=\"exercise-detail-${i}\">\n ${detailHtml}\n </div>\n </div>`;\n }).join('');\n}\n\nfunction toggleExercise(index) {\n const detail = document.getElementById('exercise-detail-' + index);\n const card = detail ? detail.closest('.exercise-card') : null;\n if (detail) detail.classList.toggle('open');\n if (card) card.classList.toggle('open');\n}\n\nfunction selectQuizOption(el) {\n const questionEl = el.closest('.quiz-question');\n if (questionEl) {\n questionEl.querySelectorAll('.quiz-option').forEach(opt => opt.classList.remove('selected'));\n }\n el.classList.add('selected');\n}\n\nasync function submitQuiz(exerciseId, cardIndex) {\n const card = document.getElementById(`exercise-${cardIndex}`);\n if (!card) return;\n\n const answers = [];\n card.querySelectorAll('.quiz-question').forEach(q => {\n const selected = q.querySelector('.quiz-option.selected');\n if (selected) {\n answers.push(parseInt(selected.dataset.option));\n } else {\n answers.push(-1);\n }\n });\n\n try {\n const result = await fetch(`/api/exercises/${exerciseId}/submit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ answers }),\n });\n const data = await result.json();\n\n if (data.results) {\n data.results.forEach((r, i) => {\n const questionEl = card.querySelectorAll('.quiz-question')[i];\n if (!questionEl) return;\n questionEl.querySelectorAll('.quiz-option').forEach((opt, oi) => {\n opt.classList.remove('selected');\n if (oi === r.correct_index) opt.classList.add('correct');\n else if (oi === answers[i] && !r.passed) opt.classList.add('incorrect');\n });\n });\n }\n\n if (data.score !== undefined) {\n const actionsEl = card.querySelector('.exercise-actions');\n if (actionsEl) {\n const scoreDiv = document.createElement('div');\n scoreDiv.style.cssText = `margin-top:8px;font-size:14px;font-weight:600;color:${data.passed ? 'var(--green)' : 'var(--yellow)'}`;\n scoreDiv.textContent = `Score: ${data.score}/${data.total}${data.passed ? ' - Passed!' : ''}`;\n actionsEl.appendChild(scoreDiv);\n }\n }\n } catch (err) {\n console.error('Submit quiz error:', err);\n }\n}\n\nasync function runExercise(exerciseId, cardIndex) {\n const btn = document.querySelector(`#exercise-${cardIndex} .btn-primary`);\n if (btn) { btn.textContent = 'Running...'; btn.disabled = true; }\n\n try {\n const res = await fetch(`/api/exercises/${exerciseId}/run`, { method: 'POST' });\n const data = await res.json();\n\n if (data.results && state.topicExercises[cardIndex]) {\n state.topicExercises[cardIndex].results = data.results;\n }\n\n renderExercisesTab();\n\n // Re-open the card\n const detail = document.getElementById(`exercise-detail-${cardIndex}`);\n if (detail) detail.classList.add('open');\n } catch (err) {\n console.error('Run exercise error:', err);\n if (btn) { btn.textContent = 'Run Tests'; btn.disabled = false; }\n }\n}\n\n// --- Resources Tab ---\nfunction renderResourcesTab() {\n const container = document.getElementById('tab-resources');\n if (!container) return;\n\n const resources = state.topicResources || [];\n\n if (resources.length === 0) {\n container.innerHTML = '<div class=\"empty-state\"><p>No resources yet</p><p class=\"text-muted\">Ask Claude to add reference links for this topic</p></div>';\n return;\n }\n\n let html = '<div class=\"resources-list\">';\n for (const r of resources) {\n html += '<a href=\"' + escapeHtml(r.url) + '\" target=\"_blank\" rel=\"noopener\" class=\"resource-card\">' +\n '<span class=\"resource-title\">' + escapeHtml(r.title) + '</span>' +\n '<span class=\"resource-url\">' + escapeHtml(r.url) + '</span>' +\n '</a>';\n }\n html += '</div>';\n container.innerHTML = html;\n}\n\n// --- Navigation ---\nfunction showPage(page) {\n document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));\n\n const target = document.getElementById(`page-${page}`);\n if (target) target.classList.add('active');\n\n document.querySelectorAll('.nav-btn').forEach(btn => {\n btn.classList.toggle('active', btn.dataset.page === page);\n });\n\n if (page === 'home') renderHome();\n else if (page === 'topics') renderTopicsPage();\n else if (page === 'search') document.getElementById('search-input')?.focus();\n}\n\n// --- Search ---\nconst searchInput = document.getElementById('search-input');\nif (searchInput) {\n searchInput.addEventListener('input', (e) => {\n clearTimeout(state.searchTimeout);\n state.searchTimeout = setTimeout(() => doSearch(e.target.value, 'search-results'), 200);\n });\n}\n\nconst modalSearchInput = document.getElementById('modal-search-input');\nif (modalSearchInput) {\n modalSearchInput.addEventListener('input', (e) => {\n clearTimeout(state.searchTimeout);\n state.searchTimeout = setTimeout(() => doSearch(e.target.value, 'modal-search-results'), 200);\n });\n}\n\nasync function doSearch(query, resultsContainerId) {\n const container = document.getElementById(resultsContainerId);\n if (!container) return;\n\n if (!query || !query.trim()) {\n container.innerHTML = '';\n return;\n }\n\n try {\n const results = await api(`/api/search?q=${encodeURIComponent(query)}`);\n\n if (!results || results.length === 0) {\n container.innerHTML = '<div class=\"search-no-results\">No results found</div>';\n return;\n }\n\n container.innerHTML = results.map(r => `\n <div class=\"search-result-item\" onclick=\"closeSearchModal(); selectTopic(${r.topic_id})\">\n <div class=\"search-result-meta\">\n <span class=\"entry-kind ${escapeHtml(r.kind)}\">${escapeHtml(r.kind)}</span>\n </div>\n <div class=\"search-result-content\">${truncate(r.content, 150)}</div>\n </div>\n `).join('');\n } catch {\n container.innerHTML = '<div class=\"search-no-results\">Search failed</div>';\n }\n}\n\nfunction openSearchModal() {\n const modal = document.getElementById('search-modal');\n if (modal) {\n modal.classList.remove('hidden');\n const input = document.getElementById('modal-search-input');\n if (input) { input.value = ''; input.focus(); }\n const results = document.getElementById('modal-search-results');\n if (results) results.innerHTML = '';\n }\n}\n\nfunction closeSearchModal() {\n const modal = document.getElementById('search-modal');\n if (modal) modal.classList.add('hidden');\n}\n\n// --- Keyboard shortcuts ---\ndocument.addEventListener('keydown', (e) => {\n if ((e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault();\n openSearchModal();\n }\n if (e.key === 'Escape') {\n closeSearchModal();\n }\n});\n";
23729
+ var appJs = "// StudyDash — Dashboard Application\n// Connects to the learn-cc API and renders a responsive learning dashboard.\n\n// --- Markdown config ---\nmarked.setOptions({\n highlight: function(code, lang) {\n if (lang && hljs.getLanguage(lang)) {\n return hljs.highlight(code, { language: lang }).value;\n }\n return hljs.highlightAuto(code).value;\n },\n breaks: true,\n gfm: true,\n});\n\n// --- State ---\nconst state = {\n subjects: [],\n activeSubject: null,\n phases: [],\n activeTopic: null,\n activeTab: 'qa',\n topicData: null,\n topicViz: [],\n topicExercises: [],\n topicResources: [],\n searchTimeout: null,\n vizIndex: 0,\n vizStep: 0,\n};\n\n// --- API Helper ---\nasync function api(path) {\n const res = await fetch(path);\n if (!res.ok) throw new Error(`API error: ${res.status}`);\n return res.json();\n}\n\n// --- Sanitize viz HTML ---\nfunction sanitizeVizHtml(html) {\n const allowed = ['div', 'span', 'small', 'br', 'code', 'strong', 'em'];\n const tmp = document.createElement('div');\n tmp.innerHTML = html;\n // Remove all script tags\n tmp.querySelectorAll('script').forEach(el => el.remove());\n // Walk all elements, remove disallowed tags, strip non-class/style attributes\n const walk = (node) => {\n const children = [...node.children];\n for (const child of children) {\n if (!allowed.includes(child.tagName.toLowerCase())) {\n child.replaceWith(...child.childNodes);\n } else {\n [...child.attributes].forEach(attr => {\n if (attr.name !== 'class' && attr.name !== 'style') child.removeAttribute(attr.name);\n });\n walk(child);\n }\n }\n };\n walk(tmp);\n return tmp.innerHTML;\n}\n\n// --- Escape HTML for text content in templates ---\nfunction escapeHtml(str) {\n if (!str) return '';\n const div = document.createElement('div');\n div.textContent = str;\n return div.innerHTML;\n}\n\n// --- Format time ---\nfunction formatTime(iso) {\n if (!iso) return '';\n const d = new Date(iso);\n return d.toLocaleDateString('en-US', {\n month: 'short', day: 'numeric', year: 'numeric',\n hour: '2-digit', minute: '2-digit',\n });\n}\n\nfunction truncate(text, max) {\n if (!text) return '';\n if (text.length <= max) return escapeHtml(text);\n return escapeHtml(text.substring(0, max)) + '...';\n}\n\n// --- On load ---\ndocument.addEventListener('DOMContentLoaded', async () => {\n try {\n state.subjects = await api('/api/subjects');\n } catch {\n state.subjects = [];\n }\n\n if (state.subjects.length > 0) {\n state.activeSubject = state.subjects[0];\n await loadSubject();\n }\n\n renderAllSubjectSwitchers();\n renderHome();\n connectSSE();\n});\n\n// --- SSE ---\nfunction connectSSE() {\n const dot = document.getElementById('sse-dot');\n let evtSource;\n\n function connect() {\n evtSource = new EventSource('/api/events');\n\n evtSource.onopen = () => {\n if (dot) { dot.classList.remove('disconnected'); dot.classList.add('connected'); }\n };\n\n evtSource.onmessage = async (event) => {\n try {\n const data = JSON.parse(event.data);\n if (data.type === 'update') {\n await refresh();\n }\n } catch { /* ignore parse errors */ }\n };\n\n evtSource.onerror = () => {\n if (dot) { dot.classList.remove('connected'); dot.classList.add('disconnected'); }\n evtSource.close();\n setTimeout(connect, 3000);\n };\n }\n\n connect();\n}\n\nasync function refresh() {\n // Re-fetch subjects\n try {\n state.subjects = await api('/api/subjects');\n } catch { /* keep existing */ }\n\n if (state.activeSubject) {\n // Refresh the active subject from the new list\n const updated = state.subjects.find(s => s.id === state.activeSubject.id);\n if (updated) state.activeSubject = updated;\n await loadSubject();\n }\n\n renderAllSubjectSwitchers();\n\n // Re-render current view\n const activePage = document.querySelector('.page.active');\n if (activePage) {\n const pageId = activePage.id.replace('page-', '');\n if (pageId === 'home') renderHome();\n else if (pageId === 'topics') renderTopicsPage();\n else if (pageId === 'topic' && state.activeTopic) await selectTopic(state.activeTopic);\n }\n}\n\n// --- Subject management ---\nasync function loadSubject() {\n if (!state.activeSubject) return;\n try {\n state.phases = await api(`/api/subjects/${state.activeSubject.id}/phases`);\n } catch {\n state.phases = [];\n }\n renderSidebar();\n}\n\nasync function switchSubject(id) {\n const subject = state.subjects.find(s => s.id === id);\n if (!subject) return;\n state.activeSubject = subject;\n state.activeTopic = null;\n state.topicData = null;\n await loadSubject();\n renderAllSubjectSwitchers();\n renderHome();\n renderTopicsPage();\n // If on topic detail page, go back to topics\n const activePage = document.querySelector('.page.active');\n if (activePage && activePage.id === 'page-topic') {\n showPage('topics');\n }\n}\n\nfunction renderAllSubjectSwitchers() {\n const containers = ['home-subjects', 'topics-subjects', 'sidebar-subjects'];\n containers.forEach(id => {\n const el = document.getElementById(id);\n if (!el) return;\n if (state.subjects.length === 0) {\n el.innerHTML = '';\n return;\n }\n el.innerHTML = state.subjects.map(s =>\n `<button class=\"subject-btn ${state.activeSubject && state.activeSubject.id === s.id ? 'active' : ''}\"\n onclick=\"switchSubject(${s.id})\">${escapeHtml(s.name)}</button>`\n ).join('');\n });\n}\n\n// --- Progress ---\nfunction renderProgress() {\n if (!state.activeSubject) return;\n const p = state.activeSubject.progress || {};\n const total = p.total_topics || 0;\n const done = p.done || 0;\n const pct = total > 0 ? Math.round((done / total) * 100) : 0;\n const text = `${done} / ${total} topics`;\n\n // Home progress\n const hFill = document.getElementById('home-progress-fill');\n const hText = document.getElementById('home-progress-text');\n if (hFill) hFill.style.width = pct + '%';\n if (hText) hText.textContent = text;\n\n // Sidebar progress\n const sFill = document.getElementById('sidebar-progress-fill');\n const sText = document.getElementById('sidebar-progress-text');\n if (sFill) sFill.style.width = pct + '%';\n if (sText) sText.textContent = text;\n}\n\n// --- Sidebar ---\nfunction renderSidebar() {\n renderProgress();\n const container = document.getElementById('sidebar-phases');\n if (!container) return;\n\n if (state.phases.length === 0) {\n container.innerHTML = '<div class=\"empty-state\"><p>No phases yet</p></div>';\n return;\n }\n\n container.innerHTML = state.phases.map(phase => {\n const topics = phase.topics || [];\n const doneCount = topics.filter(t => t.status === 'done').length;\n return `\n <div class=\"phase-group\">\n <div class=\"phase-header\" onclick=\"togglePhase(this)\">\n ${escapeHtml(phase.name)}\n <span style=\"font-size:10px;color:var(--text-muted)\">${doneCount}/${topics.length}</span>\n <span class=\"chevron\">&#9660;</span>\n </div>\n <div class=\"phase-topics\">\n ${topics.map(t => `\n <div class=\"topic-item ${state.activeTopic === t.id ? 'active' : ''}\"\n onclick=\"selectTopic(${t.id})\"\n data-topic-id=\"${t.id}\">\n <span class=\"status-dot ${escapeHtml(t.status)}\"></span>\n <span>${escapeHtml(t.name)}</span>\n </div>\n `).join('')}\n </div>\n </div>`;\n }).join('');\n}\n\nfunction togglePhase(el) {\n el.classList.toggle('collapsed');\n el.nextElementSibling.classList.toggle('collapsed');\n}\n\n// --- Home page ---\nfunction renderHome() {\n renderProgress();\n\n const statsEl = document.getElementById('home-stats');\n const recentEl = document.getElementById('home-recent');\n\n if (state.subjects.length === 0) {\n if (statsEl) statsEl.innerHTML = '';\n if (recentEl) recentEl.innerHTML = `\n <div class=\"empty-state\">\n <p>Welcome to StudyDash!</p>\n <p>Start by creating a subject with <code>/learn</code></p>\n </div>`;\n return;\n }\n\n const p = state.activeSubject ? (state.activeSubject.progress || {}) : {};\n\n if (statsEl) {\n statsEl.innerHTML = `\n <div class=\"stat-card\">\n <div class=\"stat-value\">${p.done || 0}</div>\n <div class=\"stat-label\">Topics Done</div>\n </div>\n <div class=\"stat-card\">\n <div class=\"stat-value green\">${p.total_entries || 0}</div>\n <div class=\"stat-label\">Q&amp;A Entries</div>\n </div>\n <div class=\"stat-card\">\n <div class=\"stat-value yellow\">${p.total_exercises || 0}</div>\n <div class=\"stat-label\">Exercises</div>\n </div>\n <div class=\"stat-card\">\n <div class=\"stat-value purple\">${p.total_viz || 0}</div>\n <div class=\"stat-label\">Visualizations</div>\n </div>`;\n }\n\n // Show recently active topics (in_progress first, then by updated_at)\n if (recentEl) {\n const allTopics = state.phases.flatMap(ph => (ph.topics || []).map(t => ({ ...t, phaseName: ph.name })));\n const active = allTopics\n .filter(t => t.status !== 'todo')\n .sort((a, b) => {\n if (a.status === 'in_progress' && b.status !== 'in_progress') return -1;\n if (b.status === 'in_progress' && a.status !== 'in_progress') return 1;\n return new Date(b.updated_at || 0) - new Date(a.updated_at || 0);\n })\n .slice(0, 5);\n\n if (active.length === 0) {\n recentEl.innerHTML = `<div class=\"empty-state\"><p>No active topics yet. Import a curriculum with <code>/learn import</code></p></div>`;\n } else {\n recentEl.innerHTML = active.map(t => `\n <div class=\"exercise-card\" style=\"cursor:pointer\" onclick=\"selectTopic(${t.id})\">\n <div class=\"exercise-header\">\n <span class=\"exercise-title\">${escapeHtml(t.name)}</span>\n <span class=\"badge ${escapeHtml(t.status)}\">${escapeHtml(t.status.replace('_', ' '))}</span>\n </div>\n <div class=\"exercise-desc\">${escapeHtml(t.phaseName)}</div>\n </div>\n `).join('');\n }\n }\n}\n\n// --- Topics page ---\nfunction renderTopicsPage() {\n const container = document.getElementById('topics-phases');\n if (!container) return;\n\n if (state.phases.length === 0) {\n container.innerHTML = `<div class=\"empty-state\"><p>No topics yet.</p><p>Import a curriculum with <code>/learn import</code></p></div>`;\n return;\n }\n\n container.innerHTML = state.phases.map(phase => {\n const topics = phase.topics || [];\n return `\n <div class=\"phase-group\">\n <div class=\"phase-header\" onclick=\"togglePhase(this)\">\n ${escapeHtml(phase.name)}\n <span class=\"chevron\">&#9660;</span>\n </div>\n <div class=\"phase-topics\">\n ${topics.map(t => `\n <div class=\"topic-item ${state.activeTopic === t.id ? 'active' : ''}\"\n onclick=\"selectTopic(${t.id})\">\n <span class=\"status-dot ${escapeHtml(t.status)}\"></span>\n ${escapeHtml(t.name)}\n <span class=\"topic-count\"></span>\n </div>\n `).join('')}\n </div>\n </div>`;\n }).join('');\n}\n\n// --- Topic selection ---\nasync function selectTopic(id) {\n state.activeTopic = id;\n state.activeTab = 'qa';\n\n // Fetch topic detail, viz, exercises, and resources in parallel\n try {\n const [topicData, viz, exercises, resources] = await Promise.all([\n api(`/api/topics/${id}`),\n api(`/api/topics/${id}/viz`).catch(() => []),\n api(`/api/topics/${id}/exercises`).catch(() => []),\n api(`/api/topics/${id}/resources`).catch(() => []),\n ]);\n\n state.topicData = topicData;\n state.topicViz = viz || [];\n state.topicExercises = exercises || [];\n state.topicResources = resources || [];\n } catch {\n state.topicData = null;\n state.topicViz = [];\n state.topicExercises = [];\n state.topicResources = [];\n }\n\n // Update sidebar active state\n document.querySelectorAll('.topic-item').forEach(el => {\n el.classList.toggle('active', parseInt(el.dataset?.topicId) === id);\n });\n\n showPage('topic');\n renderTopicDetail();\n switchTab('qa');\n}\n\nfunction renderTopicDetail() {\n const data = state.topicData;\n if (!data) return;\n\n document.getElementById('topic-name').textContent = data.name || '';\n const statusEl = document.getElementById('topic-status');\n statusEl.textContent = (data.status || '').replace('_', ' ');\n statusEl.className = `badge ${data.status || ''}`;\n document.getElementById('topic-desc').textContent = data.description || '';\n}\n\n// --- Tab switching ---\nfunction switchTab(tab) {\n state.activeTab = tab;\n\n document.querySelectorAll('#page-topic .tab-btn').forEach(btn => {\n btn.classList.toggle('active', btn.dataset.tab === tab);\n });\n\n document.querySelectorAll('#page-topic .tab-panel').forEach(panel => {\n panel.classList.toggle('active', panel.id === `tab-${tab}`);\n });\n\n // Render tab content\n if (tab === 'qa') renderQATab();\n else if (tab === 'viz') renderVizTab();\n else if (tab === 'exercises') renderExercisesTab();\n else if (tab === 'resources') renderResourcesTab();\n}\n\n// --- Q&A Tab ---\nfunction renderQATab() {\n const container = document.getElementById('tab-qa');\n if (!container) return;\n\n const entries = state.topicData?.entries || [];\n\n if (entries.length === 0) {\n container.innerHTML = `<div class=\"empty-state\"><p>No Q&amp;A entries yet</p><p>Ask questions in Claude to see them here</p></div>`;\n return;\n }\n\n // Group entries into Q&A cards by question_id\n const questionMap = new Map();\n const groups = [];\n\n entries.forEach(e => {\n if (e.kind === 'question') {\n const group = { question: e, answers: [] };\n questionMap.set(e.id, group);\n groups.push(group);\n } else if (e.question_id && questionMap.has(e.question_id)) {\n questionMap.get(e.question_id).answers.push(e);\n } else {\n groups.push({ standalone: e });\n }\n });\n\n // marked.parse is used intentionally for markdown rendering (same as go-learn)\n container.innerHTML = groups.map(g => {\n if (g.standalone) {\n const e = g.standalone;\n return `\n <div class=\"entry-card\">\n <div class=\"entry-header\">\n <span class=\"entry-kind ${escapeHtml(e.kind)}\">${escapeHtml(e.kind)}</span>\n <span>${formatTime(e.created_at)}</span>\n </div>\n <div class=\"entry-body\">${marked.parse(e.content || '')}</div>\n </div>`;\n }\n\n const q = g.question;\n let html = `<div class=\"qa-card\">`;\n html += `\n <div class=\"qa-question\">\n <div class=\"entry-header\">\n <span class=\"entry-kind question\">question</span>\n <span>${formatTime(q.created_at)}</span>\n </div>\n <div class=\"entry-body\">${marked.parse(q.content || '')}</div>\n </div>`;\n\n g.answers.forEach(a => {\n html += `\n <div class=\"qa-answer\">\n <div class=\"entry-header\">\n <span class=\"entry-kind ${escapeHtml(a.kind)}\">${escapeHtml(a.kind)}</span>\n <span>${formatTime(a.created_at)}</span>\n </div>\n <div class=\"entry-body\">${marked.parse(a.content || '')}</div>\n </div>`;\n });\n\n html += `</div>`;\n return html;\n }).join('');\n}\n\n// --- Visualize Tab ---\nfunction renderVizTab() {\n const container = document.getElementById('tab-viz');\n if (!container) return;\n\n const vizList = state.topicViz;\n\n if (!vizList || vizList.length === 0) {\n container.innerHTML = `<div class=\"empty-state\"><p>No visualizations yet</p><p>Visualizations will appear here as you learn</p></div>`;\n return;\n }\n\n // Reset viz state\n state.vizIndex = 0;\n state.vizStep = 0;\n\n renderVizSelector(container);\n}\n\nfunction renderVizSelector(container) {\n if (!container) container = document.getElementById('tab-viz');\n if (!container) return;\n\n const vizList = state.topicViz;\n if (!vizList || vizList.length === 0) return;\n\n let html = `<div class=\"viz-selector\">`;\n vizList.forEach((v, i) => {\n html += `<button class=\"viz-select-btn ${i === state.vizIndex ? 'active' : ''}\" onclick=\"selectViz(${i})\">${escapeHtml(v.title)}</button>`;\n });\n html += `</div>`;\n html += `<div id=\"viz-stage-container\"></div>`;\n\n container.innerHTML = html;\n renderVizStage();\n}\n\nfunction selectViz(index) {\n state.vizIndex = index;\n state.vizStep = 0;\n\n // Update selector buttons\n document.querySelectorAll('.viz-select-btn').forEach((btn, i) => {\n btn.classList.toggle('active', i === index);\n });\n\n renderVizStage();\n}\n\nfunction renderVizStage() {\n const stageContainer = document.getElementById('viz-stage-container');\n if (!stageContainer) return;\n\n const viz = state.topicViz[state.vizIndex];\n if (!viz) return;\n\n let steps;\n try {\n steps = typeof viz.steps_json === 'string' ? JSON.parse(viz.steps_json) : viz.steps_json;\n } catch {\n stageContainer.innerHTML = `<div class=\"empty-state\"><p>Invalid visualization data</p></div>`;\n return;\n }\n\n if (!steps || steps.length === 0) {\n stageContainer.innerHTML = `<div class=\"empty-state\"><p>No steps in this visualization</p></div>`;\n return;\n }\n\n const step = steps[state.vizStep] || steps[0];\n const totalSteps = steps.length;\n\n // sanitizeVizHtml strips dangerous content, allowing only safe tags with class/style\n stageContainer.innerHTML = `\n <div class=\"viz-stage\">\n <div class=\"viz-canvas\">${sanitizeVizHtml(step.html || step.canvas || '')}</div>\n ${step.description || step.desc ? `<div class=\"viz-description\">${sanitizeVizHtml(step.description || step.desc || '')}</div>` : ''}\n <div class=\"viz-controls\">\n <button onclick=\"vizPrev()\" ${state.vizStep === 0 ? 'disabled' : ''}>Prev</button>\n <span class=\"viz-step-label\">Step ${state.vizStep + 1} / ${totalSteps}</span>\n <button onclick=\"vizNext()\" ${state.vizStep >= totalSteps - 1 ? 'disabled' : ''}>Next</button>\n </div>\n </div>`;\n}\n\nfunction vizPrev() {\n if (state.vizStep > 0) {\n state.vizStep--;\n renderVizStage();\n }\n}\n\nfunction vizNext() {\n const viz = state.topicViz[state.vizIndex];\n if (!viz) return;\n let steps;\n try {\n steps = typeof viz.steps_json === 'string' ? JSON.parse(viz.steps_json) : viz.steps_json;\n } catch { return; }\n if (state.vizStep < (steps?.length || 1) - 1) {\n state.vizStep++;\n renderVizStage();\n }\n}\n\n// --- Exercises Tab ---\nfunction renderExercisesTab() {\n const container = document.getElementById('tab-exercises');\n if (!container) return;\n\n const exercises = state.topicExercises;\n\n if (!exercises || exercises.length === 0) {\n container.innerHTML = `<div class=\"empty-state\"><p>No exercises yet</p><p>Exercises are generated when you complete topics</p></div>`;\n return;\n }\n\n container.innerHTML = exercises.map((ex, i) => {\n const results = ex.results || [];\n const passed = results.filter(r => r.passed).length;\n const total = results.length;\n const hasPassed = total > 0 && passed === total;\n\n let detailHtml = '';\n\n // Quiz type\n if (ex.type === 'quiz' && ex.quiz_json) {\n let quiz;\n try {\n quiz = typeof ex.quiz_json === 'string' ? JSON.parse(ex.quiz_json) : ex.quiz_json;\n } catch { quiz = null; }\n\n if (quiz && Array.isArray(quiz)) {\n detailHtml += `<h4>Questions</h4>`;\n detailHtml += quiz.map((q, qi) => `\n <div class=\"quiz-question\" data-exercise=\"${i}\" data-question=\"${qi}\">\n <p>${marked.parse(q.question || q.text || '')}</p>\n ${(q.options || q.choices || []).map((opt, oi) => `\n <div class=\"quiz-option\" data-exercise=\"${i}\" data-question=\"${qi}\" data-option=\"${oi}\" onclick=\"selectQuizOption(this)\">\n ${escapeHtml(opt)}\n </div>\n `).join('')}\n </div>\n `).join('');\n detailHtml += `\n <div class=\"exercise-actions\">\n <button class=\"exercise-action-btn btn-primary\" onclick=\"submitQuiz(${ex.id}, ${i})\">Submit Answers</button>\n </div>`;\n }\n }\n\n // Coding type — test cases\n if (ex.type === 'coding' || ex.type === 'project' || ex.type === 'assignment') {\n if (results.length > 0) {\n detailHtml += `<h4>Test Results</h4>`;\n detailHtml += results.map(r => `\n <div class=\"test-case\">\n <div class=\"test-case-header\">\n <span class=\"test-status ${r.passed ? 'pass' : 'fail'}\"></span>\n ${escapeHtml(r.test_name)}\n </div>\n ${r.output ? `<div class=\"test-case-body\">${truncate(r.output, 300)}</div>` : ''}\n </div>\n `).join('');\n\n detailHtml += `\n <div class=\"exercise-progress\">\n <span>${passed}/${total} tests</span>\n <div class=\"exercise-progress-bar\">\n <div class=\"exercise-progress-fill ${hasPassed ? 'green' : 'yellow'}\" style=\"width:${total > 0 ? Math.round(passed / total * 100) : 0}%\"></div>\n </div>\n </div>`;\n }\n\n detailHtml += `\n <div class=\"exercise-actions\">\n <button class=\"exercise-action-btn btn-primary\" onclick=\"runExercise(${ex.id}, ${i})\">Run Tests</button>\n </div>`;\n }\n\n return `\n <div class=\"exercise-card expandable\" id=\"exercise-${i}\">\n <div class=\"exercise-header\" onclick=\"toggleExercise(${i})\">\n <span class=\"exercise-title\">${escapeHtml(ex.title)}</span>\n <span class=\"exercise-type ${escapeHtml(ex.type)}\">${escapeHtml(ex.type)}</span>\n <span class=\"exercise-expand-icon\">&#9660;</span>\n </div>\n <div class=\"exercise-desc\">${escapeHtml(ex.description || '')}</div>\n <div class=\"exercise-meta\">\n ${ex.difficulty ? `<span>Difficulty: ${escapeHtml(ex.difficulty)}</span>` : ''}\n ${ex.est_minutes ? `<span>${ex.est_minutes} min</span>` : ''}\n ${ex.source ? `<span>Source: ${escapeHtml(ex.source)}</span>` : ''}\n ${ex.status ? `<span>Status: ${escapeHtml(ex.status)}</span>` : ''}\n </div>\n <div class=\"exercise-detail\" id=\"exercise-detail-${i}\">\n ${detailHtml}\n </div>\n </div>`;\n }).join('');\n}\n\nfunction toggleExercise(index) {\n const detail = document.getElementById('exercise-detail-' + index);\n const card = detail ? detail.closest('.exercise-card') : null;\n if (detail) detail.classList.toggle('open');\n if (card) card.classList.toggle('open');\n}\n\nfunction selectQuizOption(el) {\n const questionEl = el.closest('.quiz-question');\n if (questionEl) {\n questionEl.querySelectorAll('.quiz-option').forEach(opt => opt.classList.remove('selected'));\n }\n el.classList.add('selected');\n}\n\nasync function submitQuiz(exerciseId, cardIndex) {\n const card = document.getElementById(`exercise-${cardIndex}`);\n if (!card) return;\n\n const answers = [];\n card.querySelectorAll('.quiz-question').forEach(q => {\n const selected = q.querySelector('.quiz-option.selected');\n if (selected) {\n answers.push(parseInt(selected.dataset.option));\n } else {\n answers.push(-1);\n }\n });\n\n try {\n const result = await fetch(`/api/exercises/${exerciseId}/submit`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ answers }),\n });\n const data = await result.json();\n\n if (data.results) {\n data.results.forEach((r, i) => {\n const questionEl = card.querySelectorAll('.quiz-question')[i];\n if (!questionEl) return;\n questionEl.querySelectorAll('.quiz-option').forEach((opt, oi) => {\n opt.classList.remove('selected');\n if (oi === r.correct_index) opt.classList.add('correct');\n else if (oi === answers[i] && !r.passed) opt.classList.add('incorrect');\n });\n });\n }\n\n if (data.score !== undefined) {\n const actionsEl = card.querySelector('.exercise-actions');\n if (actionsEl) {\n const scoreDiv = document.createElement('div');\n scoreDiv.style.cssText = `margin-top:8px;font-size:14px;font-weight:600;color:${data.passed ? 'var(--green)' : 'var(--yellow)'}`;\n scoreDiv.textContent = `Score: ${data.score}/${data.total}${data.passed ? ' - Passed!' : ''}`;\n actionsEl.appendChild(scoreDiv);\n }\n }\n } catch (err) {\n console.error('Submit quiz error:', err);\n }\n}\n\nasync function runExercise(exerciseId, cardIndex) {\n const btn = document.querySelector(`#exercise-${cardIndex} .btn-primary`);\n if (btn) { btn.textContent = 'Running...'; btn.disabled = true; }\n\n try {\n const res = await fetch(`/api/exercises/${exerciseId}/run`, { method: 'POST' });\n const data = await res.json();\n\n if (data.results && state.topicExercises[cardIndex]) {\n state.topicExercises[cardIndex].results = data.results;\n }\n\n renderExercisesTab();\n\n // Re-open the card\n const detail = document.getElementById(`exercise-detail-${cardIndex}`);\n if (detail) detail.classList.add('open');\n } catch (err) {\n console.error('Run exercise error:', err);\n if (btn) { btn.textContent = 'Run Tests'; btn.disabled = false; }\n }\n}\n\n// --- Resources Tab ---\nfunction renderResourcesTab() {\n const container = document.getElementById('tab-resources');\n if (!container) return;\n\n const resources = state.topicResources || [];\n\n if (resources.length === 0) {\n container.innerHTML = '<div class=\"empty-state\"><p>No resources yet</p><p class=\"text-muted\">Ask Claude to add reference links for this topic</p></div>';\n return;\n }\n\n let html = '<div class=\"resources-list\">';\n for (const r of resources) {\n const isFile = r.url.startsWith('file://');\n const isPdf = isFile && r.url.toLowerCase().endsWith('.pdf');\n\n if (isPdf) {\n html += '<div class=\"resource-card resource-pdf\">' +\n '<div class=\"resource-pdf-header\" data-resource-id=\"' + r.id + '\">' +\n '<span class=\"resource-title\">' + escapeHtml(r.title) + '</span>' +\n '<span class=\"resource-badge\">PDF</span>' +\n '<span class=\"resource-chevron\">&#9660;</span>' +\n '</div>' +\n '<div class=\"resource-pdf-viewer\" id=\"pdf-viewer-' + r.id + '\" style=\"display:none;\">' +\n '<iframe data-src=\"/api/resources/' + r.id + '/file\" type=\"application/pdf\"></iframe>' +\n '</div>' +\n '</div>';\n } else if (isFile) {\n html += '<div class=\"resource-card\">' +\n '<span class=\"resource-title\">' + escapeHtml(r.title) + '</span>' +\n '<span class=\"resource-url\">' + escapeHtml(r.url) + '</span>' +\n '</div>';\n } else {\n html += '<a href=\"' + escapeHtml(r.url) + '\" target=\"_blank\" rel=\"noopener\" class=\"resource-card\">' +\n '<span class=\"resource-title\">' + escapeHtml(r.title) + '</span>' +\n '<span class=\"resource-url\">' + escapeHtml(r.url) + '</span>' +\n '</a>';\n }\n }\n html += '</div>';\n\n container.innerHTML = html;\n\n // Attach expand/collapse handlers for PDF viewers (lazy-load on first expand)\n container.querySelectorAll('.resource-pdf-header').forEach(header => {\n header.addEventListener('click', () => {\n const id = header.dataset.resourceId;\n const viewer = document.getElementById('pdf-viewer-' + id);\n const chevron = header.querySelector('.resource-chevron');\n if (viewer) {\n const isOpen = viewer.style.display !== 'none';\n viewer.style.display = isOpen ? 'none' : 'block';\n if (chevron) chevron.classList.toggle('open', !isOpen);\n // Lazy-load: set iframe src on first expand\n const iframe = viewer.querySelector('iframe');\n if (!isOpen && iframe && !iframe.src) {\n iframe.src = iframe.dataset.src;\n }\n }\n });\n });\n}\n\n// --- Navigation ---\nfunction showPage(page) {\n document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));\n\n const target = document.getElementById(`page-${page}`);\n if (target) target.classList.add('active');\n\n document.querySelectorAll('.nav-btn').forEach(btn => {\n btn.classList.toggle('active', btn.dataset.page === page);\n });\n\n if (page === 'home') renderHome();\n else if (page === 'topics') renderTopicsPage();\n else if (page === 'search') document.getElementById('search-input')?.focus();\n}\n\n// --- Search ---\nconst searchInput = document.getElementById('search-input');\nif (searchInput) {\n searchInput.addEventListener('input', (e) => {\n clearTimeout(state.searchTimeout);\n state.searchTimeout = setTimeout(() => doSearch(e.target.value, 'search-results'), 200);\n });\n}\n\nconst modalSearchInput = document.getElementById('modal-search-input');\nif (modalSearchInput) {\n modalSearchInput.addEventListener('input', (e) => {\n clearTimeout(state.searchTimeout);\n state.searchTimeout = setTimeout(() => doSearch(e.target.value, 'modal-search-results'), 200);\n });\n}\n\nasync function doSearch(query, resultsContainerId) {\n const container = document.getElementById(resultsContainerId);\n if (!container) return;\n\n if (!query || !query.trim()) {\n container.innerHTML = '';\n return;\n }\n\n try {\n const results = await api(`/api/search?q=${encodeURIComponent(query)}`);\n\n if (!results || results.length === 0) {\n container.innerHTML = '<div class=\"search-no-results\">No results found</div>';\n return;\n }\n\n container.innerHTML = results.map(r => `\n <div class=\"search-result-item\" onclick=\"closeSearchModal(); selectTopic(${r.topic_id})\">\n <div class=\"search-result-meta\">\n <span class=\"entry-kind ${escapeHtml(r.kind)}\">${escapeHtml(r.kind)}</span>\n </div>\n <div class=\"search-result-content\">${truncate(r.content, 150)}</div>\n </div>\n `).join('');\n } catch {\n container.innerHTML = '<div class=\"search-no-results\">Search failed</div>';\n }\n}\n\nfunction openSearchModal() {\n const modal = document.getElementById('search-modal');\n if (modal) {\n modal.classList.remove('hidden');\n const input = document.getElementById('modal-search-input');\n if (input) { input.value = ''; input.focus(); }\n const results = document.getElementById('modal-search-results');\n if (results) results.innerHTML = '';\n }\n}\n\nfunction closeSearchModal() {\n const modal = document.getElementById('search-modal');\n if (modal) modal.classList.add('hidden');\n}\n\n// --- Keyboard shortcuts ---\ndocument.addEventListener('keydown', (e) => {\n if ((e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault();\n openSearchModal();\n }\n if (e.key === 'Escape') {\n closeSearchModal();\n }\n});\n";
23683
23730
 
23684
- var stylesCss = "/* ===== CSS VARIABLES (Dark Theme) ===== */\n:root {\n --bg: #0d1117;\n --bg-secondary: #161b22;\n --bg-tertiary: #21262d;\n --border: #30363d;\n --text: #e6edf3;\n --text-muted: #8b949e;\n --accent: #58a6ff;\n --green: #3fb950;\n --yellow: #d29922;\n --red: #f85149;\n --purple: #bc8cff;\n --radius: 8px;\n}\n\n* { margin: 0; padding: 0; box-sizing: border-box; }\n\nbody {\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;\n background: var(--bg);\n color: var(--text);\n min-height: 100vh;\n overflow-x: hidden;\n}\n\n.hidden { display: none !important; }\n\n/* ===== MOBILE NAV ===== */\n.mobile-nav {\n position: fixed;\n bottom: 0;\n left: 0;\n right: 0;\n background: var(--bg-secondary);\n border-top: 1px solid var(--border);\n display: flex;\n z-index: 100;\n padding-bottom: env(safe-area-inset-bottom);\n}\n\n.nav-btn {\n flex: 1;\n padding: 10px 4px;\n background: none;\n border: none;\n color: var(--text-muted);\n font-size: 10px;\n font-family: inherit;\n cursor: pointer;\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 3px;\n transition: color 0.15s;\n}\n\n.nav-btn.active { color: var(--accent); }\n.nav-btn svg { width: 22px; height: 22px; }\n\n/* ===== PAGES ===== */\n.page { display: none; padding: 16px 16px 80px; }\n.page.active { display: block; }\n\n/* ===== HEADER ===== */\n.page-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n margin-bottom: 16px;\n}\n\n.page-header h1 {\n font-size: 20px;\n font-weight: 700;\n color: var(--accent);\n}\n\n/* ===== SUBJECT SWITCHER ===== */\n.subject-switcher {\n display: flex;\n gap: 6px;\n margin-bottom: 14px;\n overflow-x: auto;\n padding-bottom: 4px;\n -webkit-overflow-scrolling: touch;\n}\n\n.subject-btn {\n padding: 6px 14px;\n background: var(--bg-tertiary);\n border: 1px solid var(--border);\n border-radius: 16px;\n color: var(--text-muted);\n font-size: 13px;\n cursor: pointer;\n font-family: inherit;\n white-space: nowrap;\n flex-shrink: 0;\n}\n\n.subject-btn.active { color: var(--accent); border-color: var(--accent); background: rgba(88,166,255,0.1); }\n\n/* ===== PROGRESS BAR ===== */\n.progress-bar {\n position: relative;\n height: 26px;\n background: var(--bg-tertiary);\n border-radius: 13px;\n overflow: hidden;\n margin-bottom: 16px;\n}\n\n.progress-fill {\n height: 100%;\n background: linear-gradient(90deg, var(--green), var(--accent));\n border-radius: 13px;\n transition: width 0.5s ease;\n}\n\n.progress-text {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 12px;\n font-weight: 600;\n}\n\n/* ===== STATS GRID ===== */\n.stats-grid {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 10px;\n margin-bottom: 20px;\n}\n\n.stat-card {\n background: var(--bg-secondary);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 14px;\n text-align: center;\n}\n\n.stat-value {\n font-size: 24px;\n font-weight: 700;\n color: var(--accent);\n}\n\n.stat-value.green { color: var(--green); }\n.stat-value.yellow { color: var(--yellow); }\n.stat-value.purple { color: var(--purple); }\n\n.stat-label {\n font-size: 11px;\n color: var(--text-muted);\n margin-top: 2px;\n}\n\n/* ===== SECTION DIVIDER ===== */\n.section-divider {\n font-size: 11px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n color: var(--text-muted);\n margin: 16px 0 10px;\n}\n\n/* ===== PHASE TREE (Topics page) ===== */\n.phase-group { margin-bottom: 8px; }\n\n.phase-header {\n padding: 10px 14px;\n font-size: 12px;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n color: var(--text-muted);\n cursor: pointer;\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: var(--bg-secondary);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n user-select: none;\n}\n\n.phase-header:hover { color: var(--text); }\n\n.phase-header .chevron {\n transition: transform 0.2s;\n font-size: 14px;\n}\n\n.phase-header.collapsed .chevron { transform: rotate(-90deg); }\n\n.phase-topics { padding: 4px 0; }\n.phase-topics.collapsed { display: none; }\n\n.topic-item {\n padding: 10px 14px 10px 20px;\n font-size: 14px;\n cursor: pointer;\n display: flex;\n align-items: center;\n gap: 10px;\n color: var(--text-muted);\n border-left: 3px solid transparent;\n transition: all 0.15s;\n}\n\n.topic-item:active { background: var(--bg-tertiary); }\n.topic-item.active { background: var(--bg-tertiary); color: var(--text); border-left-color: var(--accent); }\n\n.status-dot {\n width: 10px;\n height: 10px;\n border-radius: 50%;\n flex-shrink: 0;\n}\n\n.status-dot.done { background: var(--green); }\n.status-dot.in_progress { background: var(--yellow); }\n.status-dot.todo { background: var(--bg-tertiary); border: 1.5px solid var(--text-muted); }\n\n.topic-count {\n margin-left: auto;\n font-size: 11px;\n color: var(--text-muted);\n}\n\n/* ===== BACK BUTTON ===== */\n.back-btn {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n background: none;\n border: none;\n color: var(--accent);\n font-size: 14px;\n font-family: inherit;\n cursor: pointer;\n margin-bottom: 12px;\n padding: 4px 0;\n}\n\n/* ===== TOPIC DETAIL ===== */\n.topic-title-row {\n display: flex;\n align-items: center;\n gap: 10px;\n margin-bottom: 6px;\n flex-wrap: wrap;\n}\n\n.topic-title-row h2 { font-size: 18px; font-weight: 600; }\n\n.badge {\n font-size: 10px;\n font-weight: 600;\n padding: 3px 10px;\n border-radius: 12px;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n}\n\n.badge.todo { background: var(--bg-tertiary); color: var(--text-muted); }\n.badge.in_progress { background: rgba(210,153,34,0.15); color: var(--yellow); }\n.badge.done { background: rgba(63,185,80,0.15); color: var(--green); }\n\n.topic-desc {\n color: var(--text-muted);\n font-size: 13px;\n margin-bottom: 14px;\n line-height: 1.4;\n}\n\n/* ===== TABS ===== */\n.tabs {\n display: flex;\n gap: 4px;\n margin-bottom: 16px;\n overflow-x: auto;\n padding-bottom: 4px;\n -webkit-overflow-scrolling: touch;\n}\n\n.tab-btn {\n padding: 7px 14px;\n background: transparent;\n border: 1px solid var(--border);\n border-radius: 6px;\n color: var(--text-muted);\n cursor: pointer;\n font-size: 13px;\n font-family: inherit;\n white-space: nowrap;\n flex-shrink: 0;\n transition: all 0.15s;\n}\n\n.tab-btn:hover { color: var(--text); background: var(--bg-tertiary); }\n.tab-btn.active { color: var(--accent); border-color: var(--accent); background: rgba(88,166,255,0.1); }\n\n.tab-panel { display: none; }\n.tab-panel.active { display: block; }\n\n/* ===== Q&A CARDS ===== */\n.qa-card {\n border: 1px solid var(--border);\n border-radius: var(--radius);\n overflow: hidden;\n margin-bottom: 14px;\n}\n\n.qa-question { background: var(--bg-secondary); border-bottom: 1px solid var(--border); }\n.qa-answer { background: var(--bg-secondary); }\n.qa-answer + .qa-answer { border-top: 1px solid var(--border); }\n\n.entry-card {\n background: var(--bg-secondary);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n overflow: hidden;\n margin-bottom: 14px;\n}\n\n.entry-header {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 14px;\n background: var(--bg-tertiary);\n font-size: 11px;\n color: var(--text-muted);\n border-bottom: 1px solid var(--border);\n}\n\n.entry-kind {\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n}\n\n.entry-kind.question { color: var(--accent); }\n.entry-kind.answer { color: var(--green); }\n.entry-kind.note { color: var(--purple); }\n\n.entry-body {\n padding: 14px;\n font-size: 14px;\n line-height: 1.6;\n background: var(--bg-secondary);\n}\n\n.entry-body p { margin-bottom: 10px; }\n.entry-body p:last-child { margin-bottom: 0; }\n\n.entry-body h1, .entry-body h2, .entry-body h3 {\n margin-top: 16px;\n margin-bottom: 8px;\n}\n\n.entry-body h1:first-child, .entry-body h2:first-child, .entry-body h3:first-child {\n margin-top: 0;\n}\n\n.entry-body code {\n font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace;\n font-size: 13px;\n}\n\n.entry-body :not(pre) > code {\n background: var(--bg-tertiary);\n padding: 2px 5px;\n border-radius: 4px;\n font-size: 12px;\n}\n\n.entry-body pre {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 6px;\n padding: 12px;\n overflow-x: auto;\n margin: 8px 0;\n -webkit-overflow-scrolling: touch;\n}\n\n.entry-body pre code {\n background: none;\n padding: 0;\n font-size: 12px;\n color: var(--text);\n}\n\n.entry-body ul, .entry-body ol {\n padding-left: 24px;\n margin-bottom: 12px;\n}\n\n.entry-body li { margin-bottom: 4px; }\n\n.entry-body blockquote {\n border-left: 3px solid var(--accent);\n padding-left: 16px;\n color: var(--text-muted);\n margin: 12px 0;\n}\n\n.entry-body table {\n width: 100%;\n border-collapse: collapse;\n margin: 12px 0;\n}\n\n.entry-body th, .entry-body td {\n border: 1px solid var(--border);\n padding: 8px 12px;\n text-align: left;\n}\n\n.entry-body th {\n background: var(--bg-tertiary);\n font-weight: 600;\n}\n\n/* ===== VIZ PANEL ===== */\n.viz-selector {\n display: flex;\n gap: 6px;\n margin-bottom: 14px;\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n padding-bottom: 4px;\n}\n\n.viz-select-btn {\n padding: 7px 12px;\n background: var(--bg-secondary);\n border: 1px solid var(--border);\n border-radius: 6px;\n color: var(--text-muted);\n cursor: pointer;\n font-size: 12px;\n font-family: inherit;\n white-space: nowrap;\n flex-shrink: 0;\n transition: all 0.15s;\n}\n\n.viz-select-btn:hover { color: var(--text); border-color: var(--text-muted); }\n.viz-select-btn.active { color: var(--accent); border-color: var(--accent); background: rgba(88,166,255,0.1); }\n\n.viz-stage {\n background: var(--bg-secondary);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n overflow: hidden;\n}\n\n.viz-canvas {\n padding: 20px 12px;\n min-height: 140px;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n flex-wrap: wrap;\n}\n\n.viz-description {\n padding: 14px;\n border-top: 1px solid var(--border);\n font-size: 13px;\n line-height: 1.6;\n}\n\n.viz-description code {\n background: var(--bg-tertiary);\n padding: 2px 5px;\n border-radius: 4px;\n font-size: 11px;\n font-family: 'SF Mono', 'Fira Code', monospace;\n color: var(--accent);\n}\n\n.viz-controls {\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 14px;\n padding: 10px;\n border-top: 1px solid var(--border);\n background: var(--bg-tertiary);\n}\n\n.viz-controls button {\n padding: 8px 18px;\n background: var(--bg-secondary);\n border: 1px solid var(--border);\n border-radius: 6px;\n color: var(--text);\n cursor: pointer;\n font-size: 13px;\n font-family: inherit;\n transition: all 0.15s;\n}\n\n.viz-controls button:hover:not(:disabled) {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.viz-controls button:disabled { opacity: 0.3; cursor: default; }\n\n.viz-step-label { font-size: 12px; color: var(--text-muted); min-width: 80px; text-align: center; }\n\n/* Viz primitives */\n.viz-box {\n padding: 10px 14px;\n border-radius: 8px;\n font-size: 12px;\n font-weight: 600;\n font-family: 'SF Mono', 'Fira Code', monospace;\n text-align: center;\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 4px;\n}\n\n.viz-box-label {\n font-size: 10px;\n color: var(--text-muted);\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n font-weight: 400;\n}\n\n.viz-arrow { font-size: 20px; color: var(--accent); }\n\n.box-blue { background: rgba(88,166,255,0.15); border: 1px solid var(--accent); color: var(--accent); }\n.box-green { background: rgba(63,185,80,0.15); border: 1px solid var(--green); color: var(--green); }\n.box-yellow { background: rgba(210,153,34,0.15); border: 1px solid var(--yellow); color: var(--yellow); }\n.box-purple { background: rgba(188,140,255,0.15); border: 1px solid var(--purple); color: var(--purple); }\n\n.viz-slot {\n width: 28px;\n height: 28px;\n border: 1px solid var(--border);\n border-radius: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 10px;\n font-family: 'SF Mono', 'Fira Code', monospace;\n transition: all 0.3s ease;\n}\n\n.viz-slot.filled {\n background: rgba(88,166,255,0.2);\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.viz-slot.empty { color: var(--text-muted); }\n\n.viz-select-case {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 14px;\n border: 1px solid var(--border);\n border-radius: 6px;\n font-size: 12px;\n font-family: 'SF Mono', 'Fira Code', monospace;\n transition: all 0.3s ease;\n min-width: 200px;\n}\n\n.viz-select-case.selected {\n border-color: var(--green);\n background: rgba(63,185,80,0.1);\n color: var(--green);\n}\n\n.viz-select-case.waiting { color: var(--text-muted); }\n\n.viz-flow {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n justify-content: center;\n}\n\n/* ===== EXERCISE CARDS ===== */\n.exercise-card {\n background: var(--bg-secondary);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 14px;\n margin-bottom: 10px;\n}\n\n.exercise-card.expandable { cursor: pointer; }\n.exercise-card.expandable:active { background: var(--bg-tertiary); }\n\n.exercise-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n margin-bottom: 6px;\n gap: 8px;\n}\n\n.exercise-title { font-weight: 600; font-size: 14px; }\n\n.exercise-type {\n font-size: 10px;\n padding: 3px 8px;\n border-radius: 10px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n white-space: nowrap;\n flex-shrink: 0;\n}\n\n.exercise-type.coding { background: rgba(88,166,255,0.15); color: var(--accent); }\n.exercise-type.quiz { background: rgba(188,140,255,0.15); color: var(--purple); }\n.exercise-type.project { background: rgba(210,153,34,0.15); color: var(--yellow); }\n.exercise-type.assignment { background: rgba(248,81,73,0.15); color: var(--red); }\n\n.exercise-desc {\n color: var(--text-muted);\n font-size: 13px;\n line-height: 1.5;\n margin-bottom: 10px;\n}\n\n.exercise-meta {\n display: flex;\n gap: 12px;\n font-size: 11px;\n color: var(--text-muted);\n flex-wrap: wrap;\n}\n\n.exercise-expand-icon {\n font-size: 12px;\n color: var(--text-muted);\n transition: transform 0.2s;\n flex-shrink: 0;\n}\n\n.exercise-detail {\n display: none;\n margin-top: 12px;\n padding-top: 12px;\n border-top: 1px solid var(--border);\n}\n\n.exercise-detail.open { display: block; }\n\n.exercise-detail h4 {\n font-size: 12px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n color: var(--text-muted);\n margin-bottom: 8px;\n margin-top: 14px;\n}\n\n.exercise-detail h4:first-child { margin-top: 0; }\n\n.exercise-detail p, .exercise-detail li {\n font-size: 13px;\n line-height: 1.6;\n color: var(--text);\n}\n\n.exercise-detail ul { padding-left: 18px; margin-bottom: 8px; }\n.exercise-detail li { margin-bottom: 4px; }\n\n.exercise-detail pre {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 6px;\n padding: 12px;\n overflow-x: auto;\n margin: 8px 0;\n -webkit-overflow-scrolling: touch;\n}\n\n.exercise-detail code {\n font-family: 'SF Mono', 'Fira Code', monospace;\n font-size: 12px;\n}\n\n.exercise-detail :not(pre) > code {\n background: var(--bg-tertiary);\n padding: 1px 5px;\n border-radius: 3px;\n color: var(--accent);\n}\n\n.exercise-detail pre code {\n background: none;\n padding: 0;\n color: var(--text);\n}\n\n/* Test cases */\n.test-case {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 6px;\n margin-bottom: 8px;\n overflow: hidden;\n}\n\n.test-case-header {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 12px;\n font-size: 12px;\n font-weight: 600;\n font-family: 'SF Mono', 'Fira Code', monospace;\n background: var(--bg-tertiary);\n border-bottom: 1px solid var(--border);\n}\n\n.test-status {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n flex-shrink: 0;\n}\n\n.test-status.pass { background: var(--green); }\n.test-status.fail { background: var(--red); }\n.test-status.pending { background: var(--bg-tertiary); border: 1.5px solid var(--text-muted); }\n\n.test-case-body {\n padding: 10px 12px;\n font-size: 12px;\n font-family: 'SF Mono', 'Fira Code', monospace;\n color: var(--text-muted);\n line-height: 1.5;\n}\n\n/* Quiz questions */\n.quiz-question {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 6px;\n padding: 14px;\n margin-bottom: 10px;\n}\n\n.quiz-question p { font-size: 14px; margin-bottom: 10px; }\n\n.quiz-option {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 12px;\n margin-bottom: 4px;\n border: 1px solid var(--border);\n border-radius: 6px;\n cursor: pointer;\n font-size: 13px;\n transition: all 0.15s;\n}\n\n.quiz-option:hover { border-color: var(--accent); background: rgba(88,166,255,0.05); }\n.quiz-option.selected { border-color: var(--accent); background: rgba(88,166,255,0.1); color: var(--accent); }\n.quiz-option.correct { border-color: var(--green); background: rgba(63,185,80,0.1); color: var(--green); }\n.quiz-option.incorrect { border-color: var(--red); background: rgba(248,81,73,0.1); color: var(--red); }\n\n/* Action buttons */\n.exercise-actions {\n display: flex;\n gap: 8px;\n margin-top: 14px;\n flex-wrap: wrap;\n}\n\n.exercise-action-btn {\n padding: 10px 16px;\n border-radius: 6px;\n font-size: 13px;\n font-weight: 600;\n font-family: inherit;\n cursor: pointer;\n border: none;\n flex: 1;\n min-width: 120px;\n text-align: center;\n}\n\n.btn-primary { background: var(--accent); color: #0d1117; }\n.btn-secondary { background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); }\n.btn-success { background: rgba(63,185,80,0.15); border: 1px solid var(--green); color: var(--green); }\n\n/* Exercise progress bar */\n.exercise-progress {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-top: 12px;\n padding: 10px 12px;\n background: var(--bg);\n border-radius: 6px;\n font-size: 12px;\n}\n\n.exercise-progress-bar {\n flex: 1;\n height: 6px;\n background: var(--bg-tertiary);\n border-radius: 3px;\n overflow: hidden;\n}\n\n.exercise-progress-fill { height: 100%; border-radius: 3px; }\n.exercise-progress-fill.green { background: var(--green); }\n.exercise-progress-fill.yellow { background: var(--yellow); }\n\n/* ===== SEARCH ===== */\n.search-bar {\n position: relative;\n margin-bottom: 16px;\n}\n\n.search-bar input {\n width: 100%;\n padding: 12px 16px;\n background: var(--bg-secondary);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n color: var(--text);\n font-size: 14px;\n font-family: inherit;\n outline: none;\n}\n\n.search-bar input:focus { border-color: var(--accent); }\n.search-bar input::placeholder { color: var(--text-muted); }\n\n.search-result-item {\n padding: 12px 14px;\n cursor: pointer;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n margin-bottom: 8px;\n background: var(--bg-secondary);\n transition: background 0.1s;\n}\n\n.search-result-item:hover { background: var(--bg-tertiary); }\n\n.search-result-meta {\n font-size: 11px;\n color: var(--text-muted);\n margin-bottom: 4px;\n display: flex;\n gap: 8px;\n}\n\n.search-result-content {\n font-size: 13px;\n color: var(--text);\n line-height: 1.5;\n max-height: 60px;\n overflow: hidden;\n}\n\n.search-no-results {\n padding: 32px 20px;\n text-align: center;\n color: var(--text-muted);\n}\n\n/* Search modal (desktop) */\n.modal {\n position: fixed;\n inset: 0;\n z-index: 200;\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding-top: 15vh;\n}\n\n.modal-backdrop {\n position: absolute;\n inset: 0;\n background: rgba(0,0,0,0.6);\n backdrop-filter: blur(4px);\n}\n\n.modal-content {\n position: relative;\n width: 600px;\n max-width: 90vw;\n max-height: 500px;\n background: var(--bg-secondary);\n border: 1px solid var(--border);\n border-radius: 12px;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n box-shadow: 0 16px 48px rgba(0,0,0,0.4);\n}\n\n.modal-content input {\n width: 100%;\n padding: 16px 20px;\n background: transparent;\n border: none;\n border-bottom: 1px solid var(--border);\n color: var(--text);\n font-size: 16px;\n outline: none;\n font-family: inherit;\n}\n\n.modal-content input::placeholder { color: var(--text-muted); }\n\n.modal-results {\n overflow-y: auto;\n max-height: 400px;\n}\n\n/* ===== EMPTY STATES ===== */\n.empty-state {\n text-align: center;\n padding: 48px 16px;\n color: var(--text-muted);\n}\n\n.empty-state p { margin-bottom: 8px; }\n\n.empty-state code {\n background: var(--bg-tertiary);\n padding: 2px 6px;\n border-radius: 4px;\n font-size: 12px;\n}\n\n/* ===== KEYBOARD SHORTCUTS ===== */\nkbd {\n background: var(--bg-tertiary);\n border: 1px solid var(--border);\n border-radius: 4px;\n padding: 2px 6px;\n font-size: 11px;\n font-family: inherit;\n color: var(--text-muted);\n}\n\n/* ===== SCROLLBAR ===== */\n::-webkit-scrollbar { width: 8px; }\n::-webkit-scrollbar-track { background: transparent; }\n::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }\n::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }\n\n/* ===== SSE STATUS ===== */\n.sse-dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n display: inline-block;\n}\n\n.sse-dot.connected { background: var(--green); }\n.sse-dot.disconnected { background: var(--red); }\n\n/* ===== DESKTOP LAYOUT ===== */\n@media (min-width: 769px) {\n .mobile-nav { display: none; }\n body { display: flex; height: 100vh; overflow: hidden; }\n\n #desktop-sidebar {\n display: flex !important;\n width: 300px;\n min-width: 300px;\n background: var(--bg-secondary);\n border-right: 1px solid var(--border);\n flex-direction: column;\n overflow: hidden;\n }\n\n #desktop-sidebar .sidebar-inner {\n flex: 1;\n overflow-y: auto;\n padding: 16px;\n }\n\n #desktop-sidebar .sidebar-footer {\n padding: 12px 16px;\n border-top: 1px solid var(--border);\n font-size: 12px;\n color: var(--text-muted);\n display: flex;\n align-items: center;\n gap: 6px;\n }\n\n .page-container {\n flex: 1;\n overflow-y: auto;\n padding: 32px 48px;\n }\n\n .page { padding: 0 0 32px; }\n}\n\n@media (max-width: 768px) {\n #desktop-sidebar { display: none !important; }\n .page-container { display: contents; }\n}\n\n/* ===== RESOURCES ===== */\n.resources-list {\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n}\n\n.resource-card {\n display: flex;\n flex-direction: column;\n padding: 0.75rem 1rem;\n background: var(--bg-secondary);\n border: 1px solid var(--border);\n border-radius: 8px;\n text-decoration: none;\n color: var(--text);\n transition: border-color 0.15s, background 0.15s;\n}\n\n.resource-card:hover {\n border-color: var(--accent);\n background: var(--bg-tertiary);\n}\n\n.resource-title {\n font-weight: 500;\n}\n\n.resource-url {\n font-size: 0.8rem;\n color: var(--text-muted);\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n/* ===== EXERCISE EXPAND UX ===== */\n.exercise-header {\n cursor: pointer;\n}\n\n.exercise-header:hover {\n background: var(--bg-tertiary);\n border-radius: var(--radius);\n}\n\n.exercise-card.open .exercise-expand-icon {\n transform: rotate(180deg);\n}\n";
23731
+ var stylesCss = "/* ===== CSS VARIABLES (Dark Theme) ===== */\n:root {\n --bg: #0d1117;\n --bg-secondary: #161b22;\n --bg-tertiary: #21262d;\n --border: #30363d;\n --text: #e6edf3;\n --text-muted: #8b949e;\n --accent: #58a6ff;\n --green: #3fb950;\n --yellow: #d29922;\n --red: #f85149;\n --purple: #bc8cff;\n --radius: 8px;\n}\n\n* { margin: 0; padding: 0; box-sizing: border-box; }\n\nbody {\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;\n background: var(--bg);\n color: var(--text);\n min-height: 100vh;\n overflow-x: hidden;\n}\n\n.hidden { display: none !important; }\n\n/* ===== MOBILE NAV ===== */\n.mobile-nav {\n position: fixed;\n bottom: 0;\n left: 0;\n right: 0;\n background: var(--bg-secondary);\n border-top: 1px solid var(--border);\n display: flex;\n z-index: 100;\n padding-bottom: env(safe-area-inset-bottom);\n}\n\n.nav-btn {\n flex: 1;\n padding: 10px 4px;\n background: none;\n border: none;\n color: var(--text-muted);\n font-size: 10px;\n font-family: inherit;\n cursor: pointer;\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 3px;\n transition: color 0.15s;\n}\n\n.nav-btn.active { color: var(--accent); }\n.nav-btn svg { width: 22px; height: 22px; }\n\n/* ===== PAGES ===== */\n.page { display: none; padding: 16px 16px 80px; }\n.page.active { display: block; }\n\n/* ===== HEADER ===== */\n.page-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n margin-bottom: 16px;\n}\n\n.page-header h1 {\n font-size: 20px;\n font-weight: 700;\n color: var(--accent);\n}\n\n/* ===== SUBJECT SWITCHER ===== */\n.subject-switcher {\n display: flex;\n gap: 6px;\n margin-bottom: 14px;\n overflow-x: auto;\n padding-bottom: 4px;\n -webkit-overflow-scrolling: touch;\n}\n\n.subject-btn {\n padding: 6px 14px;\n background: var(--bg-tertiary);\n border: 1px solid var(--border);\n border-radius: 16px;\n color: var(--text-muted);\n font-size: 13px;\n cursor: pointer;\n font-family: inherit;\n white-space: nowrap;\n flex-shrink: 0;\n}\n\n.subject-btn.active { color: var(--accent); border-color: var(--accent); background: rgba(88,166,255,0.1); }\n\n/* ===== PROGRESS BAR ===== */\n.progress-bar {\n position: relative;\n height: 26px;\n background: var(--bg-tertiary);\n border-radius: 13px;\n overflow: hidden;\n margin-bottom: 16px;\n}\n\n.progress-fill {\n height: 100%;\n background: linear-gradient(90deg, var(--green), var(--accent));\n border-radius: 13px;\n transition: width 0.5s ease;\n}\n\n.progress-text {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 12px;\n font-weight: 600;\n}\n\n/* ===== STATS GRID ===== */\n.stats-grid {\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 10px;\n margin-bottom: 20px;\n}\n\n.stat-card {\n background: var(--bg-secondary);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 14px;\n text-align: center;\n}\n\n.stat-value {\n font-size: 24px;\n font-weight: 700;\n color: var(--accent);\n}\n\n.stat-value.green { color: var(--green); }\n.stat-value.yellow { color: var(--yellow); }\n.stat-value.purple { color: var(--purple); }\n\n.stat-label {\n font-size: 11px;\n color: var(--text-muted);\n margin-top: 2px;\n}\n\n/* ===== SECTION DIVIDER ===== */\n.section-divider {\n font-size: 11px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n color: var(--text-muted);\n margin: 16px 0 10px;\n}\n\n/* ===== PHASE TREE (Topics page) ===== */\n.phase-group { margin-bottom: 8px; }\n\n.phase-header {\n padding: 10px 14px;\n font-size: 12px;\n font-weight: 700;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n color: var(--text-muted);\n cursor: pointer;\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: var(--bg-secondary);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n user-select: none;\n}\n\n.phase-header:hover { color: var(--text); }\n\n.phase-header .chevron {\n transition: transform 0.2s;\n font-size: 14px;\n}\n\n.phase-header.collapsed .chevron { transform: rotate(-90deg); }\n\n.phase-topics { padding: 4px 0; }\n.phase-topics.collapsed { display: none; }\n\n.topic-item {\n padding: 10px 14px 10px 20px;\n font-size: 14px;\n cursor: pointer;\n display: flex;\n align-items: center;\n gap: 10px;\n color: var(--text-muted);\n border-left: 3px solid transparent;\n transition: all 0.15s;\n}\n\n.topic-item:active { background: var(--bg-tertiary); }\n.topic-item.active { background: var(--bg-tertiary); color: var(--text); border-left-color: var(--accent); }\n\n.status-dot {\n width: 10px;\n height: 10px;\n border-radius: 50%;\n flex-shrink: 0;\n}\n\n.status-dot.done { background: var(--green); }\n.status-dot.in_progress { background: var(--yellow); }\n.status-dot.todo { background: var(--bg-tertiary); border: 1.5px solid var(--text-muted); }\n\n.topic-count {\n margin-left: auto;\n font-size: 11px;\n color: var(--text-muted);\n}\n\n/* ===== BACK BUTTON ===== */\n.back-btn {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n background: none;\n border: none;\n color: var(--accent);\n font-size: 14px;\n font-family: inherit;\n cursor: pointer;\n margin-bottom: 12px;\n padding: 4px 0;\n}\n\n/* ===== TOPIC DETAIL ===== */\n.topic-title-row {\n display: flex;\n align-items: center;\n gap: 10px;\n margin-bottom: 6px;\n flex-wrap: wrap;\n}\n\n.topic-title-row h2 { font-size: 18px; font-weight: 600; }\n\n.badge {\n font-size: 10px;\n font-weight: 600;\n padding: 3px 10px;\n border-radius: 12px;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n}\n\n.badge.todo { background: var(--bg-tertiary); color: var(--text-muted); }\n.badge.in_progress { background: rgba(210,153,34,0.15); color: var(--yellow); }\n.badge.done { background: rgba(63,185,80,0.15); color: var(--green); }\n\n.topic-desc {\n color: var(--text-muted);\n font-size: 13px;\n margin-bottom: 14px;\n line-height: 1.4;\n}\n\n/* ===== TABS ===== */\n.tabs {\n display: flex;\n gap: 4px;\n margin-bottom: 16px;\n overflow-x: auto;\n padding-bottom: 4px;\n -webkit-overflow-scrolling: touch;\n}\n\n.tab-btn {\n padding: 7px 14px;\n background: transparent;\n border: 1px solid var(--border);\n border-radius: 6px;\n color: var(--text-muted);\n cursor: pointer;\n font-size: 13px;\n font-family: inherit;\n white-space: nowrap;\n flex-shrink: 0;\n transition: all 0.15s;\n}\n\n.tab-btn:hover { color: var(--text); background: var(--bg-tertiary); }\n.tab-btn.active { color: var(--accent); border-color: var(--accent); background: rgba(88,166,255,0.1); }\n\n.tab-panel { display: none; }\n.tab-panel.active { display: block; }\n\n/* ===== Q&A CARDS ===== */\n.qa-card {\n border: 1px solid var(--border);\n border-radius: var(--radius);\n overflow: hidden;\n margin-bottom: 14px;\n}\n\n.qa-question { background: var(--bg-secondary); border-bottom: 1px solid var(--border); }\n.qa-answer { background: var(--bg-secondary); }\n.qa-answer + .qa-answer { border-top: 1px solid var(--border); }\n\n.entry-card {\n background: var(--bg-secondary);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n overflow: hidden;\n margin-bottom: 14px;\n}\n\n.entry-header {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 14px;\n background: var(--bg-tertiary);\n font-size: 11px;\n color: var(--text-muted);\n border-bottom: 1px solid var(--border);\n}\n\n.entry-kind {\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n}\n\n.entry-kind.question { color: var(--accent); }\n.entry-kind.answer { color: var(--green); }\n.entry-kind.note { color: var(--purple); }\n\n.entry-body {\n padding: 14px;\n font-size: 14px;\n line-height: 1.6;\n background: var(--bg-secondary);\n}\n\n.entry-body p { margin-bottom: 10px; }\n.entry-body p:last-child { margin-bottom: 0; }\n\n.entry-body h1, .entry-body h2, .entry-body h3 {\n margin-top: 16px;\n margin-bottom: 8px;\n}\n\n.entry-body h1:first-child, .entry-body h2:first-child, .entry-body h3:first-child {\n margin-top: 0;\n}\n\n.entry-body code {\n font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace;\n font-size: 13px;\n}\n\n.entry-body :not(pre) > code {\n background: var(--bg-tertiary);\n padding: 2px 5px;\n border-radius: 4px;\n font-size: 12px;\n}\n\n.entry-body pre {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 6px;\n padding: 12px;\n overflow-x: auto;\n margin: 8px 0;\n -webkit-overflow-scrolling: touch;\n}\n\n.entry-body pre code {\n background: none;\n padding: 0;\n font-size: 12px;\n color: var(--text);\n}\n\n.entry-body ul, .entry-body ol {\n padding-left: 24px;\n margin-bottom: 12px;\n}\n\n.entry-body li { margin-bottom: 4px; }\n\n.entry-body blockquote {\n border-left: 3px solid var(--accent);\n padding-left: 16px;\n color: var(--text-muted);\n margin: 12px 0;\n}\n\n.entry-body table {\n width: 100%;\n border-collapse: collapse;\n margin: 12px 0;\n}\n\n.entry-body th, .entry-body td {\n border: 1px solid var(--border);\n padding: 8px 12px;\n text-align: left;\n}\n\n.entry-body th {\n background: var(--bg-tertiary);\n font-weight: 600;\n}\n\n/* ===== VIZ PANEL ===== */\n.viz-selector {\n display: flex;\n gap: 6px;\n margin-bottom: 14px;\n overflow-x: auto;\n -webkit-overflow-scrolling: touch;\n padding-bottom: 4px;\n}\n\n.viz-select-btn {\n padding: 7px 12px;\n background: var(--bg-secondary);\n border: 1px solid var(--border);\n border-radius: 6px;\n color: var(--text-muted);\n cursor: pointer;\n font-size: 12px;\n font-family: inherit;\n white-space: nowrap;\n flex-shrink: 0;\n transition: all 0.15s;\n}\n\n.viz-select-btn:hover { color: var(--text); border-color: var(--text-muted); }\n.viz-select-btn.active { color: var(--accent); border-color: var(--accent); background: rgba(88,166,255,0.1); }\n\n.viz-stage {\n background: var(--bg-secondary);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n overflow: hidden;\n}\n\n.viz-canvas {\n padding: 20px 12px;\n min-height: 140px;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n flex-wrap: wrap;\n}\n\n.viz-description {\n padding: 14px;\n border-top: 1px solid var(--border);\n font-size: 13px;\n line-height: 1.6;\n}\n\n.viz-description code {\n background: var(--bg-tertiary);\n padding: 2px 5px;\n border-radius: 4px;\n font-size: 11px;\n font-family: 'SF Mono', 'Fira Code', monospace;\n color: var(--accent);\n}\n\n.viz-controls {\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 14px;\n padding: 10px;\n border-top: 1px solid var(--border);\n background: var(--bg-tertiary);\n}\n\n.viz-controls button {\n padding: 8px 18px;\n background: var(--bg-secondary);\n border: 1px solid var(--border);\n border-radius: 6px;\n color: var(--text);\n cursor: pointer;\n font-size: 13px;\n font-family: inherit;\n transition: all 0.15s;\n}\n\n.viz-controls button:hover:not(:disabled) {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.viz-controls button:disabled { opacity: 0.3; cursor: default; }\n\n.viz-step-label { font-size: 12px; color: var(--text-muted); min-width: 80px; text-align: center; }\n\n/* Viz primitives */\n.viz-box {\n padding: 10px 14px;\n border-radius: 8px;\n font-size: 12px;\n font-weight: 600;\n font-family: 'SF Mono', 'Fira Code', monospace;\n text-align: center;\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 4px;\n}\n\n.viz-box-label {\n font-size: 10px;\n color: var(--text-muted);\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n font-weight: 400;\n}\n\n.viz-arrow { font-size: 20px; color: var(--accent); }\n\n.box-blue { background: rgba(88,166,255,0.15); border: 1px solid var(--accent); color: var(--accent); }\n.box-green { background: rgba(63,185,80,0.15); border: 1px solid var(--green); color: var(--green); }\n.box-yellow { background: rgba(210,153,34,0.15); border: 1px solid var(--yellow); color: var(--yellow); }\n.box-purple { background: rgba(188,140,255,0.15); border: 1px solid var(--purple); color: var(--purple); }\n\n.viz-slot {\n width: 28px;\n height: 28px;\n border: 1px solid var(--border);\n border-radius: 4px;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 10px;\n font-family: 'SF Mono', 'Fira Code', monospace;\n transition: all 0.3s ease;\n}\n\n.viz-slot.filled {\n background: rgba(88,166,255,0.2);\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.viz-slot.empty { color: var(--text-muted); }\n\n.viz-select-case {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 14px;\n border: 1px solid var(--border);\n border-radius: 6px;\n font-size: 12px;\n font-family: 'SF Mono', 'Fira Code', monospace;\n transition: all 0.3s ease;\n min-width: 200px;\n}\n\n.viz-select-case.selected {\n border-color: var(--green);\n background: rgba(63,185,80,0.1);\n color: var(--green);\n}\n\n.viz-select-case.waiting { color: var(--text-muted); }\n\n.viz-flow {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n justify-content: center;\n}\n\n/* ===== EXERCISE CARDS ===== */\n.exercise-card {\n background: var(--bg-secondary);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 14px;\n margin-bottom: 10px;\n}\n\n.exercise-card.expandable { cursor: pointer; }\n.exercise-card.expandable:active { background: var(--bg-tertiary); }\n\n.exercise-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n margin-bottom: 6px;\n gap: 8px;\n}\n\n.exercise-title { font-weight: 600; font-size: 14px; }\n\n.exercise-type {\n font-size: 10px;\n padding: 3px 8px;\n border-radius: 10px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n white-space: nowrap;\n flex-shrink: 0;\n}\n\n.exercise-type.coding { background: rgba(88,166,255,0.15); color: var(--accent); }\n.exercise-type.quiz { background: rgba(188,140,255,0.15); color: var(--purple); }\n.exercise-type.project { background: rgba(210,153,34,0.15); color: var(--yellow); }\n.exercise-type.assignment { background: rgba(248,81,73,0.15); color: var(--red); }\n\n.exercise-desc {\n color: var(--text-muted);\n font-size: 13px;\n line-height: 1.5;\n margin-bottom: 10px;\n}\n\n.exercise-meta {\n display: flex;\n gap: 12px;\n font-size: 11px;\n color: var(--text-muted);\n flex-wrap: wrap;\n}\n\n.exercise-expand-icon {\n font-size: 12px;\n color: var(--text-muted);\n transition: transform 0.2s;\n flex-shrink: 0;\n}\n\n.exercise-detail {\n display: none;\n margin-top: 12px;\n padding-top: 12px;\n border-top: 1px solid var(--border);\n}\n\n.exercise-detail.open { display: block; }\n\n.exercise-detail h4 {\n font-size: 12px;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n color: var(--text-muted);\n margin-bottom: 8px;\n margin-top: 14px;\n}\n\n.exercise-detail h4:first-child { margin-top: 0; }\n\n.exercise-detail p, .exercise-detail li {\n font-size: 13px;\n line-height: 1.6;\n color: var(--text);\n}\n\n.exercise-detail ul { padding-left: 18px; margin-bottom: 8px; }\n.exercise-detail li { margin-bottom: 4px; }\n\n.exercise-detail pre {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 6px;\n padding: 12px;\n overflow-x: auto;\n margin: 8px 0;\n -webkit-overflow-scrolling: touch;\n}\n\n.exercise-detail code {\n font-family: 'SF Mono', 'Fira Code', monospace;\n font-size: 12px;\n}\n\n.exercise-detail :not(pre) > code {\n background: var(--bg-tertiary);\n padding: 1px 5px;\n border-radius: 3px;\n color: var(--accent);\n}\n\n.exercise-detail pre code {\n background: none;\n padding: 0;\n color: var(--text);\n}\n\n/* Test cases */\n.test-case {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 6px;\n margin-bottom: 8px;\n overflow: hidden;\n}\n\n.test-case-header {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 12px;\n font-size: 12px;\n font-weight: 600;\n font-family: 'SF Mono', 'Fira Code', monospace;\n background: var(--bg-tertiary);\n border-bottom: 1px solid var(--border);\n}\n\n.test-status {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n flex-shrink: 0;\n}\n\n.test-status.pass { background: var(--green); }\n.test-status.fail { background: var(--red); }\n.test-status.pending { background: var(--bg-tertiary); border: 1.5px solid var(--text-muted); }\n\n.test-case-body {\n padding: 10px 12px;\n font-size: 12px;\n font-family: 'SF Mono', 'Fira Code', monospace;\n color: var(--text-muted);\n line-height: 1.5;\n}\n\n/* Quiz questions */\n.quiz-question {\n background: var(--bg);\n border: 1px solid var(--border);\n border-radius: 6px;\n padding: 14px;\n margin-bottom: 10px;\n}\n\n.quiz-question p { font-size: 14px; margin-bottom: 10px; }\n\n.quiz-option {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 12px;\n margin-bottom: 4px;\n border: 1px solid var(--border);\n border-radius: 6px;\n cursor: pointer;\n font-size: 13px;\n transition: all 0.15s;\n}\n\n.quiz-option:hover { border-color: var(--accent); background: rgba(88,166,255,0.05); }\n.quiz-option.selected { border-color: var(--accent); background: rgba(88,166,255,0.1); color: var(--accent); }\n.quiz-option.correct { border-color: var(--green); background: rgba(63,185,80,0.1); color: var(--green); }\n.quiz-option.incorrect { border-color: var(--red); background: rgba(248,81,73,0.1); color: var(--red); }\n\n/* Action buttons */\n.exercise-actions {\n display: flex;\n gap: 8px;\n margin-top: 14px;\n flex-wrap: wrap;\n}\n\n.exercise-action-btn {\n padding: 10px 16px;\n border-radius: 6px;\n font-size: 13px;\n font-weight: 600;\n font-family: inherit;\n cursor: pointer;\n border: none;\n flex: 1;\n min-width: 120px;\n text-align: center;\n}\n\n.btn-primary { background: var(--accent); color: #0d1117; }\n.btn-secondary { background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); }\n.btn-success { background: rgba(63,185,80,0.15); border: 1px solid var(--green); color: var(--green); }\n\n/* Exercise progress bar */\n.exercise-progress {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-top: 12px;\n padding: 10px 12px;\n background: var(--bg);\n border-radius: 6px;\n font-size: 12px;\n}\n\n.exercise-progress-bar {\n flex: 1;\n height: 6px;\n background: var(--bg-tertiary);\n border-radius: 3px;\n overflow: hidden;\n}\n\n.exercise-progress-fill { height: 100%; border-radius: 3px; }\n.exercise-progress-fill.green { background: var(--green); }\n.exercise-progress-fill.yellow { background: var(--yellow); }\n\n/* ===== SEARCH ===== */\n.search-bar {\n position: relative;\n margin-bottom: 16px;\n}\n\n.search-bar input {\n width: 100%;\n padding: 12px 16px;\n background: var(--bg-secondary);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n color: var(--text);\n font-size: 14px;\n font-family: inherit;\n outline: none;\n}\n\n.search-bar input:focus { border-color: var(--accent); }\n.search-bar input::placeholder { color: var(--text-muted); }\n\n.search-result-item {\n padding: 12px 14px;\n cursor: pointer;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n margin-bottom: 8px;\n background: var(--bg-secondary);\n transition: background 0.1s;\n}\n\n.search-result-item:hover { background: var(--bg-tertiary); }\n\n.search-result-meta {\n font-size: 11px;\n color: var(--text-muted);\n margin-bottom: 4px;\n display: flex;\n gap: 8px;\n}\n\n.search-result-content {\n font-size: 13px;\n color: var(--text);\n line-height: 1.5;\n max-height: 60px;\n overflow: hidden;\n}\n\n.search-no-results {\n padding: 32px 20px;\n text-align: center;\n color: var(--text-muted);\n}\n\n/* Search modal (desktop) */\n.modal {\n position: fixed;\n inset: 0;\n z-index: 200;\n display: flex;\n align-items: flex-start;\n justify-content: center;\n padding-top: 15vh;\n}\n\n.modal-backdrop {\n position: absolute;\n inset: 0;\n background: rgba(0,0,0,0.6);\n backdrop-filter: blur(4px);\n}\n\n.modal-content {\n position: relative;\n width: 600px;\n max-width: 90vw;\n max-height: 500px;\n background: var(--bg-secondary);\n border: 1px solid var(--border);\n border-radius: 12px;\n overflow: hidden;\n display: flex;\n flex-direction: column;\n box-shadow: 0 16px 48px rgba(0,0,0,0.4);\n}\n\n.modal-content input {\n width: 100%;\n padding: 16px 20px;\n background: transparent;\n border: none;\n border-bottom: 1px solid var(--border);\n color: var(--text);\n font-size: 16px;\n outline: none;\n font-family: inherit;\n}\n\n.modal-content input::placeholder { color: var(--text-muted); }\n\n.modal-results {\n overflow-y: auto;\n max-height: 400px;\n}\n\n/* ===== EMPTY STATES ===== */\n.empty-state {\n text-align: center;\n padding: 48px 16px;\n color: var(--text-muted);\n}\n\n.empty-state p { margin-bottom: 8px; }\n\n.empty-state code {\n background: var(--bg-tertiary);\n padding: 2px 6px;\n border-radius: 4px;\n font-size: 12px;\n}\n\n/* ===== KEYBOARD SHORTCUTS ===== */\nkbd {\n background: var(--bg-tertiary);\n border: 1px solid var(--border);\n border-radius: 4px;\n padding: 2px 6px;\n font-size: 11px;\n font-family: inherit;\n color: var(--text-muted);\n}\n\n/* ===== SCROLLBAR ===== */\n::-webkit-scrollbar { width: 8px; }\n::-webkit-scrollbar-track { background: transparent; }\n::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }\n::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }\n\n/* ===== SSE STATUS ===== */\n.sse-dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n display: inline-block;\n}\n\n.sse-dot.connected { background: var(--green); }\n.sse-dot.disconnected { background: var(--red); }\n\n/* ===== DESKTOP LAYOUT ===== */\n@media (min-width: 769px) {\n .mobile-nav { display: none; }\n body { display: flex; height: 100vh; overflow: hidden; }\n\n #desktop-sidebar {\n display: flex !important;\n width: 300px;\n min-width: 300px;\n background: var(--bg-secondary);\n border-right: 1px solid var(--border);\n flex-direction: column;\n overflow: hidden;\n }\n\n #desktop-sidebar .sidebar-inner {\n flex: 1;\n overflow-y: auto;\n padding: 16px;\n }\n\n #desktop-sidebar .sidebar-footer {\n padding: 12px 16px;\n border-top: 1px solid var(--border);\n font-size: 12px;\n color: var(--text-muted);\n display: flex;\n align-items: center;\n gap: 6px;\n }\n\n .page-container {\n flex: 1;\n overflow-y: auto;\n padding: 32px 48px;\n }\n\n .page { padding: 0 0 32px; }\n}\n\n@media (max-width: 768px) {\n #desktop-sidebar { display: none !important; }\n .page-container { display: contents; }\n}\n\n/* ===== RESOURCES ===== */\n.resources-list {\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n}\n\n.resource-card {\n display: flex;\n flex-direction: column;\n padding: 0.75rem 1rem;\n background: var(--bg-secondary);\n border: 1px solid var(--border);\n border-radius: 8px;\n text-decoration: none;\n color: var(--text);\n transition: border-color 0.15s, background 0.15s;\n}\n\n.resource-card:hover {\n border-color: var(--accent);\n background: var(--bg-tertiary);\n}\n\n.resource-title {\n font-weight: 500;\n}\n\n.resource-url {\n font-size: 0.8rem;\n color: var(--text-muted);\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n/* ===== EXERCISE EXPAND UX ===== */\n.exercise-header {\n cursor: pointer;\n}\n\n.exercise-header:hover {\n background: var(--bg-tertiary);\n border-radius: var(--radius);\n}\n\n.exercise-card.open .exercise-expand-icon {\n transform: rotate(180deg);\n}\n\n/* PDF resource cards */\n.resource-pdf {\n cursor: default;\n}\n\n.resource-pdf-header {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n cursor: pointer;\n padding: 0.75rem 1rem;\n}\n\n.resource-badge {\n font-size: 0.7rem;\n font-weight: 600;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n padding: 0.15rem 0.5rem;\n border-radius: 4px;\n background: #1f6feb33;\n color: #58a6ff;\n}\n\n.resource-chevron {\n margin-left: auto;\n font-size: 0.7rem;\n transition: transform 0.2s;\n color: #8b949e;\n}\n\n.resource-chevron.open {\n transform: rotate(180deg);\n}\n\n.resource-pdf-viewer {\n border-top: 1px solid #30363d;\n}\n\n.resource-pdf-viewer iframe {\n width: 100%;\n height: 70vh;\n border: none;\n background: #0d1117;\n}\n";
23685
23732
 
23686
23733
  const STATIC_FILES = {
23687
23734
  '/': { content: indexHtml, contentType: 'text/html; charset=utf-8' },
@@ -23781,6 +23828,11 @@ class DashboardServer {
23781
23828
  handlePhases(this.curriculumSvc)(req, res);
23782
23829
  return;
23783
23830
  }
23831
+ // GET /api/resources/:id/file
23832
+ if (method === 'GET' && /^\/api\/resources\/\d+\/file$/.test(path)) {
23833
+ handleResourceFile(this.resourceSvc)(req, res);
23834
+ return;
23835
+ }
23784
23836
  // GET /api/topics/:id/viz
23785
23837
  if (method === 'GET' && /^\/api\/topics\/\d+\/viz$/.test(path)) {
23786
23838
  handleTopicViz(this.vizSvc)(req, res);
@@ -16,3 +16,4 @@ export declare function handleTopicExercises(exerciseSvc: ExerciseService): (req
16
16
  export declare function handleRunTests(exerciseSvc: ExerciseService): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
17
17
  export declare function handleSubmitQuiz(exerciseSvc: ExerciseService): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
18
18
  export declare function handleSearch(qaSvc: QAService): (req: IncomingMessage, res: ServerResponse) => void;
19
+ export declare function handleResourceFile(resourceSvc: ResourceService): (req: IncomingMessage, res: ServerResponse) => void;
@@ -1,3 +1,5 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
1
3
  // ── Helpers ────────────────────────────────────────────────────────────────
2
4
  export function writeJSON(res, data, status = 200) {
3
5
  res.writeHead(status, { 'Content-Type': 'application/json' });
@@ -159,4 +161,48 @@ export function handleSearch(qaSvc) {
159
161
  }
160
162
  };
161
163
  }
164
+ export function handleResourceFile(resourceSvc) {
165
+ return (req, res) => {
166
+ const id = extractId(req.url ?? '', '/api/resources/');
167
+ if (id === null) {
168
+ writeError(res, 400, 'Invalid resource ID');
169
+ return;
170
+ }
171
+ const resource = resourceSvc.getById(id);
172
+ if (!resource) {
173
+ writeError(res, 404, 'Resource not found');
174
+ return;
175
+ }
176
+ // Only serve file:// URLs — never proxy remote URLs
177
+ if (!resource.url.startsWith('file://')) {
178
+ writeError(res, 400, 'Resource is not a local file');
179
+ return;
180
+ }
181
+ const filePath = decodeURIComponent(new URL(resource.url).pathname);
182
+ const ext = path.extname(filePath).toLowerCase();
183
+ const mimeTypes = {
184
+ '.pdf': 'application/pdf',
185
+ '.png': 'image/png',
186
+ '.jpg': 'image/jpeg',
187
+ '.jpeg': 'image/jpeg',
188
+ };
189
+ const contentType = mimeTypes[ext];
190
+ if (!contentType) {
191
+ writeError(res, 400, 'Unsupported file type');
192
+ return;
193
+ }
194
+ try {
195
+ const stat = fs.statSync(filePath);
196
+ res.writeHead(200, {
197
+ 'Content-Type': contentType,
198
+ 'Content-Length': stat.size,
199
+ 'Cache-Control': 'private, max-age=3600',
200
+ });
201
+ fs.createReadStream(filePath).pipe(res);
202
+ }
203
+ catch {
204
+ writeError(res, 404, 'File not found on disk');
205
+ }
206
+ };
207
+ }
162
208
  //# sourceMappingURL=api.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"api.js","sourceRoot":"","sources":["../../src/dashboard/api.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"api.js","sourceRoot":"","sources":["../../src/dashboard/api.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  import http from 'node:http';
2
- import { handleSubjects, handlePhases, handleTopic, handleTopicViz, handleTopicExercises, handleTopicResources, handleRunTests, handleSubmitQuiz, handleSearch, writeJSON, } from './api.js';
2
+ import { handleSubjects, handlePhases, handleTopic, handleTopicViz, handleTopicExercises, handleTopicResources, handleResourceFile, handleRunTests, handleSubmitQuiz, handleSearch, writeJSON, } from './api.js';
3
3
  // ── Embedded static content ──
4
4
  // esbuild --loader:.html=text inlines these as strings at bundle time.
5
5
  // @ts-ignore — esbuild text loader
@@ -106,6 +106,11 @@ export class DashboardServer {
106
106
  handlePhases(this.curriculumSvc)(req, res);
107
107
  return;
108
108
  }
109
+ // GET /api/resources/:id/file
110
+ if (method === 'GET' && /^\/api\/resources\/\d+\/file$/.test(path)) {
111
+ handleResourceFile(this.resourceSvc)(req, res);
112
+ return;
113
+ }
109
114
  // GET /api/topics/:id/viz
110
115
  if (method === 'GET' && /^\/api\/topics\/\d+\/viz$/.test(path)) {
111
116
  handleTopicViz(this.vizSvc)(req, res);
@@ -1 +1 @@
1
- {"version":3,"file":"server.js","sourceRoot":"","sources":["../../src/dashboard/server.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../../src/dashboard/server.ts"],"names":[],"mappings":"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"}
@@ -8,6 +8,7 @@ interface ImportResourceInput {
8
8
  export declare class ResourceService {
9
9
  private db;
10
10
  constructor(db: Database);
11
+ getById(id: number): Resource | undefined;
11
12
  addResource(topicId: number, title: string, url: string, source?: string): Resource;
12
13
  listForTopic(topicId: number): Resource[];
13
14
  importResources(resources: ImportResourceInput[]): number;
@@ -3,6 +3,9 @@ export class ResourceService {
3
3
  constructor(db) {
4
4
  this.db = db;
5
5
  }
6
+ getById(id) {
7
+ return this.db.raw.prepare('SELECT * FROM resources WHERE id = ?').get(id);
8
+ }
6
9
  addResource(topicId, title, url, source = 'manual') {
7
10
  const result = this.db.raw.prepare('INSERT INTO resources (topic_id, title, url, source) VALUES (?, ?, ?, ?)').run(topicId, title, url, source);
8
11
  return this.db.raw.prepare('SELECT * FROM resources WHERE id = ?').get(result.lastInsertRowid);
@@ -1 +1 @@
1
- {"version":3,"file":"resources.js","sourceRoot":"","sources":["../../src/services/resources.ts"],"names":[],"mappings":"AASA,MAAM,OAAO,eAAe;IACN;IAApB,YAAoB,EAAY;QAAZ,OAAE,GAAF,EAAE,CAAU;IAAG,CAAC;IAEpC,WAAW,CAAC,OAAe,EAAE,KAAa,EAAE,GAAW,EAAE,SAAiB,QAAQ;QAChF,MAAM,MAAM,GAAG,IAAI,CAAC,EAAE,CAAC,GAAG,CAAC,OAAO,CAChC,0EAA0E,CAC3E,CAAC,GAAG,CAAC,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,MAAM,CAAC,CAAC;QACnC,OAAO,IAAI,CAAC,EAAE,CAAC,GAAG,CAAC,OAAO,CAAC,sCAAsC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,eAAe,CAAa,CAAC;IAC7G,CAAC;IAED,YAAY,CAAC,OAAe;QAC1B,OAAO,IAAI,CAAC,EAAE,CAAC,GAAG,CAAC,OAAO,CACxB,4EAA4E,CAC7E,CAAC,GAAG,CAAC,OAAO,CAAe,CAAC;IAC/B,CAAC;IAED,eAAe,CAAC,SAAgC;QAC9C,MAAM,MAAM,GAAG,IAAI,CAAC,EAAE,CAAC,GAAG,CAAC,OAAO,CAChC,0EAA0E,CAC3E,CAAC;QACF,MAAM,EAAE,GAAG,IAAI,CAAC,EAAE,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC,KAA4B,EAAE,EAAE;YAClE,KAAK,MAAM,CAAC,IAAI,KAAK,EAAE,CAAC;gBACtB,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;YACnD,CAAC;YACD,OAAO,KAAK,CAAC,MAAM,CAAC;QACtB,CAAC,CAAC,CAAC;QACH,OAAO,EAAE,CAAC,SAAS,CAAC,CAAC;IACvB,CAAC;IAED,cAAc,CAAC,EAAU;QACvB,IAAI,CAAC,EAAE,CAAC,GAAG,CAAC,OAAO,CAAC,oCAAoC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IACpE,CAAC;CACF"}
1
+ {"version":3,"file":"resources.js","sourceRoot":"","sources":["../../src/services/resources.ts"],"names":[],"mappings":"AASA,MAAM,OAAO,eAAe;IACN;IAApB,YAAoB,EAAY;QAAZ,OAAE,GAAF,EAAE,CAAU;IAAG,CAAC;IAEpC,OAAO,CAAC,EAAU;QAChB,OAAO,IAAI,CAAC,EAAE,CAAC,GAAG,CAAC,OAAO,CAAC,sCAAsC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAyB,CAAC;IACrG,CAAC;IAED,WAAW,CAAC,OAAe,EAAE,KAAa,EAAE,GAAW,EAAE,SAAiB,QAAQ;QAChF,MAAM,MAAM,GAAG,IAAI,CAAC,EAAE,CAAC,GAAG,CAAC,OAAO,CAChC,0EAA0E,CAC3E,CAAC,GAAG,CAAC,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,MAAM,CAAC,CAAC;QACnC,OAAO,IAAI,CAAC,EAAE,CAAC,GAAG,CAAC,OAAO,CAAC,sCAAsC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,eAAe,CAAa,CAAC;IAC7G,CAAC;IAED,YAAY,CAAC,OAAe;QAC1B,OAAO,IAAI,CAAC,EAAE,CAAC,GAAG,CAAC,OAAO,CACxB,4EAA4E,CAC7E,CAAC,GAAG,CAAC,OAAO,CAAe,CAAC;IAC/B,CAAC;IAED,eAAe,CAAC,SAAgC;QAC9C,MAAM,MAAM,GAAG,IAAI,CAAC,EAAE,CAAC,GAAG,CAAC,OAAO,CAChC,0EAA0E,CAC3E,CAAC;QACF,MAAM,EAAE,GAAG,IAAI,CAAC,EAAE,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC,KAA4B,EAAE,EAAE;YAClE,KAAK,MAAM,CAAC,IAAI,KAAK,EAAE,CAAC;gBACtB,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;YACnD,CAAC;YACD,OAAO,KAAK,CAAC,MAAM,CAAC;QACtB,CAAC,CAAC,CAAC;QACH,OAAO,EAAE,CAAC,SAAS,CAAC,CAAC;IACvB,CAAC;IAED,cAAc,CAAC,EAAU;QACvB,IAAI,CAAC,EAAE,CAAC,GAAG,CAAC,OAAO,CAAC,oCAAoC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IACpE,CAAC;CACF"}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "study-dash-server",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {