From 7e77748b6bf2affcddeb0be19936c8f3cbebce90 Mon Sep 17 00:00:00 2001 From: a2nr Date: Fri, 8 May 2026 09:50:07 +0700 Subject: [PATCH] feat: add quiz feature --- .../src/lib/components/WorkspaceHeader.svelte | 6 +- frontend/src/lib/types/lesson.ts | 1 + .../src/routes/lesson/[slug]/+page.svelte | 939 +++--------------- .../src/routes/lesson/[slug]/QuizTab.svelte | 536 ++++++++++ frontend/src/routes/lesson/[slug]/lesson.css | 136 +++ .../src/routes/lesson/[slug]/lesson.svelte.ts | 501 ++++++++++ routes/lessons.py | 3 + services/lesson_service.py | 81 +- 8 files changed, 1407 insertions(+), 796 deletions(-) create mode 100644 frontend/src/routes/lesson/[slug]/QuizTab.svelte create mode 100644 frontend/src/routes/lesson/[slug]/lesson.svelte.ts diff --git a/frontend/src/lib/components/WorkspaceHeader.svelte b/frontend/src/lib/components/WorkspaceHeader.svelte index d8d2006..f205e45 100644 --- a/frontend/src/lib/components/WorkspaceHeader.svelte +++ b/frontend/src/lib/components/WorkspaceHeader.svelte @@ -1,5 +1,5 @@ - {data?.lesson_title ?? 'Pelajaran'} - Elemes LMS + {mgr.data?.lesson_title ?? 'Pelajaran'} - Elemes LMS -{#if data} - -
- +{#if mgr.data} +
e.preventDefault()} oncopy={(e) => e.preventDefault()} oncut={(e) => e.preventDefault()} oncontextmenu={(e) => e.preventDefault()}> - {#if data.locked} + {#if mgr.data.locked}
🔒
Pelajaran ini terkunci. Kamu dapat membaca materinya, namun workspace (editor) dinonaktifkan hingga prasyarat selesai. - {#if data.missing_prerequisites && data.missing_prerequisites.length > 0} + {#if mgr.data.missing_prerequisites?.length}
Belum selesai: - {#each data.missing_prerequisites as p, i} - {getLessonTitle(p)}{i < data.missing_prerequisites.length - 1 ? ', ' : ''} + {#each mgr.data.missing_prerequisites as p, i} + {mgr.getLessonTitle(p)}{i < mgr.data.missing_prerequisites.length - 1 ? ', ' : ''} {/each}
{/if} @@ -666,51 +107,55 @@
{/if} -
{@html data.lesson_content}
- - + {#if mgr.isQuizMode} +
+
+
🫣
+

Materi Disembunyikan

+

Selesaikan atau batalkan kuis untuk melihat materi kembali.

+ +
+
+ {:else} +
{@html mgr.data.lesson_content}
+ + {/if}
- - {#if float.floating && float.minimized && !isMobile} - + {#if float.floating && float.minimized && !mgr.isMobile} + {/if} -
-
- {#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()} - {/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. +
+ +
+ {:else if quizData.length > 0} +
+
+ Pertanyaan {currentIndex + 1} dari {quizData.length} +
+
+
+
+ + {#if currentCard.type === 'mcq'} + +
+
+
Pertanyaan
+
+ {@html currentCard.question} +
+
+ +
+ {#each randomizedOptions as option, i} + + {/each} +
+ + {#if isAnswered} + + {/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} + +
+ + + {#if currentIndex < quizData.length - 1} + + {:else} + + {/if} +
+ +
+ + {#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 }