feat: Implement quiz attempt tracking and limits with persistence, and add background transcription for lessons.

This commit is contained in:
2026-01-16 16:29:15 -03:00
parent 42976236b3
commit 55aede97ed
4 changed files with 90 additions and 11 deletions
+10 -2
View File
@@ -594,6 +594,14 @@ pub async fn process_transcription(
) )
.await; .await;
// 4. Spawn background task
let pool_clone = pool.clone();
tokio::spawn(async move {
if let Err(e) = run_transcription_task(pool_clone, id).await {
tracing::error!("Transcription task failed for lesson {}: {}", id, e);
}
});
Ok(Json(updated_lesson)) Ok(Json(updated_lesson))
} }
@@ -1842,7 +1850,7 @@ pub async fn delete_module(
return Err(StatusCode::NOT_FOUND); return Err(StatusCode::NOT_FOUND);
} }
Ok(StatusCode::OK) Ok(StatusCode::NO_CONTENT)
} }
pub async fn delete_lesson( pub async fn delete_lesson(
@@ -1897,7 +1905,7 @@ pub async fn delete_lesson(
return Err(StatusCode::NOT_FOUND); return Err(StatusCode::NOT_FOUND);
} }
Ok(StatusCode::OK) Ok(StatusCode::NO_CONTENT)
} }
// User Management // User Management
@@ -189,7 +189,40 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
/> />
)} )}
{block.type === 'quiz' && ( {block.type === 'quiz' && (
<QuizPlayer id={block.id} title={block.title} quizData={block.quiz_data || { questions: [] }} allowRetry={lesson.allow_retry} /> <QuizPlayer
id={block.id}
title={block.title}
quizData={block.quiz_data || { questions: [] }}
allowRetry={lesson.allow_retry}
maxAttempts={lesson.max_attempts || undefined}
initialAttempts={
userGrade?.metadata?.block_attempts
? (userGrade.metadata.block_attempts as Record<string, number>)[block.id] || 0
: 0
}
onAttempt={async () => {
if (user) {
const currentAttempts = (userGrade?.metadata?.block_attempts as Record<string, number>) || {};
const newAttempts = {
...currentAttempts,
[block.id]: (currentAttempts[block.id] || 0) + 1
};
try {
const res = await lmsApi.submitScore(
user.id,
params.id,
params.lessonId,
userGrade?.score || 0,
{ ...userGrade?.metadata, block_attempts: newAttempts }
);
setUserGrade(res);
} catch (err) {
console.error("Failed to persist block attempts", err);
}
}
}}
/>
)} )}
{block.type === 'fill-in-the-blanks' && ( {block.type === 'fill-in-the-blanks' && (
<FillInTheBlanksPlayer id={block.id} title={block.title} content={block.content || ""} allowRetry={lesson.allow_retry} /> <FillInTheBlanksPlayer id={block.id} title={block.title} content={block.content || ""} allowRetry={lesson.allow_retry} />
@@ -21,6 +21,12 @@ export default function MediaPlayer({ id, title, url, media_type, config, initia
const [hasStarted, setHasStarted] = useState(false); const [hasStarted, setHasStarted] = useState(false);
const [locked, setLocked] = useState(false); const [locked, setLocked] = useState(false);
useEffect(() => {
if (initialPlayCount !== undefined) {
setPlayCount(initialPlayCount);
}
}, [initialPlayCount]);
const maxPlays = config?.maxPlays || 0; const maxPlays = config?.maxPlays || 0;
const CMS_API_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001"; const CMS_API_URL = process.env.NEXT_PUBLIC_CMS_API_URL || "http://localhost:3001";
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect } from "react";
interface QuizQuestion { interface QuizQuestion {
id: string; id: string;
@@ -17,14 +17,25 @@ interface QuizPlayerProps {
questions: QuizQuestion[]; questions: QuizQuestion[];
}; };
allowRetry?: boolean; allowRetry?: boolean;
maxAttempts?: number;
initialAttempts?: number;
onAttempt?: () => void;
} }
export default function QuizPlayer({ id, title, quizData, allowRetry = true }: QuizPlayerProps) { export default function QuizPlayer({ id, title, quizData, allowRetry = true, maxAttempts = 0, initialAttempts = 0, onAttempt }: QuizPlayerProps) {
const [userAnswers, setUserAnswers] = useState<Record<string, number[]>>({}); const [userAnswers, setUserAnswers] = useState<Record<string, number[]>>({});
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const [attempts, setAttempts] = useState(initialAttempts || 0);
const questions = quizData?.questions || []; const questions = quizData?.questions || [];
// Sync attempts with prop
useEffect(() => {
if (initialAttempts !== undefined) {
setAttempts(initialAttempts);
}
}, [initialAttempts]);
const handleAnswer = (qId: string, optionIndex: number, isMulti: boolean) => { const handleAnswer = (qId: string, optionIndex: number, isMulti: boolean) => {
if (submitted) return; if (submitted) return;
setUserAnswers(prev => { setUserAnswers(prev => {
@@ -40,12 +51,27 @@ export default function QuizPlayer({ id, title, quizData, allowRetry = true }: Q
}); });
}; };
const handleValidate = () => {
if (maxAttempts > 0 && attempts >= maxAttempts) return;
setSubmitted(true);
if (onAttempt) {
onAttempt();
setAttempts(prev => prev + 1);
}
};
return ( return (
<div className="space-y-8" id={id}> <div className="space-y-8" id={id}>
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white uppercase tracking-widest text-[10px]"> <h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white uppercase tracking-widest text-[10px]">
{title || "Knowledge Check"} {title || "Knowledge Check"}
</h3> </h3>
{maxAttempts > 0 && (
<span className="text-[10px] font-bold uppercase tracking-widest px-3 py-1 rounded-full bg-white/5 border border-white/5 text-gray-500">
Attempt {attempts} / {maxAttempts}
</span>
)}
</div> </div>
<div className="space-y-8"> <div className="space-y-8">
@@ -93,18 +119,24 @@ export default function QuizPlayer({ id, title, quizData, allowRetry = true }: Q
<> <>
{!submitted && questions.length > 0 && ( {!submitted && questions.length > 0 && (
<button <button
onClick={() => setSubmitted(true)} onClick={handleValidate}
className="btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20" disabled={maxAttempts > 0 && attempts >= maxAttempts}
className={`btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20 ${maxAttempts > 0 && attempts >= maxAttempts ? 'opacity-50 cursor-not-allowed' : ''}`}
> >
Validate Answers {maxAttempts > 0 && attempts >= maxAttempts ? 'Max Attempts Reached' : 'Validate Answers'}
</button> </button>
)} )}
{submitted && ( {submitted && (
<button <button
onClick={() => { setSubmitted(false); setUserAnswers({}); }} onClick={() => {
className="w-full py-5 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-3xl border-white/5" if (maxAttempts > 0 && attempts >= maxAttempts) return;
setSubmitted(false);
setUserAnswers({});
}}
disabled={maxAttempts > 0 && attempts >= maxAttempts}
className={`w-full py-5 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-3xl border-white/5 ${maxAttempts > 0 && attempts >= maxAttempts ? 'opacity-50 cursor-not-allowed' : ''}`}
> >
Try Again {maxAttempts > 0 && attempts >= maxAttempts ? 'Max Attempts Reached' : 'Try Again'}
</button> </button>
)} )}
</> </>