feat: Implement lesson attempt tracking, retry functionality, and max attempt limits for interactive blocks.
This commit is contained in:
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 *"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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'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>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500 text-xs italic">This assessment is now closed for further submissions.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const userData = localStorage.getItem("user");
|
if (user) {
|
||||||
if (userData) {
|
|
||||||
const u = JSON.parse(userData);
|
|
||||||
try {
|
try {
|
||||||
// In a real scenario, we'd calculate the actual score from blocks
|
// In a real scenario, we'd calculate the actual score from blocks
|
||||||
await lmsApi.submitScore(u.id, params.id, params.lessonId, 100);
|
const res = await lmsApi.submitScore(user.id, params.id, params.lessonId, 1.0);
|
||||||
|
setUserGrade(res);
|
||||||
alert("Score submitted successfully!");
|
alert("Score submitted successfully!");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Submission failed", 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">
|
||||||
SUBMIT FOR GRADING <CheckCircle2 className="w-5 h-5 group-hover/btn:scale-110 transition-transform" />
|
{userGrade ? `SUBMIT ATTEMPT ${userGrade.attempts_count + 1}` : 'SUBMIT FOR GRADING'}
|
||||||
|
<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);
|
||||||
|
|
||||||
@@ -76,6 +77,8 @@ export default function FillInTheBlanksPlayer({ id, title, content }: FillInTheB
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{allowRetry && (
|
||||||
|
<>
|
||||||
{!submitted && parsed.answers.length > 0 && (
|
{!submitted && parsed.answers.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSubmitted(true)}
|
onClick={() => setSubmitted(true)}
|
||||||
@@ -88,11 +91,13 @@ export default function FillInTheBlanksPlayer({ id, title, content }: FillInTheB
|
|||||||
{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);
|
||||||
@@ -90,6 +91,7 @@ export default function MatchingPlayer({ id, title, pairs }: MatchingPlayerProps
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{allowRetry && (
|
||||||
<div className="md:col-span-2 pt-8 border-t border-white/5">
|
<div className="md:col-span-2 pt-8 border-t border-white/5">
|
||||||
{!submitted && Object.keys(matches).length === (pairs || []).length && (
|
{!submitted && Object.keys(matches).length === (pairs || []).length && (
|
||||||
<button
|
<button
|
||||||
@@ -108,6 +110,7 @@ export default function MatchingPlayer({ id, title, pairs }: MatchingPlayerProps
|
|||||||
</button>
|
</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);
|
||||||
|
|
||||||
@@ -92,6 +93,7 @@ export default function OrderingPlayer({ id, title, items }: OrderingPlayerProps
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{allowRetry && (
|
||||||
<div className="pt-8 border-t border-white/5">
|
<div className="pt-8 border-t border-white/5">
|
||||||
{!submitted && userOrder.length === (items || []).length && (
|
{!submitted && userOrder.length === (items || []).length && (
|
||||||
<button
|
<button
|
||||||
@@ -110,6 +112,7 @@ export default function OrderingPlayer({ id, title, items }: OrderingPlayerProps
|
|||||||
</button>
|
</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,6 +89,8 @@ export default function QuizPlayer({ id, title, quizData }: QuizPlayerProps) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{allowRetry && (
|
||||||
|
<>
|
||||||
{!submitted && questions.length > 0 && (
|
{!submitted && questions.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSubmitted(true)}
|
onClick={() => setSubmitted(true)}
|
||||||
@@ -104,6 +107,8 @@ export default function QuizPlayer({ id, title, quizData }: QuizPlayerProps) {
|
|||||||
Try Again
|
Try Again
|
||||||
</button>
|
</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);
|
||||||
|
|
||||||
@@ -52,6 +53,8 @@ export default function ShortAnswerPlayer({ id, title, prompt, correctAnswers }:
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{allowRetry && (
|
||||||
|
<>
|
||||||
{!submitted && (
|
{!submitted && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSubmitted(true)}
|
onClick={() => setSubmitted(true)}
|
||||||
@@ -65,11 +68,13 @@ export default function ShortAnswerPlayer({ id, title, prompt, correctAnswers }:
|
|||||||
{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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,6 +167,7 @@ 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">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 animate-in zoom-in-95 duration-300">
|
||||||
{gradingCategories.length === 0 ? (
|
{gradingCategories.length === 0 ? (
|
||||||
<div className="col-span-full py-4 text-center border border-dashed border-white/10 rounded-2xl text-xs text-gray-500 italic">
|
<div className="col-span-full py-4 text-center border border-dashed border-white/10 rounded-2xl text-xs text-gray-500 italic">
|
||||||
@@ -183,6 +190,40 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 className="space-y-2">
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500 block mb-2">After Submission</span>
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer group relative">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allowRetry}
|
||||||
|
onChange={(e) => setAllowRetry(e.target.checked)}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
<span className="text-sm font-bold text-gray-400 peer-checked:text-white transition-colors">Allow Instant Corrections</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-[10px] text-gray-600 italic">Enables "Check Answer" buttons for individual blocks</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user