refactor(frontend): pecah komponen raksasa
+page.svelte dan perbaiki backend parser
Pembaruan ini
mencakup:
- Refactoring skala besar pada halaman lesson SvelteKit untuk efisiensi kode.
- Ekstraksi evaluasi Code, Circuit, dan Velxio ke dalam direktori lib/services/.
- Memisahkan manajemen iframe (Zustand & PostMessage) ke velxio-manager.ts.
- Memisahkan Tab Panel HTML menjadi komponen spesifik (CodeTab, CircuitTab, VelxioTab).
- Ekstraksi blok <style> menjadi stylesheet terpisah (lesson.css).
- Perbaikan warning A11y & penghapusan class usang pada Navbar.svelte.
- Memperbaiki regex parser 'Available_Lessons' pada lesson_service.py di backend.
- Menambahkan mekanisme auto-append ekstensi '.md' untuk mengatasi bug Home kosong dan tombol 'Next Lesson' yang hilang akibat format URL baru di home.md.
This commit is contained in:
parent
b8fb67115e
commit
5aebd4487d
|
|
@ -42,6 +42,8 @@
|
|||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="18" x2="20" y2="18"></line></svg>
|
||||
</button>
|
||||
{#if showDropdown}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="dropdown-menu" onclick={(e) => e.stopPropagation()}>
|
||||
<button type="button" class="dropdown-item" onclick={() => { theme.toggle(); showDropdown = false; }}>
|
||||
{#if $themeDark}
|
||||
|
|
@ -289,25 +291,6 @@
|
|||
line-height: 1;
|
||||
padding: 0.2rem;
|
||||
}
|
||||
.btn-xs {
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.nav-help-link {
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.nav-help-link:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ── Modal ──────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
import { checkKeyText, validateNodes } from '$services/exercise';
|
||||
import type { LessonContent } from '$types/lesson';
|
||||
import type { CircuitJSApi } from '$types/circuitjs';
|
||||
|
||||
export interface CircuitEvalResult {
|
||||
pass: boolean;
|
||||
output: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function evaluateCircuitSubmission(
|
||||
simApi: CircuitJSApi,
|
||||
circuitText: string,
|
||||
isHybrid: boolean,
|
||||
data: LessonContent,
|
||||
checkAllPassed: () => boolean
|
||||
): CircuitEvalResult {
|
||||
const circuitExpected = (isHybrid && data.expected_circuit_output)
|
||||
? data.expected_circuit_output
|
||||
: data.expected_output;
|
||||
|
||||
let expectedState: any = null;
|
||||
try {
|
||||
if (circuitExpected) expectedState = JSON.parse(circuitExpected);
|
||||
} catch {
|
||||
return { pass: false, output: '', error: "Format EXPECTED_OUTPUT tidak valid (Harus JSON)." };
|
||||
}
|
||||
|
||||
if (!expectedState) {
|
||||
return { pass: true, output: "Tidak ada kriteria evaluasi yang ditetapkan." };
|
||||
}
|
||||
|
||||
let { allPassed, messages } = expectedState.nodes
|
||||
? validateNodes(simApi, expectedState.nodes)
|
||||
: { allPassed: true, messages: [] as string[] };
|
||||
|
||||
const circuitKeyText = (isHybrid && data.key_text_circuit) ? data.key_text_circuit : data.key_text;
|
||||
if (!checkKeyText(circuitText, circuitKeyText ?? '')) {
|
||||
allPassed = false;
|
||||
messages.push(`❌ Komponen wajib belum lengkap (lihat instruksi).`);
|
||||
}
|
||||
|
||||
let finalOutput = messages.join('\n');
|
||||
|
||||
if (allPassed && isHybrid) {
|
||||
finalOutput += '\n✅ Rangkaian benar!';
|
||||
if (!checkAllPassed()) {
|
||||
finalOutput += '\n⏳ Selesaikan juga tantangan kode untuk menyelesaikan pelajaran ini.';
|
||||
}
|
||||
}
|
||||
|
||||
return { pass: allPassed, output: finalOutput };
|
||||
}
|
||||
|
||||
export function processLanguageEvaluation(
|
||||
compileOutput: string,
|
||||
code: string,
|
||||
lang: 'c' | 'python',
|
||||
currentLanguage: string,
|
||||
cCode: string,
|
||||
pythonCode: string,
|
||||
data: LessonContent
|
||||
): { isCorrect: boolean } {
|
||||
if (!data.expected_output) return { isCorrect: true };
|
||||
|
||||
const currentCCode = (currentLanguage === 'c') ? code : cCode;
|
||||
const currentPythonCode = (currentLanguage === 'python') ? code : pythonCode;
|
||||
const mergedCode = currentCCode + '\n' + currentPythonCode;
|
||||
|
||||
const isCorrect = compileOutput.trim() === data.expected_output.trim() && checkKeyText(mergedCode, data.key_text ?? '');
|
||||
return { isCorrect };
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import type { LessonContent } from '$types/lesson';
|
||||
|
||||
export interface VelxioEvalResult {
|
||||
pass: boolean;
|
||||
messages: string[];
|
||||
dbg: string[];
|
||||
}
|
||||
|
||||
export function matchSerialSubsequence(actual: string, expected: string): boolean {
|
||||
if (!expected) return true;
|
||||
if (!actual) return false;
|
||||
|
||||
const actualLines = actual.split('\n').map(l => l.trim()).filter(l => l.length > 0);
|
||||
const expectedLines = expected.split('\n').map(l => l.trim()).filter(l => l.length > 0);
|
||||
|
||||
let expectedIdx = 0;
|
||||
for (const actualLine of actualLines) {
|
||||
if (expectedIdx < expectedLines.length) {
|
||||
if (actualLine.toLowerCase().includes(expectedLines[expectedIdx].toLowerCase())) {
|
||||
expectedIdx++;
|
||||
}
|
||||
}
|
||||
if (expectedIdx === expectedLines.length) return true;
|
||||
}
|
||||
return expectedIdx === expectedLines.length;
|
||||
}
|
||||
|
||||
export function evaluateVelxioSubmission(
|
||||
sourceCode: string,
|
||||
serialLog: string,
|
||||
wireList: any[],
|
||||
data: LessonContent
|
||||
): VelxioEvalResult {
|
||||
const messages: string[] = [];
|
||||
const dbg: string[] = [];
|
||||
let keyTextPass: boolean | undefined;
|
||||
let serialPass: boolean | undefined;
|
||||
let wiringPass: boolean | undefined;
|
||||
|
||||
// 1. Key text
|
||||
if (data.key_text) {
|
||||
const keys = data.key_text.split('\n').map(k => k.trim()).filter(k => k.length > 0);
|
||||
keyTextPass = keys.every(key => sourceCode.includes(key));
|
||||
messages.push(keyTextPass
|
||||
? '✅ Kata kunci ditemukan dalam kode'
|
||||
: '❌ Kata kunci belum ada dalam kode');
|
||||
dbg.push(`[key_text] keys="${keys.join(', ')}" → ${keyTextPass}`);
|
||||
}
|
||||
|
||||
// 2. Serial output
|
||||
if (data.expected_serial_output) {
|
||||
const actualLog = serialLog.trim();
|
||||
const expectedLog = data.expected_serial_output.trim();
|
||||
|
||||
serialPass = matchSerialSubsequence(actualLog, expectedLog);
|
||||
|
||||
messages.push(serialPass
|
||||
? '✅ Serial output sesuai'
|
||||
: '❌ Serial output belum sesuai');
|
||||
|
||||
const preview = serialLog.substring(0, 150).replace(/\n/g, '↵');
|
||||
dbg.push(`[serial] actual(${serialLog.length}ch)="${preview}"`);
|
||||
dbg.push(`[serial] expected="${expectedLog.substring(0, 150).replace(/\n/g, '↵')}" → ${serialPass}`);
|
||||
}
|
||||
|
||||
// 3. Wiring
|
||||
if (data.expected_wiring) {
|
||||
let expectedWires: any[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(data.expected_wiring);
|
||||
if (Array.isArray(parsed)) {
|
||||
expectedWires = parsed;
|
||||
} else if (parsed.wires && Array.isArray(parsed.wires)) {
|
||||
expectedWires = parsed.wires;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const nonPolarizedTypes = new Set(['resistor']);
|
||||
|
||||
const normalizePin = (pin: string) => {
|
||||
return pin.replace(/^(GND|VCC|5V|3V3|3\.3V|POWER)\.\d+$/i, '$1');
|
||||
};
|
||||
|
||||
const norm = (a: string, b: string) => {
|
||||
const [compA, pinA] = a.split(':');
|
||||
const [compB, pinB] = b.split(':');
|
||||
const normA = `${compA}:${normalizePin(pinA || '')}`;
|
||||
const normB = `${compB}:${normalizePin(pinB || '')}`;
|
||||
const finalA = nonPolarizedTypes.has(compA) || compA.startsWith('resistor')
|
||||
? `${compA}:PIN` : normA;
|
||||
const finalB = nonPolarizedTypes.has(compB) || compB.startsWith('resistor')
|
||||
? `${compB}:PIN` : normB;
|
||||
return [finalA, finalB].sort().join('↔');
|
||||
};
|
||||
|
||||
const studentEdges = new Set(
|
||||
wireList.map(w => norm(
|
||||
`${w.start.componentId}:${w.start.pinName}`,
|
||||
`${w.end.componentId}:${w.end.pinName}`
|
||||
))
|
||||
);
|
||||
|
||||
wiringPass = expectedWires.every((expected: any) => {
|
||||
let edgeKey: string;
|
||||
if (Array.isArray(expected) && expected.length === 2) {
|
||||
edgeKey = norm(expected[0], expected[1]);
|
||||
} else if (expected.start && expected.end) {
|
||||
const startPin = `${expected.start.componentId}:${expected.start.pinName}`;
|
||||
const endPin = `${expected.end.componentId}:${expected.end.pinName}`;
|
||||
edgeKey = norm(startPin, endPin);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
const exists = studentEdges.has(edgeKey);
|
||||
if (!exists) {
|
||||
dbg.push(`[wiring] MISSING: ${edgeKey}`);
|
||||
}
|
||||
return exists;
|
||||
});
|
||||
|
||||
messages.push(wiringPass
|
||||
? '✅ Rangkaian wiring benar'
|
||||
: '❌ Wiring belum sesuai');
|
||||
|
||||
const edgesStr = wireList.map(w =>
|
||||
`${w.start.componentId}:${w.start.pinName}↔${w.end.componentId}:${w.end.pinName}`
|
||||
);
|
||||
dbg.push(`[wiring] student (${wireList.length} wires): ${edgesStr.join(' | ') || '(kosong)'}`);
|
||||
dbg.push(`[wiring] expected (${expectedWires.length} connections): ${expectedWires.map((w: any) => {
|
||||
if (Array.isArray(w) && w.length === 2) return w.join('↔');
|
||||
if (w.start && w.end) return `${w.start.componentId}:${w.start.pinName}↔${w.end.componentId}:${w.end.pinName}`;
|
||||
return JSON.stringify(w);
|
||||
}).join(' | ')}`);
|
||||
dbg.push(`[wiring] result → ${wiringPass}`);
|
||||
}
|
||||
|
||||
const checks = [keyTextPass, serialPass, wiringPass].filter(v => v !== undefined);
|
||||
const pass = checks.length > 0 && checks.every(Boolean);
|
||||
|
||||
return { pass, messages, dbg };
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
import { VelxioBridge } from '$services/velxio-bridge';
|
||||
import type { LessonContent } from '$types/lesson';
|
||||
|
||||
export function getVelxioState(velxioIframe: HTMLIFrameElement | null): { code: string; circuit: string } | null {
|
||||
if (!velxioIframe) return null;
|
||||
try {
|
||||
const win = velxioIframe.contentWindow as any;
|
||||
if (!win) return null;
|
||||
|
||||
const editorStore = win.__VELXIO_EDITOR_STORE__?.getState?.();
|
||||
const simStore = win.__VELXIO_SIMULATOR_STORE__?.getState?.();
|
||||
|
||||
if (!editorStore || !simStore) return null;
|
||||
|
||||
// Extract code
|
||||
const code = (editorStore.files as any[] || []).map((f: any) => f.content).join('\n');
|
||||
|
||||
// Extract circuit state (Diagram format compatible with elemes:load_circuit)
|
||||
const circuit = {
|
||||
board: simStore.activeBoardId,
|
||||
components: (simStore.components as any[] || []).map((c: any) => ({
|
||||
type: c.metadataId,
|
||||
id: c.id,
|
||||
x: c.x,
|
||||
y: c.y,
|
||||
rotation: c.properties?.rotation || 0,
|
||||
props: { ...c.properties }
|
||||
})),
|
||||
wires: simStore.wires || []
|
||||
};
|
||||
|
||||
return {
|
||||
code,
|
||||
circuit: JSON.stringify(circuit)
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function initVelxioBridge(
|
||||
iframe: HTMLIFrameElement,
|
||||
data: LessonContent | null,
|
||||
arduinoCircuitKey: string,
|
||||
arduinoCodeKey: string,
|
||||
onReady: (bridge: VelxioBridge) => void,
|
||||
onSubmit: () => void
|
||||
) {
|
||||
let settled = false;
|
||||
const onMessage = (e: MessageEvent) => {
|
||||
const type = e.data?.type;
|
||||
if (!type) return;
|
||||
|
||||
if (!settled && type === 'velxio:ready') {
|
||||
settled = true;
|
||||
const velxioBridge = new VelxioBridge(iframe);
|
||||
onReady(velxioBridge);
|
||||
|
||||
if (!data) return;
|
||||
velxioBridge.setEmbedMode({
|
||||
hideAuth: true,
|
||||
hideComponentPicker: true,
|
||||
lockComponents: true
|
||||
});
|
||||
|
||||
// Priority: Restore from localStorage if available, otherwise use data from backend
|
||||
const savedCircuit = localStorage.getItem(arduinoCircuitKey);
|
||||
const savedCode = localStorage.getItem(arduinoCodeKey);
|
||||
|
||||
if (savedCircuit) {
|
||||
console.log('[Velxio Bridge] Restoring circuit from draft');
|
||||
velxioBridge.loadCircuit(savedCircuit);
|
||||
} else if (data.velxio_circuit) {
|
||||
velxioBridge.loadCircuit(data.velxio_circuit);
|
||||
}
|
||||
|
||||
if (savedCode) {
|
||||
console.log('[Velxio Bridge] Restoring code from draft');
|
||||
velxioBridge.loadCode([{ name: 'sketch.ino', content: savedCode }]);
|
||||
} else if (data.initial_code_arduino) {
|
||||
velxioBridge.loadCode([{ name: 'sketch.ino', content: data.initial_code_arduino }]);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'velxio:compile_result' && e.data.success) {
|
||||
const timeout = data?.evaluation_config?.timeout_ms ?? 5000;
|
||||
setTimeout(() => onSubmit(), timeout);
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', onMessage);
|
||||
|
||||
// Fallback: if PostMessage bridge never connects, try direct iframe access (same-origin)
|
||||
const pollReady = setInterval(() => {
|
||||
if (settled) { clearInterval(pollReady); return; }
|
||||
try {
|
||||
const win = iframe.contentWindow as any;
|
||||
if (!win || !win.document) return;
|
||||
const root = win.document.getElementById('root');
|
||||
if (!root || !root.children.length) return;
|
||||
|
||||
settled = true;
|
||||
clearInterval(pollReady);
|
||||
onReady(new VelxioBridge(iframe)); // Just provide dummy or initialize
|
||||
|
||||
if (data) {
|
||||
win.postMessage({
|
||||
type: 'elemes:set_embed_mode',
|
||||
hideAuth: true,
|
||||
hideComponentPicker: true,
|
||||
lockComponents: true
|
||||
}, '*');
|
||||
|
||||
const savedCircuit = localStorage.getItem(arduinoCircuitKey);
|
||||
const savedCode = localStorage.getItem(arduinoCodeKey);
|
||||
|
||||
if (savedCircuit) {
|
||||
try {
|
||||
console.log('[Velxio Fallback] Restoring circuit from draft');
|
||||
const circuitData = JSON.parse(savedCircuit);
|
||||
win.postMessage({ type: 'elemes:load_circuit', ...circuitData }, '*');
|
||||
} catch {}
|
||||
} else if (data.velxio_circuit) {
|
||||
try {
|
||||
const circuitData = JSON.parse(data.velxio_circuit);
|
||||
win.postMessage({ type: 'elemes:load_circuit', ...circuitData }, '*');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (savedCode) {
|
||||
console.log('[Velxio Fallback] Restoring code from draft');
|
||||
win.postMessage({ type: 'elemes:load_code', files: [{ name: 'sketch.ino', content: savedCode }] }, '*');
|
||||
} else if (data.initial_code_arduino) {
|
||||
win.postMessage({ type: 'elemes:load_code', files: [{ name: 'sketch.ino', content: data.initial_code_arduino }] }, '*');
|
||||
}
|
||||
}
|
||||
} catch { /* cross-origin or not ready yet */ }
|
||||
}, 1000);
|
||||
|
||||
setTimeout(() => {
|
||||
clearInterval(pollReady);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
window.removeEventListener('message', onMessage);
|
||||
}, 30_000);
|
||||
}
|
||||
|
|
@ -1,14 +1,21 @@
|
|||
<script lang="ts">
|
||||
import './lesson.css';
|
||||
import { page } from '$app/stores';
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import CodeEditor from '$components/CodeEditor.svelte';
|
||||
import CircuitEditor from '$components/CircuitEditor.svelte';
|
||||
import CodeTab from './CodeTab.svelte';
|
||||
import CircuitTab from './CircuitTab.svelte';
|
||||
import VelxioTab from './VelxioTab.svelte';
|
||||
import OutputPanel, { type OutputEntry } from '$components/OutputPanel.svelte';
|
||||
import CelebrationOverlay from '$components/CelebrationOverlay.svelte';
|
||||
import WorkspaceHeader from '$components/WorkspaceHeader.svelte';
|
||||
import LessonList from '$components/LessonList.svelte';
|
||||
import { compileCode, trackProgress } from '$services/api';
|
||||
import { checkKeyText, validateNodes } from '$services/exercise';
|
||||
import { evaluateVelxioSubmission } from '$services/velxio-evaluator';
|
||||
import { evaluateCircuitSubmission, processLanguageEvaluation } from '$services/evaluators';
|
||||
import { getVelxioState, initVelxioBridge } from '$services/velxio-manager';
|
||||
import { VelxioBridge, type EvaluationResult } from '$services/velxio-bridge';
|
||||
import { auth, authLoggedIn } from '$stores/auth';
|
||||
import { lessonContext } from '$stores/lessonContext';
|
||||
|
|
@ -62,49 +69,11 @@
|
|||
let arduinoCodeKey = $derived(`elemes_arduino_code_${slug}`);
|
||||
let arduinoCircuitKey = $derived(`elemes_arduino_circuit_${slug}`);
|
||||
|
||||
/** Try to directly read Velxio Zustand stores from iframe (same-origin). */
|
||||
function getVelxioState(): { code: string; circuit: string } | null {
|
||||
if (!velxioIframe) return null;
|
||||
try {
|
||||
const win = velxioIframe.contentWindow as any;
|
||||
if (!win) return null;
|
||||
|
||||
const editorStore = win.__VELXIO_EDITOR_STORE__?.getState?.();
|
||||
const simStore = win.__VELXIO_SIMULATOR_STORE__?.getState?.();
|
||||
|
||||
if (!editorStore || !simStore) return null;
|
||||
|
||||
// Extract code
|
||||
const code = (editorStore.files as any[] || []).map((f: any) => f.content).join('\n');
|
||||
|
||||
// Extract circuit state (Diagram format compatible with elemes:load_circuit)
|
||||
const circuit = {
|
||||
board: simStore.activeBoardId,
|
||||
components: (simStore.components as any[] || []).map(c => ({
|
||||
type: c.metadataId,
|
||||
id: c.id,
|
||||
x: c.x,
|
||||
y: c.y,
|
||||
rotation: c.properties?.rotation || 0,
|
||||
props: { ...c.properties }
|
||||
})),
|
||||
wires: simStore.wires || []
|
||||
};
|
||||
|
||||
return {
|
||||
code,
|
||||
circuit: JSON.stringify(circuit)
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-save Velxio state periodically
|
||||
$effect(() => {
|
||||
if (velxioReady && $authLoggedIn && !showSolution) {
|
||||
const interval = setInterval(() => {
|
||||
const state = getVelxioState();
|
||||
const state = getVelxioState(velxioIframe);
|
||||
if (!state) return;
|
||||
|
||||
let changed = false;
|
||||
|
|
@ -331,43 +300,19 @@
|
|||
activeTab = 'output';
|
||||
|
||||
try {
|
||||
const circuitExpected = (isHybrid && data.expected_circuit_output)
|
||||
? data.expected_circuit_output
|
||||
: data.expected_output;
|
||||
|
||||
let expectedState: any = null;
|
||||
try {
|
||||
if (circuitExpected) expectedState = JSON.parse(circuitExpected);
|
||||
} catch {
|
||||
Object.assign(circuitOut, { error: "Format EXPECTED_OUTPUT tidak valid (Harus JSON).", success: false, loading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!expectedState) {
|
||||
Object.assign(circuitOut, { output: "Tidak ada kriteria evaluasi yang ditetapkan.", success: true, loading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
let { allPassed, messages } = expectedState.nodes
|
||||
? validateNodes(simApi, expectedState.nodes)
|
||||
: { allPassed: true, messages: [] as string[] };
|
||||
|
||||
const circuitText = circuitEditor.getCircuitText();
|
||||
const circuitKeyText = (isHybrid && data.key_text_circuit) ? data.key_text_circuit : data.key_text;
|
||||
if (!checkKeyText(circuitText, circuitKeyText ?? '')) {
|
||||
allPassed = false;
|
||||
messages.push(`❌ Komponen wajib belum lengkap (lihat instruksi).`);
|
||||
const res = evaluateCircuitSubmission(simApi, circuitText, isHybrid, data, checkAllPassed);
|
||||
|
||||
if (res.error) {
|
||||
Object.assign(circuitOut, { error: res.error, success: false, loading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
circuitOut.output = messages.join('\n');
|
||||
circuitOut.success = allPassed;
|
||||
circuitOut.output = res.output;
|
||||
circuitOut.success = res.pass;
|
||||
|
||||
if (allPassed) {
|
||||
if (res.pass) {
|
||||
circuitPassed = true;
|
||||
if (isHybrid) {
|
||||
circuitOut.output += '\n✅ Rangkaian benar!';
|
||||
if (!checkAllPassed()) circuitOut.output += '\n⏳ Selesaikan juga tantangan kode untuk menyelesaikan pelajaran ini.';
|
||||
}
|
||||
if (checkAllPassed()) {
|
||||
await completeLesson();
|
||||
setTimeout(() => { showCelebration = false; activeTab = 'circuit'; }, 3000);
|
||||
|
|
@ -400,11 +345,8 @@
|
|||
out.success = true;
|
||||
|
||||
if (data.expected_output) {
|
||||
const currentCCode = (currentLanguage === 'c') ? code : cCode;
|
||||
const currentPythonCode = (currentLanguage === 'python') ? code : pythonCode;
|
||||
const mergedCode = currentCCode + '\n' + currentPythonCode;
|
||||
const passed = res.output.trim() === data.expected_output.trim() && checkKeyText(mergedCode, data.key_text ?? '');
|
||||
if (passed) {
|
||||
const { isCorrect } = processLanguageEvaluation(res.output, code, lang, currentLanguage, cCode, pythonCode, data);
|
||||
if (isCorrect) {
|
||||
if (lang === 'c') cPassed = true;
|
||||
else if (lang === 'python') pythonPassed = true;
|
||||
|
||||
|
|
@ -419,7 +361,6 @@
|
|||
if (data.solution_code || data.solution_python || data.solution_circuit) {
|
||||
showSolution = true;
|
||||
handleShowSolution();
|
||||
// To avoid logic flipping
|
||||
showSolution = true;
|
||||
}
|
||||
setTimeout(() => { showCelebration = false; activeTab = 'editor'; }, 3000);
|
||||
|
|
@ -511,124 +452,19 @@
|
|||
}
|
||||
}
|
||||
|
||||
/** Match serial output using subsequence matching. */
|
||||
function matchSerialSubsequence(actual: string, expected: string): boolean {
|
||||
if (!expected) return true;
|
||||
if (!actual) return false;
|
||||
|
||||
const actualLines = actual.split('\n').map(l => l.trim()).filter(l => l.length > 0);
|
||||
const expectedLines = expected.split('\n').map(l => l.trim()).filter(l => l.length > 0);
|
||||
|
||||
let expectedIdx = 0;
|
||||
for (const actualLine of actualLines) {
|
||||
if (expectedIdx < expectedLines.length) {
|
||||
if (actualLine.toLowerCase().includes(expectedLines[expectedIdx].toLowerCase())) {
|
||||
expectedIdx++;
|
||||
}
|
||||
}
|
||||
if (expectedIdx === expectedLines.length) return true;
|
||||
}
|
||||
return expectedIdx === expectedLines.length;
|
||||
}
|
||||
|
||||
function initVelxioBridge(iframe: HTMLIFrameElement) {
|
||||
function setupVelxioBridge(iframe: HTMLIFrameElement) {
|
||||
velxioIframe = iframe;
|
||||
|
||||
let settled = false;
|
||||
const onMessage = (e: MessageEvent) => {
|
||||
const type = e.data?.type;
|
||||
if (!type) return;
|
||||
|
||||
if (!settled && type === 'velxio:ready') {
|
||||
settled = true;
|
||||
velxioBridge = new VelxioBridge(iframe);
|
||||
initVelxioBridge(
|
||||
iframe,
|
||||
data,
|
||||
arduinoCircuitKey,
|
||||
arduinoCodeKey,
|
||||
(bridge) => {
|
||||
velxioBridge = bridge;
|
||||
velxioReady = true;
|
||||
if (!data) return;
|
||||
velxioBridge.setEmbedMode({
|
||||
hideAuth: true,
|
||||
hideComponentPicker: true,
|
||||
lockComponents: true
|
||||
});
|
||||
|
||||
// Priority: Restore from localStorage if available, otherwise use data from backend
|
||||
const savedCircuit = localStorage.getItem(arduinoCircuitKey);
|
||||
const savedCode = localStorage.getItem(arduinoCodeKey);
|
||||
|
||||
if (savedCircuit) {
|
||||
console.log('[Velxio Bridge] Restoring circuit from draft');
|
||||
velxioBridge.loadCircuit(savedCircuit);
|
||||
} else if (data.velxio_circuit) {
|
||||
velxioBridge.loadCircuit(data.velxio_circuit);
|
||||
}
|
||||
|
||||
if (savedCode) {
|
||||
console.log('[Velxio Bridge] Restoring code from draft');
|
||||
velxioBridge.loadCode([{ name: 'sketch.ino', content: savedCode }]);
|
||||
} else if (data.initial_code_arduino) {
|
||||
velxioBridge.loadCode([{ name: 'sketch.ino', content: data.initial_code_arduino }]);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'velxio:compile_result' && e.data.success) {
|
||||
const timeout = data?.evaluation_config?.timeout_ms ?? 5000;
|
||||
setTimeout(() => handleVelxioSubmit(), timeout);
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', onMessage);
|
||||
|
||||
// Fallback: if PostMessage bridge never connects, try direct iframe access (same-origin)
|
||||
const pollReady = setInterval(() => {
|
||||
if (settled) { clearInterval(pollReady); return; }
|
||||
try {
|
||||
const win = iframe.contentWindow as any;
|
||||
if (!win || !win.document) return;
|
||||
const root = win.document.getElementById('root');
|
||||
if (!root || !root.children.length) return;
|
||||
|
||||
settled = true;
|
||||
clearInterval(pollReady);
|
||||
velxioReady = true;
|
||||
|
||||
if (data) {
|
||||
win.postMessage({
|
||||
type: 'elemes:set_embed_mode',
|
||||
hideAuth: true,
|
||||
hideComponentPicker: true,
|
||||
lockComponents: true
|
||||
}, '*');
|
||||
|
||||
const savedCircuit = localStorage.getItem(arduinoCircuitKey);
|
||||
const savedCode = localStorage.getItem(arduinoCodeKey);
|
||||
|
||||
if (savedCircuit) {
|
||||
try {
|
||||
console.log('[Velxio Fallback] Restoring circuit from draft');
|
||||
const circuitData = JSON.parse(savedCircuit);
|
||||
win.postMessage({ type: 'elemes:load_circuit', ...circuitData }, '*');
|
||||
} catch {}
|
||||
} else if (data.velxio_circuit) {
|
||||
try {
|
||||
const circuitData = JSON.parse(data.velxio_circuit);
|
||||
win.postMessage({ type: 'elemes:load_circuit', ...circuitData }, '*');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (savedCode) {
|
||||
console.log('[Velxio Fallback] Restoring code from draft');
|
||||
win.postMessage({ type: 'elemes:load_code', files: [{ name: 'sketch.ino', content: savedCode }] }, '*');
|
||||
} else if (data.initial_code_arduino) {
|
||||
win.postMessage({ type: 'elemes:load_code', files: [{ name: 'sketch.ino', content: data.initial_code_arduino }] }, '*');
|
||||
}
|
||||
}
|
||||
} catch { /* cross-origin or not ready yet */ }
|
||||
}, 1000);
|
||||
|
||||
setTimeout(() => {
|
||||
clearInterval(pollReady);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
window.removeEventListener('message', onMessage);
|
||||
}, 30_000);
|
||||
},
|
||||
() => handleVelxioSubmit()
|
||||
);
|
||||
}
|
||||
|
||||
async function handleVelxioSubmit() {
|
||||
|
|
@ -651,7 +487,7 @@
|
|||
}
|
||||
|
||||
// Use getVelxioState for code and wires (more reliable/complete)
|
||||
const state = getVelxioState();
|
||||
const state = getVelxioState(velxioIframe);
|
||||
if (state) {
|
||||
sourceCode = state.code;
|
||||
try {
|
||||
|
|
@ -664,146 +500,13 @@
|
|||
}
|
||||
|
||||
// === Evaluate ===
|
||||
const messages: string[] = [];
|
||||
let keyTextPass: boolean | undefined;
|
||||
let serialPass: boolean | undefined;
|
||||
let wiringPass: boolean | undefined;
|
||||
const evalRes = evaluateVelxioSubmission(sourceCode, serialLog, wireList, data);
|
||||
|
||||
// 1. Key text
|
||||
if (data.key_text) {
|
||||
const keys = data.key_text.split('\n').map(k => k.trim()).filter(k => k.length > 0);
|
||||
keyTextPass = keys.every(key => sourceCode.includes(key));
|
||||
messages.push(keyTextPass
|
||||
? '✅ Kata kunci ditemukan dalam kode'
|
||||
: '❌ Kata kunci belum ada dalam kode');
|
||||
dbg.push(`[key_text] keys="${keys.join(', ')}" → ${keyTextPass}`);
|
||||
}
|
||||
velxioOut.output = evalRes.messages.join('\n');
|
||||
velxioOut.debug = dbg.concat(evalRes.dbg);
|
||||
velxioOut.success = evalRes.pass;
|
||||
|
||||
// 2. Serial output
|
||||
if (data.expected_serial_output) {
|
||||
const actualLog = serialLog.trim();
|
||||
const expectedLog = data.expected_serial_output.trim();
|
||||
|
||||
// Use subsequence matching: expected lines must appear in order in actual output
|
||||
serialPass = matchSerialSubsequence(actualLog, expectedLog);
|
||||
|
||||
messages.push(serialPass
|
||||
? '✅ Serial output sesuai'
|
||||
: '❌ Serial output belum sesuai');
|
||||
|
||||
const preview = serialLog.substring(0, 150).replace(/\n/g, '↵');
|
||||
dbg.push(`[serial] actual(${serialLog.length}ch)="${preview}"`);
|
||||
dbg.push(`[serial] expected="${expectedLog.substring(0, 150).replace(/\n/g, '↵')}" → ${serialPass}`);
|
||||
}
|
||||
|
||||
// 3. Wiring
|
||||
if (data.expected_wiring) {
|
||||
let expectedWires: any[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(data.expected_wiring);
|
||||
// Support both old format (array of pairs) and new format (object with wires array)
|
||||
if (Array.isArray(parsed)) {
|
||||
expectedWires = parsed;
|
||||
} else if (parsed.wires && Array.isArray(parsed.wires)) {
|
||||
expectedWires = parsed.wires;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Detect non-polarized component types (pins are interchangeable)
|
||||
const nonPolarizedTypes = new Set(['resistor']);
|
||||
|
||||
// Normalize power pin names (e.g., GND.2 → GND, VCC.1 → VCC)
|
||||
// This handles multiple power pin variants
|
||||
const normalizePin = (pin: string) => {
|
||||
// Match power pins with numeric suffixes: GND.1, VCC.2, 5V.3, etc.
|
||||
return pin.replace(/^(GND|VCC|5V|3V3|3\.3V|POWER)\.\d+$/i, '$1');
|
||||
};
|
||||
|
||||
// Normalize component:pin for comparison
|
||||
// For non-polarized components (like resistors), strip pin numbers
|
||||
// so that resistor:1 and resistor:2 are treated as equivalent
|
||||
const normalizeEdge = (compId: string, pinName: string): string => {
|
||||
const normPin = normalizePin(pinName);
|
||||
// Check if this component is non-polarized by looking at expected_wires
|
||||
// We detect resistor components by their ID pattern or type
|
||||
const isNonPolarized = nonPolarizedTypes.has(compId.split('-')[0]) ||
|
||||
compId.startsWith('resistor');
|
||||
if (isNonPolarized && /^\d+$/.test(normPin)) {
|
||||
// For non-polarized components with numeric pins, use a generic pin name
|
||||
return `${compId}:PIN`;
|
||||
}
|
||||
return `${compId}:${normPin}`;
|
||||
};
|
||||
|
||||
const norm = (a: string, b: string) => {
|
||||
// Parse component:pin format
|
||||
const [compA, pinA] = a.split(':');
|
||||
const [compB, pinB] = b.split(':');
|
||||
const normA = `${compA}:${normalizePin(pinA || '')}`;
|
||||
const normB = `${compB}:${normalizePin(pinB || '')}`;
|
||||
// For non-polarized components, normalize numeric pins to generic
|
||||
const finalA = nonPolarizedTypes.has(compA) || compA.startsWith('resistor')
|
||||
? `${compA}:PIN` : normA;
|
||||
const finalB = nonPolarizedTypes.has(compB) || compB.startsWith('resistor')
|
||||
? `${compB}:PIN` : normB;
|
||||
// Sort to make edge comparison order-independent
|
||||
return [finalA, finalB].sort().join('↔');
|
||||
};
|
||||
|
||||
const studentEdges = new Set(
|
||||
wireList.map(w => norm(
|
||||
`${w.start.componentId}:${w.start.pinName}`,
|
||||
`${w.end.componentId}:${w.end.pinName}`
|
||||
))
|
||||
);
|
||||
|
||||
// Check all expected connections exist
|
||||
wiringPass = expectedWires.every((expected: any) => {
|
||||
let edgeKey: string;
|
||||
// Handle both formats
|
||||
if (Array.isArray(expected) && expected.length === 2) {
|
||||
// Old format: ["component:pin", "component:pin"]
|
||||
edgeKey = norm(expected[0], expected[1]);
|
||||
} else if (expected.start && expected.end) {
|
||||
// New format: { start: { componentId, pinName }, end: { componentId, pinName } }
|
||||
const startPin = `${expected.start.componentId}:${expected.start.pinName}`;
|
||||
const endPin = `${expected.end.componentId}:${expected.end.pinName}`;
|
||||
edgeKey = norm(startPin, endPin);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
const exists = studentEdges.has(edgeKey);
|
||||
if (!exists) {
|
||||
dbg.push(`[wiring] MISSING: ${edgeKey}`);
|
||||
}
|
||||
return exists;
|
||||
});
|
||||
|
||||
messages.push(wiringPass
|
||||
? '✅ Rangkaian wiring benar'
|
||||
: '❌ Wiring belum sesuai');
|
||||
|
||||
const edgesStr = wireList.map(w =>
|
||||
`${w.start.componentId}:${w.start.pinName}↔${w.end.componentId}:${w.end.pinName}`
|
||||
);
|
||||
dbg.push(`[wiring] student (${wireList.length} wires): ${edgesStr.join(' | ') || '(kosong)'}`);
|
||||
dbg.push(`[wiring] expected (${expectedWires.length} connections): ${expectedWires.map((w: any) => {
|
||||
if (Array.isArray(w) && w.length === 2) return w.join('↔');
|
||||
if (w.start && w.end) return `${w.start.componentId}:${w.start.pinName}↔${w.end.componentId}:${w.end.pinName}`;
|
||||
return JSON.stringify(w);
|
||||
}).join(' | ')}`);
|
||||
dbg.push(`[wiring] result → ${wiringPass}`);
|
||||
}
|
||||
|
||||
const checks = [keyTextPass, serialPass, wiringPass].filter(v => v !== undefined);
|
||||
const pass = checks.length > 0 && checks.every(Boolean);
|
||||
|
||||
velxioOut.output = messages.join('\n');
|
||||
velxioOut.debug = dbg;
|
||||
velxioOut.success = pass;
|
||||
|
||||
if (pass) {
|
||||
if (evalRes.pass) {
|
||||
await completeLesson();
|
||||
setTimeout(() => { showCelebration = false; activeTab = 'velxio'; }, 3000);
|
||||
}
|
||||
|
|
@ -904,92 +607,52 @@
|
|||
<!-- Circuit tab panel -->
|
||||
{#if data.active_tabs?.includes('circuit')}
|
||||
<div class="tab-panel" class:tab-hidden={activeTab !== 'circuit'}>
|
||||
<div class="toolbar">
|
||||
<button type="button" class="btn btn-success" onclick={handleRun} disabled={compiling}>
|
||||
{compiling ? 'Mengevaluasi...' : '▶ Cek Rangkaian'}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick={handleReset}>Reset</button>
|
||||
{#if data.solution_code && $authLoggedIn && lessonCompleted}
|
||||
<button type="button" class="btn btn-secondary" onclick={handleShowSolution}>
|
||||
{showSolution ? 'Sembunyikan Solusi' : 'Lihat Solusi'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="panel">
|
||||
<CircuitEditor
|
||||
bind:this={circuitEditor}
|
||||
initialCircuit={data.initial_circuit || data.initial_code}
|
||||
storageKey={($authLoggedIn && !showSolution) ? `elemes_circuit_${slug}` : undefined}
|
||||
/>
|
||||
</div>
|
||||
<CircuitTab
|
||||
{data}
|
||||
bind:circuitEditor
|
||||
compiling={compiling}
|
||||
authLoggedIn={$authLoggedIn}
|
||||
lessonCompleted={lessonCompleted}
|
||||
showSolution={showSolution}
|
||||
slug={slug}
|
||||
onRun={handleRun}
|
||||
onReset={handleReset}
|
||||
onShowSolution={handleShowSolution}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Velxio (Arduino) tab panel -->
|
||||
{#if isVelxio}
|
||||
<div class="tab-panel velxio-panel" class:tab-hidden={activeTab !== 'velxio'}>
|
||||
{#if velxioError}
|
||||
<div class="velxio-fallback">
|
||||
Simulator Arduino sedang tidak tersedia.
|
||||
Hubungi guru jika masalah berlanjut.
|
||||
</div>
|
||||
{:else}
|
||||
{#if $authLoggedIn}
|
||||
<div class="storage-indicator-inline" title={velxioSaving ? "Menyimpan draf..." : "Draf tersimpan di browser"}>
|
||||
<span class="indicator-icon" class:saving={velxioSaving}>
|
||||
{velxioSaving ? '●' : '☁'}
|
||||
</span>
|
||||
<span class="indicator-text">Auto-save</span>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_missing_attribute -->
|
||||
<iframe
|
||||
class="velxio-iframe"
|
||||
src="/velxio/editor?embed=true{hasArduinoCode ? '' : '&hideEditor=true'}&lockComponents=true"
|
||||
onload={(e) => initVelxioBridge(e.currentTarget as HTMLIFrameElement)}
|
||||
allow="cross-origin-isolated"
|
||||
></iframe>
|
||||
{/if}
|
||||
<VelxioTab
|
||||
{hasArduinoCode}
|
||||
velxioError={velxioError}
|
||||
authLoggedIn={$authLoggedIn}
|
||||
velxioSaving={velxioSaving}
|
||||
onSetupBridge={setupVelxioBridge}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Editor tab panel -->
|
||||
{#if !data.active_tabs || data.active_tabs.length === 0 || data.active_tabs.includes('c') || data.active_tabs.includes('python')}
|
||||
<div class="tab-panel" class:tab-hidden={activeTab !== 'editor'}>
|
||||
<div class="toolbar">
|
||||
<button type="button" class="btn btn-success" onclick={handleRun} disabled={compiling}>
|
||||
{compiling ? 'Compiling...' : '\u25B6 Run'}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick={handleReset}>Reset</button>
|
||||
{#if data.solution_code && $authLoggedIn && lessonCompleted}
|
||||
<button type="button" class="btn btn-secondary" onclick={handleShowSolution}>
|
||||
{showSolution ? 'Sembunyikan Solusi' : 'Lihat Solusi'}
|
||||
</button>
|
||||
{/if}
|
||||
<span class="lang-label">{currentLanguage === 'python' ? 'Python' : 'C'}</span>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
{#key currentLanguage}
|
||||
<CodeEditor
|
||||
bind:this={editor}
|
||||
code={currentCode}
|
||||
language={currentLanguage}
|
||||
noPaste={true}
|
||||
storageKey={($authLoggedIn && !showSolution) ? `elemes_draft_${slug}_${currentLanguage}` : undefined}
|
||||
onchange={(val) => { if (!showSolution) currentCode = val; }}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
{#if data.expected_output}
|
||||
<details class="expected-output">
|
||||
<summary>Expected Output</summary>
|
||||
<pre>{data.expected_output}</pre>
|
||||
</details>
|
||||
{/if}
|
||||
<CodeTab
|
||||
{data}
|
||||
bind:currentLanguage
|
||||
bind:currentCode
|
||||
bind:editor
|
||||
compiling={compiling}
|
||||
authLoggedIn={$authLoggedIn}
|
||||
lessonCompleted={lessonCompleted}
|
||||
showSolution={showSolution}
|
||||
slug={slug}
|
||||
onRun={handleRun}
|
||||
onReset={handleReset}
|
||||
onShowSolution={handleShowSolution}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/if}
|
||||
|
||||
<!-- Output tab panel -->
|
||||
|
|
@ -1010,304 +673,3 @@
|
|||
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* ── Shared rich-text styles (.prose + .tab-content) ──── */
|
||||
.tab-content {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.tab-heading {
|
||||
color: var(--color-primary);
|
||||
font-size: 1.1rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
.prose :global(pre),
|
||||
.tab-content :global(pre) {
|
||||
background: var(--color-bg-secondary);
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.prose :global(code),
|
||||
.tab-content :global(code) {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.prose :global(p),
|
||||
.tab-content :global(p) {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.prose :global(h2), .prose :global(h3),
|
||||
.tab-content :global(h2), .tab-content :global(h3) {
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.tab-content :global(ul),
|
||||
.tab-content :global(ol) {
|
||||
margin-bottom: 0.75rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.tab-content :global(li) {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* ── Two-column layout ─────────────────────────────────── */
|
||||
.lesson-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 2fr;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.lesson-content {
|
||||
overflow-y: auto;
|
||||
max-height: 90vh;
|
||||
padding-right: 0.5rem;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
/* ── Editor area (docked mode) ──────────────────────────── */
|
||||
.editor-area {
|
||||
position: sticky;
|
||||
top: 3.5rem;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
max-height: 85vh;
|
||||
}
|
||||
.editor-area .editor-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── Single-column layout ──────────────────────────────── */
|
||||
.lesson-layout.single-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.lesson-content.full-width {
|
||||
max-height: none;
|
||||
padding-right: 0;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
/* ── Toolbar ───────────────────────────────────────────── */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: var(--color-border);
|
||||
}
|
||||
.btn-run-all {
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.lang-label {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.panel {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.expected-output {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.expected-output pre {
|
||||
background: var(--color-bg-secondary);
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--radius);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Floating restore button ────────────────────────────── */
|
||||
.float-restore-btn {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 9999;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
}
|
||||
.float-restore-btn:hover {
|
||||
background: var(--color-primary-dark);
|
||||
}
|
||||
.float-restore-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* ── Desktop floating mode ─────────────────────────────── */
|
||||
.editor-area.floating {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
top: auto;
|
||||
width: 45vw;
|
||||
height: 70vh;
|
||||
min-width: 320px;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
z-index: 9999;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.editor-area.floating-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ── Mobile bottom sheet ──────────────────────────────── */
|
||||
.editor-area.mobile-sheet {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
background: var(--color-bg);
|
||||
border-top: 2px solid var(--color-primary);
|
||||
border-radius: 12px 12px 0 0;
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1), border-radius 0.2s ease;
|
||||
}
|
||||
.editor-area.mobile-hidden {
|
||||
height: 52px;
|
||||
}
|
||||
.editor-area.mobile-half {
|
||||
height: 60vh;
|
||||
}
|
||||
.editor-area.mobile-full {
|
||||
height: calc(100vh - 3rem);
|
||||
border-radius: 0;
|
||||
}
|
||||
.mobile-sheet .editor-body {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
/* ── Mobile full: expand content to fill ────────────── */
|
||||
.editor-area.mobile-full .editor-body,
|
||||
.editor-area.mobile-full .tab-panel:not(.tab-hidden),
|
||||
.editor-area.mobile-full .panel,
|
||||
.editor-area.mobile-full :global(.circuit-container),
|
||||
.editor-area.mobile-full :global(.editor-wrapper) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.editor-area.mobile-full :global(.circuit-wrapper) {
|
||||
flex: 1;
|
||||
height: auto;
|
||||
}
|
||||
.editor-area.mobile-full :global(.cm-editor) {
|
||||
flex: 1;
|
||||
max-height: none;
|
||||
min-height: 0;
|
||||
}
|
||||
.editor-area.mobile-full :global(.cm-scroller) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── Tab panels ────────────────────────────────────────── */
|
||||
.tab-panel {
|
||||
overflow-y: auto;
|
||||
}
|
||||
.tab-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Velxio (Arduino simulator) ─────────────────────── */
|
||||
.storage-indicator-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-secondary);
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
right: 1.5rem;
|
||||
z-index: 10;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
|
||||
pointer-events: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.storage-indicator-inline .indicator-icon {
|
||||
line-height: 1;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-success);
|
||||
}
|
||||
.storage-indicator-inline .indicator-icon.saving {
|
||||
color: var(--color-primary);
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
.storage-indicator-inline .indicator-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.velxio-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
.velxio-panel.tab-hidden {
|
||||
display: none;
|
||||
}
|
||||
.velxio-iframe {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.velxio-fallback {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
<script lang="ts">
|
||||
import CircuitEditor from '$components/CircuitEditor.svelte';
|
||||
import type { LessonContent } from '$types/lesson';
|
||||
|
||||
let {
|
||||
data,
|
||||
circuitEditor = $bindable(),
|
||||
compiling,
|
||||
authLoggedIn,
|
||||
lessonCompleted,
|
||||
showSolution,
|
||||
slug,
|
||||
onRun,
|
||||
onReset,
|
||||
onShowSolution
|
||||
}: {
|
||||
data: LessonContent;
|
||||
circuitEditor: CircuitEditor | null;
|
||||
compiling: boolean;
|
||||
authLoggedIn: boolean;
|
||||
lessonCompleted: boolean;
|
||||
showSolution: boolean;
|
||||
slug: string;
|
||||
onRun: () => void;
|
||||
onReset: () => void;
|
||||
onShowSolution: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="toolbar">
|
||||
<button type="button" class="btn btn-success" onclick={onRun} disabled={compiling}>
|
||||
{compiling ? 'Mengevaluasi...' : '▶ Cek Rangkaian'}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick={onReset}>Reset</button>
|
||||
{#if data.solution_code && authLoggedIn && lessonCompleted}
|
||||
<button type="button" class="btn btn-secondary" onclick={onShowSolution}>
|
||||
{showSolution ? 'Sembunyikan Solusi' : 'Lihat Solusi'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="panel">
|
||||
<CircuitEditor
|
||||
bind:this={circuitEditor}
|
||||
initialCircuit={data.initial_circuit || data.initial_code}
|
||||
storageKey={(authLoggedIn && !showSolution) ? `elemes_circuit_${slug}` : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
<script lang="ts">
|
||||
import CodeEditor from '$components/CodeEditor.svelte';
|
||||
import type { LessonContent } from '$types/lesson';
|
||||
|
||||
let {
|
||||
data,
|
||||
currentLanguage = $bindable(),
|
||||
currentCode = $bindable(),
|
||||
editor = $bindable(),
|
||||
compiling,
|
||||
authLoggedIn,
|
||||
lessonCompleted,
|
||||
showSolution,
|
||||
slug,
|
||||
onRun,
|
||||
onReset,
|
||||
onShowSolution
|
||||
}: {
|
||||
data: LessonContent;
|
||||
currentLanguage: string;
|
||||
currentCode: string;
|
||||
editor: CodeEditor | null;
|
||||
compiling: boolean;
|
||||
authLoggedIn: boolean;
|
||||
lessonCompleted: boolean;
|
||||
showSolution: boolean;
|
||||
slug: string;
|
||||
onRun: () => void;
|
||||
onReset: () => void;
|
||||
onShowSolution: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="toolbar">
|
||||
<button type="button" class="btn btn-success" onclick={onRun} disabled={compiling}>
|
||||
{compiling ? 'Compiling...' : '▶ Run'}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick={onReset}>Reset</button>
|
||||
{#if data.solution_code && authLoggedIn && lessonCompleted}
|
||||
<button type="button" class="btn btn-secondary" onclick={onShowSolution}>
|
||||
{showSolution ? 'Sembunyikan Solusi' : 'Lihat Solusi'}
|
||||
</button>
|
||||
{/if}
|
||||
<span class="lang-label">{currentLanguage === 'python' ? 'Python' : 'C'}</span>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
{#key currentLanguage}
|
||||
<CodeEditor
|
||||
bind:this={editor}
|
||||
code={currentCode}
|
||||
language={currentLanguage}
|
||||
noPaste={true}
|
||||
storageKey={(authLoggedIn && !showSolution) ? `elemes_draft_${slug}_${currentLanguage}` : undefined}
|
||||
onchange={(val) => { if (!showSolution) currentCode = val; }}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
{#if data.expected_output}
|
||||
<details class="expected-output">
|
||||
<summary>Expected Output</summary>
|
||||
<pre>{data.expected_output}</pre>
|
||||
</details>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<script lang="ts">
|
||||
let {
|
||||
hasArduinoCode,
|
||||
velxioError,
|
||||
authLoggedIn,
|
||||
velxioSaving,
|
||||
onSetupBridge
|
||||
}: {
|
||||
hasArduinoCode: boolean;
|
||||
velxioError: boolean;
|
||||
authLoggedIn: boolean;
|
||||
velxioSaving: boolean;
|
||||
onSetupBridge: (iframe: HTMLIFrameElement) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if velxioError}
|
||||
<div class="velxio-fallback">
|
||||
Simulator Arduino sedang tidak tersedia.
|
||||
Hubungi guru jika masalah berlanjut.
|
||||
</div>
|
||||
{:else}
|
||||
{#if authLoggedIn}
|
||||
<div class="storage-indicator-inline" title={velxioSaving ? "Menyimpan draf..." : "Draf tersimpan di browser"}>
|
||||
<span class="indicator-icon" class:saving={velxioSaving}>
|
||||
{velxioSaving ? '●' : '☁'}
|
||||
</span>
|
||||
<span class="indicator-text">Auto-save</span>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_missing_attribute -->
|
||||
<iframe
|
||||
class="velxio-iframe"
|
||||
src="/velxio/editor?embed=true{hasArduinoCode ? '' : '&hideEditor=true'}&lockComponents=true"
|
||||
onload={(e) => onSetupBridge(e.currentTarget as HTMLIFrameElement)}
|
||||
allow="cross-origin-isolated"
|
||||
></iframe>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
/* ── Shared rich-text styles (.prose + .tab-content) ──── */
|
||||
.tab-content {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.tab-heading {
|
||||
color: var(--color-primary);
|
||||
font-size: 1.1rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
.prose pre,
|
||||
.tab-content pre {
|
||||
background: var(--color-bg-secondary);
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.prose code,
|
||||
.tab-content code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.prose p,
|
||||
.tab-content p {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.prose h2, .prose h3,
|
||||
.tab-content h2, .tab-content h3 {
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.tab-content ul,
|
||||
.tab-content ol {
|
||||
margin-bottom: 0.75rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.tab-content li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* ── Two-column layout ─────────────────────────────────── */
|
||||
.lesson-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 2fr;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.lesson-content {
|
||||
overflow-y: auto;
|
||||
max-height: 90vh;
|
||||
padding-right: 0.5rem;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
/* ── Editor area (docked mode) ──────────────────────────── */
|
||||
.editor-area {
|
||||
position: sticky;
|
||||
top: 3.5rem;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
max-height: 85vh;
|
||||
}
|
||||
.editor-area .editor-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── Single-column layout ──────────────────────────────── */
|
||||
.lesson-layout.single-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.lesson-content.full-width {
|
||||
max-height: none;
|
||||
padding-right: 0;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
/* ── Toolbar ───────────────────────────────────────────── */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: var(--color-border);
|
||||
}
|
||||
.btn-run-all {
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.lang-label {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.panel {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.expected-output {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.expected-output pre {
|
||||
background: var(--color-bg-secondary);
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--radius);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Floating restore button ────────────────────────────── */
|
||||
.float-restore-btn {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 9999;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
}
|
||||
.float-restore-btn:hover {
|
||||
background: var(--color-primary-dark);
|
||||
}
|
||||
.float-restore-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* ── Desktop floating mode ─────────────────────────────── */
|
||||
.editor-area.floating {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
top: auto;
|
||||
width: 45vw;
|
||||
height: 70vh;
|
||||
min-width: 320px;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
z-index: 9999;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.editor-area.floating-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ── Mobile bottom sheet ──────────────────────────────── */
|
||||
.editor-area.mobile-sheet {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
background: var(--color-bg);
|
||||
border-top: 2px solid var(--color-primary);
|
||||
border-radius: 12px 12px 0 0;
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1), border-radius 0.2s ease;
|
||||
}
|
||||
.editor-area.mobile-hidden {
|
||||
height: 52px;
|
||||
}
|
||||
.editor-area.mobile-half {
|
||||
height: 60vh;
|
||||
}
|
||||
.editor-area.mobile-full {
|
||||
height: calc(100vh - 3rem);
|
||||
border-radius: 0;
|
||||
}
|
||||
.mobile-sheet .editor-body {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
/* ── Mobile full: expand content to fill ────────────── */
|
||||
.editor-area.mobile-full .editor-body,
|
||||
.editor-area.mobile-full .tab-panel:not(.tab-hidden),
|
||||
.editor-area.mobile-full .panel,
|
||||
.editor-area.mobile-full .circuit-container,
|
||||
.editor-area.mobile-full .editor-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.editor-area.mobile-full .circuit-wrapper {
|
||||
flex: 1;
|
||||
height: auto;
|
||||
}
|
||||
.editor-area.mobile-full .cm-editor {
|
||||
flex: 1;
|
||||
max-height: none;
|
||||
min-height: 0;
|
||||
}
|
||||
.editor-area.mobile-full .cm-scroller {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── Tab panels ────────────────────────────────────────── */
|
||||
.tab-panel {
|
||||
overflow-y: auto;
|
||||
}
|
||||
.tab-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Velxio (Arduino simulator) ─────────────────────── */
|
||||
.storage-indicator-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-secondary);
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
right: 1.5rem;
|
||||
z-index: 10;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
|
||||
pointer-events: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.storage-indicator-inline .indicator-icon {
|
||||
line-height: 1;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-success);
|
||||
}
|
||||
.storage-indicator-inline .indicator-icon.saving {
|
||||
color: var(--color-primary);
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
.storage-indicator-inline .indicator-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.velxio-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
.velxio-panel.tab-hidden {
|
||||
display: none;
|
||||
}
|
||||
.velxio-iframe {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.velxio-fallback {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
|
@ -24,11 +24,18 @@ def _read_home_md():
|
|||
|
||||
def _parse_lesson_links(home_content):
|
||||
"""Extract (link_text, filename) pairs from the Available_Lessons section."""
|
||||
parts = home_content.split('---Available_Lessons---')
|
||||
parts = re.split(r'-{3,}Available_Lessons-{3,}', home_content)
|
||||
if len(parts) <= 1:
|
||||
return []
|
||||
lesson_list_content = parts[1]
|
||||
return re.findall(r'\[([^\]]+)\]\((?:lesson/)?([^\)]+)\)', lesson_list_content)
|
||||
lesson_list_content = parts[-1]
|
||||
|
||||
links = re.findall(r'\[([^\]]+)\]\((?:/lesson/)?([^\)]+)\)', lesson_list_content)
|
||||
|
||||
processed_links = []
|
||||
for title, slug in links:
|
||||
filename = slug if slug.endswith('.md') else slug + '.md'
|
||||
processed_links.append((title, filename))
|
||||
return processed_links
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue