feat: implement interactive quiz with secure content protection, scoring, and teacher reset
This commit is contained in:
parent
7e77748b6b
commit
ea3556d05f
|
|
@ -1,12 +1,31 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let { visible }: { visible: boolean } = $props();
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
let { visible = $bindable() }: { visible: boolean } = $props();
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto dismiss after 3 seconds
|
||||||
|
$effect(() => {
|
||||||
|
if (visible) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
visible = false;
|
||||||
|
}, 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if visible}
|
{#if visible}
|
||||||
<div class="celebration-overlay">
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="celebration-overlay" onclick={dismiss} transition:fade={{ duration: 300 }}>
|
||||||
<div class="celebration-content">
|
<div class="celebration-content">
|
||||||
<div class="celebration-icon">✓</div>
|
<div class="celebration-icon">✓</div>
|
||||||
<p class="celebration-text">Selamat! Latihan Selesai!</p>
|
<p class="celebration-text">Selamat! Latihan Selesai!</p>
|
||||||
|
<p class="celebration-hint">(Klik untuk menutup)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -15,51 +34,47 @@
|
||||||
.celebration-overlay {
|
.celebration-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 10;
|
z-index: 9999;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: rgba(0, 0, 0, 0.4);
|
background: rgba(0, 0, 0, 0.7);
|
||||||
animation: celebFadeIn 0.3s ease-out;
|
cursor: pointer;
|
||||||
pointer-events: none;
|
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
}
|
}
|
||||||
.celebration-content {
|
.celebration-content {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
animation: celebPop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
animation: celebPop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.celebration-icon {
|
.celebration-icon {
|
||||||
width: 80px;
|
width: 100px;
|
||||||
height: 80px;
|
height: 100px;
|
||||||
margin: 0 auto 1rem;
|
margin: 0 auto 1.5rem;
|
||||||
background: var(--color-success);
|
background: #198754;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 2.5rem;
|
font-size: 3rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.4);
|
box-shadow: 0 0 20px rgba(25, 135, 84, 0.5);
|
||||||
animation: celebRing 1.5s ease-out;
|
|
||||||
}
|
}
|
||||||
.celebration-text {
|
.celebration-text {
|
||||||
font-size: 1.4rem;
|
font-size: 1.8rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #fff;
|
color: #ffffff;
|
||||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
@keyframes celebFadeIn {
|
.celebration-hint {
|
||||||
from { opacity: 0; }
|
font-size: 0.9rem;
|
||||||
to { opacity: 1; }
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-style: italic;
|
||||||
}
|
}
|
||||||
@keyframes celebPop {
|
@keyframes celebPop {
|
||||||
0% { transform: scale(0.5); opacity: 0; }
|
0% { transform: scale(0.5); opacity: 0; }
|
||||||
100% { transform: scale(1); opacity: 1; }
|
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); }
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -77,3 +77,16 @@ export function trackProgress(
|
||||||
customFetch
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,5 +41,6 @@ export interface LessonContent {
|
||||||
language_display_name: string;
|
language_display_name: string;
|
||||||
active_tabs: string[];
|
active_tabs: string[];
|
||||||
evaluation_config: Record<string, any>;
|
evaluation_config: Record<string, any>;
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,8 @@
|
||||||
<QuizTab
|
<QuizTab
|
||||||
quizData={mgr.data.quiz_data ?? []}
|
quizData={mgr.data.quiz_data ?? []}
|
||||||
bind:isQuizMode={mgr.isQuizMode}
|
bind:isQuizMode={mgr.isQuizMode}
|
||||||
onComplete={mgr.completeLesson.bind(mgr)}
|
completedStatus={mgr.data.lesson_progress_status}
|
||||||
|
onComplete={(status) => mgr.completeLesson(status)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -262,7 +263,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CelebrationOverlay visible={mgr.showCelebration} />
|
<CelebrationOverlay bind:visible={mgr.showCelebration} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -16,20 +16,39 @@
|
||||||
interface Props {
|
interface Props {
|
||||||
quizData: FlashcardData[];
|
quizData: FlashcardData[];
|
||||||
isQuizMode: boolean;
|
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 currentIndex = $state(0);
|
||||||
let isFlipped = $state(false);
|
let isFlipped = $state(false);
|
||||||
let selectedOption = $state<number | null>(null);
|
let selectedOption = $state<number | null>(null);
|
||||||
let randomizedOptions = $state<Option[]>([]);
|
let randomizedOptions = $state<Option[]>([]);
|
||||||
let answersStatus = $state<boolean[]>([]); // Track answer status for each card
|
let answersStatus = $state<boolean[]>([]); // 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 currentCard = $derived(quizData[currentIndex]);
|
||||||
const isAllAnswered = $derived(answersStatus.every(status => status === true));
|
const isAllAnswered = $derived(answersStatus.every(status => status === true));
|
||||||
const isAnswered = $derived(answersStatus[currentIndex]);
|
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<boolean[]>([]);
|
||||||
|
|
||||||
function shuffleArray<T>(array: T[]): T[] {
|
function shuffleArray<T>(array: T[]): T[] {
|
||||||
const newArray = [...array];
|
const newArray = [...array];
|
||||||
|
|
@ -44,6 +63,9 @@
|
||||||
isQuizMode = true;
|
isQuizMode = true;
|
||||||
currentIndex = 0;
|
currentIndex = 0;
|
||||||
answersStatus = new Array(quizData.length).fill(false);
|
answersStatus = new Array(quizData.length).fill(false);
|
||||||
|
userSelections = new Array(quizData.length).fill(null);
|
||||||
|
isCorrectArray = new Array(quizData.length).fill(false);
|
||||||
|
showSummary = false;
|
||||||
resetCardState();
|
resetCardState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,9 +78,24 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFinish() {
|
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;
|
isQuizMode = false;
|
||||||
if (onComplete) onComplete();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,6 +104,7 @@
|
||||||
isFlipped = !isFlipped;
|
isFlipped = !isFlipped;
|
||||||
if (!answersStatus[currentIndex]) {
|
if (!answersStatus[currentIndex]) {
|
||||||
answersStatus[currentIndex] = true;
|
answersStatus[currentIndex] = true;
|
||||||
|
isCorrectArray[currentIndex] = true; // Flashcards are always "correct" once seen
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -75,6 +113,8 @@
|
||||||
if (isAnswered) return;
|
if (isAnswered) return;
|
||||||
selectedOption = index;
|
selectedOption = index;
|
||||||
answersStatus[currentIndex] = true;
|
answersStatus[currentIndex] = true;
|
||||||
|
userSelections[currentIndex] = index;
|
||||||
|
isCorrectArray[currentIndex] = randomizedOptions[index].is_correct;
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextCard() {
|
function nextCard() {
|
||||||
|
|
@ -90,16 +130,60 @@
|
||||||
resetCardState();
|
resetCardState();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine wrong questions for summary
|
||||||
|
const wrongQuestions = $derived(
|
||||||
|
quizData.map((card, i) => ({ card, i }))
|
||||||
|
.filter(({ card, i }) => card.type === 'mcq' && !isCorrectArray[i])
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="quiz-container">
|
<div class="quiz-container">
|
||||||
{#if !isQuizMode}
|
{#if showSummary || (completedStatus && completedStatus !== 'not_started' && !isQuizMode)}
|
||||||
|
<div class="summary-view">
|
||||||
|
<div class="summary-header">
|
||||||
|
<div class="summary-icon">🏁</div>
|
||||||
|
<h2>Kuis Selesai!</h2>
|
||||||
|
{#if completedStatus && !showSummary}
|
||||||
|
<p class="score-display">Nilai Anda: <strong>{completedStatus}</strong></p>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
Kuis ini sudah diselesaikan. Silakan hubungi guru jika ingin mengulang.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{@const mcqTotal = quizData.filter(c => c.type === 'mcq').length}
|
||||||
|
{@const correctTotal = isCorrectArray.filter((v, i) => quizData[i].type === 'mcq' && v).length}
|
||||||
|
<p class="score-display">Skor: <strong>{correctTotal} / {mcqTotal}</strong></p>
|
||||||
|
|
||||||
|
{#if mcqTotal > 0 && (correctTotal / mcqTotal) < 0.75}
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
Nilai Anda di bawah ambang batas 75%. Pelajari kembali topik di bawah ini.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="alert alert-success">
|
||||||
|
Selamat! Anda telah memahami materi ini dengan baik.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if wrongQuestions.length > 0}
|
||||||
|
<div class="wrong-topics">
|
||||||
|
<h3>Topik yang perlu dipelajari lagi:</h3>
|
||||||
|
<ul>
|
||||||
|
{#each wrongQuestions as { card }}
|
||||||
|
<li>{@html card.question}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if !isQuizMode}
|
||||||
<div class="quiz-start-view">
|
<div class="quiz-start-view">
|
||||||
<div class="quiz-icon">📝</div>
|
<div class="quiz-icon">📝</div>
|
||||||
<h2>Kuis Interaktif</h2>
|
<h2>Kuis Interaktif</h2>
|
||||||
<p>Uji pemahamanmu dengan kuis flashcard dan pilihan ganda ini.</p>
|
<p>Uji pemahamanmu dengan kuis ini.</p>
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning">
|
||||||
⚠️ <strong>Penting:</strong> Saat kuis dimulai, materi pelajaran di sisi kiri akan disembunyikan agar kamu bisa fokus menjawab.
|
⚠️ <strong>Penting:</strong> Materi pelajaran akan disembunyikan. Kamu harus menjawab semua soal untuk dapat menyelesaikannya.
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary btn-lg" onclick={handleStart}>
|
<button class="btn btn-primary btn-lg" onclick={handleStart}>
|
||||||
Mulai Kuis Sekarang
|
Mulai Kuis Sekarang
|
||||||
|
|
@ -115,7 +199,6 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if currentCard.type === 'mcq'}
|
{#if currentCard.type === 'mcq'}
|
||||||
<!-- Multiple Choice View -->
|
|
||||||
<div class="mcq-view">
|
<div class="mcq-view">
|
||||||
<div class="question-box">
|
<div class="question-box">
|
||||||
<div class="side-label">Pertanyaan</div>
|
<div class="side-label">Pertanyaan</div>
|
||||||
|
|
@ -134,55 +217,42 @@
|
||||||
onclick={() => handleOptionSelect(i)}
|
onclick={() => handleOptionSelect(i)}
|
||||||
disabled={isAnswered}
|
disabled={isAnswered}
|
||||||
>
|
>
|
||||||
<div class="option-marker">
|
<div class="option-marker">{String.fromCharCode(65 + i)}</div>
|
||||||
{String.fromCharCode(65 + i)}
|
<div class="option-text">{@html option.text}</div>
|
||||||
</div>
|
|
||||||
<div class="option-text">
|
|
||||||
{@html option.text}
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if isAnswered}
|
{#if isAnswered}
|
||||||
<div class="feedback-area" class:correct={randomizedOptions[selectedOption!].is_correct}>
|
<div class="feedback-area" class:correct={isCorrectArray[currentIndex]}>
|
||||||
<div class="feedback-status">
|
<div class="feedback-status">
|
||||||
{#if randomizedOptions[selectedOption!].is_correct}
|
{#if isCorrectArray[currentIndex]}
|
||||||
🎉 <strong>Benar!</strong> Jawabanmu tepat.
|
🎉 <strong>Benar!</strong>
|
||||||
{:else}
|
{:else}
|
||||||
❌ <strong>Kurang tepat.</strong> Coba pelajari lagi materinya nanti ya.
|
❌ <strong>Kurang tepat.</strong>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if currentCard.explanation}
|
{#if currentCard.explanation}
|
||||||
<div class="explanation-box">
|
<div class="explanation-box">{@html currentCard.explanation}</div>
|
||||||
{@html currentCard.explanation}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Flashcard View -->
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="flashcard-wrapper" onclick={handleFlip}>
|
<div class="flashcard-wrapper" onclick={handleFlip}>
|
||||||
<div class="flashcard" class:flipped={isFlipped}>
|
<div class="flashcard" class:flipped={isFlipped}>
|
||||||
<div class="flashcard-front">
|
<div class="flashcard-front">
|
||||||
<div class="side-label">Pertanyaan</div>
|
<div class="side-label">Pertanyaan</div>
|
||||||
<div class="card-content">
|
<div class="card-content">{@html currentCard.front}</div>
|
||||||
{@html currentCard.front}
|
|
||||||
</div>
|
|
||||||
<div class="flip-hint">Klik untuk melihat jawaban</div>
|
<div class="flip-hint">Klik untuk melihat jawaban</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flashcard-back">
|
<div class="flashcard-back">
|
||||||
<div class="side-label">Jawaban</div>
|
<div class="side-label">Jawaban</div>
|
||||||
<div class="card-content">
|
<div class="card-content">{@html currentCard.back}</div>
|
||||||
{@html currentCard.back}
|
|
||||||
</div>
|
|
||||||
{#if currentCard.explanation}
|
{#if currentCard.explanation}
|
||||||
<div class="explanation-box small">
|
<div class="explanation-box small">{@html currentCard.explanation}</div>
|
||||||
{@html currentCard.explanation}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flip-hint">Klik untuk kembali ke pertanyaan</div>
|
<div class="flip-hint">Klik untuk kembali ke pertanyaan</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -191,32 +261,21 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="quiz-controls">
|
<div class="quiz-controls">
|
||||||
<button class="btn btn-outline" onclick={prevCard} disabled={currentIndex === 0}>
|
<button class="btn btn-outline" onclick={prevCard} disabled={currentIndex === 0}>← Sebelumnya</button>
|
||||||
← Sebelumnya
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if currentIndex < quizData.length - 1}
|
{#if currentIndex < quizData.length - 1}
|
||||||
<button class="btn btn-primary" onclick={nextCard} disabled={!isAnswered}>
|
<button class="btn btn-primary" onclick={nextCard} disabled={!isAnswered}>Selanjutnya →</button>
|
||||||
Selanjutnya →
|
|
||||||
</button>
|
|
||||||
{:else}
|
{:else}
|
||||||
<button class="btn btn-success" onclick={handleFinish} disabled={!isAllAnswered}>
|
<button class="btn btn-success" onclick={handleFinish} disabled={!isAllAnswered}>Selesai Kuis</button>
|
||||||
Selesai Kuis
|
|
||||||
</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cancel-container">
|
<div class="cancel-container">
|
||||||
<button class="btn-exit-quiz" onclick={handleFinish} disabled={!isAllAnswered}>Batal Kuis</button>
|
<button class="btn-exit-quiz" disabled>Batal Kuis</button>
|
||||||
{#if !isAllAnswered}
|
<span class="cancel-lock-hint">🔒 Selesaikan kuis untuk melihat materi kembali</span>
|
||||||
<span class="cancel-lock-hint">🔒 Selesaikan semua pertanyaan untuk dapat keluar</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="quiz-empty">
|
<div class="quiz-empty"><p>Tidak ada data kuis.</p></div>
|
||||||
<p>Tidak ada data kuis untuk pelajaran ini.</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -230,307 +289,90 @@
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quiz-active-view {
|
.summary-view {
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-start-view {
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
max-width: 400px;
|
padding: 2rem 1rem;
|
||||||
|
max-width: 500px;
|
||||||
margin: auto;
|
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 {
|
.wrong-topics {
|
||||||
font-size: 4rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert {
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
text-align: left;
|
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 {
|
.quiz-active-view { flex: 1; display: flex; flex-direction: column; }
|
||||||
background: #fff9db;
|
.quiz-start-view { text-align: center; max-width: 400px; margin: auto; display: flex; flex-direction: column; gap: 1rem; }
|
||||||
border: 1px solid #fab005;
|
.quiz-icon { font-size: 4rem; margin-bottom: 0.5rem; }
|
||||||
color: #862e00;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quiz-progress {
|
.alert { padding: 1rem; border-radius: 8px; font-size: 0.9rem; line-height: 1.4; text-align: left; }
|
||||||
font-size: 0.85rem;
|
.alert-warning { background: var(--color-bg-secondary); border: 1px solid var(--color-warning); color: var(--color-text); }
|
||||||
color: var(--color-text-muted);
|
.alert-success { background: rgba(25, 135, 84, 0.1); border: 1px solid var(--color-success); color: var(--color-success); }
|
||||||
margin-bottom: 1.5rem;
|
.alert-info { background: rgba(13, 110, 253, 0.1); border: 1px solid var(--color-primary); color: var(--color-primary); }
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar-bg {
|
.quiz-progress { font-size: 0.85rem; color: var(--color-text-muted); margin-bottom: 1.5rem; }
|
||||||
height: 6px;
|
.progress-bar-bg { height: 6px; background: var(--color-border); border-radius: 3px; margin-top: 0.5rem; overflow: hidden; }
|
||||||
background: var(--color-border);
|
.progress-bar-fill { height: 100%; background: var(--color-primary, #339af0); transition: width 0.3s ease; }
|
||||||
border-radius: 3px;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar-fill {
|
.mcq-view { flex: 1; display: flex; flex-direction: column; gap: 1.5rem; margin-bottom: 2rem; }
|
||||||
height: 100%;
|
.question-box { background: var(--color-bg-secondary); border: 2px solid var(--color-border); border-radius: 12px; padding: 1.5rem; }
|
||||||
background: var(--color-primary, #339af0);
|
.options-grid { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
transition: width 0.3s ease;
|
.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); }
|
||||||
/* MCQ Styling */
|
.option-btn.correct { border-color: var(--color-success); background: rgba(25, 135, 84, 0.15); }
|
||||||
.mcq-view {
|
.option-btn.wrong { border-color: var(--color-danger); background: rgba(220, 53, 69, 0.15); }
|
||||||
flex: 1;
|
.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); }
|
||||||
display: flex;
|
.option-btn.correct .option-marker { background: var(--color-success); border-color: var(--color-success); color: white; }
|
||||||
flex-direction: column;
|
.option-btn.wrong .option-marker { background: var(--color-danger); border-color: var(--color-danger); color: white; }
|
||||||
gap: 1.5rem;
|
.option-text { flex: 1; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
.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; }
|
.explanation-box :global(p) { margin: 0; }
|
||||||
|
|
||||||
/* Flashcard Styling */
|
.flashcard-wrapper { flex: 1; perspective: 1000px; min-height: 250px; cursor: pointer; margin-bottom: 2rem; }
|
||||||
.flashcard-wrapper {
|
.flashcard { position: relative; width: 100%; height: 100%; transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1); transform-style: preserve-3d; }
|
||||||
flex: 1;
|
.flashcard.flipped { transform: rotateY(180deg); }
|
||||||
perspective: 1000px;
|
.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); }
|
||||||
min-height: 250px;
|
.flashcard-back { transform: rotateY(180deg); background: var(--color-bg); }
|
||||||
cursor: pointer;
|
.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; }
|
||||||
margin-bottom: 2rem;
|
.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;
|
position: relative;
|
||||||
width: 100%;
|
z-index: 10; /* Ensure buttons are clickable */
|
||||||
height: 100%;
|
background: var(--color-bg); /* Prevents card content from showing through during scroll */
|
||||||
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
padding-top: 1rem;
|
||||||
transform-style: preserve-3d;
|
|
||||||
}
|
}
|
||||||
|
.btn { padding: 0.6rem 1.2rem; border-radius: 8px; font-weight: 600; cursor: pointer; border: none; transition: opacity 0.2s; }
|
||||||
.flashcard.flipped {
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
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-primary { background: #339af0; color: white; }
|
||||||
.btn-success { background: #40c057; color: white; }
|
.btn-success { background: #40c057; color: white; }
|
||||||
.btn-outline { background: white; border: 1px solid var(--color-border); color: var(--color-text); }
|
.btn-outline { background: white; border: 1px solid var(--color-border); color: var(--color-text); }
|
||||||
.btn-lg { padding: 1rem 2rem; font-size: 1.1rem; }
|
.btn-lg { padding: 1rem 2rem; font-size: 1.1rem; }
|
||||||
|
|
||||||
.btn-exit-quiz {
|
.btn-exit-quiz { background: none; border: none; color: var(--color-text-muted); text-decoration: underline; font-size: 0.85rem; cursor: pointer; }
|
||||||
background: none;
|
.cancel-container { display: flex; flex-direction: column; align-items: center; margin-top: 1rem; gap: 0.5rem; }
|
||||||
border: none;
|
.cancel-lock-hint { font-size: 0.75rem; color: #fa5252; font-weight: 500; }
|
||||||
color: var(--color-text-muted);
|
.quiz-empty { text-align: center; margin: auto; color: var(--color-text-muted); }
|
||||||
text-decoration: underline;
|
@media (max-width: 600px) { .card-content { font-size: 1.1rem; } .option-btn { padding: 0.75rem; font-size: 0.95rem; } }
|
||||||
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -199,12 +199,13 @@ export class LessonManager {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async completeLesson() {
|
async completeLesson(status = 'completed') {
|
||||||
if (this.lessonCompleted) return;
|
if (this.lessonCompleted && status === 'completed') return;
|
||||||
this.showCelebration = true;
|
this.showCelebration = true;
|
||||||
|
|
||||||
if (get(authLoggedIn)) {
|
if (get(authLoggedIn)) {
|
||||||
const lessonName = this.slug.replace('.md', '');
|
const lessonName = this.slug.replace('.md', '');
|
||||||
await trackProgress(get(auth).token, lessonName);
|
await trackProgress(auth.token, lessonName, status);
|
||||||
this.lessonCompleted = true;
|
this.lessonCompleted = true;
|
||||||
lessonContext.update(ctx => ctx ? { ...ctx, completed: true } : ctx);
|
lessonContext.update(ctx => ctx ? { ...ctx, completed: true } : ctx);
|
||||||
}
|
}
|
||||||
|
|
@ -298,7 +299,7 @@ export class LessonManager {
|
||||||
this.activeTab = 'output';
|
this.activeTab = 'output';
|
||||||
try {
|
try {
|
||||||
const code = (this.currentLanguage === lang) ? (this.editor?.getCode() ?? this.currentCode) : (lang === 'c' ? this.cCode : this.pythonCode);
|
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) {
|
if (!res.success) {
|
||||||
Object.assign(out, { error: res.error || 'Compilation failed', success: false });
|
Object.assign(out, { error: res.error || 'Compilation failed', success: false });
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
await loadData();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
if (!$authIsTeacher) {
|
if (!$authIsTeacher) {
|
||||||
loading = false;
|
loading = false;
|
||||||
return;
|
return;
|
||||||
|
|
@ -33,7 +37,33 @@
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
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);
|
const totalLessons = $derived(lessons.length);
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -78,11 +108,25 @@
|
||||||
{@const key = lesson.filename.replace('.md', '')}
|
{@const key = lesson.filename.replace('.md', '')}
|
||||||
{@const status = student[key]}
|
{@const status = student[key]}
|
||||||
<td class="status-cell">
|
<td class="status-cell">
|
||||||
{#if status === 'completed'}
|
<div class="cell-content">
|
||||||
<span class="badge done">✓</span>
|
{#if status === 'completed'}
|
||||||
{:else}
|
<span class="badge done">✓</span>
|
||||||
<span class="badge empty">—</span>
|
{:else if status && status !== 'not_started'}
|
||||||
{/if}
|
<span class="badge score">{status}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="badge empty">—</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if status && status !== 'not_started'}
|
||||||
|
<button
|
||||||
|
class="btn-reset-mini"
|
||||||
|
onclick={() => handleReset(student.token as string, key, student.nama_siswa)}
|
||||||
|
title="Reset Progres"
|
||||||
|
>
|
||||||
|
↻
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{/each}
|
{/each}
|
||||||
<td class="completion-count">
|
<td class="completion-count">
|
||||||
|
|
@ -172,9 +216,36 @@
|
||||||
color: var(--color-success);
|
color: var(--color-success);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
.badge.score {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
.badge.empty {
|
.badge.empty {
|
||||||
color: var(--color-text-muted);
|
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 {
|
.completion-count {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
min-width: 60px;
|
min-width: 60px;
|
||||||
|
|
|
||||||
|
|
@ -108,10 +108,13 @@ def api_lesson(filename):
|
||||||
token = request.args.get('token', '') or request.cookies.get('student_token', '')
|
token = request.args.get('token', '') or request.cookies.get('student_token', '')
|
||||||
progress = None
|
progress = None
|
||||||
lesson_completed = False
|
lesson_completed = False
|
||||||
|
lesson_progress_status = ''
|
||||||
if token:
|
if token:
|
||||||
progress = get_student_progress(token)
|
progress = get_student_progress(token)
|
||||||
if progress and full_filename.replace('.md', '') in progress:
|
if progress:
|
||||||
lesson_completed = progress[full_filename.replace('.md', '')] == 'completed'
|
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)
|
all_lessons = get_ordered_lessons_with_learning_objectives(progress)
|
||||||
|
|
||||||
|
|
@ -194,6 +197,7 @@ def api_lesson(filename):
|
||||||
'key_text_circuit': key_text_circuit,
|
'key_text_circuit': key_text_circuit,
|
||||||
'active_tabs': active_tabs,
|
'active_tabs': active_tabs,
|
||||||
'quiz_data': quiz_data,
|
'quiz_data': quiz_data,
|
||||||
|
'lesson_progress_status': lesson_progress_status,
|
||||||
'lesson_title': full_filename.replace('.md', '').replace('_', ' ').title(),
|
'lesson_title': full_filename.replace('.md', '').replace('_', ' ').title(),
|
||||||
'lesson_completed': lesson_completed,
|
'lesson_completed': lesson_completed,
|
||||||
'locked': is_locked,
|
'locked': is_locked,
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,35 @@ def track_progress():
|
||||||
return jsonify({'success': False, 'message': f'Error tracking progress: {e}'})
|
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')
|
@progress_bp.route('/progress-report.json')
|
||||||
def api_progress_report():
|
def api_progress_report():
|
||||||
"""Return progress report data as JSON."""
|
"""Return progress report data as JSON."""
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,8 @@ def get_ordered_lessons_with_learning_objectives(progress=None):
|
||||||
def _add_completion_and_prereqs(lesson, progress):
|
def _add_completion_and_prereqs(lesson, progress):
|
||||||
slug = lesson['filename'].replace('.md', '')
|
slug = lesson['filename'].replace('.md', '')
|
||||||
if progress:
|
if progress:
|
||||||
lesson['completed'] = progress.get(slug) == 'completed'
|
status = progress.get(slug, '')
|
||||||
|
lesson['completed'] = status not in (None, '', 'not_started')
|
||||||
else:
|
else:
|
||||||
lesson['completed'] = False
|
lesson['completed'] = False
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,8 @@ def calculate_student_completion(student_data, all_lessons):
|
||||||
else:
|
else:
|
||||||
lesson_key = lesson.replace('.md', '')
|
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
|
completed_count += 1
|
||||||
return completed_count
|
return completed_count
|
||||||
|
|
||||||
|
|
@ -204,11 +205,7 @@ def get_all_students_progress(all_lessons_func):
|
||||||
})
|
})
|
||||||
|
|
||||||
for row in tokens.values():
|
for row in tokens.values():
|
||||||
student_data = dict(row)
|
student_data_copy = 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['completed_count'] = calculate_student_completion(student_data_copy, ordered_lessons)
|
student_data_copy['completed_count'] = calculate_student_completion(student_data_copy, ordered_lessons)
|
||||||
all_students_progress.append(student_data_copy)
|
all_students_progress.append(student_data_copy)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue