feat: add quiz feature
This commit is contained in:
parent
9ed207d2fa
commit
7e77748b6b
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
type TabType = 'info' | 'exercise' | 'editor' | 'circuit' | 'output' | 'velxio' | 'flowchart';
|
||||
type TabType = 'info' | 'exercise' | 'editor' | 'circuit' | 'output' | 'velxio' | 'flowchart' | 'quiz';
|
||||
|
||||
interface Props {
|
||||
isMobile: boolean;
|
||||
|
|
@ -46,6 +46,7 @@
|
|||
);
|
||||
const hasMultiLang = $derived(hasC && hasPython);
|
||||
const hasCircuit = $derived(activeTabs?.includes('circuit') ?? false);
|
||||
const hasQuiz = $derived(activeTabs?.includes('quiz') ?? false);
|
||||
|
||||
function onSheetTouchStart(e: TouchEvent) {
|
||||
touchStartY = e.touches[0].clientY;
|
||||
|
|
@ -97,6 +98,9 @@
|
|||
{#if hasFlowchart}
|
||||
<button class="chrome-tab" class:active={activeTab === 'flowchart'} onclick={() => handleTabClick('flowchart')}>Flowchart</button>
|
||||
{/if}
|
||||
{#if hasQuiz}
|
||||
<button class="chrome-tab" class:active={activeTab === 'quiz'} onclick={() => handleTabClick('quiz')}>Quiz</button>
|
||||
{/if}
|
||||
<button class="chrome-tab" class:active={activeTab === 'output'} onclick={() => handleTabClick('output')}>Output</button>
|
||||
{/snippet}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export interface LessonContent {
|
|||
language_display_name: string;
|
||||
active_tabs: string[];
|
||||
evaluation_config: Record<string, any>;
|
||||
quiz_data?: Array<{ front: string; back: string }>;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,536 @@
|
|||
<script lang="ts">
|
||||
interface Option {
|
||||
text: string;
|
||||
is_correct: boolean;
|
||||
}
|
||||
|
||||
interface FlashcardData {
|
||||
type: 'flashcard' | 'mcq';
|
||||
front?: string;
|
||||
back?: string;
|
||||
question?: string;
|
||||
options?: Option[];
|
||||
explanation?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
quizData: FlashcardData[];
|
||||
isQuizMode: boolean;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
let { quizData = [], isQuizMode = $bindable(), onComplete }: Props = $props();
|
||||
|
||||
let currentIndex = $state(0);
|
||||
let isFlipped = $state(false);
|
||||
let selectedOption = $state<number | null>(null);
|
||||
let randomizedOptions = $state<Option[]>([]);
|
||||
let answersStatus = $state<boolean[]>([]); // Track answer status for each card
|
||||
|
||||
const currentCard = $derived(quizData[currentIndex]);
|
||||
const isAllAnswered = $derived(answersStatus.every(status => status === true));
|
||||
const isAnswered = $derived(answersStatus[currentIndex]);
|
||||
|
||||
function shuffleArray<T>(array: T[]): T[] {
|
||||
const newArray = [...array];
|
||||
for (let i = newArray.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[newArray[i], newArray[j]] = [newArray[j], newArray[i]];
|
||||
}
|
||||
return newArray;
|
||||
}
|
||||
|
||||
function handleStart() {
|
||||
isQuizMode = true;
|
||||
currentIndex = 0;
|
||||
answersStatus = new Array(quizData.length).fill(false);
|
||||
resetCardState();
|
||||
}
|
||||
|
||||
function resetCardState() {
|
||||
isFlipped = false;
|
||||
selectedOption = null;
|
||||
if (currentCard && currentCard.type === 'mcq' && currentCard.options) {
|
||||
randomizedOptions = shuffleArray(currentCard.options);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFinish() {
|
||||
if (isAllAnswered) {
|
||||
isQuizMode = false;
|
||||
if (onComplete) onComplete();
|
||||
}
|
||||
}
|
||||
|
||||
function handleFlip() {
|
||||
if (currentCard.type === 'flashcard') {
|
||||
isFlipped = !isFlipped;
|
||||
if (!answersStatus[currentIndex]) {
|
||||
answersStatus[currentIndex] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleOptionSelect(index: number) {
|
||||
if (isAnswered) return;
|
||||
selectedOption = index;
|
||||
answersStatus[currentIndex] = true;
|
||||
}
|
||||
|
||||
function nextCard() {
|
||||
if (currentIndex < quizData.length - 1) {
|
||||
currentIndex++;
|
||||
resetCardState();
|
||||
}
|
||||
}
|
||||
|
||||
function prevCard() {
|
||||
if (currentIndex > 0) {
|
||||
currentIndex--;
|
||||
resetCardState();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="quiz-container">
|
||||
{#if !isQuizMode}
|
||||
<div class="quiz-start-view">
|
||||
<div class="quiz-icon">📝</div>
|
||||
<h2>Kuis Interaktif</h2>
|
||||
<p>Uji pemahamanmu dengan kuis flashcard dan pilihan ganda ini.</p>
|
||||
<div class="alert alert-warning">
|
||||
⚠️ <strong>Penting:</strong> Saat kuis dimulai, materi pelajaran di sisi kiri akan disembunyikan agar kamu bisa fokus menjawab.
|
||||
</div>
|
||||
<button class="btn btn-primary btn-lg" onclick={handleStart}>
|
||||
Mulai Kuis Sekarang
|
||||
</button>
|
||||
</div>
|
||||
{:else if quizData.length > 0}
|
||||
<div class="quiz-active-view">
|
||||
<div class="quiz-progress">
|
||||
Pertanyaan {currentIndex + 1} dari {quizData.length}
|
||||
<div class="progress-bar-bg">
|
||||
<div class="progress-bar-fill" style="width: {((currentIndex + 1) / quizData.length) * 100}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if currentCard.type === 'mcq'}
|
||||
<!-- Multiple Choice View -->
|
||||
<div class="mcq-view">
|
||||
<div class="question-box">
|
||||
<div class="side-label">Pertanyaan</div>
|
||||
<div class="card-content">
|
||||
{@html currentCard.question}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="options-grid">
|
||||
{#each randomizedOptions as option, i}
|
||||
<button
|
||||
class="option-btn"
|
||||
class:selected={selectedOption === i}
|
||||
class:correct={isAnswered && option.is_correct}
|
||||
class:wrong={isAnswered && selectedOption === i && !option.is_correct}
|
||||
onclick={() => handleOptionSelect(i)}
|
||||
disabled={isAnswered}
|
||||
>
|
||||
<div class="option-marker">
|
||||
{String.fromCharCode(65 + i)}
|
||||
</div>
|
||||
<div class="option-text">
|
||||
{@html option.text}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if isAnswered}
|
||||
<div class="feedback-area" class:correct={randomizedOptions[selectedOption!].is_correct}>
|
||||
<div class="feedback-status">
|
||||
{#if randomizedOptions[selectedOption!].is_correct}
|
||||
🎉 <strong>Benar!</strong> Jawabanmu tepat.
|
||||
{:else}
|
||||
❌ <strong>Kurang tepat.</strong> Coba pelajari lagi materinya nanti ya.
|
||||
{/if}
|
||||
</div>
|
||||
{#if currentCard.explanation}
|
||||
<div class="explanation-box">
|
||||
{@html currentCard.explanation}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Flashcard View -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="flashcard-wrapper" onclick={handleFlip}>
|
||||
<div class="flashcard" class:flipped={isFlipped}>
|
||||
<div class="flashcard-front">
|
||||
<div class="side-label">Pertanyaan</div>
|
||||
<div class="card-content">
|
||||
{@html currentCard.front}
|
||||
</div>
|
||||
<div class="flip-hint">Klik untuk melihat jawaban</div>
|
||||
</div>
|
||||
<div class="flashcard-back">
|
||||
<div class="side-label">Jawaban</div>
|
||||
<div class="card-content">
|
||||
{@html currentCard.back}
|
||||
</div>
|
||||
{#if currentCard.explanation}
|
||||
<div class="explanation-box small">
|
||||
{@html currentCard.explanation}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flip-hint">Klik untuk kembali ke pertanyaan</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="quiz-controls">
|
||||
<button class="btn btn-outline" onclick={prevCard} disabled={currentIndex === 0}>
|
||||
← Sebelumnya
|
||||
</button>
|
||||
|
||||
{#if currentIndex < quizData.length - 1}
|
||||
<button class="btn btn-primary" onclick={nextCard} disabled={!isAnswered}>
|
||||
Selanjutnya →
|
||||
</button>
|
||||
{:else}
|
||||
<button class="btn btn-success" onclick={handleFinish} disabled={!isAllAnswered}>
|
||||
Selesai Kuis
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="cancel-container">
|
||||
<button class="btn-exit-quiz" onclick={handleFinish} disabled={!isAllAnswered}>Batal Kuis</button>
|
||||
{#if !isAllAnswered}
|
||||
<span class="cancel-lock-hint">🔒 Selesaikan semua pertanyaan untuk dapat keluar</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="quiz-empty">
|
||||
<p>Tidak ada data kuis untuk pelajaran ini.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.quiz-container {
|
||||
padding: 1.5rem;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-bg);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.quiz-active-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quiz-start-view {
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
margin: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.quiz-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background: #fff9db;
|
||||
border: 1px solid #fab005;
|
||||
color: #862e00;
|
||||
}
|
||||
|
||||
.quiz-progress {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.progress-bar-bg {
|
||||
height: 6px;
|
||||
background: var(--color-border);
|
||||
border-radius: 3px;
|
||||
margin-top: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--color-primary, #339af0);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* MCQ Styling */
|
||||
.mcq-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.question-box {
|
||||
background: white;
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.options-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.option-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.2s;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.option-btn:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
background: #f1f9ff;
|
||||
}
|
||||
|
||||
.option-btn.selected {
|
||||
border-color: var(--color-primary);
|
||||
background: #e7f5ff;
|
||||
}
|
||||
|
||||
.option-btn.correct {
|
||||
border-color: #40c057;
|
||||
background: #ebfbee;
|
||||
}
|
||||
|
||||
.option-btn.wrong {
|
||||
border-color: #fa5252;
|
||||
background: #fff5f5;
|
||||
}
|
||||
|
||||
.option-marker {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f1f3f5;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.option-btn.correct .option-marker { background: #40c057; color: white; }
|
||||
.option-btn.wrong .option-marker { background: #fa5252; color: white; }
|
||||
|
||||
.option-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.feedback-area {
|
||||
padding: 1.25rem;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.feedback-area.correct { color: #2b8a3e; background: #ebfbee; }
|
||||
.feedback-area:not(.correct) { color: #c92a2a; background: #fff5f5; }
|
||||
|
||||
.explanation-box {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.explanation-box.small {
|
||||
margin-top: 1rem;
|
||||
max-width: 100%;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.explanation-box :global(p) { margin: 0; }
|
||||
|
||||
/* Flashcard Styling */
|
||||
.flashcard-wrapper {
|
||||
flex: 1;
|
||||
perspective: 1000px;
|
||||
min-height: 250px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.flashcard {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.flashcard.flipped {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.flashcard-front, .flashcard-back {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
backface-visibility: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.flashcard-back {
|
||||
transform: rotateY(180deg);
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.side-label {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
font-size: 1.25rem;
|
||||
color: var(--color-text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
:global(.card-content code) {
|
||||
background: #eee;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
:global(.card-content p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.flip-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 1rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.quiz-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary { background: #339af0; color: white; }
|
||||
.btn-success { background: #40c057; color: white; }
|
||||
.btn-outline { background: white; border: 1px solid var(--color-border); color: var(--color-text); }
|
||||
.btn-lg { padding: 1rem 2rem; font-size: 1.1rem; }
|
||||
|
||||
.btn-exit-quiz {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: underline;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-exit-quiz:disabled {
|
||||
color: #adb5bd;
|
||||
text-decoration: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cancel-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cancel-lock-hint {
|
||||
font-size: 0.75rem;
|
||||
color: #fa5252;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.quiz-empty {
|
||||
text-align: center;
|
||||
margin: auto;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.card-content { font-size: 1.1rem; }
|
||||
.option-btn { padding: 0.75rem; font-size: 0.95rem; }
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<LessonContent | null>(null);
|
||||
lessonCompleted = $state(false);
|
||||
isQuizMode = $state(false);
|
||||
currentCode = $state('');
|
||||
currentLanguage = $state<string>('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<VelxioBridge | null>(null);
|
||||
velxioReady = $state(false);
|
||||
velxioSaving = $state(false);
|
||||
velxioError = $state(false);
|
||||
velxioIframe = $state<HTMLIFrameElement | null>(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<CodeEditor | null>(null);
|
||||
circuitEditor = $state<CircuitEditor | null>(null);
|
||||
flowchartTab = $state<any>(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<string>('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' });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue