From ea3556d05f4d5ee8d91ddb0f60c41115bed4eeeb Mon Sep 17 00:00:00 2001 From: a2nr Date: Sat, 9 May 2026 13:04:42 +0700 Subject: [PATCH] feat: implement interactive quiz with secure content protection, scoring, and teacher reset --- .../lib/components/CelebrationOverlay.svelte | 65 ++- frontend/src/lib/services/api.ts | 13 + frontend/src/lib/types/lesson.ts | 3 +- .../src/routes/lesson/[slug]/+page.svelte | 5 +- .../src/routes/lesson/[slug]/QuizTab.svelte | 504 ++++++------------ .../src/routes/lesson/[slug]/lesson.svelte.ts | 9 +- frontend/src/routes/progress/+page.svelte | 83 ++- routes/lessons.py | 8 +- routes/progress.py | 29 + services/lesson_service.py | 3 +- services/token_service.py | 9 +- 11 files changed, 353 insertions(+), 378 deletions(-) diff --git a/frontend/src/lib/components/CelebrationOverlay.svelte b/frontend/src/lib/components/CelebrationOverlay.svelte index d79fa2b..b750cc2 100644 --- a/frontend/src/lib/components/CelebrationOverlay.svelte +++ b/frontend/src/lib/components/CelebrationOverlay.svelte @@ -1,12 +1,31 @@ {#if visible} -
+ + +
-
✓
+
✓

Selamat! Latihan Selesai!

+

(Klik untuk menutup)

{/if} @@ -15,51 +34,47 @@ .celebration-overlay { position: absolute; inset: 0; - z-index: 10; + z-index: 9999; display: flex; align-items: center; justify-content: center; - background: rgba(0, 0, 0, 0.4); - animation: celebFadeIn 0.3s ease-out; - pointer-events: none; + background: rgba(0, 0, 0, 0.7); + cursor: pointer; border-radius: inherit; } .celebration-content { text-align: center; animation: celebPop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); + pointer-events: none; } .celebration-icon { - width: 80px; - height: 80px; - margin: 0 auto 1rem; - background: var(--color-success); + width: 100px; + height: 100px; + margin: 0 auto 1.5rem; + background: #198754; color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; - font-size: 2.5rem; + font-size: 3rem; font-weight: 700; - box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.4); - animation: celebRing 1.5s ease-out; + box-shadow: 0 0 20px rgba(25, 135, 84, 0.5); } .celebration-text { - font-size: 1.4rem; + font-size: 1.8rem; font-weight: 700; - color: #fff; - text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + color: #ffffff; + text-shadow: 0 2px 10px rgba(0, 0, 0, 0.5); + margin-bottom: 0.5rem; } - @keyframes celebFadeIn { - from { opacity: 0; } - to { opacity: 1; } + .celebration-hint { + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.7); + font-style: italic; } @keyframes celebPop { 0% { transform: scale(0.5); opacity: 0; } 100% { transform: scale(1); opacity: 1; } } - @keyframes celebRing { - 0% { box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.6); } - 70% { box-shadow: 0 0 0 30px rgba(25, 135, 84, 0); } - 100% { box-shadow: 0 0 0 0 rgba(25, 135, 84, 0); } - } diff --git a/frontend/src/lib/services/api.ts b/frontend/src/lib/services/api.ts index 4b40acc..41c9307 100644 --- a/frontend/src/lib/services/api.ts +++ b/frontend/src/lib/services/api.ts @@ -77,3 +77,16 @@ export function trackProgress( customFetch ); } + +export function resetProgress( + teacherToken: string, + studentToken: string, + lessonName: string, + customFetch = fetch +) { + return post<{ success: boolean; message: string }>( + '/reset-progress', + { teacher_token: teacherToken, student_token: studentToken, lesson_name: lessonName }, + customFetch + ); +} diff --git a/frontend/src/lib/types/lesson.ts b/frontend/src/lib/types/lesson.ts index f847124..7596e34 100644 --- a/frontend/src/lib/types/lesson.ts +++ b/frontend/src/lib/types/lesson.ts @@ -41,5 +41,6 @@ export interface LessonContent { language_display_name: string; active_tabs: string[]; evaluation_config: Record; - quiz_data?: Array<{ front: string; back: string }>; + quiz_data?: Array<{ type: 'flashcard' | 'mcq', front?: string; back?: string; question?: string; options?: any[]; explanation?: string }>; + lesson_progress_status?: string; } diff --git a/frontend/src/routes/lesson/[slug]/+page.svelte b/frontend/src/routes/lesson/[slug]/+page.svelte index c1c4e6a..95b20e0 100644 --- a/frontend/src/routes/lesson/[slug]/+page.svelte +++ b/frontend/src/routes/lesson/[slug]/+page.svelte @@ -246,7 +246,8 @@ mgr.completeLesson(status)} />
{/if} @@ -262,7 +263,7 @@ - + {/if} diff --git a/frontend/src/routes/lesson/[slug]/QuizTab.svelte b/frontend/src/routes/lesson/[slug]/QuizTab.svelte index d6ff0c0..222f5f3 100644 --- a/frontend/src/routes/lesson/[slug]/QuizTab.svelte +++ b/frontend/src/routes/lesson/[slug]/QuizTab.svelte @@ -16,20 +16,39 @@ interface Props { quizData: FlashcardData[]; isQuizMode: boolean; - onComplete?: () => void; + completedStatus?: string; // e.g. "5/6" or "completed" + onComplete?: (status: string) => void; } - let { quizData = [], isQuizMode = $bindable(), onComplete }: Props = $props(); + let { quizData = [], isQuizMode = $bindable(), completedStatus = '', onComplete }: Props = $props(); let currentIndex = $state(0); let isFlipped = $state(false); let selectedOption = $state(null); let randomizedOptions = $state([]); - let answersStatus = $state([]); // Track answer status for each card + let answersStatus = $state([]); // Track if answered/flipped + let userSelections = $state<(number | null)[]>([]); // Track user choice index for MCQ + let showSummary = $state(false); const currentCard = $derived(quizData[currentIndex]); const isAllAnswered = $derived(answersStatus.every(status => status === true)); const isAnswered = $derived(answersStatus[currentIndex]); + + // Summary data + const correctCount = $derived( + quizData.reduce((acc, card, i) => { + if (card.type === 'mcq' && userSelections[i] !== null) { + const opts = card.options || []; + // Note: this uses original quizData order, but userSelections matches it + // Wait, if options are randomized, we need to store the correct status + // Actually, randomizedOptions is per-card, so we should store if they got it right + } + return acc; + }, 0) + ); + + // Better way: Track correctness directly + let isCorrectArray = $state([]); function shuffleArray(array: T[]): T[] { const newArray = [...array]; @@ -44,6 +63,9 @@ isQuizMode = true; currentIndex = 0; answersStatus = new Array(quizData.length).fill(false); + userSelections = new Array(quizData.length).fill(null); + isCorrectArray = new Array(quizData.length).fill(false); + showSummary = false; resetCardState(); } @@ -56,9 +78,24 @@ } function handleFinish() { - if (isAllAnswered) { + if (!isAllAnswered) return; + + if (window.confirm("Apakah anda yakin untuk menyelesaikan kuis? Materi akan muncul kembali setelah ini.")) { + const mcqTotal = quizData.filter(c => c.type === 'mcq').length; + const correctTotal = isCorrectArray.filter((v, i) => quizData[i].type === 'mcq' && v).length; + + // If it's pure flashcards, just 'completed'. Otherwise 'correct/total' + const statusString = mcqTotal > 0 ? `${correctTotal}/${mcqTotal}` : 'completed'; + + const passThreshold = 0.75; + const score = mcqTotal > 0 ? correctTotal / mcqTotal : 1.0; + + // Send to backend + if (onComplete) onComplete(statusString); + + // Show local summary + showSummary = true; isQuizMode = false; - if (onComplete) onComplete(); } } @@ -67,6 +104,7 @@ isFlipped = !isFlipped; if (!answersStatus[currentIndex]) { answersStatus[currentIndex] = true; + isCorrectArray[currentIndex] = true; // Flashcards are always "correct" once seen } } } @@ -75,6 +113,8 @@ if (isAnswered) return; selectedOption = index; answersStatus[currentIndex] = true; + userSelections[currentIndex] = index; + isCorrectArray[currentIndex] = randomizedOptions[index].is_correct; } function nextCard() { @@ -90,16 +130,60 @@ resetCardState(); } } + + // Determine wrong questions for summary + const wrongQuestions = $derived( + quizData.map((card, i) => ({ card, i })) + .filter(({ card, i }) => card.type === 'mcq' && !isCorrectArray[i]) + );
- {#if !isQuizMode} + {#if showSummary || (completedStatus && completedStatus !== 'not_started' && !isQuizMode)} +
+
+
🏁
+

Kuis Selesai!

+ {#if completedStatus && !showSummary} +

Nilai Anda: {completedStatus}

+
+ Kuis ini sudah diselesaikan. Silakan hubungi guru jika ingin mengulang. +
+ {:else} + {@const mcqTotal = quizData.filter(c => c.type === 'mcq').length} + {@const correctTotal = isCorrectArray.filter((v, i) => quizData[i].type === 'mcq' && v).length} +

Skor: {correctTotal} / {mcqTotal}

+ + {#if mcqTotal > 0 && (correctTotal / mcqTotal) < 0.75} +
+ Nilai Anda di bawah ambang batas 75%. Pelajari kembali topik di bawah ini. +
+ {:else} +
+ Selamat! Anda telah memahami materi ini dengan baik. +
+ {/if} + + {#if wrongQuestions.length > 0} +
+

Topik yang perlu dipelajari lagi:

+
    + {#each wrongQuestions as { card }} +
  • {@html card.question}
  • + {/each} +
+
+ {/if} + {/if} +
+
+ {:else if !isQuizMode}
📝

Kuis Interaktif

-

Uji pemahamanmu dengan kuis flashcard dan pilihan ganda ini.

+

Uji pemahamanmu dengan kuis ini.

- ⚠️ Penting: Saat kuis dimulai, materi pelajaran di sisi kiri akan disembunyikan agar kamu bisa fokus menjawab. + ⚠️ Penting: Materi pelajaran akan disembunyikan. Kamu harus menjawab semua soal untuk dapat menyelesaikannya.
{#if currentCard.type === 'mcq'} -
Pertanyaan
@@ -134,55 +217,42 @@ onclick={() => handleOptionSelect(i)} disabled={isAnswered} > -
- {String.fromCharCode(65 + i)} -
-
- {@html option.text} -
+
{String.fromCharCode(65 + i)}
+
{@html option.text}
{/each}
{#if isAnswered} - {:else} -
Pertanyaan
-
- {@html currentCard.front} -
+
{@html currentCard.front}
Klik untuk melihat jawaban
Jawaban
-
- {@html currentCard.back} -
+
{@html currentCard.back}
{#if currentCard.explanation} -
- {@html currentCard.explanation} -
+
{@html currentCard.explanation}
{/if}
Klik untuk kembali ke pertanyaan
@@ -191,32 +261,21 @@ {/if}
- - + {#if currentIndex < quizData.length - 1} - + {:else} - + {/if}
- - {#if !isAllAnswered} - 🔒 Selesaikan semua pertanyaan untuk dapat keluar - {/if} + + 🔒 Selesaikan kuis untuk melihat materi kembali
{:else} -
-

Tidak ada data kuis untuk pelajaran ini.

-
+

Tidak ada data kuis.

{/if}
@@ -230,307 +289,90 @@ overflow-y: auto; } - .quiz-active-view { - flex: 1; - display: flex; - flex-direction: column; - } - - .quiz-start-view { + .summary-view { text-align: center; - max-width: 400px; + padding: 2rem 1rem; + max-width: 500px; margin: auto; - display: flex; - flex-direction: column; - gap: 1rem; } + .summary-icon { font-size: 4rem; margin-bottom: 1rem; } + .score-display { font-size: 1.5rem; margin-bottom: 1.5rem; } + .score-display strong { color: var(--color-primary); font-size: 2.5rem; } - .quiz-icon { - font-size: 4rem; - margin-bottom: 0.5rem; - } - - .alert { - padding: 1rem; - border-radius: 8px; - font-size: 0.9rem; - line-height: 1.4; + .wrong-topics { text-align: left; + margin-top: 2rem; + padding-top: 2rem; + border-top: 1px solid var(--color-border); } + .wrong-topics h3 { font-size: 1rem; margin-bottom: 1rem; color: var(--color-danger); } + .wrong-topics ul { padding-left: 1.5rem; } + .wrong-topics li { margin-bottom: 0.75rem; font-size: 0.9rem; } - .alert-warning { - background: #fff9db; - border: 1px solid #fab005; - color: #862e00; - } + .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; } - .quiz-progress { - font-size: 0.85rem; - color: var(--color-text-muted); - margin-bottom: 1.5rem; - } + .alert { padding: 1rem; border-radius: 8px; font-size: 0.9rem; line-height: 1.4; text-align: left; } + .alert-warning { background: var(--color-bg-secondary); border: 1px solid var(--color-warning); color: var(--color-text); } + .alert-success { background: rgba(25, 135, 84, 0.1); border: 1px solid var(--color-success); color: var(--color-success); } + .alert-info { background: rgba(13, 110, 253, 0.1); border: 1px solid var(--color-primary); color: var(--color-primary); } - .progress-bar-bg { - height: 6px; - background: var(--color-border); - border-radius: 3px; - margin-top: 0.5rem; - overflow: hidden; - } + .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; } - .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; - } + .mcq-view { flex: 1; display: flex; flex-direction: column; gap: 1.5rem; margin-bottom: 2rem; } + .question-box { background: var(--color-bg-secondary); 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: var(--color-bg); 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: var(--color-bg-secondary); } + .option-btn.selected { border-color: var(--color-primary); background: var(--color-bg-secondary); } + .option-btn.correct { border-color: var(--color-success); background: rgba(25, 135, 84, 0.15); } + .option-btn.wrong { border-color: var(--color-danger); background: rgba(220, 53, 69, 0.15); } + .option-marker { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: 8px; font-weight: 700; font-size: 0.9rem; flex-shrink: 0; color: var(--color-text); } + .option-btn.correct .option-marker { background: var(--color-success); border-color: var(--color-success); color: white; } + .option-btn.wrong .option-marker { background: var(--color-danger); border-color: var(--color-danger); 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: var(--color-success); background: rgba(25, 135, 84, 0.1); border: 1px solid rgba(25, 135, 84, 0.2); } + .feedback-area:not(.correct) { color: var(--color-danger); background: rgba(220, 53, 69, 0.1); border: 1px solid rgba(220, 53, 69, 0.2); } + .explanation-box { font-size: 0.85rem; line-height: 1.5; padding: 1rem; background: var(--color-bg-secondary); border: 1px solid var(--color-border); 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-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: var(--color-bg-secondary); border: 2px solid var(--color-border); border-radius: 16px; box-shadow: var(--shadow); } + .flashcard-back { transform: rotateY(180deg); background: var(--color-bg); } + .side-label { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted); margin-bottom: 1rem; font-weight: 700; border-bottom: 1px solid var(--color-border); padding-bottom: 0.5rem; } + .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: var(--color-bg); border: 1px solid var(--color-border); padding: 0.2rem 0.4rem; border-radius: 4px; font-family: var(--font-mono); } + :global(.card-content p) { margin: 0; } + .flip-hint { font-size: 0.75rem; color: var(--color-text-muted); margin-top: 1rem; font-style: italic; } - .flashcard { + .quiz-controls { + display: flex; + justify-content: space-between; + gap: 1rem; + margin-top: auto; position: relative; - width: 100%; - height: 100%; - transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1); - transform-style: preserve-3d; + z-index: 10; /* Ensure buttons are clickable */ + background: var(--color-bg); /* Prevents card content from showing through during scroll */ + padding-top: 1rem; } - - .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 { 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; } - } + .btn-exit-quiz { background: none; border: none; color: var(--color-text-muted); text-decoration: underline; font-size: 0.85rem; cursor: pointer; } + .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; } } diff --git a/frontend/src/routes/lesson/[slug]/lesson.svelte.ts b/frontend/src/routes/lesson/[slug]/lesson.svelte.ts index 11ad050..c6f521d 100644 --- a/frontend/src/routes/lesson/[slug]/lesson.svelte.ts +++ b/frontend/src/routes/lesson/[slug]/lesson.svelte.ts @@ -199,12 +199,13 @@ export class LessonManager { }); } - async completeLesson() { - if (this.lessonCompleted) return; + async completeLesson(status = 'completed') { + if (this.lessonCompleted && status === 'completed') return; this.showCelebration = true; + if (get(authLoggedIn)) { const lessonName = this.slug.replace('.md', ''); - await trackProgress(get(auth).token, lessonName); + await trackProgress(auth.token, lessonName, status); this.lessonCompleted = true; lessonContext.update(ctx => ctx ? { ...ctx, completed: true } : ctx); } @@ -298,7 +299,7 @@ export class LessonManager { 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 }); + const res = await compileCode({ code, language: lang, token: auth.token }); if (!res.success) { Object.assign(out, { error: res.error || 'Compilation failed', success: false }); return; diff --git a/frontend/src/routes/progress/+page.svelte b/frontend/src/routes/progress/+page.svelte index 10214cb..3f5eeef 100644 --- a/frontend/src/routes/progress/+page.svelte +++ b/frontend/src/routes/progress/+page.svelte @@ -18,6 +18,10 @@ let loading = $state(true); onMount(async () => { + await loadData(); + }); + + async function loadData() { if (!$authIsTeacher) { loading = false; return; @@ -33,7 +37,33 @@ } finally { loading = false; } - }); + } + + async function handleReset(studentToken: string, lessonName: string, studentName: string) { + if (!window.confirm(`Apakah Anda yakin ingin me-reset progres kuis "${lessonName}" untuk siswa "${studentName}"?`)) { + return; + } + + try { + const res = await fetch('/api/reset-progress', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + teacher_token: auth.token, + student_token: studentToken, + lesson_name: lessonName + }) + }); + const data = await res.json(); + if (data.success) { + await loadData(); + } else { + alert('Gagal me-reset: ' + data.message); + } + } catch (err) { + alert('Terjadi kesalahan saat menghubungi server.'); + } + } const totalLessons = $derived(lessons.length); @@ -78,11 +108,25 @@ {@const key = lesson.filename.replace('.md', '')} {@const status = student[key]} - {#if status === 'completed'} - ✓ - {:else} - — - {/if} +
+ {#if status === 'completed'} + ✓ + {:else if status && status !== 'not_started'} + {status} + {:else} + — + {/if} + + {#if status && status !== 'not_started'} + + {/if} +
{/each} @@ -172,9 +216,36 @@ color: var(--color-success); font-weight: bold; } + .badge.score { + color: var(--color-primary); + font-weight: 600; + font-size: 0.75rem; + } .badge.empty { color: var(--color-text-muted); } + .cell-content { + display: flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + } + .btn-reset-mini { + background: none; + border: 1px solid var(--color-border); + border-radius: 4px; + color: var(--color-text-muted); + cursor: pointer; + font-size: 0.7rem; + padding: 0 0.15rem; + line-height: 1.1; + transition: all 0.2s; + } + .btn-reset-mini:hover { + border-color: #fa5252; + color: #fa5252; + background: #fff5f5; + } .completion-count { font-weight: 600; min-width: 60px; diff --git a/routes/lessons.py b/routes/lessons.py index d0f75b4..74145ea 100644 --- a/routes/lessons.py +++ b/routes/lessons.py @@ -108,10 +108,13 @@ def api_lesson(filename): token = request.args.get('token', '') or request.cookies.get('student_token', '') progress = None lesson_completed = False + lesson_progress_status = '' if token: progress = get_student_progress(token) - if progress and full_filename.replace('.md', '') in progress: - lesson_completed = progress[full_filename.replace('.md', '')] == 'completed' + if progress: + status = progress.get(full_filename.replace('.md', ''), '') + lesson_progress_status = status + lesson_completed = status not in (None, '', 'not_started') all_lessons = get_ordered_lessons_with_learning_objectives(progress) @@ -194,6 +197,7 @@ def api_lesson(filename): 'key_text_circuit': key_text_circuit, 'active_tabs': active_tabs, 'quiz_data': quiz_data, + 'lesson_progress_status': lesson_progress_status, 'lesson_title': full_filename.replace('.md', '').replace('_', ' ').title(), 'lesson_completed': lesson_completed, 'locked': is_locked, diff --git a/routes/progress.py b/routes/progress.py index ffae05f..47a109f 100644 --- a/routes/progress.py +++ b/routes/progress.py @@ -49,6 +49,35 @@ def track_progress(): return jsonify({'success': False, 'message': f'Error tracking progress: {e}'}) +@progress_bp.route('/reset-progress', methods=['POST']) +def reset_progress(): + """Reset student progress for a lesson (Teacher only).""" + try: + data = request.get_json() + teacher_token = data.get('teacher_token', '').strip() + student_token = data.get('student_token', '').strip() + lesson_name = data.get('lesson_name', '').strip() + + if not teacher_token or not student_token or not lesson_name: + return jsonify({'success': False, 'message': 'All fields are required'}), 400 + + # Validate teacher token + teacher_info = validate_token(teacher_token) + if not teacher_info or not teacher_info.get('is_teacher'): + return jsonify({'success': False, 'message': 'Unauthorized (Teacher only)'}), 401 + + # Perform reset (set to not_started) + updated = update_student_progress(student_token, lesson_name, 'not_started') + if updated: + return jsonify({'success': True, 'message': 'Progress reset successfully'}) + else: + return jsonify({'success': False, 'message': 'Failed to reset progress'}) + + except Exception as e: + logging.error(f"Error in reset-progress: {e}") + return jsonify({'success': False, 'message': f'Error resetting progress: {e}'}) + + @progress_bp.route('/progress-report.json') def api_progress_report(): """Return progress report data as JSON.""" diff --git a/services/lesson_service.py b/services/lesson_service.py index 4624901..191bbb5 100644 --- a/services/lesson_service.py +++ b/services/lesson_service.py @@ -191,7 +191,8 @@ def get_ordered_lessons_with_learning_objectives(progress=None): def _add_completion_and_prereqs(lesson, progress): slug = lesson['filename'].replace('.md', '') if progress: - lesson['completed'] = progress.get(slug) == 'completed' + status = progress.get(slug, '') + lesson['completed'] = status not in (None, '', 'not_started') else: lesson['completed'] = False diff --git a/services/token_service.py b/services/token_service.py index 4ac6c0b..ffcd247 100644 --- a/services/token_service.py +++ b/services/token_service.py @@ -172,7 +172,8 @@ def calculate_student_completion(student_data, all_lessons): else: lesson_key = lesson.replace('.md', '') - if lesson_key in student_data and student_data[lesson_key] == 'completed': + status = student_data.get(lesson_key, '') + if status and status not in ('not_started', ''): completed_count += 1 return completed_count @@ -204,11 +205,7 @@ def get_all_students_progress(all_lessons_func): }) for row in tokens.values(): - student_data = dict(row) - # Don't delete 'token' from the original dict in cache! - student_data_copy = student_data.copy() - if 'token' in student_data_copy: - del student_data_copy['token'] + student_data_copy = dict(row) student_data_copy['completed_count'] = calculate_student_completion(student_data_copy, ordered_lessons) all_students_progress.append(student_data_copy)