diff --git a/frontend/src/routes/lesson/[slug]/+page.svelte b/frontend/src/routes/lesson/[slug]/+page.svelte index aee0be5..1cf5a29 100644 --- a/frontend/src/routes/lesson/[slug]/+page.svelte +++ b/frontend/src/routes/lesson/[slug]/+page.svelte @@ -20,9 +20,10 @@ import { renderFlowchartEmbeds } from '$actions/renderFlowchartEmbeds'; import { renderMath, autoRenderMath } from '$lib/actions/renderMath'; - import { tick, mount, unmount } from 'svelte'; + import { tick, mount, unmount, onMount } from 'svelte'; import { LessonManager } from './lesson.svelte'; - import { authLoggedIn } from '$stores/auth'; + import { authLoggedIn, authToken } from '$stores/auth'; + import { get } from 'svelte/store'; import SlideCarousel from '$components/SlideCarousel.svelte'; let { data: pageData } = $props(); @@ -104,6 +105,12 @@ beforeNavigate(() => { lessonContext.set(null); + + // Quiz integrity: submit with penalty on SPA navigation + if (mgr.isQuizMode) { + mgr.submitQuiz(); // fire-and-forget, async + } + if (mgr.velxioCleanup) { mgr.velxioCleanup(); mgr.velxioCleanup = null; @@ -114,6 +121,35 @@ } }); + onMount(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (mgr.isQuizMode) { + // Calculate score with penalty + const mcqTotal = mgr.quizQuestionTypes.filter(t => t === 'mcq').length; + const correctTotal = mgr.quizIsCorrect.filter((v, i) => mgr.quizQuestionTypes[i] === 'mcq' && v).length; + const statusString = mcqTotal > 0 ? `${correctTotal}/${mcqTotal}` : 'completed'; + const lessonName = mgr.slug.replace('.md', ''); + + // Send beacon to /track-progress (fire-and-forget on page unload) + const payload = JSON.stringify({ + token: get(authToken) || localStorage.getItem('student_token') || '', + lesson_name: lessonName, + status: statusString + }); + navigator.sendBeacon( + '/api/track-progress', + new Blob([payload], { type: 'application/json' }) + ); + + // Trigger browser confirm dialog + e.preventDefault(); + e.returnValue = ''; + } + }; + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }); + let contentEl = $state(null); let tabsEl = $state(null); @@ -156,8 +192,8 @@
🫣

Materi Disembunyikan

-

Selesaikan atau batalkan kuis untuk melihat materi kembali.

- +

Keluar dari kuis akan menyimpan skor akhir. Soal yang belum dijawab dianggap salah.

+
{:else} @@ -303,6 +339,7 @@ bind:isQuizMode={mgr.isQuizMode} completedStatus={mgr.data.lesson_progress_status} onComplete={(status) => mgr.completeLesson(status)} + onAnswerChange={(status, isCorrect, total, types) => mgr.updateQuizSnapshot(status, isCorrect, total, types)} /> {/if} diff --git a/frontend/src/routes/lesson/[slug]/QuizTab.svelte b/frontend/src/routes/lesson/[slug]/QuizTab.svelte index 4233c8c..74a0901 100644 --- a/frontend/src/routes/lesson/[slug]/QuizTab.svelte +++ b/frontend/src/routes/lesson/[slug]/QuizTab.svelte @@ -1,4 +1,6 @@
- {#if showSummary || (completedStatus && completedStatus !== 'not_started' && !isQuizMode)} + {#if showSummary || quizAlreadyCompleted}
🏁
@@ -183,12 +231,22 @@
📝

Kuis Interaktif

Uji pemahamanmu dengan kuis ini.

-
- ⚠️ Penting: Materi pelajaran akan disembunyikan. Kamu harus menjawab semua soal untuk dapat menyelesaikannya. -
- + + {#if !isLoggedIn} +
+ ⚠️ Login diperlukan. Klik tombol login di navbar atas untuk memulai kuis. +
+ + {:else} +
+ ⚠️ Penting: Materi pelajaran akan disembunyikan. Kamu harus menjawab semua soal untuk dapat menyelesaikannya. Jika keluar di tengah kuis, soal yang belum dijawab akan dianggap salah. +
+ + {/if}
{:else if quizData.length > 0}
@@ -279,8 +337,8 @@
- - 🔒 Selesaikan kuis untuk melihat materi kembali + + ⚠️ Keluar = selesai. Soal belum dijawab dianggap salah.
{:else} @@ -390,9 +448,10 @@ .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 { background: none; border: none; color: var(--color-danger, #dc3545); text-decoration: underline; font-size: 0.85rem; cursor: pointer; } + .btn-exit-quiz:hover { color: #c82333; } .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; } + .cancel-lock-hint { font-size: 0.75rem; color: var(--color-danger, #dc3545); 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 03a6b16..151df27 100644 --- a/frontend/src/routes/lesson/[slug]/lesson.svelte.ts +++ b/frontend/src/routes/lesson/[slug]/lesson.svelte.ts @@ -18,6 +18,12 @@ export class LessonManager { data = $state(null); lessonCompleted = $state(false); isQuizMode = $state(false); + + // Quiz answer snapshot (updated by QuizTab via onAnswerChange callback) + quizAnswersStatus = $state([]); + quizIsCorrect = $state([]); + quizQuestionTypes = $state<('flashcard' | 'mcq')[]>([]); + quizTotalQuestions = $state(0); currentCode = $state(''); currentLanguage = $state('c'); @@ -162,9 +168,13 @@ export class LessonManager { untrack(() => { this.data = lesson; this.lessonCompleted = lesson.lesson_completed; - this.isQuizMode = false; + this.isQuizMode = false; + this.quizAnswersStatus = []; + this.quizIsCorrect = []; + this.quizQuestionTypes = []; + this.quizTotalQuestions = 0; - this.cCode = lesson.initial_code_c || ''; + this.cCode = lesson.initial_code_c || ''; this.pythonCode = lesson.initial_python || ''; const hasC = lesson.active_tabs?.includes('c'); @@ -219,6 +229,25 @@ export class LessonManager { } } + updateQuizSnapshot(status: boolean[], isCorrect: boolean[], total: number, types: ('flashcard' | 'mcq')[]) { + this.quizAnswersStatus = [...status]; + this.quizIsCorrect = [...isCorrect]; + this.quizQuestionTypes = [...types]; + this.quizTotalQuestions = total; + } + + async submitQuiz() { + if (!this.isQuizMode) return; + + // Calculate score: unanswered MCQ = wrong (not in correct count) + const mcqTotal = this.quizQuestionTypes.filter(t => t === 'mcq').length; + const correctTotal = this.quizIsCorrect.filter((v, i) => this.quizQuestionTypes[i] === 'mcq' && v).length; + const statusString = mcqTotal > 0 ? `${correctTotal}/${mcqTotal}` : 'completed'; + + this.isQuizMode = false; + await this.completeLesson(statusString); + } + checkAllPassed(): boolean { const needsC = this.data?.active_tabs?.includes('c'); const needsPython = this.data?.active_tabs?.includes('python');