feat: Implement quiz attempt tracking and limits with persistence, and add background transcription for lessons.
This commit is contained in:
@@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user