@bis-code/study-dash 0.5.1 → 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/hooks/hooks.json +1 -1
- package/package.json +2 -2
- package/rules/tutor-mode.md +12 -0
- package/server/dist/bundle.mjs +201 -47
- package/server/dist/dashboard/api.js +1 -1
- package/server/dist/dashboard/api.js.map +1 -1
- package/server/dist/languages.d.ts +23 -0
- package/server/dist/languages.js +89 -0
- package/server/dist/languages.js.map +1 -0
- package/server/dist/services/exercises.d.ts +5 -0
- package/server/dist/services/exercises.js +38 -41
- package/server/dist/services/exercises.js.map +1 -1
- package/server/dist/tools/curriculum.js +8 -0
- package/server/dist/tools/curriculum.js.map +1 -1
- package/server/dist/tools/exercises.js +62 -0
- package/server/dist/tools/exercises.js.map +1 -1
- package/server/package.json +1 -1
- package/server/start.sh +12 -0
- package/skills/learn/SKILL.md +20 -0
- package/server/node_modules/better-sqlite3/LICENSE +0 -21
- package/server/node_modules/better-sqlite3/README.md +0 -99
- package/server/node_modules/better-sqlite3/binding.gyp +0 -38
- package/server/node_modules/better-sqlite3/build/Release/better_sqlite3.node +0 -0
- package/server/node_modules/better-sqlite3/deps/common.gypi +0 -68
- package/server/node_modules/better-sqlite3/deps/copy.js +0 -31
- package/server/node_modules/better-sqlite3/deps/defines.gypi +0 -41
- package/server/node_modules/better-sqlite3/deps/download.sh +0 -122
- package/server/node_modules/better-sqlite3/deps/patches/1208.patch +0 -15
- package/server/node_modules/better-sqlite3/deps/sqlite3/sqlite3.c +0 -261480
- package/server/node_modules/better-sqlite3/deps/sqlite3/sqlite3.h +0 -13715
- package/server/node_modules/better-sqlite3/deps/sqlite3/sqlite3ext.h +0 -719
- package/server/node_modules/better-sqlite3/deps/sqlite3.gyp +0 -80
- package/server/node_modules/better-sqlite3/deps/test_extension.c +0 -21
- package/server/node_modules/better-sqlite3/lib/database.js +0 -90
- package/server/node_modules/better-sqlite3/lib/index.js +0 -3
- package/server/node_modules/better-sqlite3/lib/methods/aggregate.js +0 -43
- package/server/node_modules/better-sqlite3/lib/methods/backup.js +0 -67
- package/server/node_modules/better-sqlite3/lib/methods/function.js +0 -31
- package/server/node_modules/better-sqlite3/lib/methods/inspect.js +0 -7
- package/server/node_modules/better-sqlite3/lib/methods/pragma.js +0 -12
- package/server/node_modules/better-sqlite3/lib/methods/serialize.js +0 -16
- package/server/node_modules/better-sqlite3/lib/methods/table.js +0 -189
- package/server/node_modules/better-sqlite3/lib/methods/transaction.js +0 -78
- package/server/node_modules/better-sqlite3/lib/methods/wrappers.js +0 -54
- package/server/node_modules/better-sqlite3/lib/sqlite-error.js +0 -20
- package/server/node_modules/better-sqlite3/lib/util.js +0 -12
- package/server/node_modules/better-sqlite3/package.json +0 -54
- package/server/node_modules/better-sqlite3/src/better_sqlite3.cpp +0 -2186
- package/server/node_modules/better-sqlite3/src/better_sqlite3.hpp +0 -1036
package/server/dist/bundle.mjs
CHANGED
|
@@ -6199,10 +6199,10 @@ class $ZodRegistry {
|
|
|
6199
6199
|
}
|
|
6200
6200
|
}
|
|
6201
6201
|
// registries
|
|
6202
|
-
function registry() {
|
|
6202
|
+
function registry$1() {
|
|
6203
6203
|
return new $ZodRegistry();
|
|
6204
6204
|
}
|
|
6205
|
-
const globalRegistry = /*@__PURE__*/ registry();
|
|
6205
|
+
const globalRegistry = /*@__PURE__*/ registry$1();
|
|
6206
6206
|
|
|
6207
6207
|
function _string(Class, params) {
|
|
6208
6208
|
return new Class({
|
|
@@ -22909,6 +22909,95 @@ class VizService {
|
|
|
22909
22909
|
}
|
|
22910
22910
|
}
|
|
22911
22911
|
|
|
22912
|
+
const registry = new Map();
|
|
22913
|
+
registry.set('go', {
|
|
22914
|
+
id: 'go',
|
|
22915
|
+
name: 'Go',
|
|
22916
|
+
extension: '.go',
|
|
22917
|
+
mainFile: 'main.go',
|
|
22918
|
+
testFile: 'main_test.go',
|
|
22919
|
+
testCommand: 'go',
|
|
22920
|
+
testArgs: ['test', '-json', '-count=1', './...'],
|
|
22921
|
+
scaffoldFiles: (subjectSlug, exerciseSlug) => ({
|
|
22922
|
+
'go.mod': `module exercises/${subjectSlug}/${exerciseSlug}\n\ngo 1.21\n`,
|
|
22923
|
+
}),
|
|
22924
|
+
});
|
|
22925
|
+
registry.set('python', {
|
|
22926
|
+
id: 'python',
|
|
22927
|
+
name: 'Python',
|
|
22928
|
+
extension: '.py',
|
|
22929
|
+
mainFile: 'main.py',
|
|
22930
|
+
testFile: 'test_main.py',
|
|
22931
|
+
testCommand: 'python3',
|
|
22932
|
+
testArgs: ['-m', 'pytest', '--tb=short', '-q', '.'],
|
|
22933
|
+
});
|
|
22934
|
+
registry.set('rust', {
|
|
22935
|
+
id: 'rust',
|
|
22936
|
+
name: 'Rust',
|
|
22937
|
+
extension: '.rs',
|
|
22938
|
+
mainFile: 'main.rs',
|
|
22939
|
+
testFile: 'main_test.rs',
|
|
22940
|
+
testCommand: 'cargo',
|
|
22941
|
+
testArgs: ['test'],
|
|
22942
|
+
scaffoldFiles: (_subjectSlug, exerciseSlug) => ({
|
|
22943
|
+
'Cargo.toml': `[package]\nname = "${exerciseSlug}"\nversion = "0.1.0"\nedition = "2021"\n`,
|
|
22944
|
+
}),
|
|
22945
|
+
});
|
|
22946
|
+
const tsScaffold = (_subjectSlug, _exerciseSlug) => ({
|
|
22947
|
+
'package.json': `{"type":"module","scripts":{"test":"vitest run"},"devDependencies":{"vitest":"^2.0.0"}}`,
|
|
22948
|
+
});
|
|
22949
|
+
registry.set('typescript', {
|
|
22950
|
+
id: 'typescript',
|
|
22951
|
+
name: 'TypeScript',
|
|
22952
|
+
extension: '.ts',
|
|
22953
|
+
mainFile: 'main.ts',
|
|
22954
|
+
testFile: 'main.test.ts',
|
|
22955
|
+
testCommand: 'npx',
|
|
22956
|
+
testArgs: ['vitest', 'run'],
|
|
22957
|
+
scaffoldFiles: tsScaffold,
|
|
22958
|
+
});
|
|
22959
|
+
registry.set('javascript', {
|
|
22960
|
+
id: 'javascript',
|
|
22961
|
+
name: 'JavaScript',
|
|
22962
|
+
extension: '.ts',
|
|
22963
|
+
mainFile: 'main.ts',
|
|
22964
|
+
testFile: 'main.test.ts',
|
|
22965
|
+
testCommand: 'npx',
|
|
22966
|
+
testArgs: ['vitest', 'run'],
|
|
22967
|
+
scaffoldFiles: tsScaffold,
|
|
22968
|
+
});
|
|
22969
|
+
function getLanguageConfig(language) {
|
|
22970
|
+
if (!language)
|
|
22971
|
+
return undefined;
|
|
22972
|
+
return registry.get(language.toLowerCase());
|
|
22973
|
+
}
|
|
22974
|
+
function getExtension(language) {
|
|
22975
|
+
return getLanguageConfig(language)?.extension ?? '.txt';
|
|
22976
|
+
}
|
|
22977
|
+
function getTestCommand(language) {
|
|
22978
|
+
const config = getLanguageConfig(language);
|
|
22979
|
+
if (!config)
|
|
22980
|
+
return undefined;
|
|
22981
|
+
return { command: config.testCommand, args: config.testArgs };
|
|
22982
|
+
}
|
|
22983
|
+
function getScaffoldFiles(language, subjectSlug, exerciseSlug) {
|
|
22984
|
+
const config = getLanguageConfig(language);
|
|
22985
|
+
if (!config?.scaffoldFiles)
|
|
22986
|
+
return {};
|
|
22987
|
+
return config.scaffoldFiles(subjectSlug, exerciseSlug);
|
|
22988
|
+
}
|
|
22989
|
+
function getFileNames(language) {
|
|
22990
|
+
const config = getLanguageConfig(language);
|
|
22991
|
+
return {
|
|
22992
|
+
mainFile: config?.mainFile ?? 'main.txt',
|
|
22993
|
+
testFile: config?.testFile ?? 'main_test.txt',
|
|
22994
|
+
};
|
|
22995
|
+
}
|
|
22996
|
+
function isLanguageSupported(language) {
|
|
22997
|
+
return getLanguageConfig(language) !== undefined;
|
|
22998
|
+
}
|
|
22999
|
+
const SUPPORTED_LANGUAGES = Array.from(registry.keys());
|
|
23000
|
+
|
|
22912
23001
|
const execFileAsync = promisify(execFile);
|
|
22913
23002
|
function slugify(name) {
|
|
22914
23003
|
return name
|
|
@@ -22916,21 +23005,6 @@ function slugify(name) {
|
|
|
22916
23005
|
.replace(/[^a-z0-9]+/g, '-')
|
|
22917
23006
|
.replace(/^-+|-+$/g, '');
|
|
22918
23007
|
}
|
|
22919
|
-
function extensionForLanguage(language) {
|
|
22920
|
-
switch (language) {
|
|
22921
|
-
case 'go':
|
|
22922
|
-
return '.go';
|
|
22923
|
-
case 'python':
|
|
22924
|
-
return '.py';
|
|
22925
|
-
case 'rust':
|
|
22926
|
-
return '.rs';
|
|
22927
|
-
case 'javascript':
|
|
22928
|
-
case 'typescript':
|
|
22929
|
-
return '.ts';
|
|
22930
|
-
default:
|
|
22931
|
-
return '.txt';
|
|
22932
|
-
}
|
|
22933
|
-
}
|
|
22934
23008
|
class ExerciseService {
|
|
22935
23009
|
db;
|
|
22936
23010
|
fileStore;
|
|
@@ -22951,20 +23025,20 @@ class ExerciseService {
|
|
|
22951
23025
|
if ((type === 'coding' || type === 'project') && (starter_code || test_content)) {
|
|
22952
23026
|
const subject = this.getSubjectForTopic(topicId);
|
|
22953
23027
|
if (subject) {
|
|
23028
|
+
const lang = subject.language.toLowerCase();
|
|
22954
23029
|
const exerciseSlug = slugify(title);
|
|
22955
|
-
const
|
|
23030
|
+
const { mainFile, testFile } = getFileNames(lang);
|
|
22956
23031
|
const files = {};
|
|
22957
|
-
if (starter_code)
|
|
22958
|
-
files[
|
|
22959
|
-
|
|
22960
|
-
|
|
22961
|
-
files[`main_test${ext}`] = test_content;
|
|
22962
|
-
}
|
|
23032
|
+
if (starter_code)
|
|
23033
|
+
files[mainFile] = starter_code;
|
|
23034
|
+
if (test_content)
|
|
23035
|
+
files[testFile] = test_content;
|
|
22963
23036
|
files['README.md'] = `# ${title}\n\n${description}`;
|
|
23037
|
+
// Add scaffold files (go.mod, Cargo.toml, etc.)
|
|
23038
|
+
const scaffold = getScaffoldFiles(lang, subject.slug, exerciseSlug);
|
|
23039
|
+
Object.assign(files, scaffold);
|
|
22964
23040
|
const filePath = this.fileStore.writeExerciseFiles(subject.slug, exerciseSlug, files);
|
|
22965
|
-
this.db.raw
|
|
22966
|
-
.prepare('UPDATE exercises SET file_path = ? WHERE id = ?')
|
|
22967
|
-
.run(filePath, exerciseId);
|
|
23041
|
+
this.db.raw.prepare('UPDATE exercises SET file_path = ? WHERE id = ?').run(filePath, exerciseId);
|
|
22968
23042
|
}
|
|
22969
23043
|
}
|
|
22970
23044
|
return this.db.raw
|
|
@@ -22982,14 +23056,8 @@ class ExerciseService {
|
|
|
22982
23056
|
const subject = this.getSubjectForTopic(exercise.topic_id);
|
|
22983
23057
|
if (!subject)
|
|
22984
23058
|
throw new Error(`No subject found for exercise ${exerciseId}`);
|
|
22985
|
-
const
|
|
22986
|
-
|
|
22987
|
-
python: { command: 'python3', args: ['-m', 'pytest', '--tb=short', '-q', '.'] },
|
|
22988
|
-
rust: { command: 'cargo', args: ['test'] },
|
|
22989
|
-
javascript: { command: 'npx', args: ['vitest', 'run'] },
|
|
22990
|
-
typescript: { command: 'npx', args: ['vitest', 'run'] },
|
|
22991
|
-
};
|
|
22992
|
-
const config = commandMap[subject.language.toLowerCase()];
|
|
23059
|
+
const lang = subject.language.toLowerCase();
|
|
23060
|
+
const config = getTestCommand(lang);
|
|
22993
23061
|
if (!config)
|
|
22994
23062
|
throw new Error(`Unsupported language: ${subject.language}`);
|
|
22995
23063
|
let stdout = '';
|
|
@@ -23112,6 +23180,14 @@ class ExerciseService {
|
|
|
23112
23180
|
.prepare('SELECT * FROM exercises WHERE topic_id = ? ORDER BY created_at ASC, id ASC')
|
|
23113
23181
|
.all(topicId);
|
|
23114
23182
|
}
|
|
23183
|
+
listForTopicWithResults(topicId) {
|
|
23184
|
+
const exercises = this.listForTopic(topicId);
|
|
23185
|
+
const getResults = this.db.raw.prepare('SELECT * FROM exercise_results WHERE exercise_id = ? ORDER BY id ASC');
|
|
23186
|
+
return exercises.map(ex => ({
|
|
23187
|
+
...ex,
|
|
23188
|
+
results: getResults.all(ex.id),
|
|
23189
|
+
}));
|
|
23190
|
+
}
|
|
23115
23191
|
getExerciseFiles(exerciseId) {
|
|
23116
23192
|
const exercise = this.db.raw
|
|
23117
23193
|
.prepare('SELECT * FROM exercises WHERE id = ?')
|
|
@@ -23120,9 +23196,7 @@ class ExerciseService {
|
|
|
23120
23196
|
return undefined;
|
|
23121
23197
|
const subject = this.getSubjectForTopic(exercise.topic_id);
|
|
23122
23198
|
const lang = subject?.language.toLowerCase() ?? '';
|
|
23123
|
-
const
|
|
23124
|
-
const mainFile = `main${ext}`;
|
|
23125
|
-
const testFile = `main_test${ext}`;
|
|
23199
|
+
const { mainFile, testFile } = getFileNames(lang);
|
|
23126
23200
|
let main = '';
|
|
23127
23201
|
let test = '';
|
|
23128
23202
|
if (exercise.file_path) {
|
|
@@ -23135,7 +23209,7 @@ class ExerciseService {
|
|
|
23135
23209
|
}
|
|
23136
23210
|
catch { }
|
|
23137
23211
|
}
|
|
23138
|
-
return { main, test, language: lang, mainFile, testFile };
|
|
23212
|
+
return { main, test, language: lang, mainFile, testFile, filePath: exercise.file_path || '' };
|
|
23139
23213
|
}
|
|
23140
23214
|
saveExerciseFiles(exerciseId, main, test) {
|
|
23141
23215
|
const exercise = this.db.raw
|
|
@@ -23147,15 +23221,22 @@ class ExerciseService {
|
|
|
23147
23221
|
if (!subject)
|
|
23148
23222
|
throw new Error(`No subject found for exercise ${exerciseId}`);
|
|
23149
23223
|
const lang = subject.language.toLowerCase();
|
|
23150
|
-
const
|
|
23224
|
+
const { mainFile, testFile } = getFileNames(lang);
|
|
23151
23225
|
let filePath = exercise.file_path;
|
|
23152
23226
|
if (!filePath) {
|
|
23153
23227
|
const exerciseSlug = slugify(exercise.title);
|
|
23154
23228
|
filePath = this.fileStore.writeExerciseFiles(subject.slug, exerciseSlug, {});
|
|
23155
23229
|
this.db.raw.prepare('UPDATE exercises SET file_path = ? WHERE id = ?').run(filePath, exerciseId);
|
|
23156
23230
|
}
|
|
23157
|
-
|
|
23158
|
-
|
|
23231
|
+
// Add scaffold files if missing
|
|
23232
|
+
const scaffold = getScaffoldFiles(lang, subject.slug, slugify(exercise.title));
|
|
23233
|
+
for (const [name, content] of Object.entries(scaffold)) {
|
|
23234
|
+
const p = join(filePath, name);
|
|
23235
|
+
if (!existsSync(p))
|
|
23236
|
+
writeFileSync(p, content, 'utf-8');
|
|
23237
|
+
}
|
|
23238
|
+
writeFileSync(join(filePath, mainFile), main, 'utf-8');
|
|
23239
|
+
writeFileSync(join(filePath, testFile), test, 'utf-8');
|
|
23159
23240
|
}
|
|
23160
23241
|
migrateFileExtensions() {
|
|
23161
23242
|
const exercises = this.db.raw
|
|
@@ -23166,7 +23247,7 @@ class ExerciseService {
|
|
|
23166
23247
|
const subject = this.getSubjectForTopic(exercise.topic_id);
|
|
23167
23248
|
if (!subject)
|
|
23168
23249
|
continue;
|
|
23169
|
-
const ext =
|
|
23250
|
+
const ext = getExtension(subject.language.toLowerCase());
|
|
23170
23251
|
if (ext === '.txt')
|
|
23171
23252
|
continue;
|
|
23172
23253
|
const dir = exercise.file_path;
|
|
@@ -23185,6 +23266,10 @@ class ExerciseService {
|
|
|
23185
23266
|
}
|
|
23186
23267
|
return migrated;
|
|
23187
23268
|
}
|
|
23269
|
+
getSubjectLanguage(topicId) {
|
|
23270
|
+
const subject = this.getSubjectForTopic(topicId);
|
|
23271
|
+
return subject?.language ?? '';
|
|
23272
|
+
}
|
|
23188
23273
|
getSubjectForTopic(topicId) {
|
|
23189
23274
|
return this.db.raw
|
|
23190
23275
|
.prepare(`SELECT s.* FROM subjects s
|
|
@@ -23359,6 +23444,14 @@ function registerCurriculumTools(server, svc, sessions, notify) {
|
|
|
23359
23444
|
const curriculum = svc.getCurriculum(session.subjectId);
|
|
23360
23445
|
return ok$4(JSON.stringify(curriculum, null, 2));
|
|
23361
23446
|
});
|
|
23447
|
+
// 8. learn_list_subjects
|
|
23448
|
+
server.tool('learn_list_subjects', 'List all available subjects', {}, async () => {
|
|
23449
|
+
const subjects = svc.listSubjects();
|
|
23450
|
+
if (subjects.length === 0) {
|
|
23451
|
+
return ok$4('No subjects found. Create one with learn_create_subject.');
|
|
23452
|
+
}
|
|
23453
|
+
return ok$4(JSON.stringify(subjects, null, 2));
|
|
23454
|
+
});
|
|
23362
23455
|
}
|
|
23363
23456
|
|
|
23364
23457
|
function getSession$3(sessions, sessionId) {
|
|
@@ -23501,6 +23594,16 @@ function registerExerciseTools(server, svc, sessions, notify) {
|
|
|
23501
23594
|
if (session.topicId === null) {
|
|
23502
23595
|
return err$1('No active topic. Use learn_set_topic first.');
|
|
23503
23596
|
}
|
|
23597
|
+
// Gate coding/project exercises for subjects without a language
|
|
23598
|
+
if (type === 'coding' || type === 'project') {
|
|
23599
|
+
const lang = svc.getSubjectLanguage(session.topicId);
|
|
23600
|
+
if (!lang) {
|
|
23601
|
+
return err$1('Coding/project exercises require a subject with a programming language. Use quiz or assignment type instead.');
|
|
23602
|
+
}
|
|
23603
|
+
if (!isLanguageSupported(lang)) {
|
|
23604
|
+
return err$1(`Unsupported language: "${lang}". Supported: ${SUPPORTED_LANGUAGES.join(', ')}`);
|
|
23605
|
+
}
|
|
23606
|
+
}
|
|
23504
23607
|
const exercise = svc.createExercise(session.topicId, {
|
|
23505
23608
|
title,
|
|
23506
23609
|
type,
|
|
@@ -23540,6 +23643,57 @@ function registerExerciseTools(server, svc, sessions, notify) {
|
|
|
23540
23643
|
const exercises = svc.listForTopic(session.topicId);
|
|
23541
23644
|
return ok$1(JSON.stringify(exercises, null, 2));
|
|
23542
23645
|
});
|
|
23646
|
+
// 4. learn_submit_quiz
|
|
23647
|
+
server.tool('learn_submit_quiz', 'Submit answers for a quiz exercise and get the score', {
|
|
23648
|
+
exercise_id: numberType().describe('ID of the quiz exercise'),
|
|
23649
|
+
answers: stringType().describe('JSON array of answers — numbers for multiple_choice, booleans for true_false, strings for fill_in'),
|
|
23650
|
+
}, async ({ exercise_id, answers }) => {
|
|
23651
|
+
let parsed;
|
|
23652
|
+
try {
|
|
23653
|
+
parsed = JSON.parse(answers);
|
|
23654
|
+
}
|
|
23655
|
+
catch {
|
|
23656
|
+
return err$1('Invalid JSON in answers parameter');
|
|
23657
|
+
}
|
|
23658
|
+
if (!Array.isArray(parsed)) {
|
|
23659
|
+
return err$1('answers must be a JSON array');
|
|
23660
|
+
}
|
|
23661
|
+
try {
|
|
23662
|
+
const result = svc.submitQuiz(exercise_id, parsed);
|
|
23663
|
+
notify();
|
|
23664
|
+
return ok$1(JSON.stringify(result, null, 2));
|
|
23665
|
+
}
|
|
23666
|
+
catch (error) {
|
|
23667
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
23668
|
+
return err$1(`Failed to submit quiz: ${msg}`);
|
|
23669
|
+
}
|
|
23670
|
+
});
|
|
23671
|
+
// 5. learn_get_exercise_files
|
|
23672
|
+
server.tool('learn_get_exercise_files', 'Get the source code files for a coding exercise', {
|
|
23673
|
+
exercise_id: numberType().describe('ID of the exercise'),
|
|
23674
|
+
}, async ({ exercise_id }) => {
|
|
23675
|
+
const files = svc.getExerciseFiles(exercise_id);
|
|
23676
|
+
if (!files) {
|
|
23677
|
+
return err$1(`Exercise ${exercise_id} not found`);
|
|
23678
|
+
}
|
|
23679
|
+
return ok$1(JSON.stringify(files, null, 2));
|
|
23680
|
+
});
|
|
23681
|
+
// 6. learn_save_exercise_files
|
|
23682
|
+
server.tool('learn_save_exercise_files', 'Save updated source code for a coding exercise', {
|
|
23683
|
+
exercise_id: numberType().describe('ID of the exercise'),
|
|
23684
|
+
main: stringType().describe('Main source file content'),
|
|
23685
|
+
test: stringType().describe('Test file content'),
|
|
23686
|
+
}, async ({ exercise_id, main, test }) => {
|
|
23687
|
+
try {
|
|
23688
|
+
svc.saveExerciseFiles(exercise_id, main, test);
|
|
23689
|
+
notify();
|
|
23690
|
+
return ok$1(`Saved files for exercise ${exercise_id}`);
|
|
23691
|
+
}
|
|
23692
|
+
catch (error) {
|
|
23693
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
23694
|
+
return err$1(`Failed to save files: ${msg}`);
|
|
23695
|
+
}
|
|
23696
|
+
});
|
|
23543
23697
|
}
|
|
23544
23698
|
|
|
23545
23699
|
function getSession(sessions, sessionId) {
|
|
@@ -23691,7 +23845,7 @@ function handleTopicExercises(exerciseSvc) {
|
|
|
23691
23845
|
writeError(res, 400, 'Invalid topic ID');
|
|
23692
23846
|
return;
|
|
23693
23847
|
}
|
|
23694
|
-
const exercises = exerciseSvc.
|
|
23848
|
+
const exercises = exerciseSvc.listForTopicWithResults(id);
|
|
23695
23849
|
writeJSON(res, exercises);
|
|
23696
23850
|
};
|
|
23697
23851
|
}
|
|
@@ -23834,11 +23988,11 @@ function handleResourceFile(resourceSvc) {
|
|
|
23834
23988
|
};
|
|
23835
23989
|
}
|
|
23836
23990
|
|
|
23837
|
-
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
|
|
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";
|
|
23838
23992
|
|
|
23839
|
-
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 if (quiz && Array.isArray(quiz)) {\n detailHtml += `<h4>Questions</h4>`;\n detailHtml += quiz.map((q, qi) => `\n <div class=\"quiz-question\" data-exercise=\"${i}\" data-question=\"${qi}\">\n <p>${marked.parse(q.question || q.text || '')}</p>\n ${(q.options || q.choices || []).map((opt, oi) => `\n <div class=\"quiz-option\" data-exercise=\"${i}\" data-question=\"${qi}\" data-option=\"${oi}\" onclick=\"selectQuizOption(this)\">\n ${escapeHtml(opt)}\n </div>\n `).join('')}\n </div>\n `).join('');\n detailHtml += `\n <div class=\"exercise-actions\">\n <button class=\"exercise-action-btn btn-primary\" onclick=\"submitQuiz(${ex.id}, ${i})\">Submit Answers</button>\n </div>`;\n }\n }\n\n // Coding type — test cases\n if (ex.type === 'coding' || ex.type === 'project' || ex.type === 'assignment') {\n if (results.length > 0) {\n detailHtml += `<h4>Test Results</h4>`;\n detailHtml += results.map(r => `\n <div class=\"test-case\">\n <div class=\"test-case-header\">\n <span class=\"test-status ${r.passed ? 'pass' : 'fail'}\"></span>\n ${escapeHtml(r.test_name)}\n </div>\n ${r.output ? `<div class=\"test-case-body\">${truncate(r.output, 300)}</div>` : ''}\n </div>\n `).join('');\n\n detailHtml += `\n <div class=\"exercise-progress\">\n <span>${passed}/${total} tests</span>\n <div class=\"exercise-progress-bar\">\n <div class=\"exercise-progress-fill ${hasPassed ? 'green' : 'yellow'}\" style=\"width:${total > 0 ? Math.round(passed / total * 100) : 0}%\"></div>\n </div>\n </div>`;\n }\n\n detailHtml += `\n <div class=\"exercise-actions\">\n <button class=\"exercise-action-btn btn-primary\" onclick=\"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 questionEl.querySelectorAll('.quiz-option').forEach((opt, oi) => {\n opt.classList.remove('selected');\n if (oi === r.correct_index) opt.classList.add('correct');\n else if (oi === answers[i] && !r.passed) opt.classList.add('incorrect');\n });\n });\n }\n\n if (data.score !== undefined) {\n const actionsEl = card.querySelector('.exercise-actions');\n if (actionsEl) {\n const scoreDiv = document.createElement('div');\n scoreDiv.style.cssText = `margin-top:8px;font-size:14px;font-weight:600;color:${data.passed ? 'var(--green)' : 'var(--yellow)'}`;\n scoreDiv.textContent = `Score: ${data.score}/${data.total}${data.passed ? ' - Passed!' : ''}`;\n actionsEl.appendChild(scoreDiv);\n }\n }\n } catch (err) {\n console.error('Submit quiz error:', err);\n }\n}\n\nasync function runExercise(exerciseId, cardIndex) {\n const btn = document.querySelector(`#exercise-${cardIndex} .btn-primary`);\n if (btn) { btn.textContent = 'Running...'; btn.disabled = true; }\n\n try {\n const res = await fetch(`/api/exercises/${exerciseId}/run`, { method: 'POST' });\n const data = await res.json();\n\n if (data.results && state.topicExercises[cardIndex]) {\n state.topicExercises[cardIndex].results = data.results;\n }\n\n renderExercisesTab();\n\n // Re-open the card\n const detail = document.getElementById(`exercise-detail-${cardIndex}`);\n if (detail) detail.classList.add('open');\n } catch (err) {\n console.error('Run exercise error:', err);\n if (btn) { btn.textContent = 'Run Tests'; btn.disabled = false; }\n }\n}\n\n// --- Resources Tab ---\nfunction renderResourcesTab() {\n const container = document.getElementById('tab-resources');\n if (!container) return;\n\n const resources = state.topicResources || [];\n\n if (resources.length === 0) {\n container.innerHTML = '<div class=\"empty-state\"><p>No resources yet</p><p class=\"text-muted\">Ask Claude to add reference links for this topic</p></div>';\n return;\n }\n\n let html = '<div class=\"resources-list\">';\n for (const r of resources) {\n const isFile = r.url.startsWith('file://');\n const isPdf = isFile && r.url.toLowerCase().endsWith('.pdf');\n\n if (isPdf) {\n html += '<div class=\"resource-card resource-pdf\">' +\n '<div class=\"resource-pdf-header\" data-resource-id=\"' + r.id + '\">' +\n '<span class=\"resource-title\">' + escapeHtml(r.title) + '</span>' +\n '<span class=\"resource-badge\">PDF</span>' +\n '<span class=\"resource-chevron\">▼</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() {\n if (window._cm) return window._cm;\n\n const [\n { EditorView, basicSetup },\n { EditorState },\n { go },\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/lang-go@6.0.1'),\n import('https://esm.sh/@codemirror/theme-one-dark@6.1.2'),\n import('https://esm.sh/@codemirror/view@6.36.5'),\n ]);\n\n window._cm = { EditorView, EditorState, basicSetup, go, oneDark, keymap };\n return window._cm;\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();\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.go(), 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";
|
|
23840
23994
|
|
|
23841
|
-
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";
|
|
23842
23996
|
|
|
23843
23997
|
const STATIC_FILES = {
|
|
23844
23998
|
'/': { content: indexHtml, contentType: 'text/html; charset=utf-8' },
|