- {#if data.locked}
+ {#if mgr.data.locked}
🔒
@@ -720,198 +165,104 @@
{/if}
-
-
-
e.preventDefault()}
- oncopy={(e) => e.preventDefault()}
- oncontextmenu={(e) => e.preventDefault()}>
- {#if data.lesson_info}
-
{@html data.lesson_info}
+
+ {#if mgr.data.lesson_info}
+
{@html mgr.data.lesson_info}
{/if}
-
-
-
e.preventDefault()}
- oncopy={(e) => e.preventDefault()}
- oncontextmenu={(e) => e.preventDefault()}>
- {#if data.exercise_content}
+
+ {#if mgr.data.exercise_content}
Latihan
- {@html data.exercise_content}
+ {@html mgr.data.exercise_content}
{/if}
-
- {#if data.active_tabs?.includes('circuit')}
-
-
-
+ {#if mgr.data.active_tabs?.includes('circuit')}
+
+
+
{/if}
-
- {#if isVelxio}
-
-
-
+ {#if mgr.isVelxio}
+
+
+
{/if}
-
- {#if isFlowchart}
-
-
-
+ {#if mgr.isFlowchart}
+
+
+
{/if}
-
- {#if !data.active_tabs || data.active_tabs.length === 0 || data.active_tabs.includes('c') || data.active_tabs.includes('python')}
-
-
-
+ {#if !mgr.data.active_tabs?.length || mgr.data.active_tabs.includes('c') || mgr.data.active_tabs.includes('python')}
+
+
+
{/if}
-
-
-
+ {#if mgr.data.active_tabs?.includes('quiz')}
+
+
+
+ {/if}
+
+
+
{#snippet actions()}
-
- {compiling ? 'Mengevaluasi...' : 'ā¶ Run Keseluruhan'}
+ mgr.handleRunAll()} disabled={mgr.compiling}>
+ {mgr.compiling ? 'Mengevaluasi...' : 'ā¶ Run Keseluruhan'}
{/snippet}
-
+
{/if}
-
-
-
diff --git a/frontend/src/routes/lesson/[slug]/QuizTab.svelte b/frontend/src/routes/lesson/[slug]/QuizTab.svelte
new file mode 100644
index 0000000..d6ff0c0
--- /dev/null
+++ b/frontend/src/routes/lesson/[slug]/QuizTab.svelte
@@ -0,0 +1,536 @@
+
+
+
+ {#if !isQuizMode}
+
+
š
+
Kuis Interaktif
+
Uji pemahamanmu dengan kuis flashcard dan pilihan ganda ini.
+
+ ā ļø Penting: Saat kuis dimulai, materi pelajaran di sisi kiri akan disembunyikan agar kamu bisa fokus menjawab.
+
+
+ Mulai Kuis Sekarang
+
+
+ {:else if quizData.length > 0}
+
+
+ Pertanyaan {currentIndex + 1} dari {quizData.length}
+
+
+
+ {#if currentCard.type === 'mcq'}
+
+
+
+
Pertanyaan
+
+ {@html currentCard.question}
+
+
+
+
+ {#each randomizedOptions as option, i}
+
handleOptionSelect(i)}
+ disabled={isAnswered}
+ >
+
+ {String.fromCharCode(65 + i)}
+
+
+ {@html option.text}
+
+
+ {/each}
+
+
+ {#if isAnswered}
+
+
+ {#if randomizedOptions[selectedOption!].is_correct}
+ š Benar! Jawabanmu tepat.
+ {:else}
+ ā Kurang tepat. Coba pelajari lagi materinya nanti ya.
+ {/if}
+
+ {#if currentCard.explanation}
+
+ {@html currentCard.explanation}
+
+ {/if}
+
+ {/if}
+
+ {:else}
+
+
+
+
+
+
+
Pertanyaan
+
+ {@html currentCard.front}
+
+
Klik untuk melihat jawaban
+
+
+
Jawaban
+
+ {@html currentCard.back}
+
+ {#if currentCard.explanation}
+
+ {@html currentCard.explanation}
+
+ {/if}
+
Klik untuk kembali ke pertanyaan
+
+
+
+ {/if}
+
+
+
+ ā Sebelumnya
+
+
+ {#if currentIndex < quizData.length - 1}
+
+ Selanjutnya ā
+
+ {:else}
+
+ Selesai Kuis
+
+ {/if}
+
+
+
+ Batal Kuis
+ {#if !isAllAnswered}
+ š Selesaikan semua pertanyaan untuk dapat keluar
+ {/if}
+
+
+ {:else}
+
+
Tidak ada data kuis untuk pelajaran ini.
+
+ {/if}
+
+
+
diff --git a/frontend/src/routes/lesson/[slug]/lesson.css b/frontend/src/routes/lesson/[slug]/lesson.css
index e4a6654..a415ea6 100644
--- a/frontend/src/routes/lesson/[slug]/lesson.css
+++ b/frontend/src/routes/lesson/[slug]/lesson.css
@@ -326,4 +326,140 @@
background: var(--color-bg-secondary);
border-radius: var(--radius);
margin: 0.5rem 0;
+}
+
+/* āā Banner & Overlay styles āāāāāāāāāāāāāāāāāāāāāāāāāāāāā */
+.locked-banner {
+ background: #fff9db;
+ border: 1px solid #fab005;
+ color: #862e00;
+ padding: 1rem;
+ border-radius: 8px;
+ margin-bottom: 2rem;
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ font-size: 0.95rem;
+ line-height: 1.4;
+}
+.locked-banner-icon {
+ font-size: 1.25rem;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 2rem;
+ height: 2rem;
+ background: rgba(134, 46, 0, 0.1);
+ border-radius: 50%;
+}
+.missing-list {
+ font-size: 0.8rem;
+ margin-top: 0.25rem;
+ font-weight: 600;
+}
+.prereq-link {
+ color: #862e00;
+ text-decoration: underline;
+ font-weight: 700;
+}
+.prereq-link:hover {
+ color: #5c1e00;
+}
+.workspace-lock-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(255, 255, 255, 0.9);
+ backdrop-filter: blur(4px);
+ z-index: 100;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1.5rem;
+ text-align: center;
+ border-radius: inherit;
+}
+.lock-overlay-content {
+ max-width: 280px;
+}
+.lock-overlay-content h3 {
+ margin: 0.75rem 0 0.5rem;
+ font-size: 1.1rem;
+ color: var(--color-text);
+}
+.lock-overlay-content p {
+ color: var(--color-text-muted);
+ font-size: 0.85rem;
+ line-height: 1.4;
+}
+.lock-overlay-icon {
+ font-size: 2.5rem;
+ line-height: 1;
+ margin-bottom: 0.5rem;
+ display: inline-block;
+}
+@media (max-width: 600px) {
+ .lock-overlay-icon {
+ font-size: 2rem;
+ }
+ .lock-overlay-content h3 {
+ font-size: 1rem;
+ }
+}
+.editor-locked {
+ overflow: hidden;
+}
+
+.quiz-blur-container {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 400px;
+ background: #f1f3f5;
+ border-radius: 12px;
+ margin: 2rem 0;
+ text-align: center;
+ border: 2px dashed var(--color-border);
+ position: relative;
+ overflow: hidden;
+}
+
+.quiz-blur-container::before {
+ content: "";
+ position: absolute;
+ top: 0; left: 0; right: 0; bottom: 0;
+ background: linear-gradient(to bottom, #eee 20%, #ddd 20%, #ddd 40%, #eee 40%, #eee 60%, #ddd 60%, #ddd 80%, #eee 80%);
+ background-size: 100% 40px;
+ filter: blur(8px);
+ opacity: 0.3;
+}
+
+.quiz-blur-content {
+ position: relative;
+ z-index: 1;
+ max-width: 300px;
+ background: white;
+ padding: 2rem;
+ border-radius: 16px;
+ box-shadow: 0 10px 25px rgba(0,0,0,0.1);
+}
+
+.quiz-blur-icon {
+ font-size: 3rem;
+ margin-bottom: 1rem;
+}
+
+.quiz-blur-content h3 {
+ margin-bottom: 0.5rem;
+ color: var(--color-text);
+}
+
+.quiz-blur-content p {
+ font-size: 0.9rem;
+ color: var(--color-text-muted);
+ margin-bottom: 1.5rem;
+ line-height: 1.4;
}
\ No newline at end of file
diff --git a/frontend/src/routes/lesson/[slug]/lesson.svelte.ts b/frontend/src/routes/lesson/[slug]/lesson.svelte.ts
new file mode 100644
index 0000000..11ad050
--- /dev/null
+++ b/frontend/src/routes/lesson/[slug]/lesson.svelte.ts
@@ -0,0 +1,501 @@
+import { page } from '$app/stores';
+import { get } from 'svelte/store';
+import { tick, untrack } from 'svelte';
+import { auth, authLoggedIn } from '$stores/auth';
+import { lessonContext } from '$stores/lessonContext';
+import { compileCode, trackProgress } from '$services/api';
+import { evaluateVelxioSubmission } from '$services/velxio-evaluator';
+import { evaluateFlowchartSubmission } from '$services/flowchart-evaluator';
+import { evaluateCircuitSubmission, processLanguageEvaluation } from '$services/evaluators';
+import { getVelxioState, initVelxioBridge } from '$services/velxio-manager';
+import { VelxioBridge } from '$services/velxio-bridge';
+import type { LessonContent } from '$types/lesson';
+import type { CodeEditor } from '$components/CodeEditor.svelte';
+import type { CircuitEditor } from '$components/CircuitEditor.svelte';
+
+export class LessonManager {
+ data = $state
(null);
+ lessonCompleted = $state(false);
+ isQuizMode = $state(false);
+ currentCode = $state('');
+ currentLanguage = $state('c');
+
+ // Per-language code tracking
+ cCode = $state('');
+ pythonCode = $state('');
+
+ // Output state
+ freshOutput = () => ({ output: '', error: '', loading: false, success: null as boolean | null, debug: undefined as string[] | undefined });
+ cOut = $state(this.freshOutput());
+ pyOut = $state(this.freshOutput());
+ circuitOut = $state(this.freshOutput());
+ velxioOut = $state(this.freshOutput());
+ flowchartOut = $state(this.freshOutput());
+
+ cPassed = $state(false);
+ pythonPassed = $state(false);
+ circuitPassed = $state(false);
+ flowchartPassed = $state(false);
+
+ velxioBridge = $state(null);
+ velxioReady = $state(false);
+ velxioSaving = $state(false);
+ velxioError = $state(false);
+ velxioIframe = $state(null);
+
+ showSolution = $state(false);
+ activeTab = $state<'info' | 'exercise' | 'editor' | 'circuit' | 'output' | 'velxio' | 'flowchart'>('info');
+ showCelebration = $state(false);
+ mobileMode = $state<'hidden' | 'half' | 'full'>('hidden');
+ isMobile = $state(false);
+
+ // Refs (set from component)
+ editor = $state(null);
+ circuitEditor = $state(null);
+ flowchartTab = $state(null);
+
+ slug = $derived(get(page).params.slug);
+
+ isVelxio = $derived(this.data?.active_tabs?.includes('velxio') ?? false);
+ isFlowchart = $derived(this.data?.active_tabs?.includes('flowchart') ?? false);
+ outputSections = $derived.by(() => {
+ const tabs = this.data?.active_tabs ?? [];
+ const secs: any[] = [];
+ if (tabs.includes('c') || (!tabs.length && !tabs.includes('python'))) {
+ secs.push({ key: 'c', label: 'C', icon: '\u{1F4BB}', data: this.cOut, placeholder: 'Klik "Run" untuk menjalankan kode C', loadingText: 'Mengompilasi C...' });
+ }
+ if (tabs.includes('python')) {
+ secs.push({ key: 'python', label: 'Python', icon: '\u{1F40D}', data: this.pyOut, placeholder: 'Klik "Run" untuk menjalankan kode Python', loadingText: 'Menjalankan Python...' });
+ }
+ if (tabs.includes('circuit')) {
+ secs.push({ key: 'circuit', label: 'Circuit', icon: '\u26A1', data: this.circuitOut, placeholder: 'Klik "Cek Rangkaian" untuk mengevaluasi', loadingText: 'Mengevaluasi rangkaian...' });
+ }
+ if (tabs.includes('velxio')) {
+ secs.push({ key: 'velxio', label: 'Arduino', icon: '\u{1F4DF}', data: this.velxioOut, placeholder: 'Klik "Compile & Run" untuk menjalankan kode', loadingText: 'Mengevaluasi...' });
+ }
+ if (tabs.includes('flowchart')) {
+ secs.push({ key: 'flowchart', label: 'Flowchart', icon: '\u{1F531}', data: this.flowchartOut, placeholder: 'Klik "Cek Flowchart" untuk mengevaluasi alur logika', loadingText: 'Mengevaluasi alur...' });
+ }
+ return secs;
+ });
+
+ isHybrid = $derived(
+ (this.data?.active_tabs?.includes('c') || this.data?.active_tabs?.includes('python')) &&
+ this.data?.active_tabs?.includes('circuit')
+ );
+ compiling = $derived(this.cOut.loading || this.pyOut.loading || this.circuitOut.loading || this.flowchartOut.loading);
+
+ arduinoCodeKey = $derived(`elemes_arduino_code_${this.slug}`);
+ arduinoCircuitKey = $derived(`elemes_arduino_circuit_${this.slug}`);
+ flowchartStorageKey = $derived(`elemes_flowchart_draft_${this.slug}`);
+
+ constructor() {
+ // Media query detection
+ if (typeof window !== 'undefined') {
+ const mql = window.matchMedia('(max-width: 768px)');
+ this.isMobile = mql.matches;
+ const handler = (e: MediaQueryListEvent) => {
+ this.isMobile = e.matches;
+ };
+ mql.addEventListener('change', handler);
+
+ // Auto-save Velxio state periodically
+ $effect(() => {
+ if (this.velxioReady && get(authLoggedIn) && !this.showSolution) {
+ const interval = setInterval(() => {
+ const state = getVelxioState(this.velxioIframe);
+ if (!state) return;
+
+ let changed = false;
+
+ // 1. Source Code
+ const savedCode = localStorage.getItem(this.arduinoCodeKey);
+ if (state.code && state.code !== savedCode) {
+ console.log('[Velxio Auto-save] Saving code changes (Zustand)');
+ localStorage.setItem(this.arduinoCodeKey, state.code);
+ changed = true;
+ }
+
+ // 2. Circuit (Diagram + Wires)
+ const savedCircuit = localStorage.getItem(this.arduinoCircuitKey);
+ if (state.circuit && state.circuit !== savedCircuit) {
+ console.log('[Velxio Auto-save] Saving circuit changes (Zustand)');
+ localStorage.setItem(this.arduinoCircuitKey, state.circuit);
+ changed = true;
+ }
+
+ if (changed) {
+ this.velxioSaving = true;
+ setTimeout(() => { this.velxioSaving = false; }, 1500);
+ }
+ }, 7000); // Poll every 7 seconds
+
+ return () => clearInterval(interval);
+ }
+ });
+ }
+ }
+
+ // Switch editor content when language tab changes
+ prevLanguage = $state('c');
+ setupLanguageSync() {
+ $effect(() => {
+ if (!this.data || this.currentLanguage === this.prevLanguage) return;
+ // Save current code to the previous language slot
+ const code = this.editor?.getCode() ?? this.currentCode;
+ if (this.prevLanguage === 'c') this.cCode = code;
+ else if (this.prevLanguage === 'python') this.pythonCode = code;
+ // Load code for the new language
+ const newCode = this.currentLanguage === 'python' ? this.pythonCode : (this.cCode || this.data.initial_code || '');
+ this.currentCode = newCode;
+ this.editor?.setCode(newCode);
+ this.prevLanguage = this.currentLanguage;
+ });
+ }
+
+ init(lesson: LessonContent) {
+ untrack(() => {
+ this.data = lesson;
+ this.lessonCompleted = lesson.lesson_completed;
+ this.isQuizMode = false;
+
+ this.cCode = lesson.initial_code_c || '';
+ this.pythonCode = lesson.initial_python || '';
+
+ const hasC = lesson.active_tabs?.includes('c');
+ const hasPython = lesson.active_tabs?.includes('python');
+ const initLang = (hasPython && !hasC) ? 'python' : 'c';
+ this.currentLanguage = initLang;
+ this.currentCode = initLang === 'python' ? this.pythonCode : (this.cCode || lesson.initial_code || '');
+
+ this.cOut = this.freshOutput();
+ this.pyOut = this.freshOutput();
+ this.circuitOut = this.freshOutput();
+ this.velxioOut = this.freshOutput();
+ this.cPassed = false;
+ this.pythonPassed = false;
+ this.circuitPassed = false;
+ this.showSolution = false;
+
+ if (this.velxioBridge) { this.velxioBridge.destroy(); this.velxioBridge = null; }
+ this.velxioReady = false;
+ this.velxioError = false;
+
+ if (lesson.lesson_info) this.activeTab = 'info';
+ else if (lesson.exercise_content) this.activeTab = 'exercise';
+ else if (lesson.active_tabs?.includes('velxio')) this.activeTab = 'velxio';
+ else if (lesson.active_tabs?.includes('flowchart')) this.activeTab = 'flowchart';
+ else if (lesson.active_tabs?.includes('circuit') && !hasC && !hasPython) this.activeTab = 'circuit';
+ else this.activeTab = 'editor';
+
+ this.mobileMode = 'hidden';
+
+ lessonContext.set({
+ title: lesson.lesson_title,
+ completed: lesson.lesson_completed,
+ prevLesson: lesson.prev_lesson,
+ nextLesson: lesson.next_lesson
+ });
+ });
+ }
+
+ async completeLesson() {
+ if (this.lessonCompleted) return;
+ this.showCelebration = true;
+ if (get(authLoggedIn)) {
+ const lessonName = this.slug.replace('.md', '');
+ await trackProgress(get(auth).token, lessonName);
+ this.lessonCompleted = true;
+ lessonContext.update(ctx => ctx ? { ...ctx, completed: true } : ctx);
+ }
+ }
+
+ checkAllPassed(): boolean {
+ const needsC = this.data?.active_tabs?.includes('c');
+ const needsPython = this.data?.active_tabs?.includes('python');
+ const needsCircuit = this.data?.active_tabs?.includes('circuit');
+ const needsFlowchart = this.data?.active_tabs?.includes('flowchart');
+
+ if (!this.data?.active_tabs?.length) return true;
+
+ if (needsC && !this.cPassed) return false;
+ if (needsPython && !this.pythonPassed) return false;
+ if (needsCircuit && !this.circuitPassed) return false;
+ if (needsFlowchart && !this.flowchartPassed) return false;
+
+ return true;
+ }
+
+ async evaluateFlowchart() {
+ if (!this.data || !this.flowchartTab) return;
+ Object.assign(this.flowchartOut, { loading: true, output: 'Mengevaluasi alur...', error: '', success: null });
+ this.activeTab = 'output';
+ try {
+ const flowchartText = await this.flowchartTab.getFlowchartText();
+ if (!flowchartText) {
+ Object.assign(this.flowchartOut, { error: 'Gagal mengambil data flowchart.', success: false });
+ return;
+ }
+ const expectedFlowchart = this.data.expected_flowchart || '';
+ if (!expectedFlowchart) {
+ Object.assign(this.flowchartOut, { error: 'Kunci jawaban tidak tersedia untuk pelajaran ini.', success: false });
+ return;
+ }
+ const result = evaluateFlowchartSubmission(flowchartText, expectedFlowchart);
+ this.flowchartOut.output = result.output;
+ this.flowchartOut.success = result.pass;
+ if (this.flowchartOut.success) {
+ this.flowchartPassed = true;
+ if (this.checkAllPassed()) {
+ await this.completeLesson();
+ setTimeout(() => { this.showCelebration = false; this.activeTab = 'flowchart'; }, 3000);
+ }
+ }
+ } catch (err: any) {
+ Object.assign(this.flowchartOut, { error: `Terjadi kesalahan saat evaluasi: ${err.message}`, success: false });
+ } finally {
+ this.flowchartOut.loading = false;
+ }
+ }
+
+ async evaluateCircuit() {
+ if (!this.data || !this.circuitEditor) return;
+ const simApi = this.circuitEditor.getApi();
+ if (!simApi) {
+ Object.assign(this.circuitOut, { error: "Simulator belum siap.", success: false });
+ this.activeTab = 'output';
+ return;
+ }
+ Object.assign(this.circuitOut, { loading: true, output: 'Mengevaluasi rangkaian...', error: '', success: null });
+ this.activeTab = 'output';
+ try {
+ const circuitText = this.circuitEditor.getCircuitText();
+ const res = evaluateCircuitSubmission(simApi, circuitText, this.isHybrid, this.data, () => this.checkAllPassed());
+ if (res.error) {
+ Object.assign(this.circuitOut, { error: res.error, success: false, loading: false });
+ return;
+ }
+ this.circuitOut.output = res.output;
+ this.circuitOut.success = res.pass;
+ if (res.pass) {
+ this.circuitPassed = true;
+ if (this.checkAllPassed()) {
+ await this.completeLesson();
+ setTimeout(() => { this.showCelebration = false; this.activeTab = 'circuit'; }, 3000);
+ }
+ }
+ } catch (err: any) {
+ Object.assign(this.circuitOut, { error: `Evaluasi gagal: ${err.message}`, success: false });
+ } finally {
+ this.circuitOut.loading = false;
+ }
+ }
+
+ async evaluateLanguage(lang: 'c' | 'python') {
+ if (!this.data) return;
+ const out = lang === 'c' ? this.cOut : this.pyOut;
+ Object.assign(out, { loading: true, output: '', error: '', success: null });
+ this.activeTab = 'output';
+ try {
+ const code = (this.currentLanguage === lang) ? (this.editor?.getCode() ?? this.currentCode) : (lang === 'c' ? this.cCode : this.pythonCode);
+ const res = await compileCode({ code, language: lang, token: get(auth).token });
+ if (!res.success) {
+ Object.assign(out, { error: res.error || 'Compilation failed', success: false });
+ return;
+ }
+ out.output = res.output;
+ out.success = true;
+ if (this.data.expected_output || this.data.expected_output_python) {
+ const { isCorrect } = processLanguageEvaluation(res.output, code, lang, this.currentLanguage, this.cCode, this.pythonCode, this.data);
+ if (isCorrect) {
+ if (lang === 'c') this.cPassed = true;
+ else if (lang === 'python') this.pythonPassed = true;
+ if (!this.checkAllPassed()) {
+ out.output += '\nā
Kode benar!\nā³ Selesaikan juga tantangan di tab lainnya untuk menyelesaikan pelajaran ini.';
+ } else {
+ out.output += '\nš Semuanya benar!';
+ }
+ if (this.checkAllPassed()) {
+ await this.completeLesson();
+ if (this.data.solution_code || this.data.solution_python || this.data.solution_circuit) {
+ this.showSolution = true;
+ this.handleShowSolution();
+ this.showSolution = true;
+ }
+ setTimeout(() => { this.showCelebration = false; this.activeTab = 'editor'; }, 3000);
+ }
+ }
+ }
+ } catch {
+ Object.assign(out, { error: 'Gagal terhubung ke server', success: false });
+ } finally {
+ out.loading = false;
+ }
+ }
+
+ async handleRun() {
+ if (this.activeTab === 'circuit') { await this.evaluateCircuit(); return; }
+ if (this.activeTab === 'flowchart') { await this.evaluateFlowchart(); return; }
+ if (!this.data) return;
+ this.activeTab = 'output';
+ await this.evaluateLanguage(this.currentLanguage as 'c' | 'python');
+ }
+
+ async handleRunAll() {
+ if (!this.data) return;
+ this.activeTab = 'output';
+ const tabs = this.data.active_tabs ?? [];
+ if (tabs.includes('c') || (!tabs.length && !tabs.includes('python'))) await this.evaluateLanguage('c');
+ if (tabs.includes('python')) await this.evaluateLanguage('python');
+ if (tabs.includes('circuit')) await this.evaluateCircuit();
+ if (tabs.includes('velxio')) await this.handleVelxioSubmit();
+ if (tabs.includes('flowchart')) await this.evaluateFlowchart();
+ }
+
+ handleReset() {
+ if (!this.data) return;
+ if (this.activeTab === 'circuit') {
+ this.circuitEditor?.setCircuitText(this.data.initial_circuit || this.data.initial_code);
+ Object.assign(this.circuitOut, this.freshOutput());
+ } else if (this.activeTab === 'velxio') {
+ localStorage.removeItem(this.arduinoCodeKey);
+ localStorage.removeItem(this.arduinoCircuitKey);
+ if (this.data.initial_code_arduino) {
+ this.velxioBridge?.loadCode([{ name: 'sketch.ino', content: this.data.initial_code_arduino }]);
+ }
+ if (this.data.velxio_circuit) {
+ this.velxioBridge?.loadCircuit(this.data.velxio_circuit);
+ }
+ Object.assign(this.velxioOut, this.freshOutput());
+ } else if (this.activeTab === 'flowchart') {
+ localStorage.removeItem(this.flowchartStorageKey);
+ if (this.flowchartTab && typeof this.flowchartTab.handleLoad === 'function') {
+ this.flowchartTab.handleLoad(true);
+ }
+ } else {
+ const resetCode = this.currentLanguage === 'python'
+ ? (this.data.initial_python || '')
+ : (this.data.initial_code_c || this.data.initial_code || '');
+ this.currentCode = resetCode;
+ if (this.currentLanguage === 'c') this.cCode = resetCode;
+ else this.pythonCode = resetCode;
+ this.editor?.setCode(resetCode);
+ const out = this.currentLanguage === 'python' ? this.pyOut : this.cOut;
+ Object.assign(out, this.freshOutput());
+ }
+ }
+
+ handleShowSolution() {
+ if (!this.data) return;
+ if (!this.data.solution_code && !this.data.solution_circuit && !this.data.solution_python) return;
+ this.showSolution = !this.showSolution;
+ if (this.showSolution) {
+ if (this.data.active_tabs?.includes('circuit') && this.data.solution_circuit) {
+ this.circuitEditor?.setCircuitText(this.data.solution_circuit);
+ }
+ if (this.currentLanguage === 'python' && this.data.solution_python) {
+ this.editor?.setCode(this.data.solution_python);
+ } else if (this.data.solution_code) {
+ this.editor?.setCode(this.data.solution_code);
+ }
+ } else {
+ if (this.data.active_tabs?.includes('circuit') && this.data.initial_circuit) {
+ this.circuitEditor?.setCircuitText(this.data.initial_circuit);
+ }
+ if (this.currentLanguage === 'python' || this.currentLanguage === 'c' || this.data.initial_code) {
+ this.editor?.setCode(this.currentCode);
+ }
+ }
+ }
+
+ setupVelxioBridge(iframe: HTMLIFrameElement) {
+ this.velxioIframe = iframe;
+ initVelxioBridge(
+ iframe,
+ this.data,
+ this.arduinoCircuitKey,
+ this.arduinoCodeKey,
+ (bridge) => {
+ this.velxioBridge = bridge;
+ this.velxioReady = true;
+ },
+ () => this.handleVelxioSubmit()
+ );
+ }
+
+ async handleVelxioSubmit() {
+ if (!this.data) return;
+ Object.assign(this.velxioOut, { loading: true, output: 'Mengevaluasi...', error: '', success: null });
+ this.activeTab = 'output';
+ try {
+ let sourceCode = '';
+ let serialLog = '';
+ let wireList: any[] = [];
+ const dbg: string[] = [];
+ if (this.velxioBridge) {
+ const serResp = await this.velxioBridge['request']('elemes:get_serial_log', 'velxio:serial_log');
+ if (serResp) serialLog = serResp.log as string;
+ }
+ const state = getVelxioState(this.velxioIframe);
+ if (state) {
+ sourceCode = state.code;
+ try {
+ const circuit = JSON.parse(state.circuit);
+ wireList = circuit.wires || [];
+ } catch {}
+ dbg.push('[metode: Zustand store]');
+ } else {
+ dbg.push('[!] Gagal mengakses simulator state');
+ }
+ const evalRes = evaluateVelxioSubmission(sourceCode, serialLog, wireList, this.data);
+ this.velxioOut.output = evalRes.messages.join('\n');
+ this.velxioOut.debug = dbg.concat(evalRes.dbg);
+ this.velxioOut.success = evalRes.pass;
+ if (evalRes.pass) {
+ await this.completeLesson();
+ setTimeout(() => { this.showCelebration = false; this.activeTab = 'velxio'; }, 3000);
+ }
+ } catch (err: any) {
+ Object.assign(this.velxioOut, { error: `Evaluasi gagal: ${err.message}`, success: false });
+ } finally {
+ this.velxioOut.loading = false;
+ }
+ }
+
+ getLessonTitle(slug: string) {
+ const lesson = this.data?.ordered_lessons?.find(l => l.filename.replace('.md', '') === slug);
+ return lesson?.title || slug.replace(/_/g, ' ').toUpperCase();
+ }
+
+ handleTryCode(code: string, lang: string, float: any) {
+ if (lang === 'python' || lang === 'c') {
+ this.currentLanguage = lang;
+ this.currentCode = code;
+ if (lang === 'c') this.cCode = code;
+ else this.pythonCode = code;
+ this.editor?.setCode(code);
+ this.activeTab = 'editor';
+ if (!this.isMobile) {
+ float.floating = true;
+ } else {
+ this.mobileMode = 'half';
+ tick().then(() => {
+ document.querySelector('.editor-area')?.scrollIntoView({ behavior: 'smooth' });
+ });
+ }
+ } else if (lang === 'cpp') {
+ if (this.isVelxio && this.velxioBridge) {
+ this.velxioBridge.loadCode([{ name: 'sketch.ino', content: code }]);
+ this.activeTab = 'velxio';
+ if (!this.isMobile) {
+ float.floating = true;
+ } else {
+ this.mobileMode = 'half';
+ tick().then(() => {
+ document.querySelector('.editor-area')?.scrollIntoView({ behavior: 'smooth' });
+ });
+ }
+ }
+ }
+ }
+}
diff --git a/routes/lessons.py b/routes/lessons.py
index 6924582..d0f75b4 100644
--- a/routes/lessons.py
+++ b/routes/lessons.py
@@ -72,6 +72,7 @@ def api_lesson(filename):
solution_python = parsed_data.get('solution_python', '')
key_text = parsed_data['key_text']
active_tabs = parsed_data['active_tabs']
+ quiz_data = parsed_data.get('quiz_data', [])
# New specific fields for hybrid lessons
initial_circuit = parsed_data.get('initial_circuit', '')
@@ -164,6 +165,7 @@ def api_lesson(filename):
solution_python = ""
key_text = ""
key_text_circuit = ""
+ quiz_data = []
# Keep lesson_html, lesson_info, etc. for reading
return jsonify({
@@ -191,6 +193,7 @@ def api_lesson(filename):
'key_text': key_text,
'key_text_circuit': key_text_circuit,
'active_tabs': active_tabs,
+ 'quiz_data': quiz_data,
'lesson_title': full_filename.replace('.md', '').replace('_', ' ').title(),
'lesson_completed': lesson_completed,
'locked': is_locked,
diff --git a/services/lesson_service.py b/services/lesson_service.py
index df61142..4624901 100644
--- a/services/lesson_service.py
+++ b/services/lesson_service.py
@@ -307,6 +307,78 @@ def _process_flowchart_embeds(text):
return pattern.sub(_replacer, text)
+def _parse_flashcards(text):
+ """Parse a string of markdown with headings and options into a list of dicts.
+
+ Supports two formats:
+ 1. Simple Flashcard: '### Question\nAnswer'
+ 2. Multiple Choice (MCQ):
+ '### Question
+ - [] option 1
+ - [x] option 2 (Correct)
+ - [] option 3
+ > Explanation'
+ """
+ if not text.strip():
+ return []
+
+ # Split by headings starting with #, ##, or ###
+ parts = re.split(r'^#{1,3}\s+', text, flags=re.MULTILINE)
+ flashcards = []
+
+ for part in parts:
+ if not part.strip():
+ continue
+
+ # First line is the question (Front)
+ subparts = part.split('\n', 1)
+ question = subparts[0].strip()
+ body = subparts[1].strip() if len(subparts) > 1 else ""
+
+ if not question:
+ continue
+
+ # Check for MCQ options: - [ ] or - [x]
+ option_pattern = re.compile(r'^\s*-\s*\[([ xX]?)\]\s*(.*)$', re.MULTILINE)
+ options = option_pattern.findall(body)
+
+ # Check for explanation (blockquote starting with >)
+ explanation_match = re.search(r'^\s*>\s*(.*)$', body, re.MULTILINE | re.DOTALL)
+ explanation = explanation_match.group(1).strip() if explanation_match else ""
+
+ # If MCQ options exist, it's an MCQ. We also remove the options from the body to find clean explanation.
+ if options:
+ parsed_options = []
+ for mark, content in options:
+ is_correct = mark.lower() == 'x'
+ parsed_options.append({
+ 'text': md.markdown(content.strip(), extensions=MD_EXTENSIONS),
+ 'is_correct': is_correct
+ })
+
+ flashcards.append({
+ 'type': 'mcq',
+ 'question': md.markdown(question, extensions=MD_EXTENSIONS),
+ 'options': parsed_options,
+ 'explanation': md.markdown(explanation, extensions=MD_EXTENSIONS) if explanation else ""
+ })
+ else:
+ # It's a simple Flashcard
+ # Remove explanation from body if it's there to keep 'back' clean
+ clean_back = body
+ if explanation_match:
+ clean_back = body[:explanation_match.start()].strip()
+
+ flashcards.append({
+ 'type': 'flashcard',
+ 'front': md.markdown(question, extensions=MD_EXTENSIONS),
+ 'back': md.markdown(clean_back, extensions=MD_EXTENSIONS),
+ 'explanation': md.markdown(explanation, extensions=MD_EXTENSIONS) if explanation else ""
+ })
+
+ return flashcards
+
+
def _extract_section(content, start_marker, end_marker):
"""Extract text between markers and return (extracted, remaining_content)."""
if start_marker not in content or end_marker not in content:
@@ -346,12 +418,14 @@ def render_markdown_content(file_path):
active_tabs.append('flowchart')
if '---INITIAL_QUIZ---' in lesson_content:
active_tabs.append('quiz')
+ if '---QUIZ_FLASHCARD---' in lesson_content:
+ active_tabs.append('quiz')
# Velxio circuit-only: has VELXIO_CIRCUIT but no INITIAL_CODE_ARDUINO
if '---VELXIO_CIRCUIT---' in lesson_content and 'velxio' not in active_tabs:
active_tabs.append('velxio')
# Default to 'c' if nothing specified (for backwards compatibility)
- if not active_tabs and '---INITIAL_CODE---' not in lesson_content and '---INITIAL_PYTHON---' not in lesson_content and '---INITIAL_CIRCUIT---' not in lesson_content and '---INITIAL_FLOWCHART---' not in lesson_content and '---INITIAL_QUIZ---' not in lesson_content:
+ if not active_tabs and '---INITIAL_CODE---' not in lesson_content and '---INITIAL_PYTHON---' not in lesson_content and '---INITIAL_CIRCUIT---' not in lesson_content and '---INITIAL_FLOWCHART---' not in lesson_content and '---INITIAL_QUIZ---' not in lesson_content and '---QUIZ_FLASHCARD---' not in lesson_content:
# If it's a completely plain old file, assume it has a code editor available
if '---EXERCISE---' in lesson_content:
active_tabs.append('c')
@@ -416,6 +490,10 @@ def render_markdown_content(file_path):
initial_quiz, lesson_content = _extract_section(
lesson_content, '---INITIAL_QUIZ---', '---END_INITIAL_QUIZ---')
+ quiz_flashcard_raw, lesson_content = _extract_section(
+ lesson_content, '---QUIZ_FLASHCARD---', '---END_QUIZ_FLASHCARD---')
+ quiz_data = _parse_flashcards(quiz_flashcard_raw)
+
# Arduino/Velxio sections
initial_code_arduino, lesson_content = _extract_section(
lesson_content, '---INITIAL_CODE_ARDUINO---', '---END_INITIAL_CODE_ARDUINO---')
@@ -483,6 +561,7 @@ def render_markdown_content(file_path):
'expected_serial_output': expected_serial_output,
'expected_wiring': expected_wiring,
'evaluation_config': evaluation_config,
+ 'quiz_data': quiz_data,
'active_tabs': active_tabs
}