@bis-code/study-dash 0.6.0 → 0.7.0
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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +20 -1
- package/package.json +1 -1
- package/server/dist/bundle.mjs +4 -4
- package/server/dist/services/exercises.d.ts +1 -0
- package/server/dist/services/exercises.js +1 -1
- package/server/dist/services/exercises.js.map +1 -1
- package/server/package.json +1 -1
- package/skills/learn/SKILL.md +20 -0
package/README.md
CHANGED
|
@@ -69,6 +69,7 @@ Mobile-responsive: bottom nav on phones, sidebar on desktop.
|
|
|
69
69
|
| `learn_mark_done` | Mark a topic as completed |
|
|
70
70
|
| `learn_get_progress` | Get completion stats |
|
|
71
71
|
| `learn_get_curriculum` | Get the full topic tree |
|
|
72
|
+
| `learn_list_subjects` | List all available subjects |
|
|
72
73
|
|
|
73
74
|
### Q&A
|
|
74
75
|
| Tool | Description |
|
|
@@ -89,6 +90,9 @@ Mobile-responsive: bottom nav on phones, sidebar on desktop.
|
|
|
89
90
|
| `learn_create_exercise` | Create a coding exercise, quiz, project, or assignment |
|
|
90
91
|
| `learn_run_tests` | Execute tests and return pass/fail results |
|
|
91
92
|
| `learn_get_exercises` | List exercises for the current topic |
|
|
93
|
+
| `learn_submit_quiz` | Submit quiz answers and get scored results |
|
|
94
|
+
| `learn_get_exercise_files` | Get source code files for a coding exercise |
|
|
95
|
+
| `learn_save_exercise_files` | Save updated source code for a coding exercise |
|
|
92
96
|
|
|
93
97
|
## Hooks
|
|
94
98
|
|
|
@@ -110,11 +114,25 @@ Mobile-responsive: bottom nav on phones, sidebar on desktop.
|
|
|
110
114
|
|
|
111
115
|
| Type | Validation |
|
|
112
116
|
|------|-----------|
|
|
113
|
-
| **Coding** | Real test execution (`go test`, `pytest`, `cargo test`, `vitest`) |
|
|
117
|
+
| **Coding** | Real test execution — language-aware (`go test`, `pytest`, `cargo test`, `vitest`) |
|
|
114
118
|
| **Quiz** | Multiple choice, true/false, fill-in-the-blank — scored in dashboard |
|
|
115
119
|
| **Project** | Larger exercises with acceptance criteria and test files |
|
|
116
120
|
| **Assignment** | Imported from school PDFs with grading criteria |
|
|
117
121
|
|
|
122
|
+
## Supported Languages
|
|
123
|
+
|
|
124
|
+
Coding exercises use the correct file naming, test runner, and scaffold files per language. Adding a new language is a single entry in `server/src/languages.ts`.
|
|
125
|
+
|
|
126
|
+
| Language | Files | Test Runner | Scaffold |
|
|
127
|
+
|----------|-------|-------------|----------|
|
|
128
|
+
| Go | `main.go` + `main_test.go` | `go test` | `go.mod` |
|
|
129
|
+
| Python | `main.py` + `test_main.py` | `pytest` | — |
|
|
130
|
+
| Rust | `main.rs` + `main_test.rs` | `cargo test` | `Cargo.toml` |
|
|
131
|
+
| TypeScript | `main.ts` + `main.test.ts` | `vitest` | `package.json` |
|
|
132
|
+
| JavaScript | `main.ts` + `main.test.ts` | `vitest` | `package.json` |
|
|
133
|
+
|
|
134
|
+
Non-coding subjects (no language set) support quizzes and assignments only.
|
|
135
|
+
|
|
118
136
|
## Settings
|
|
119
137
|
|
|
120
138
|
| Setting | Default | Description |
|
|
@@ -129,6 +147,7 @@ Single Node.js process with layered internals:
|
|
|
129
147
|
```
|
|
130
148
|
Transport: MCP Tools (stdio) + HTTP API (dashboard)
|
|
131
149
|
Services: CurriculumService | QAService | VizService | ExerciseService
|
|
150
|
+
Config: Language Registry (languages.ts)
|
|
132
151
|
Storage: SQLite (better-sqlite3) + File System (exercises)
|
|
133
152
|
```
|
|
134
153
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bis-code/study-dash",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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>",
|
package/server/dist/bundle.mjs
CHANGED
|
@@ -23209,7 +23209,7 @@ class ExerciseService {
|
|
|
23209
23209
|
}
|
|
23210
23210
|
catch { }
|
|
23211
23211
|
}
|
|
23212
|
-
return { main, test, language: lang, mainFile, testFile };
|
|
23212
|
+
return { main, test, language: lang, mainFile, testFile, filePath: exercise.file_path || '' };
|
|
23213
23213
|
}
|
|
23214
23214
|
saveExerciseFiles(exerciseId, main, test) {
|
|
23215
23215
|
const exercise = this.db.raw
|
|
@@ -23988,11 +23988,11 @@ function handleResourceFile(resourceSvc) {
|
|
|
23988
23988
|
};
|
|
23989
23989
|
}
|
|
23990
23990
|
|
|
23991
|
-
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')\">← 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&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: EXERCISE EDITOR ==================== -->\n <div id=\"page-exercise-editor\" class=\"page\">\n <div class=\"exercise-editor\">\n <div class=\"exercise-editor-problem\" id=\"editor-problem\"></div>\n <div class=\"exercise-editor-right\">\n <div class=\"exercise-editor-code\">\n <div class=\"editor-tabs\">\n <button class=\"editor-tab active\" id=\"tab-main\" onclick=\"switchEditorTab('main')\">main</button>\n <button class=\"editor-tab\" id=\"tab-test\" onclick=\"switchEditorTab('test')\">test</button>\n <button class=\"editor-back-btn\" onclick=\"closeExerciseEditor()\">← Back</button>\n </div>\n <div id=\"editor-container\"></div>\n </div>\n <div class=\"exercise-editor-output\" id=\"editor-output\">\n <div class=\"editor-output-header\">\n <span>Test Output</span>\n <button class=\"editor-run-btn\" id=\"editor-run-btn\" onclick=\"runTestsFromEditor()\">Run Tests</button>\n </div>\n <div class=\"editor-output-body\" id=\"editor-output-body\">\n <span class=\"text-muted\">Click \"Run Tests\" or press Ctrl+Enter</span>\n </div>\n </div>\n </div>\n </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";
|
|
23991
|
+
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')\">← 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&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: EXERCISE EDITOR ==================== -->\n <div id=\"page-exercise-editor\" class=\"page\">\n <div class=\"exercise-editor\">\n <div class=\"exercise-editor-problem\" id=\"editor-problem\"></div>\n <div class=\"exercise-editor-right\">\n <div class=\"exercise-editor-code\">\n <div class=\"editor-tabs\">\n <button class=\"editor-tab active\" id=\"tab-main\" onclick=\"switchEditorTab('main')\">main</button>\n <button class=\"editor-tab\" id=\"tab-test\" onclick=\"switchEditorTab('test')\">test</button>\n <button class=\"editor-add-test-btn\" id=\"editor-add-test-btn\" onclick=\"toggleAddTestForm()\">+ Add Test</button>\n <button class=\"editor-vscode-btn\" id=\"editor-vscode-btn\" onclick=\"openInVSCode()\" title=\"Open in VS Code\">VS Code</button>\n <button class=\"editor-back-btn\" onclick=\"closeExerciseEditor()\">← Back</button>\n </div>\n <div id=\"add-test-form\" class=\"add-test-form hidden\">\n <div class=\"form-row\">\n <input type=\"text\" id=\"test-name-input\" placeholder=\"Test name (e.g., empty slice returns -1)\">\n </div>\n <div class=\"form-row\">\n <textarea id=\"test-input-input\" placeholder=\"Input as Go literal (e.g., []int{}, 5)\" rows=\"2\"></textarea>\n </div>\n <div class=\"form-row\">\n <textarea id=\"test-expected-input\" placeholder=\"Expected result (e.g., -1)\" rows=\"2\"></textarea>\n </div>\n <div class=\"form-row\">\n <select id=\"test-assertion-type\">\n <option value=\"equals\">equals</option>\n <option value=\"deep equals\">deep equals</option>\n <option value=\"error expected\">error expected</option>\n <option value=\"no error\">no error</option>\n <option value=\"contains\">contains</option>\n </select>\n </div>\n <div class=\"form-actions\">\n <button class=\"btn-primary\" onclick=\"addTestCase()\">Add</button>\n <button onclick=\"toggleAddTestForm()\">Cancel</button>\n </div>\n </div>\n <div id=\"editor-container\"></div>\n </div>\n <div class=\"exercise-editor-output\" id=\"editor-output\">\n <div class=\"editor-output-header\">\n <span>Test Output</span>\n <button class=\"editor-run-btn\" id=\"editor-run-btn\" onclick=\"runTestsFromEditor()\">Run Tests</button>\n </div>\n <div class=\"editor-output-body\" id=\"editor-output-body\">\n <span class=\"text-muted\">Click \"Run Tests\" or press Ctrl+Enter</span>\n </div>\n </div>\n </div>\n </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";
|
|
23992
23992
|
|
|
23993
|
-
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 editorExercise: null,\n editorActiveTab: 'main',\n editorMainContent: '',\n editorTestContent: '',\n editorView: null,\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\">▼</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&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\">▼</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&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 const questions = Array.isArray(quiz) ? quiz : (quiz && Array.isArray(quiz.questions) ? quiz.questions : null);\n if (questions) {\n detailHtml += `<h4>Questions</h4>`;\n detailHtml += questions.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=\"openExerciseEditor(${ex.id})\">Open Editor</button>\n <button class=\"exercise-action-btn\" 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\">▼</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 const selectedOpt = questionEl.querySelector('.quiz-option.selected');\n if (selectedOpt) {\n selectedOpt.classList.add(r.passed ? 'correct' : '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 const correct = data.results ? data.results.filter(r => r.passed).length : 0;\n scoreDiv.textContent = `Score: ${correct}/${data.total}${data.passed ? ' — Passed!' : ' — Try again'}`;\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 (Array.isArray(data) && state.topicExercises[cardIndex]) {\n state.topicExercises[cardIndex].results = data;\n } else if (data.error) {\n console.error('Run exercise error:', data.error);\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\">▼</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// --- CodeMirror Lazy Loading ---\nasync function loadCodeMirror(language) {\n if (!window._cmBase) {\n const [\n { EditorView, basicSetup },\n { EditorState },\n { oneDark },\n { keymap },\n ] = await Promise.all([\n import('https://esm.sh/codemirror@6.65.7'),\n import('https://esm.sh/@codemirror/state@6.5.2'),\n import('https://esm.sh/@codemirror/theme-one-dark@6.1.2'),\n import('https://esm.sh/@codemirror/view@6.36.5'),\n ]);\n window._cmBase = { EditorView, EditorState, basicSetup, oneDark, keymap };\n }\n\n const langMap = {\n go: () => import('https://esm.sh/@codemirror/lang-go@6.0.1').then(m => m.go()),\n python: () => import('https://esm.sh/@codemirror/lang-python@6.1.6').then(m => m.python()),\n rust: () => import('https://esm.sh/@codemirror/lang-rust@6.0.1').then(m => m.rust()),\n javascript: () => import('https://esm.sh/@codemirror/lang-javascript@6.2.2').then(m => m.javascript({ typescript: true })),\n typescript: () => import('https://esm.sh/@codemirror/lang-javascript@6.2.2').then(m => m.javascript({ typescript: true })),\n };\n\n const langFn = langMap[language] || langMap['go'];\n const langExt = await langFn();\n\n return { ...window._cmBase, langExt };\n}\n\n// --- Exercise Editor ---\nasync function openExerciseEditor(exerciseId) {\n const [exerciseList, files] = await Promise.all([\n api(`/api/topics/${state.activeTopic}/exercises`),\n api(`/api/exercises/${exerciseId}/files`),\n ]);\n\n const exercise = exerciseList.find(e => e.id === exerciseId);\n if (!exercise || !files) return;\n\n state.editorExercise = exercise;\n state.editorMainContent = files.main;\n state.editorTestContent = files.test;\n state.editorActiveTab = 'main';\n\n const problemEl = document.getElementById('editor-problem');\n if (problemEl) {\n problemEl.innerHTML =\n '<h2>' + escapeHtml(exercise.title) + '</h2>' +\n '<div class=\"exercise-meta\">' +\n (exercise.difficulty ? '<span class=\"badge\">' + escapeHtml(exercise.difficulty) + '</span>' : '') +\n (exercise.est_minutes ? '<span class=\"badge\">' + exercise.est_minutes + ' min</span>' : '') +\n (exercise.status ? '<span class=\"badge ' + escapeHtml(exercise.status) + '\">' + escapeHtml(exercise.status) + '</span>' : '') +\n '</div>' +\n '<div class=\"description\">' + marked.parse(exercise.description || '') + '</div>';\n }\n\n const tabMain = document.getElementById('tab-main');\n const tabTest = document.getElementById('tab-test');\n if (tabMain) tabMain.textContent = files.mainFile || 'main.go';\n if (tabTest) tabTest.textContent = files.testFile || 'main_test.go';\n\n const outputBody = document.getElementById('editor-output-body');\n if (outputBody) outputBody.innerHTML = '<span class=\"text-muted\">Click \"Run Tests\" or press Ctrl+Enter</span>';\n\n showPage('exercise-editor');\n\n const cm = await loadCodeMirror(files.language || 'go');\n const container = document.getElementById('editor-container');\n if (!container) return;\n container.innerHTML = '';\n\n const runTestsKeymap = cm.keymap.of([{\n key: 'Mod-Enter',\n run: () => { runTestsFromEditor(); return true; },\n }]);\n\n state.editorView = new cm.EditorView({\n state: cm.EditorState.create({\n doc: state.editorMainContent,\n extensions: [cm.basicSetup, cm.langExt, cm.oneDark, runTestsKeymap],\n }),\n parent: container,\n });\n}\n\nfunction switchEditorTab(tab) {\n if (!state.editorView || tab === state.editorActiveTab) return;\n\n const currentContent = state.editorView.state.doc.toString();\n if (state.editorActiveTab === 'main') {\n state.editorMainContent = currentContent;\n } else {\n state.editorTestContent = currentContent;\n }\n\n state.editorActiveTab = tab;\n\n const newContent = tab === 'main' ? state.editorMainContent : state.editorTestContent;\n state.editorView.dispatch({\n changes: {\n from: 0,\n to: state.editorView.state.doc.length,\n insert: newContent,\n },\n });\n\n document.querySelectorAll('.editor-tab').forEach(t => t.classList.remove('active'));\n const activeTab = document.getElementById(tab === 'main' ? 'tab-main' : 'tab-test');\n if (activeTab) activeTab.classList.add('active');\n}\n\nfunction closeExerciseEditor() {\n if (state.editorView) {\n state.editorView.destroy();\n state.editorView = null;\n }\n state.editorExercise = null;\n showPage('topic');\n switchTab('exercises');\n}\n\nasync function runTestsFromEditor() {\n if (!state.editorExercise) return;\n\n const exerciseId = state.editorExercise.id;\n const btn = document.getElementById('editor-run-btn');\n const outputBody = document.getElementById('editor-output-body');\n\n if (btn) { btn.textContent = 'Running...'; btn.disabled = true; }\n if (outputBody) outputBody.innerHTML = '<span class=\"text-muted\">Running tests...</span>';\n\n if (state.editorView) {\n const content = state.editorView.state.doc.toString();\n if (state.editorActiveTab === 'main') {\n state.editorMainContent = content;\n } else {\n state.editorTestContent = content;\n }\n }\n\n try {\n await fetch(`/api/exercises/${exerciseId}/files`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ main: state.editorMainContent, test: state.editorTestContent }),\n });\n\n const res = await fetch(`/api/exercises/${exerciseId}/run`, { method: 'POST' });\n const data = await res.json();\n\n if (data.error) {\n outputBody.innerHTML = '<div class=\"test-result-row fail\"><span class=\"test-icon\">✗</span><span class=\"test-name\">Error: ' + escapeHtml(data.error) + '</span></div>';\n } else if (Array.isArray(data)) {\n const passed = data.filter(r => r.passed).length;\n const total = data.length;\n const allPassed = passed === total;\n\n let html = '';\n for (const r of data) {\n const cls = r.passed ? 'pass' : 'fail';\n const icon = r.passed ? '✓' : '✗';\n html += '<div class=\"test-result-row ' + cls + '\">' +\n '<span class=\"test-icon\">' + icon + '</span>' +\n '<span class=\"test-name\">' + escapeHtml(r.test_name) + '</span>' +\n '</div>';\n if (r.output && !r.passed) {\n html += '<div class=\"test-result-output\">' + escapeHtml(r.output) + '</div>';\n }\n }\n\n html += '<div class=\"editor-progress-bar\">' +\n '<span>' + passed + '/' + total + ' passed</span>' +\n '<div class=\"editor-progress-fill\">' +\n '<div class=\"editor-progress-fill-inner ' + (allPassed ? 'green' : 'red') + '\" style=\"width:' + (total > 0 ? Math.round(passed / total * 100) : 0) + '%\"></div>' +\n '</div>' +\n '</div>';\n\n outputBody.innerHTML = html;\n state.editorExercise.status = allPassed ? 'passed' : 'failed';\n }\n } catch (err) {\n if (outputBody) outputBody.innerHTML = '<div class=\"test-result-row fail\"><span class=\"test-icon\">✗</span><span class=\"test-name\">Error: ' + escapeHtml(String(err)) + '</span></div>';\n } finally {\n if (btn) { btn.textContent = 'Run Tests'; btn.disabled = false; }\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";
|
|
23993
|
+
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 editorExercise: null,\n editorActiveTab: 'main',\n editorMainContent: '',\n editorTestContent: '',\n editorView: null,\n editorFilePath: '',\n editorMainFile: '',\n editorTestFile: '',\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\">▼</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&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\">▼</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&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 const questions = Array.isArray(quiz) ? quiz : (quiz && Array.isArray(quiz.questions) ? quiz.questions : null);\n if (questions) {\n detailHtml += `<h4>Questions</h4>`;\n detailHtml += questions.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=\"event.stopPropagation(); 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=\"event.stopPropagation(); openExerciseEditor(${ex.id})\">Open Editor</button>\n <button class=\"exercise-action-btn\" onclick=\"event.stopPropagation(); runExercise(${ex.id}, ${i})\">Run Tests</button>\n </div>`;\n }\n\n return `\n <div class=\"exercise-card expandable\" id=\"exercise-${i}\" onclick=\"toggleExercise(${i})\">\n <div class=\"exercise-header\">\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\">▼</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 const selectedOpt = questionEl.querySelector('.quiz-option.selected');\n if (selectedOpt) {\n selectedOpt.classList.add(r.passed ? 'correct' : '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 const correct = data.results ? data.results.filter(r => r.passed).length : 0;\n scoreDiv.textContent = `Score: ${correct}/${data.total}${data.passed ? ' — Passed!' : ' — Try again'}`;\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 (Array.isArray(data) && state.topicExercises[cardIndex]) {\n state.topicExercises[cardIndex].results = data;\n } else if (data.error) {\n console.error('Run exercise error:', data.error);\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\">▼</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// --- CodeMirror Lazy Loading ---\nasync function loadCodeMirror(language) {\n if (!window._cmBase) {\n const [\n { EditorView, basicSetup },\n { EditorState },\n { oneDark },\n { keymap },\n ] = await Promise.all([\n import('https://esm.sh/codemirror@6.0.1'),\n import('https://esm.sh/@codemirror/state'),\n import('https://esm.sh/@codemirror/theme-one-dark'),\n import('https://esm.sh/@codemirror/view'),\n ]);\n window._cmBase = { EditorView, EditorState, basicSetup, oneDark, keymap };\n }\n\n const langMap = {\n go: () => import('https://esm.sh/@codemirror/lang-go').then(m => m.go()),\n python: () => import('https://esm.sh/@codemirror/lang-python').then(m => m.python()),\n rust: () => import('https://esm.sh/@codemirror/lang-rust').then(m => m.rust()),\n javascript: () => import('https://esm.sh/@codemirror/lang-javascript').then(m => m.javascript({ typescript: true })),\n typescript: () => import('https://esm.sh/@codemirror/lang-javascript').then(m => m.javascript({ typescript: true })),\n };\n\n const langFn = langMap[language] || langMap['go'];\n const langExt = await langFn();\n\n return { ...window._cmBase, langExt };\n}\n\n// --- Exercise Editor ---\nasync function openExerciseEditor(exerciseId) {\n const [exerciseList, files] = await Promise.all([\n api(`/api/topics/${state.activeTopic}/exercises`),\n api(`/api/exercises/${exerciseId}/files`),\n ]);\n\n const exercise = exerciseList.find(e => e.id === exerciseId);\n if (!exercise || !files) return;\n\n state.editorExercise = exercise;\n state.editorMainContent = files.main;\n state.editorTestContent = files.test;\n state.editorActiveTab = 'main';\n state.editorFilePath = files.filePath || '';\n state.editorMainFile = files.mainFile || 'main.go';\n state.editorTestFile = files.testFile || 'main_test.go';\n\n const problemEl = document.getElementById('editor-problem');\n if (problemEl) {\n problemEl.innerHTML =\n '<h2>' + escapeHtml(exercise.title) + '</h2>' +\n '<div class=\"exercise-meta\">' +\n (exercise.difficulty ? '<span class=\"badge\">' + escapeHtml(exercise.difficulty) + '</span>' : '') +\n (exercise.est_minutes ? '<span class=\"badge\">' + exercise.est_minutes + ' min</span>' : '') +\n (exercise.status ? '<span class=\"badge ' + escapeHtml(exercise.status) + '\">' + escapeHtml(exercise.status) + '</span>' : '') +\n '</div>' +\n '<div class=\"description\">' + marked.parse(exercise.description || '') + '</div>';\n }\n\n const tabMain = document.getElementById('tab-main');\n const tabTest = document.getElementById('tab-test');\n if (tabMain) tabMain.textContent = files.mainFile || 'main.go';\n if (tabTest) tabTest.textContent = files.testFile || 'main_test.go';\n\n const isGo = (files.mainFile || '').endsWith('.go');\n const addTestBtn = document.getElementById('editor-add-test-btn');\n if (addTestBtn) addTestBtn.style.display = isGo ? '' : 'none';\n const addTestForm = document.getElementById('add-test-form');\n if (addTestForm) addTestForm.classList.add('hidden');\n\n const outputBody = document.getElementById('editor-output-body');\n if (outputBody) outputBody.innerHTML = '<span class=\"text-muted\">Click \"Run Tests\" or press Ctrl+Enter</span>';\n\n showPage('exercise-editor');\n\n const cm = await loadCodeMirror(files.language || 'go');\n const container = document.getElementById('editor-container');\n if (!container) return;\n container.innerHTML = '';\n\n const runTestsKeymap = cm.keymap.of([{\n key: 'Mod-Enter',\n run: () => { runTestsFromEditor(); return true; },\n }]);\n\n state.editorView = new cm.EditorView({\n state: cm.EditorState.create({\n doc: state.editorMainContent,\n extensions: [cm.basicSetup, cm.langExt, cm.oneDark, runTestsKeymap],\n }),\n parent: container,\n });\n}\n\nfunction switchEditorTab(tab) {\n if (!state.editorView || tab === state.editorActiveTab) return;\n\n const currentContent = state.editorView.state.doc.toString();\n if (state.editorActiveTab === 'main') {\n state.editorMainContent = currentContent;\n } else {\n state.editorTestContent = currentContent;\n }\n\n state.editorActiveTab = tab;\n\n const newContent = tab === 'main' ? state.editorMainContent : state.editorTestContent;\n state.editorView.dispatch({\n changes: {\n from: 0,\n to: state.editorView.state.doc.length,\n insert: newContent,\n },\n });\n\n document.querySelectorAll('.editor-tab').forEach(t => t.classList.remove('active'));\n const activeTab = document.getElementById(tab === 'main' ? 'tab-main' : 'tab-test');\n if (activeTab) activeTab.classList.add('active');\n}\n\nfunction openInVSCode() {\n if (!state.editorFilePath) return;\n const file = state.editorActiveTab === 'main' ? state.editorMainFile : state.editorTestFile;\n const fullPath = state.editorFilePath + '/' + file;\n window.open('vscode://file' + fullPath, '_blank');\n}\n\n// --- Add Test Case ---\nfunction toggleAddTestForm() {\n var form = document.getElementById('add-test-form');\n if (!form) return;\n form.classList.toggle('hidden');\n if (!form.classList.contains('hidden')) {\n var nameInput = document.getElementById('test-name-input');\n if (nameInput) nameInput.focus();\n }\n}\n\nfunction detectFuncName() {\n var content = state.editorMainContent;\n var funcMatch = content.match(/^func\\s+([A-Z]\\w+)\\(/m);\n if (funcMatch) return funcMatch[1];\n var methodMatch = content.match(/^func\\s+\\([^)]+\\)\\s+([A-Z]\\w+)\\(/m);\n if (methodMatch) return methodMatch[1];\n return 'FuncName';\n}\n\nfunction generateTestCode(name, input, expected, assertionType) {\n var fn = detectFuncName();\n // Escape quotes/backslashes in test name for Go string literal\n var safeName = name.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"');\n switch (assertionType) {\n case 'equals':\n return `\\tt.Run(\"${safeName}\", func(t *testing.T) {\\n\\t\\tgot := ${fn}(${input})\\n\\t\\tif got != ${expected} {\\n\\t\\t\\tt.Errorf(\"${fn}(${input}) = %v, want %v\", got, ${expected})\\n\\t\\t}\\n\\t})\\n`;\n case 'deep equals':\n return `\\tt.Run(\"${safeName}\", func(t *testing.T) {\\n\\t\\tgot := ${fn}(${input})\\n\\t\\tif !reflect.DeepEqual(got, ${expected}) {\\n\\t\\t\\tt.Errorf(\"${fn}(${input}) = %v, want %v\", got, ${expected})\\n\\t\\t}\\n\\t})\\n`;\n case 'error expected':\n return `\\tt.Run(\"${safeName}\", func(t *testing.T) {\\n\\t\\t_, err := ${fn}(${input})\\n\\t\\tif err == nil {\\n\\t\\t\\tt.Error(\"expected error, got nil\")\\n\\t\\t}\\n\\t})\\n`;\n case 'no error':\n return `\\tt.Run(\"${safeName}\", func(t *testing.T) {\\n\\t\\t_, err := ${fn}(${input})\\n\\t\\tif err != nil {\\n\\t\\t\\tt.Errorf(\"unexpected error: %v\", err)\\n\\t\\t}\\n\\t})\\n`;\n case 'contains':\n return `\\tt.Run(\"${safeName}\", func(t *testing.T) {\\n\\t\\tgot := ${fn}(${input})\\n\\t\\tif !strings.Contains(got, ${expected}) {\\n\\t\\t\\tt.Errorf(\"${fn}(${input}) = %q, want substring %q\", got, ${expected})\\n\\t\\t}\\n\\t})\\n`;\n default:\n return `\\tt.Run(\"${safeName}\", func(t *testing.T) {\\n\\t\\tgot := ${fn}(${input})\\n\\t\\tif got != ${expected} {\\n\\t\\t\\tt.Errorf(\"${fn}(${input}) = %v, want %v\", got, ${expected})\\n\\t\\t}\\n\\t})\\n`;\n }\n}\n\nfunction ensureImport(content, pkg) {\n var quoted = '\"' + pkg + '\"';\n if (content.includes(quoted)) return content;\n if (content.includes('import (')) {\n return content.replace('import (', 'import (\\n\\t' + quoted);\n }\n var singleImportMatch = content.match(/^import \".*\"$/m);\n if (singleImportMatch) {\n return content.replace(singleImportMatch[0], singleImportMatch[0] + '\\nimport ' + quoted);\n }\n var pkgMatch = content.match(/^package .+$/m);\n if (pkgMatch) {\n return content.replace(pkgMatch[0], pkgMatch[0] + '\\nimport ' + quoted);\n }\n return content;\n}\n\nfunction insertTestCase(testContent, testCode) {\n var testFuncRe = /^func Test\\w+\\(t \\*testing\\.T\\) \\{/gm;\n var lastMatch = null;\n var m;\n while ((m = testFuncRe.exec(testContent)) !== null) {\n lastMatch = m;\n }\n\n if (lastMatch) {\n // Find matching closing } by tracking brace depth.\n // Note: this is a simple character scan — braces inside string literals\n // or comments may cause incorrect depth tracking in rare edge cases.\n var startIdx = lastMatch.index + lastMatch[0].length;\n var depth = 1;\n var i = startIdx;\n while (i < testContent.length && depth > 0) {\n if (testContent[i] === '{') depth++;\n else if (testContent[i] === '}') depth--;\n i++;\n }\n var closingBraceIdx = i - 1;\n return testContent.slice(0, closingBraceIdx) + testCode + testContent.slice(closingBraceIdx);\n }\n\n // No test function found — wrap in TestSolution\n // If no package line exists, add one with testing import\n var hasPkg = /^package .+$/m.test(testContent);\n var preamble = hasPkg ? '' : 'package main\\n\\nimport \"testing\"\\n';\n var wrapper = preamble + '\\nfunc TestSolution(t *testing.T) {\\n' + testCode + '}\\n';\n var importBlockEnd = testContent.lastIndexOf(')');\n var singleImportMatch = testContent.match(/^import \".*\"$/m);\n var pkgMatch = testContent.match(/^package .+$/m);\n\n if (importBlockEnd !== -1 && testContent.slice(0, importBlockEnd + 1).includes('import (')) {\n return testContent.slice(0, importBlockEnd + 1) + wrapper + testContent.slice(importBlockEnd + 1);\n } else if (singleImportMatch) {\n var idx = testContent.indexOf(singleImportMatch[0]) + singleImportMatch[0].length;\n return testContent.slice(0, idx) + wrapper + testContent.slice(idx);\n } else if (pkgMatch) {\n var idx2 = testContent.indexOf(pkgMatch[0]) + pkgMatch[0].length;\n return testContent.slice(0, idx2) + wrapper + testContent.slice(idx2);\n }\n return testContent + wrapper;\n}\n\nfunction addTestCase() {\n var name = (document.getElementById('test-name-input') || {}).value || '';\n var input = (document.getElementById('test-input-input') || {}).value || '';\n var expected = (document.getElementById('test-expected-input') || {}).value || '';\n var assertionType = (document.getElementById('test-assertion-type') || {}).value || 'equals';\n\n if (!name.trim()) {\n alert('Test name is required');\n return;\n }\n\n var testCode = generateTestCode(name.trim(), input, expected, assertionType);\n var result = insertTestCase(state.editorTestContent, testCode);\n\n if (assertionType === 'deep equals') result = ensureImport(result, 'reflect');\n if (assertionType === 'contains') result = ensureImport(result, 'strings');\n\n state.editorTestContent = result;\n\n if (state.editorActiveTab === 'test' && state.editorView) {\n state.editorView.dispatch({\n changes: { from: 0, to: state.editorView.state.doc.length, insert: state.editorTestContent },\n });\n }\n\n var nameInput = document.getElementById('test-name-input');\n var inputInput = document.getElementById('test-input-input');\n var expectedInput = document.getElementById('test-expected-input');\n var assertionSelect = document.getElementById('test-assertion-type');\n if (nameInput) nameInput.value = '';\n if (inputInput) inputInput.value = '';\n if (expectedInput) expectedInput.value = '';\n if (assertionSelect) assertionSelect.value = 'equals';\n\n var form = document.getElementById('add-test-form');\n if (form) form.classList.add('hidden');\n\n switchEditorTab('test');\n}\n\nfunction closeExerciseEditor() {\n if (state.editorView) {\n state.editorView.destroy();\n state.editorView = null;\n }\n state.editorExercise = null;\n showPage('topic');\n switchTab('exercises');\n}\n\nasync function runTestsFromEditor() {\n if (!state.editorExercise) return;\n\n const exerciseId = state.editorExercise.id;\n const btn = document.getElementById('editor-run-btn');\n const outputBody = document.getElementById('editor-output-body');\n\n if (btn) { btn.textContent = 'Running...'; btn.disabled = true; }\n if (outputBody) outputBody.innerHTML = '<span class=\"text-muted\">Running tests...</span>';\n\n if (state.editorView) {\n const content = state.editorView.state.doc.toString();\n if (state.editorActiveTab === 'main') {\n state.editorMainContent = content;\n } else {\n state.editorTestContent = content;\n }\n }\n\n try {\n await fetch(`/api/exercises/${exerciseId}/files`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ main: state.editorMainContent, test: state.editorTestContent }),\n });\n\n const res = await fetch(`/api/exercises/${exerciseId}/run`, { method: 'POST' });\n const data = await res.json();\n\n if (data.error) {\n outputBody.innerHTML = '<div class=\"test-result-row fail\"><span class=\"test-icon\">✗</span><span class=\"test-name\">Error: ' + escapeHtml(data.error) + '</span></div>';\n } else if (Array.isArray(data)) {\n const passed = data.filter(r => r.passed).length;\n const total = data.length;\n const allPassed = passed === total;\n\n let html = '';\n for (const r of data) {\n const cls = r.passed ? 'pass' : 'fail';\n const icon = r.passed ? '✓' : '✗';\n html += '<div class=\"test-result-row ' + cls + '\">' +\n '<span class=\"test-icon\">' + icon + '</span>' +\n '<span class=\"test-name\">' + escapeHtml(r.test_name) + '</span>' +\n '</div>';\n if (r.output && !r.passed) {\n html += '<div class=\"test-result-output\">' + escapeHtml(r.output) + '</div>';\n }\n }\n\n html += '<div class=\"editor-progress-bar\">' +\n '<span>' + passed + '/' + total + ' passed</span>' +\n '<div class=\"editor-progress-fill\">' +\n '<div class=\"editor-progress-fill-inner ' + (allPassed ? 'green' : 'red') + '\" style=\"width:' + (total > 0 ? Math.round(passed / total * 100) : 0) + '%\"></div>' +\n '</div>' +\n '</div>';\n\n outputBody.innerHTML = html;\n state.editorExercise.status = allPassed ? 'passed' : 'failed';\n }\n } catch (err) {\n if (outputBody) outputBody.innerHTML = '<div class=\"test-result-row fail\"><span class=\"test-icon\">✗</span><span class=\"test-name\">Error: ' + escapeHtml(String(err)) + '</span></div>';\n } finally {\n if (btn) { btn.textContent = 'Run Tests'; btn.disabled = false; }\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";
|
|
23994
23994
|
|
|
23995
|
-
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\n/* ── Exercise Editor ─────────────────────────────────────────────── */\n.exercise-editor {\n display: grid;\n grid-template-columns: 2fr 3fr;\n height: calc(100vh - 20px);\n gap: 1px;\n background: var(--border);\n}\n\n.exercise-editor-problem {\n background: var(--bg-primary);\n padding: 1.5rem;\n overflow-y: auto;\n}\n\n.exercise-editor-problem h2 {\n margin: 0 0 0.5rem 0;\n font-size: 1.25rem;\n}\n\n.exercise-editor-problem .exercise-meta {\n display: flex;\n gap: 0.75rem;\n margin-bottom: 1rem;\n flex-wrap: wrap;\n}\n\n.exercise-editor-problem .description {\n line-height: 1.7;\n color: var(--text-secondary);\n}\n\n.exercise-editor-problem .description pre {\n background: var(--bg-secondary);\n padding: 0.75rem;\n border-radius: 6px;\n overflow-x: auto;\n}\n\n.exercise-editor-right {\n display: flex;\n flex-direction: column;\n background: var(--bg-primary);\n min-height: 0;\n}\n\n.exercise-editor-code {\n flex: 1;\n display: flex;\n flex-direction: column;\n min-height: 0;\n}\n\n.editor-tabs {\n display: flex;\n gap: 0;\n background: var(--bg-secondary);\n border-bottom: 1px solid var(--border);\n padding: 0 0.5rem;\n align-items: center;\n}\n\n.editor-tab {\n padding: 0.5rem 1rem;\n background: none;\n border: none;\n color: var(--text-secondary);\n cursor: pointer;\n font-size: 0.8rem;\n font-family: monospace;\n border-bottom: 2px solid transparent;\n}\n\n.editor-tab:hover {\n color: var(--text);\n}\n\n.editor-tab.active {\n color: var(--accent);\n border-bottom-color: var(--accent);\n}\n\n.editor-back-btn {\n margin-left: auto;\n padding: 0.3rem 0.75rem;\n background: none;\n border: 1px solid var(--border);\n color: var(--text-secondary);\n border-radius: 4px;\n cursor: pointer;\n font-size: 0.75rem;\n}\n\n.editor-back-btn:hover {\n color: var(--text);\n border-color: var(--text-secondary);\n}\n\n#editor-container {\n flex: 1;\n overflow: hidden;\n}\n\n#editor-container .cm-editor {\n height: 100%;\n}\n\n#editor-container .cm-scroller {\n overflow: auto;\n}\n\n.exercise-editor-output {\n height: 200px;\n border-top: 1px solid var(--border);\n display: flex;\n flex-direction: column;\n}\n\n.editor-output-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 0.5rem 1rem;\n background: var(--bg-secondary);\n font-size: 0.8rem;\n font-weight: 600;\n color: var(--text-secondary);\n}\n\n.editor-run-btn {\n padding: 0.35rem 1rem;\n background: #238636;\n color: #fff;\n border: none;\n border-radius: 4px;\n cursor: pointer;\n font-size: 0.8rem;\n font-weight: 600;\n}\n\n.editor-run-btn:hover {\n background: #2ea043;\n}\n\n.editor-run-btn:disabled {\n opacity: 0.6;\n cursor: not-allowed;\n}\n\n.editor-output-body {\n flex: 1;\n overflow-y: auto;\n padding: 0.75rem 1rem;\n font-family: monospace;\n font-size: 0.8rem;\n}\n\n.test-result-row {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n padding: 0.3rem 0;\n}\n\n.test-result-row .test-icon {\n font-size: 0.9rem;\n}\n\n.test-result-row.pass .test-icon { color: var(--green); }\n.test-result-row.fail .test-icon { color: #f85149; }\n.test-result-row.pass .test-name { color: var(--text-secondary); }\n.test-result-row.fail .test-name { color: var(--text); }\n\n.test-result-output {\n margin: 0.25rem 0 0.5rem 1.5rem;\n padding: 0.5rem;\n background: var(--bg-secondary);\n border-radius: 4px;\n font-size: 0.75rem;\n color: #f85149;\n white-space: pre-wrap;\n word-break: break-all;\n}\n\n.editor-progress-bar {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n padding: 0.5rem 1rem;\n border-top: 1px solid var(--border);\n font-size: 0.8rem;\n color: var(--text-secondary);\n}\n\n.editor-progress-fill {\n flex: 1;\n height: 4px;\n background: var(--bg-tertiary);\n border-radius: 2px;\n overflow: hidden;\n}\n\n.editor-progress-fill-inner {\n height: 100%;\n border-radius: 2px;\n transition: width 0.3s;\n}\n\n.editor-progress-fill-inner.green { background: var(--green); }\n.editor-progress-fill-inner.red { background: #f85149; }\n\n@media (max-width: 768px) {\n .exercise-editor {\n grid-template-columns: 1fr;\n grid-template-rows: auto 1fr;\n height: auto;\n }\n .exercise-editor-problem {\n max-height: 40vh;\n }\n}\n";
|
|
23995
|
+
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\n/* ── Exercise Editor ─────────────────────────────────────────────── */\n.exercise-editor {\n display: grid;\n grid-template-columns: 2fr 3fr;\n height: calc(100vh - 20px);\n gap: 1px;\n background: var(--border);\n}\n\n.exercise-editor-problem {\n background: var(--bg-primary);\n padding: 1.5rem;\n overflow-y: auto;\n}\n\n.exercise-editor-problem h2 {\n margin: 0 0 0.5rem 0;\n font-size: 1.25rem;\n}\n\n.exercise-editor-problem .exercise-meta {\n display: flex;\n gap: 0.75rem;\n margin-bottom: 1rem;\n flex-wrap: wrap;\n}\n\n.exercise-editor-problem .description {\n line-height: 1.7;\n color: var(--text-secondary);\n}\n\n.exercise-editor-problem .description pre {\n background: var(--bg-secondary);\n padding: 0.75rem;\n border-radius: 6px;\n overflow-x: auto;\n}\n\n.exercise-editor-right {\n display: flex;\n flex-direction: column;\n background: var(--bg-primary);\n min-height: 0;\n}\n\n.exercise-editor-code {\n flex: 1;\n display: flex;\n flex-direction: column;\n min-height: 0;\n}\n\n.editor-tabs {\n display: flex;\n gap: 0;\n background: var(--bg-secondary);\n border-bottom: 1px solid var(--border);\n padding: 0 0.5rem;\n align-items: center;\n}\n\n.editor-tab {\n padding: 0.5rem 1rem;\n background: none;\n border: none;\n color: var(--text-secondary);\n cursor: pointer;\n font-size: 0.8rem;\n font-family: monospace;\n border-bottom: 2px solid transparent;\n}\n\n.editor-tab:hover {\n color: var(--text);\n}\n\n.editor-tab.active {\n color: var(--accent);\n border-bottom-color: var(--accent);\n}\n\n.editor-vscode-btn {\n margin-left: auto;\n padding: 0.3rem 0.75rem;\n background: none;\n border: 1px solid #007acc;\n color: #007acc;\n border-radius: 4px;\n cursor: pointer;\n font-size: 0.75rem;\n}\n\n.editor-vscode-btn:hover {\n background: #007acc;\n color: #fff;\n}\n\n.editor-back-btn {\n padding: 0.3rem 0.75rem;\n background: none;\n border: 1px solid var(--border);\n color: var(--text-secondary);\n border-radius: 4px;\n cursor: pointer;\n font-size: 0.75rem;\n}\n\n.editor-back-btn:hover {\n color: var(--text);\n border-color: var(--text-secondary);\n}\n\n#editor-container {\n flex: 1;\n overflow: hidden;\n}\n\n#editor-container .cm-editor {\n height: 100%;\n}\n\n#editor-container .cm-scroller {\n overflow: auto;\n}\n\n.exercise-editor-output {\n height: 200px;\n border-top: 1px solid var(--border);\n display: flex;\n flex-direction: column;\n}\n\n.editor-output-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 0.5rem 1rem;\n background: var(--bg-secondary);\n font-size: 0.8rem;\n font-weight: 600;\n color: var(--text-secondary);\n}\n\n.editor-run-btn {\n padding: 0.35rem 1rem;\n background: #238636;\n color: #fff;\n border: none;\n border-radius: 4px;\n cursor: pointer;\n font-size: 0.8rem;\n font-weight: 600;\n}\n\n.editor-run-btn:hover {\n background: #2ea043;\n}\n\n.editor-run-btn:disabled {\n opacity: 0.6;\n cursor: not-allowed;\n}\n\n.editor-output-body {\n flex: 1;\n overflow-y: auto;\n padding: 0.75rem 1rem;\n font-family: monospace;\n font-size: 0.8rem;\n}\n\n/* ===== ADD TEST FORM ===== */\n.editor-add-test-btn {\n padding: 0.3rem 0.75rem;\n background: none;\n border: 1px solid var(--green);\n color: var(--green);\n border-radius: 4px;\n cursor: pointer;\n font-family: inherit;\n font-size: 0.75rem;\n}\n\n.editor-add-test-btn:hover {\n background: var(--green);\n color: #fff;\n}\n\n.add-test-form {\n background: var(--bg-secondary);\n border-bottom: 1px solid var(--border);\n padding: 0.75rem 1rem;\n}\n\n.add-test-form.hidden {\n display: none;\n}\n\n.add-test-form input,\n.add-test-form textarea,\n.add-test-form select {\n width: 100%;\n background: var(--bg);\n color: var(--text);\n border: 1px solid var(--border);\n border-radius: 4px;\n padding: 0.4rem 0.6rem;\n font-family: inherit;\n font-size: 0.8rem;\n}\n\n.add-test-form input:focus,\n.add-test-form textarea:focus,\n.add-test-form select:focus {\n outline: none;\n border-color: var(--accent);\n}\n\n.add-test-form .form-row {\n margin-bottom: 8px;\n}\n\n.add-test-form .form-actions {\n display: flex;\n flex-direction: row;\n gap: 8px;\n justify-content: flex-end;\n}\n\n.add-test-form .form-actions .btn-primary {\n padding: 0.3rem 0.75rem;\n background: var(--accent);\n color: #fff;\n border: none;\n border-radius: 4px;\n cursor: pointer;\n font-family: inherit;\n font-size: 0.75rem;\n}\n\n.add-test-form .form-actions .btn-primary:hover {\n opacity: 0.85;\n}\n\n.add-test-form .form-actions button:not(.btn-primary) {\n padding: 0.3rem 0.75rem;\n background: none;\n border: 1px solid var(--border);\n color: var(--text-secondary);\n border-radius: 4px;\n cursor: pointer;\n font-family: inherit;\n font-size: 0.75rem;\n}\n\n.add-test-form .form-actions button:not(.btn-primary):hover {\n color: var(--text);\n border-color: var(--text-secondary);\n}\n\n.test-result-row {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n padding: 0.3rem 0;\n}\n\n.test-result-row .test-icon {\n font-size: 0.9rem;\n}\n\n.test-result-row.pass .test-icon { color: var(--green); }\n.test-result-row.fail .test-icon { color: #f85149; }\n.test-result-row.pass .test-name { color: var(--text-secondary); }\n.test-result-row.fail .test-name { color: var(--text); }\n\n.test-result-output {\n margin: 0.25rem 0 0.5rem 1.5rem;\n padding: 0.5rem;\n background: var(--bg-secondary);\n border-radius: 4px;\n font-size: 0.75rem;\n color: #f85149;\n white-space: pre-wrap;\n word-break: break-all;\n}\n\n.editor-progress-bar {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n padding: 0.5rem 1rem;\n border-top: 1px solid var(--border);\n font-size: 0.8rem;\n color: var(--text-secondary);\n}\n\n.editor-progress-fill {\n flex: 1;\n height: 4px;\n background: var(--bg-tertiary);\n border-radius: 2px;\n overflow: hidden;\n}\n\n.editor-progress-fill-inner {\n height: 100%;\n border-radius: 2px;\n transition: width 0.3s;\n}\n\n.editor-progress-fill-inner.green { background: var(--green); }\n.editor-progress-fill-inner.red { background: #f85149; }\n\n@media (max-width: 768px) {\n .exercise-editor {\n grid-template-columns: 1fr;\n grid-template-rows: auto 1fr;\n height: auto;\n }\n .exercise-editor-problem {\n max-height: 40vh;\n }\n}\n";
|
|
23996
23996
|
|
|
23997
23997
|
const STATIC_FILES = {
|
|
23998
23998
|
'/': { content: indexHtml, contentType: 'text/html; charset=utf-8' },
|
|
@@ -214,7 +214,7 @@ export class ExerciseService {
|
|
|
214
214
|
}
|
|
215
215
|
catch { }
|
|
216
216
|
}
|
|
217
|
-
return { main, test, language: lang, mainFile, testFile };
|
|
217
|
+
return { main, test, language: lang, mainFile, testFile, filePath: exercise.file_path || '' };
|
|
218
218
|
}
|
|
219
219
|
saveExerciseFiles(exerciseId, main, test) {
|
|
220
220
|
const exercise = this.db.raw
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"exercises.js","sourceRoot":"","sources":["../../src/services/exercises.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"exercises.js","sourceRoot":"","sources":["../../src/services/exercises.ts"],"names":[],"mappings":"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"}
|
package/server/package.json
CHANGED
package/skills/learn/SKILL.md
CHANGED
|
@@ -15,3 +15,23 @@ When the user is in a learning session (asking questions about a subject they're
|
|
|
15
15
|
4. Suggest the next topic when one is completed
|
|
16
16
|
|
|
17
17
|
Use the learn_* MCP tools for all operations. Never ask the user to manually track their progress.
|
|
18
|
+
|
|
19
|
+
## Exercise Creation Rules
|
|
20
|
+
|
|
21
|
+
When creating coding or project exercises via `learn_create_exercise`, these rules are mandatory:
|
|
22
|
+
|
|
23
|
+
1. **Always provide `test_content`** with real, runnable tests. Never use comments, placeholders, or TODOs in place of tests. `test_content` is optional in the tool schema for quiz-type exercises only — coding and project exercises without tests are incomplete.
|
|
24
|
+
|
|
25
|
+
2. **Minimum 3 test cases per exercise:**
|
|
26
|
+
- Happy path (typical valid input)
|
|
27
|
+
- Edge case (empty, nil, zero, or null input)
|
|
28
|
+
- Boundary case (limits, single element, max values, etc.)
|
|
29
|
+
|
|
30
|
+
3. **Use idiomatic test patterns per language:**
|
|
31
|
+
- Go: table-driven `t.Run()` subtests with a `tests` slice of structs
|
|
32
|
+
- Python: `pytest.mark.parametrize` or individual `test_*` functions
|
|
33
|
+
- Rust: `#[cfg(test)]` module with `#[test]` functions and `assert_eq!`
|
|
34
|
+
|
|
35
|
+
4. **`starter_code` must compile but return zero values** (e.g., `return 0`, `return ""`, `return nil`), so tests fail in a meaningful way until the user implements the solution. Never leave stubs as syntax errors or empty bodies in languages that require return values.
|
|
36
|
+
|
|
37
|
+
5. **Test names must be descriptive** — they appear in the dashboard test results UI. Prefer names like `"empty input returns zero"` over `"test1"` or `"case2"`.
|