feat: Implement lesson attempt tracking, retry functionality, and max attempt limits for interactive blocks.

This commit is contained in:
2025-12-22 15:47:18 -03:00
parent 32f71852d9
commit f592f78b6c
16 changed files with 325 additions and 178 deletions
@@ -0,0 +1,3 @@
-- Add attempt limits and retry configuration to lessons
ALTER TABLE lessons ADD COLUMN max_attempts INTEGER;
ALTER TABLE lessons ADD COLUMN allow_retry BOOLEAN NOT NULL DEFAULT TRUE;
+18 -3
View File
@@ -171,9 +171,12 @@ pub async fn create_lesson(
let is_graded = payload.get("is_graded").and_then(|v| v.as_bool()).unwrap_or(false); let is_graded = payload.get("is_graded").and_then(|v| v.as_bool()).unwrap_or(false);
let grading_category_id = payload.get("grading_category_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok()); let grading_category_id = payload.get("grading_category_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok());
let max_attempts = payload.get("max_attempts").and_then(|v| v.as_i64()).map(|v| v as i32);
let allow_retry = payload.get("allow_retry").and_then(|v| v.as_bool()).unwrap_or(true);
let lesson = sqlx::query_as::<_, Lesson>( let lesson = sqlx::query_as::<_, Lesson>(
"INSERT INTO lessons (module_id, title, content_type, content_url, position, transcription, metadata, is_graded, grading_category_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *" "INSERT INTO lessons (module_id, title, content_type, content_url, position, transcription, metadata, is_graded, grading_category_id, max_attempts, allow_retry)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *"
) )
.bind(module_id) .bind(module_id)
.bind(title) .bind(title)
@@ -184,6 +187,8 @@ pub async fn create_lesson(
.bind(metadata) .bind(metadata)
.bind(is_graded) .bind(is_graded)
.bind(grading_category_id) .bind(grading_category_id)
.bind(max_attempts)
.bind(allow_retry)
.fetch_one(&pool) .fetch_one(&pool)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -261,6 +266,10 @@ pub async fn update_lesson(
} }
}); });
let max_attempts = payload.get("max_attempts").and_then(|v| v.as_i64()).map(|v| v as i32);
let allow_retry = payload.get("allow_retry").and_then(|v| v.as_bool());
let metadata = payload.get("metadata");
let updated_lesson = sqlx::query_as::<_, Lesson>( let updated_lesson = sqlx::query_as::<_, Lesson>(
"UPDATE lessons "UPDATE lessons
SET title = COALESCE($1, title), SET title = COALESCE($1, title),
@@ -268,8 +277,11 @@ pub async fn update_lesson(
content_url = COALESCE($3, content_url), content_url = COALESCE($3, content_url),
position = COALESCE($4, position), position = COALESCE($4, position),
is_graded = COALESCE($5, is_graded), is_graded = COALESCE($5, is_graded),
grading_category_id = CASE WHEN $6 = 'SET_NULL' THEN NULL WHEN $7::UUID IS NOT NULL THEN $7 ELSE grading_category_id END grading_category_id = CASE WHEN $6 = 'SET_NULL' THEN NULL WHEN $7::UUID IS NOT NULL THEN $7 ELSE grading_category_id END,
WHERE id = $8 RETURNING *" metadata = COALESCE($8, metadata),
max_attempts = COALESCE($9, max_attempts),
allow_retry = COALESCE($10, allow_retry)
WHERE id = $11 RETURNING *"
) )
.bind(title) .bind(title)
.bind(content_type) .bind(content_type)
@@ -278,6 +290,9 @@ pub async fn update_lesson(
.bind(is_graded) .bind(is_graded)
.bind(if payload.get("grading_category_id").map(|v| v.is_null()).unwrap_or(false) { "SET_NULL" } else { "" }) .bind(if payload.get("grading_category_id").map(|v| v.is_null()).unwrap_or(false) { "SET_NULL" } else { "" })
.bind(payload.get("grading_category_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok())) .bind(payload.get("grading_category_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok()))
.bind(metadata)
.bind(max_attempts)
.bind(allow_retry)
.bind(id) .bind(id)
.fetch_one(&pool) .fetch_one(&pool)
.await .await
@@ -0,0 +1,6 @@
-- Add attempt limits and retry configuration to lessons
ALTER TABLE lessons ADD COLUMN max_attempts INTEGER;
ALTER TABLE lessons ADD COLUMN allow_retry BOOLEAN NOT NULL DEFAULT TRUE;
-- Track attempt count in user_grades
ALTER TABLE user_grades ADD COLUMN attempts_count INTEGER NOT NULL DEFAULT 1;
+37 -4
View File
@@ -199,8 +199,8 @@ pub async fn ingest_course(
for lesson in pub_module.lessons { for lesson in pub_module.lessons {
sqlx::query( sqlx::query(
"INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id) "INSERT INTO lessons (id, module_id, title, content_type, content_url, transcription, metadata, position, created_at, is_graded, grading_category_id, max_attempts, allow_retry)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)" VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)"
) )
.bind(lesson.id) .bind(lesson.id)
.bind(pub_module.module.id) .bind(pub_module.module.id)
@@ -213,6 +213,8 @@ pub async fn ingest_course(
.bind(lesson.created_at) .bind(lesson.created_at)
.bind(lesson.is_graded) .bind(lesson.is_graded)
.bind(lesson.grading_category_id) .bind(lesson.grading_category_id)
.bind(lesson.max_attempts)
.bind(lesson.allow_retry)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|e| { .map_err(|e| {
@@ -306,12 +308,43 @@ pub async fn submit_lesson_score(
State(pool): State<PgPool>, State(pool): State<PgPool>,
Json(payload): Json<GradeSubmissionPayload>, Json(payload): Json<GradeSubmissionPayload>,
) -> Result<Json<common::models::UserGrade>, (StatusCode, String)> { ) -> Result<Json<common::models::UserGrade>, (StatusCode, String)> {
// 1. Get lesson attempt rules
let max_attempts: Option<Option<i32>> = sqlx::query_scalar("SELECT max_attempts FROM lessons WHERE id = $1")
.bind(payload.lesson_id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if max_attempts.is_none() {
return Err((StatusCode::NOT_FOUND, "Lesson not found".into()));
}
let max_attempts = max_attempts.flatten();
// 2. Check existing grade/attempts
let existing_attempts: Option<i32> = sqlx::query_scalar("SELECT attempts_count FROM user_grades WHERE user_id = $1 AND lesson_id = $2")
.bind(payload.user_id)
.bind(payload.lesson_id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if let Some(count) = existing_attempts {
if let Some(max) = max_attempts {
if count >= max {
tracing::warn!("User {} attempted to resubmit lesson {} but reached max_attempts ({})", payload.user_id, payload.lesson_id, max);
return Err((StatusCode::FORBIDDEN, "Maximum attempts reached for this assessment".into()));
}
}
}
// 3. Upsert with increment
let grade = sqlx::query_as::<_, common::models::UserGrade>( let grade = sqlx::query_as::<_, common::models::UserGrade>(
"INSERT INTO user_grades (user_id, course_id, lesson_id, score, metadata) "INSERT INTO user_grades (user_id, course_id, lesson_id, score, metadata, attempts_count)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, 1)
ON CONFLICT (user_id, lesson_id) DO UPDATE SET ON CONFLICT (user_id, lesson_id) DO UPDATE SET
score = EXCLUDED.score, score = EXCLUDED.score,
metadata = EXCLUDED.metadata, metadata = EXCLUDED.metadata,
attempts_count = user_grades.attempts_count + 1,
created_at = CURRENT_TIMESTAMP created_at = CURRENT_TIMESTAMP
RETURNING *" RETURNING *"
) )
+3
View File
@@ -34,6 +34,8 @@ pub struct Lesson {
pub metadata: Option<serde_json::Value>, pub metadata: Option<serde_json::Value>,
pub grading_category_id: Option<Uuid>, pub grading_category_id: Option<Uuid>,
pub is_graded: bool, pub is_graded: bool,
pub max_attempts: Option<i32>,
pub allow_retry: bool,
pub position: i32, pub position: i32,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
@@ -55,6 +57,7 @@ pub struct UserGrade {
pub course_id: Uuid, pub course_id: Uuid,
pub lesson_id: Uuid, pub lesson_id: Uuid,
pub score: f32, // 0.0 to 1.0 pub score: f32, // 0.0 to 1.0
pub attempts_count: i32,
pub metadata: Option<serde_json::Value>, pub metadata: Option<serde_json::Value>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { lmsApi, Lesson, Course, Module } from "@/lib/api"; import { lmsApi, Lesson, Course, Module } from "@/lib/api";
import Link from "next/link"; import Link from "next/link";
import { ChevronLeft, ChevronRight, Menu, CheckCircle2 } from "lucide-react"; import { ChevronLeft, ChevronRight, Menu, CheckCircle2 } from "lucide-react";
import { useAuth } from "@/context/AuthContext";
import DescriptionPlayer from "@/components/blocks/DescriptionPlayer"; import DescriptionPlayer from "@/components/blocks/DescriptionPlayer";
import MediaPlayer from "@/components/blocks/MediaPlayer"; import MediaPlayer from "@/components/blocks/MediaPlayer";
@@ -18,6 +19,8 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
const [course, setCourse] = useState<(Course & { modules: Module[] }) | null>(null); const [course, setCourse] = useState<(Course & { modules: Module[] }) | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
const [userGrade, setUserGrade] = useState<any | null>(null);
const { user } = useAuth();
useEffect(() => { useEffect(() => {
const fetchAll = async () => { const fetchAll = async () => {
@@ -28,8 +31,14 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
]); ]);
setLesson(lessonData); setLesson(lessonData);
setCourse(courseData); setCourse(courseData);
if (user) {
const grades = await lmsApi.getUserGrades(user.id, params.id);
const currentGrade = grades.find((g: any) => g.lesson_id === params.lessonId);
setUserGrade(currentGrade || null);
}
} catch (err) { } catch (err) {
console.error(err); console.error("Failed to load lesson data", err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -116,16 +125,16 @@ 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: [] }} /> <QuizPlayer id={block.id} title={block.title} quizData={block.quiz_data || { questions: [] }} allowRetry={lesson.allow_retry} />
)} )}
{block.type === 'fill-in-the-blanks' && ( {block.type === 'fill-in-the-blanks' && (
<FillInTheBlanksPlayer id={block.id} title={block.title} content={block.content || ""} /> <FillInTheBlanksPlayer id={block.id} title={block.title} content={block.content || ""} allowRetry={lesson.allow_retry} />
)} )}
{block.type === 'matching' && ( {block.type === 'matching' && (
<MatchingPlayer id={block.id} title={block.title} pairs={block.pairs || []} /> <MatchingPlayer id={block.id} title={block.title} pairs={block.pairs || []} allowRetry={lesson.allow_retry} />
)} )}
{block.type === 'ordering' && ( {block.type === 'ordering' && (
<OrderingPlayer id={block.id} title={block.title} items={block.items || []} /> <OrderingPlayer id={block.id} title={block.title} items={block.items || []} allowRetry={lesson.allow_retry} />
)} )}
{block.type === 'short-answer' && ( {block.type === 'short-answer' && (
<ShortAnswerPlayer <ShortAnswerPlayer
@@ -133,6 +142,7 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
title={block.title} title={block.title}
prompt={block.prompt || ""} prompt={block.prompt || ""}
correctAnswers={block.correctAnswers || []} correctAnswers={block.correctAnswers || []}
allowRetry={lesson.allow_retry}
/> />
)} )}
</div> </div>
@@ -146,41 +156,53 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
{lesson.is_graded && ( {lesson.is_graded && (
<div className="pt-20 border-t border-white/5 animate-in fade-in slide-in-from-bottom-8 duration-1000"> <div className="pt-20 border-t border-white/5 animate-in fade-in slide-in-from-bottom-8 duration-1000">
<div className="bg-blue-600/10 border border-blue-500/20 rounded-[2rem] p-12 text-center relative overflow-hidden group"> {userGrade && lesson.max_attempts && userGrade.attempts_count >= lesson.max_attempts ? (
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-500/10 blur-3xl rounded-full -translate-y-1/2 translate-x-1/2 group-hover:bg-blue-500/20 transition-all duration-700"></div> <div className="space-y-4">
<h3 className="text-2xl font-black mb-4">Complete & Submit</h3> <div className="inline-flex items-center gap-2 px-6 py-2 bg-amber-500/10 border border-amber-500/30 text-amber-400 rounded-full text-xs font-black uppercase tracking-widest">
<p className="text-gray-400 max-w-sm mx-auto mb-8 text-sm"> Locked: Maximum attempts reached ({lesson.max_attempts})
This activity is graded. Make sure you&apos;ve completed all blocks before submitting your work for evaluation. </div>
</p> <div className="text-4xl font-black text-white">
Score: <span className="text-blue-500">{userGrade.score * 100}%</span>
<button </div>
onClick={async () => { <p className="text-gray-500 text-xs italic">This assessment is now closed for further submissions.</p>
const userData = localStorage.getItem("user"); </div>
if (userData) { ) : (
const u = JSON.parse(userData); <>
try { <button
// In a real scenario, we'd calculate the actual score from blocks onClick={async () => {
await lmsApi.submitScore(u.id, params.id, params.lessonId, 100); if (user) {
alert("Score submitted successfully!"); try {
} catch (err) { // In a real scenario, we'd calculate the actual score from blocks
console.error("Submission failed", err); const res = await lmsApi.submitScore(user.id, params.id, params.lessonId, 1.0);
setUserGrade(res);
alert("Score submitted successfully!");
} catch (err) {
console.error("Submission failed", err);
alert("Failed to submit score. Please try again.");
}
} }
} }}
}} className="btn-premium px-12 py-4 rounded-2xl shadow-blue-500/40 shadow-xl group/btn"
className="btn-premium px-12 py-4 rounded-2xl shadow-blue-500/40 shadow-xl group/btn" >
> <span className="flex items-center gap-2 font-black italic">
<span className="flex items-center gap-2 font-black italic"> {userGrade ? `SUBMIT ATTEMPT ${userGrade.attempts_count + 1}` : 'SUBMIT FOR GRADING'}
SUBMIT FOR GRADING <CheckCircle2 className="w-5 h-5 group-hover/btn:scale-110 transition-transform" /> <CheckCircle2 className="w-5 h-5 group-hover/btn:scale-110 transition-transform" />
</span> </span>
</button> </button>
</div> {lesson.max_attempts && (
<p className="mt-4 text-[10px] text-gray-500 font-bold uppercase tracking-widest">
Attempt {userGrade ? userGrade.attempts_count : 0} of {lesson.max_attempts} used
</p>
)}
</>
)}
</div> </div>
)} )}
</div> </div>
</div> </div>
{/* Footer Controls */} {/* Footer Controls */}
<footer className="h-20 glass border-t border-white/5 px-6 flex items-center justify-between bg-black/60 backdrop-blur-3xl"> <footer className="h-20 glass border-t border-white/5 px-6 flex items-center justify-between bg-black/60 backdrop-blur-3xl shrink-0">
{prevLesson ? ( {prevLesson ? (
<Link href={`/courses/${params.id}/lessons/${prevLesson.id}`} className="group flex items-center gap-3"> <Link href={`/courses/${params.id}/lessons/${prevLesson.id}`} className="group flex items-center gap-3">
<div className="w-10 h-10 rounded-xl glass border-white/10 flex items-center justify-center group-hover:bg-white/5 transition-all text-gray-400 group-hover:text-white"> <div className="w-10 h-10 rounded-xl glass border-white/10 flex items-center justify-center group-hover:bg-white/5 transition-all text-gray-400 group-hover:text-white">
@@ -3,6 +3,7 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { lmsApi, GradingCategory, UserGrade, Course, Module } from "@/lib/api"; import { lmsApi, GradingCategory, UserGrade, Course, Module } from "@/lib/api";
import { useAuth } from "@/context/AuthContext";
import { import {
Award, Award,
BarChart3, BarChart3,
@@ -26,17 +27,15 @@ export default function StudentProgressPage() {
const [course, setCourse] = useState<(Course & { modules: Module[], grading_categories?: GradingCategory[] }) | null>(null); const [course, setCourse] = useState<(Course & { modules: Module[], grading_categories?: GradingCategory[] }) | null>(null);
const [userGrades, setUserGrades] = useState<UserGrade[]>([]); const [userGrades, setUserGrades] = useState<UserGrade[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [user, setUser] = useState<any>(null); const { user } = useAuth();
const loadData = React.useCallback(async () => { const loadData = React.useCallback(async () => {
try { try {
const courseData = await lmsApi.getCourseOutline(id); const courseData = await lmsApi.getCourseOutline(id);
setCourse(courseData as any); setCourse(courseData);
const userData = localStorage.getItem("user"); if (user) {
if (userData) { const grades = await lmsApi.getUserGrades(user.id, id);
const u = JSON.parse(userData);
const grades = await lmsApi.getUserGrades(u.id, id);
setUserGrades(grades); setUserGrades(grades);
} }
} catch (err) { } catch (err) {
@@ -44,13 +43,11 @@ export default function StudentProgressPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [id]); }, [id, user]);
useEffect(() => { useEffect(() => {
const userData = localStorage.getItem("user");
if (userData) setUser(JSON.parse(userData));
loadData(); loadData();
}, [id, loadData]); }, [loadData]);
if (loading) return ( if (loading) return (
<div className="min-h-screen bg-slate-950 flex items-center justify-center"> <div className="min-h-screen bg-slate-950 flex items-center justify-center">
@@ -70,7 +67,7 @@ export default function StudentProgressPage() {
const count = catLessons.length; const count = catLessons.length;
const completedCount = catGrades.length; const completedCount = catGrades.length;
const avgScore = completedCount > 0 const avgScore = completedCount > 0
? catGrades.reduce((sum, g) => sum + g.score, 0) / completedCount ? (catGrades.reduce((sum, g) => sum + g.score, 0) / completedCount) * 100
: 0; : 0;
const weightedScore = (avgScore * cat.weight) / 100; const weightedScore = (avgScore * cat.weight) / 100;
@@ -6,9 +6,10 @@ interface FillInTheBlanksPlayerProps {
id: string; id: string;
title?: string; title?: string;
content: string; content: string;
allowRetry?: boolean;
} }
export default function FillInTheBlanksPlayer({ id, title, content }: FillInTheBlanksPlayerProps) { export default function FillInTheBlanksPlayer({ id, title, content, allowRetry = true }: FillInTheBlanksPlayerProps) {
const [userAnswers, setUserAnswers] = useState<string[]>([]); const [userAnswers, setUserAnswers] = useState<string[]>([]);
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
@@ -66,8 +67,8 @@ export default function FillInTheBlanksPlayer({ id, title, content }: FillInTheB
}} }}
disabled={submitted} disabled={submitted}
className={`mx-1 px-2 py-0 border-b-2 bg-transparent transition-all focus:outline-none text-center rounded-t-sm ${submitted className={`mx-1 px-2 py-0 border-b-2 bg-transparent transition-all focus:outline-none text-center rounded-t-sm ${submitted
? (isCorrect(part.index!) ? "border-green-500 text-green-400 bg-green-500/10" : "border-red-500 text-red-100 bg-red-500/10") ? (isCorrect(part.index!) ? "border-green-500 text-green-400 bg-green-500/10" : "border-red-500 text-red-100 bg-red-500/10")
: "border-blue-500/30 focus:border-blue-500 text-blue-400 focus:bg-blue-500/5" : "border-blue-500/30 focus:border-blue-500 text-blue-400 focus:bg-blue-500/5"
}`} }`}
style={{ width: `${Math.max((part.answer?.length || 5) * 12, 60)}px` }} style={{ width: `${Math.max((part.answer?.length || 5) * 12, 60)}px` }}
placeholder="..." placeholder="..."
@@ -76,22 +77,26 @@ export default function FillInTheBlanksPlayer({ id, title, content }: FillInTheB
))} ))}
</div> </div>
{!submitted && parsed.answers.length > 0 && ( {allowRetry && (
<button <>
onClick={() => setSubmitted(true)} {!submitted && parsed.answers.length > 0 && (
className="btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20" <button
> onClick={() => setSubmitted(true)}
Validate Answers className="btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20"
</button> >
)} Validate Answers
</button>
)}
{submitted && ( {submitted && (
<button <button
onClick={handleReset} onClick={handleReset}
className="w-full py-5 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-2xl border-white/5" 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"
> >
Try Again Try Again
</button> </button>
)}
</>
)} )}
</div> </div>
</div> </div>
@@ -11,9 +11,10 @@ interface MatchingPlayerProps {
id: string; id: string;
title?: string; title?: string;
pairs: MatchingPair[]; pairs: MatchingPair[];
allowRetry?: boolean;
} }
export default function MatchingPlayer({ id, title, pairs }: MatchingPlayerProps) { export default function MatchingPlayer({ id, title, pairs, allowRetry = true }: MatchingPlayerProps) {
const [selectedLeft, setSelectedLeft] = useState<number | null>(null); const [selectedLeft, setSelectedLeft] = useState<number | null>(null);
const [matches, setMatches] = useState<Record<number, number>>({}); const [matches, setMatches] = useState<Record<number, number>>({});
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
@@ -52,8 +53,8 @@ export default function MatchingPlayer({ id, title, pairs }: MatchingPlayerProps
key={i} key={i}
onClick={() => !submitted && setSelectedLeft(i)} onClick={() => !submitted && setSelectedLeft(i)}
className={`w-full p-4 rounded-xl border text-left text-sm font-bold transition-all ${selectedLeft === i ? "border-blue-500 bg-blue-500/10 text-white shadow-lg" : className={`w-full p-4 rounded-xl border text-left text-sm font-bold transition-all ${selectedLeft === i ? "border-blue-500 bg-blue-500/10 text-white shadow-lg" :
matches[i] !== undefined ? "border-blue-500/20 bg-blue-500/5 text-blue-400" : matches[i] !== undefined ? "border-blue-500/20 bg-blue-500/5 text-blue-400" :
"border-white/5 bg-white/5 text-gray-200 hover:border-white/20" "border-white/5 bg-white/5 text-gray-200 hover:border-white/20"
}`} }`}
> >
{pair.left} {pair.left}
@@ -90,24 +91,26 @@ export default function MatchingPlayer({ id, title, pairs }: MatchingPlayerProps
})} })}
</div> </div>
<div className="md:col-span-2 pt-8 border-t border-white/5"> {allowRetry && (
{!submitted && Object.keys(matches).length === (pairs || []).length && ( <div className="md:col-span-2 pt-8 border-t border-white/5">
<button {!submitted && Object.keys(matches).length === (pairs || []).length && (
onClick={() => setSubmitted(true)} <button
className="btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20" onClick={() => setSubmitted(true)}
> className="btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20"
Validate Matching >
</button> Validate Matching
)} </button>
{submitted && ( )}
<button {submitted && (
onClick={handleReset} <button
className="w-full py-5 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-2xl border-white/5" onClick={handleReset}
> className="w-full py-5 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-2xl border-white/5"
Try Again >
</button> Try Again
)} </button>
</div> )}
</div>
)}
</div> </div>
</div> </div>
); );
@@ -6,9 +6,10 @@ interface OrderingPlayerProps {
id: string; id: string;
title?: string; title?: string;
items: string[]; items: string[];
allowRetry?: boolean;
} }
export default function OrderingPlayer({ id, title, items }: OrderingPlayerProps) { export default function OrderingPlayer({ id, title, items, allowRetry = true }: OrderingPlayerProps) {
const [userOrder, setUserOrder] = useState<number[]>([]); const [userOrder, setUserOrder] = useState<number[]>([]);
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
@@ -53,7 +54,7 @@ export default function OrderingPlayer({ id, title, items }: OrderingPlayerProps
disabled={isPicked || submitted} disabled={isPicked || submitted}
onClick={() => handlePick(item.originalIdx)} onClick={() => handlePick(item.originalIdx)}
className={`px-6 py-3 rounded-full border text-sm font-bold transition-all ${isPicked ? "opacity-20 grayscale border-white/5 bg-white/5" : className={`px-6 py-3 rounded-full border text-sm font-bold transition-all ${isPicked ? "opacity-20 grayscale border-white/5 bg-white/5" :
"border-white/10 bg-white/5 text-gray-200 hover:border-blue-500/50 hover:bg-blue-500/5" "border-white/10 bg-white/5 text-gray-200 hover:border-blue-500/50 hover:bg-blue-500/5"
}`} }`}
> >
{item.value} {item.value}
@@ -76,8 +77,8 @@ export default function OrderingPlayer({ id, title, items }: OrderingPlayerProps
key={i} key={i}
onClick={() => !submitted && handlePick(idx)} onClick={() => !submitted && handlePick(idx)}
className={`flex items-center gap-4 p-4 rounded-xl border text-sm font-bold transition-all cursor-pointer ${isItemCorrect ? "border-green-500 bg-green-500/20 text-green-400" : className={`flex items-center gap-4 p-4 rounded-xl border text-sm font-bold transition-all cursor-pointer ${isItemCorrect ? "border-green-500 bg-green-500/20 text-green-400" :
isItemWrong ? "border-red-500 bg-red-500/20 text-red-100" : isItemWrong ? "border-red-500 bg-red-500/20 text-red-100" :
"border-blue-500/30 bg-blue-500/5 text-blue-400 hover:bg-blue-500/10" "border-blue-500/30 bg-blue-500/5 text-blue-400 hover:bg-blue-500/10"
}`} }`}
> >
<span className="opacity-50 text-xs">{i + 1}.</span> <span className="opacity-50 text-xs">{i + 1}.</span>
@@ -92,24 +93,26 @@ export default function OrderingPlayer({ id, title, items }: OrderingPlayerProps
</div> </div>
</div> </div>
<div className="pt-8 border-t border-white/5"> {allowRetry && (
{!submitted && userOrder.length === (items || []).length && ( <div className="pt-8 border-t border-white/5">
<button {!submitted && userOrder.length === (items || []).length && (
onClick={() => setSubmitted(true)} <button
className="btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20" onClick={() => setSubmitted(true)}
> className="btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20"
Validate Sequence >
</button> Validate Sequence
)} </button>
{submitted && ( )}
<button {submitted && (
onClick={handleReset} <button
className="w-full py-5 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-2xl border-white/5" onClick={handleReset}
> className="w-full py-5 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-2xl border-white/5"
Try Again >
</button> Try Again
)} </button>
</div> )}
</div>
)}
</div> </div>
</div> </div>
); );
@@ -16,9 +16,10 @@ interface QuizPlayerProps {
quizData: { quizData: {
questions: QuizQuestion[]; questions: QuizQuestion[];
}; };
allowRetry?: boolean;
} }
export default function QuizPlayer({ id, title, quizData }: QuizPlayerProps) { export default function QuizPlayer({ id, title, quizData, allowRetry = true }: 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);
@@ -88,21 +89,25 @@ export default function QuizPlayer({ id, title, quizData }: QuizPlayerProps) {
</div> </div>
))} ))}
{!submitted && questions.length > 0 && ( {allowRetry && (
<button <>
onClick={() => setSubmitted(true)} {!submitted && questions.length > 0 && (
className="btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20" <button
> onClick={() => setSubmitted(true)}
Validate Answers className="btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20"
</button> >
)} Validate Answers
{submitted && ( </button>
<button )}
onClick={() => { setSubmitted(false); setUserAnswers({}); }} {submitted && (
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" <button
> onClick={() => { setSubmitted(false); setUserAnswers({}); }}
Try Again 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"
</button> >
Try Again
</button>
)}
</>
)} )}
</div> </div>
</div> </div>
@@ -7,9 +7,10 @@ interface ShortAnswerPlayerProps {
title?: string; title?: string;
prompt: string; prompt: string;
correctAnswers: string[]; correctAnswers: string[];
allowRetry?: boolean;
} }
export default function ShortAnswerPlayer({ id, title, prompt, correctAnswers }: ShortAnswerPlayerProps) { export default function ShortAnswerPlayer({ id, title, prompt, correctAnswers, allowRetry = true }: ShortAnswerPlayerProps) {
const [userAnswer, setUserAnswer] = useState(""); const [userAnswer, setUserAnswer] = useState("");
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
@@ -38,8 +39,8 @@ export default function ShortAnswerPlayer({ id, title, prompt, correctAnswers }:
onChange={(e) => setUserAnswer(e.target.value)} onChange={(e) => setUserAnswer(e.target.value)}
disabled={submitted} disabled={submitted}
className={`w-full bg-white/5 border-2 rounded-2xl px-6 py-4 text-lg transition-all focus:outline-none ${submitted className={`w-full bg-white/5 border-2 rounded-2xl px-6 py-4 text-lg transition-all focus:outline-none ${submitted
? (isCorrect ? "border-green-500 bg-green-500/10 text-green-400" : "border-red-500 bg-red-500/10 text-red-100") ? (isCorrect ? "border-green-500 bg-green-500/10 text-green-400" : "border-red-500 bg-red-500/10 text-red-100")
: "border-white/10 focus:border-blue-500 text-white" : "border-white/10 focus:border-blue-500 text-white"
}`} }`}
placeholder="Type your answer..." placeholder="Type your answer..."
/> />
@@ -52,23 +53,27 @@ export default function ShortAnswerPlayer({ id, title, prompt, correctAnswers }:
)} )}
</div> </div>
{!submitted && ( {allowRetry && (
<button <>
onClick={() => setSubmitted(true)} {!submitted && (
disabled={!userAnswer.trim()} <button
className="btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20 disabled:opacity-50 disabled:grayscale" onClick={() => setSubmitted(true)}
> disabled={!userAnswer.trim()}
Submit Answer className="btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20 disabled:opacity-50 disabled:grayscale"
</button> >
)} Submit Answer
</button>
)}
{submitted && ( {submitted && (
<button <button
onClick={handleReset} onClick={handleReset}
className="w-full py-5 glass text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-white/5 transition-all rounded-2xl border-white/5" 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"
> >
Try Again Try Again
</button> </button>
)}
</>
)} )}
</div> </div>
</div> </div>
+6 -2
View File
@@ -43,7 +43,10 @@ export interface Lesson {
}; };
is_graded: boolean; is_graded: boolean;
grading_category_id: string | null; grading_category_id: string | null;
max_attempts: number | null;
allow_retry: boolean;
position: number; position: number;
created_at: string;
} }
export interface GradingCategory { export interface GradingCategory {
@@ -60,7 +63,8 @@ export interface UserGrade {
course_id: string; course_id: string;
lesson_id: string; lesson_id: string;
score: number; score: number;
metadata?: any; attempts_count: number;
metadata: any;
created_at: string; created_at: string;
} }
@@ -104,7 +108,7 @@ export const lmsApi = {
return response.json(); return response.json();
}, },
async getCourseOutline(courseId: string): Promise<Course & { modules: Module[] }> { async getCourseOutline(courseId: string): Promise<Course & { modules: Module[], grading_categories: GradingCategory[] }> {
const response = await fetch(`${API_BASE_URL}/courses/${courseId}/outline`); const response = await fetch(`${API_BASE_URL}/courses/${courseId}/outline`);
if (!response.ok) throw new Error('Failed to fetch course outline'); if (!response.ok) throw new Error('Failed to fetch course outline');
return response.json(); return response.json();
@@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { cmsApi, GradingCategory } from "@/lib/api"; import { cmsApi, GradingCategory } from "@/lib/api";
import { import {
@@ -29,11 +29,7 @@ export default function GradingPolicyPage() {
const [newWeight, setNewWeight] = useState<number>(0); const [newWeight, setNewWeight] = useState<number>(0);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
useEffect(() => { const loadCategories = useCallback(async () => {
loadCategories();
}, [id]);
async function loadCategories() {
try { try {
const data = await cmsApi.getGradingCategories(id); const data = await cmsApi.getGradingCategories(id);
setCategories(data); setCategories(data);
@@ -42,7 +38,11 @@ export default function GradingPolicyPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }, [id]);
useEffect(() => {
loadCategories();
}, [loadCategories]);
async function handleAdd() { async function handleAdd() {
if (!newName || newWeight <= 0) return; if (!newName || newWeight <= 0) return;
@@ -22,6 +22,8 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
const [gradingCategories, setGradingCategories] = useState<GradingCategory[]>([]); const [gradingCategories, setGradingCategories] = useState<GradingCategory[]>([]);
const [isGraded, setIsGraded] = useState(false); const [isGraded, setIsGraded] = useState(false);
const [selectedCategoryId, setSelectedCategoryId] = useState<string | "">(""); const [selectedCategoryId, setSelectedCategoryId] = useState<string | "">("");
const [maxAttempts, setMaxAttempts] = useState<number | null>(null);
const [allowRetry, setAllowRetry] = useState(true);
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
@@ -31,6 +33,8 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
setLesson(lessonData); setLesson(lessonData);
setIsGraded(lessonData.is_graded); setIsGraded(lessonData.is_graded);
setSelectedCategoryId(lessonData.grading_category_id || ""); setSelectedCategoryId(lessonData.grading_category_id || "");
setMaxAttempts(lessonData.max_attempts);
setAllowRetry(lessonData.allow_retry);
if (lessonData.metadata?.blocks) { if (lessonData.metadata?.blocks) {
setBlocks(lessonData.metadata.blocks); setBlocks(lessonData.metadata.blocks);
@@ -63,7 +67,9 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
const updated = await cmsApi.updateLesson(lesson.id, { const updated = await cmsApi.updateLesson(lesson.id, {
metadata: { ...lesson.metadata, blocks }, metadata: { ...lesson.metadata, blocks },
is_graded: isGraded, is_graded: isGraded,
grading_category_id: selectedCategoryId || null grading_category_id: selectedCategoryId || null,
max_attempts: maxAttempts,
allow_retry: allowRetry
}); });
setLesson(updated); setLesson(updated);
setEditMode(false); setEditMode(false);
@@ -161,28 +167,63 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
</div> </div>
{isGraded && ( {isGraded && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 animate-in zoom-in-95 duration-300"> <>
{gradingCategories.length === 0 ? ( <div className="grid grid-cols-2 md:grid-cols-4 gap-4 animate-in zoom-in-95 duration-300">
<div className="col-span-full py-4 text-center border border-dashed border-white/10 rounded-2xl text-xs text-gray-500 italic"> {gradingCategories.length === 0 ? (
No grading categories defined. <Link href={`/courses/${params.id}/grading`} className="text-blue-400 underline ml-1">Go to Grading Policy</Link> <div className="col-span-full py-4 text-center border border-dashed border-white/10 rounded-2xl text-xs text-gray-500 italic">
No grading categories defined. <Link href={`/courses/${params.id}/grading`} className="text-blue-400 underline ml-1">Go to Grading Policy</Link>
</div>
) : (
gradingCategories.map((cat) => (
<button
key={cat.id}
onClick={() => setSelectedCategoryId(cat.id)}
className={`p-4 rounded-2xl border transition-all text-left group ${selectedCategoryId === cat.id
? "bg-blue-500/10 border-blue-500 text-blue-400"
: "bg-white/5 border-white/10 text-gray-500 hover:border-white/20"
}`}
>
<div className="text-[10px] font-bold uppercase tracking-widest opacity-60 mb-1">Category</div>
<div className="font-bold truncate">{cat.name}</div>
<div className="text-xs mt-2 font-medium opacity-80">{cat.weight}% Weight</div>
</button>
))
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-6 border-t border-white/5 animate-in fade-in duration-500">
<div className="space-y-4">
<label className="block">
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2 block">Maximum Attempts</span>
<div className="flex items-center gap-3">
<input
type="number"
value={maxAttempts || ""}
onChange={(e) => setMaxAttempts(e.target.value ? parseInt(e.target.value) : null)}
placeholder="Unlimited"
className="bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-blue-500 transition-all w-32"
/>
<span className="text-xs text-gray-500">Leave empty for unlimited</span>
</div>
</label>
</div> </div>
) : (
gradingCategories.map((cat) => ( <div className="space-y-2">
<button <span className="text-[10px] font-black uppercase tracking-widest text-gray-500 block mb-2">After Submission</span>
key={cat.id} <label className="flex items-center gap-3 cursor-pointer group relative">
onClick={() => setSelectedCategoryId(cat.id)} <input
className={`p-4 rounded-2xl border transition-all text-left group ${selectedCategoryId === cat.id type="checkbox"
? "bg-blue-500/10 border-blue-500 text-blue-400" checked={allowRetry}
: "bg-white/5 border-white/10 text-gray-500 hover:border-white/20" onChange={(e) => setAllowRetry(e.target.checked)}
}`} className="sr-only peer"
> />
<div className="text-[10px] font-bold uppercase tracking-widest opacity-60 mb-1">Category</div> <div className="w-10 h-6 bg-gray-700 rounded-full peer peer-checked:bg-blue-600 after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:after:translate-x-4"></div>
<div className="font-bold truncate">{cat.name}</div> <span className="text-sm font-bold text-gray-400 peer-checked:text-white transition-colors">Allow Instant Corrections</span>
<div className="text-xs mt-2 font-medium opacity-80">{cat.weight}% Weight</div> </label>
</button> <p className="text-[10px] text-gray-600 italic">Enables &quot;Check Answer&quot; buttons for individual blocks</p>
)) </div>
)} </div>
</div> </>
)} )}
</div> </div>
)} )}
+2
View File
@@ -59,6 +59,8 @@ export interface Lesson {
} | null; } | null;
is_graded: boolean; is_graded: boolean;
grading_category_id: string | null; grading_category_id: string | null;
max_attempts: number | null;
allow_retry: boolean;
position: number; position: number;
created_at: string; created_at: string;
} }