feat: Implement advanced grading (rubrics) and lesson dependencies across CMS service, API, and Studio UI.

This commit is contained in:
2026-02-17 22:43:19 -03:00
parent 12df920f60
commit f9e78a265a
17 changed files with 2181 additions and 124 deletions
@@ -1,8 +1,27 @@
"use client";
import { useEffect, useState } from "react";
import { cmsApi, Lesson, Block, GradingCategory, LibraryBlock } from "@/lib/api";
import Link from "next/link";
import { useEffect, useState, useCallback } from "react";
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { cmsApi, Lesson, Block, GradingCategory, LibraryBlock, Rubric, RubricLevel, RubricCriterion, LessonDependency } from '@/lib/api';
import {
Layout,
CheckCircle2,
Pencil,
Save,
Trash2,
Plus,
X,
ChevronDown,
ChevronUp,
Settings,
Target,
Eye,
Brain,
Library,
BookMarked,
ArrowLeft
} from 'lucide-react';
import DescriptionBlock from "@/components/blocks/DescriptionBlock";
import MediaBlock from "@/components/blocks/MediaBlock";
import QuizBlock from "@/components/blocks/QuizBlock";
@@ -19,16 +38,6 @@ import PeerReviewBlock from "@/components/blocks/PeerReviewBlock";
import SaveToLibraryModal from "@/components/modals/SaveToLibraryModal";
import LibraryPanel from "@/components/LibraryPanel";
import Modal from "@/components/Modal";
import {
Save,
X,
Pencil,
ChevronUp,
ChevronDown,
Trash2,
BookMarked,
Library
} from "lucide-react";
export default function LessonEditor({ params }: { params: { id: string; lessonId: string } }) {
const [lesson, setLesson] = useState<Lesson | null>(null);
@@ -50,15 +59,24 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
const [dueDate, setDueDate] = useState<string>("");
const [importantDateType, setImportantDateType] = useState<string>("");
const [isAIQuizModalOpen, setIsAIQuizModalOpen] = useState(false);
const [aiQuizContext, setAiQuizContext] = useState("");
const [aiQuizType, setAiQuizType] = useState("multiple-choice");
// Rubric State
const [courseRubrics, setCourseRubrics] = useState<Rubric[]>([]);
const [assignedRubricIds, setAssignedRubricIds] = useState<string[]>([]);
// Content Libraries states
const [isSaveToLibraryModalOpen, setIsSaveToLibraryModalOpen] = useState(false);
const [blockToSave, setBlockToSave] = useState<Block | null>(null);
const [isLibraryPanelOpen, setIsLibraryPanelOpen] = useState(false);
// Learning Sequences State
const [allLessons, setAllLessons] = useState<Lesson[]>([]);
const [dependencies, setDependencies] = useState<LessonDependency[]>([]);
// AI Quiz Generation State
const [isAIQuizModalOpen, setIsAIQuizModalOpen] = useState(false);
const [aiQuizContext, setAiQuizContext] = useState("");
const [aiQuizType, setAiQuizType] = useState("multiple-choice");
const [editValue, setEditValue] = useState("");
@@ -120,6 +138,28 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
// Load grading categories
const categories = await cmsApi.getGradingCategories(params.id);
setGradingCategories(categories);
// Load course rubrics and lesson rubrics
const [allRubrics, lessonRubrics, courseOutline, lessonDeps] = await Promise.all([
cmsApi.listCourseRubrics(params.id),
cmsApi.getLessonRubrics(params.lessonId),
cmsApi.getCourseWithFullOutline(params.id),
cmsApi.listLessonDependencies(params.lessonId)
]);
setCourseRubrics(allRubrics);
setAssignedRubricIds(lessonRubrics.map(r => r.id));
// Extract all lessons from outline for prerequisite selection
const lessons: Lesson[] = [];
courseOutline.modules?.forEach(m => {
m.lessons.forEach(l => {
if (l.id !== params.lessonId) {
lessons.push(l);
}
});
});
setAllLessons(lessons);
setDependencies(lessonDeps);
} catch {
console.error("Failed to load lesson or categories");
} finally {
@@ -140,6 +180,21 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
}
};
const toggleRubric = async (rubricId: string, isAssigned: boolean) => {
try {
if (isAssigned) {
await cmsApi.unassignRubricFromLesson(params.lessonId, rubricId);
setAssignedRubricIds(assignedRubricIds.filter(id => id !== rubricId));
} else {
await cmsApi.assignRubricToLesson(params.lessonId, rubricId);
setAssignedRubricIds([...assignedRubricIds, rubricId]);
}
} catch (err) {
console.error("Failed to toggle rubric", err);
alert("Failed to update rubric assignment.");
}
};
const handleSave = async () => {
if (!lesson) return;
setIsSaving(true);
@@ -265,7 +320,22 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
}
};
const handleGenerateQuiz = async () => {
const toggleDependency = async (prerequisiteId: string, isAssigned: boolean) => {
try {
if (isAssigned) {
await cmsApi.removeDependency(params.lessonId, prerequisiteId);
setDependencies(dependencies.filter(d => d.prerequisite_lesson_id !== prerequisiteId));
} else {
const newDep = await cmsApi.assignDependency(params.lessonId, { prerequisite_lesson_id: prerequisiteId });
setDependencies([...dependencies, newDep]);
}
} catch (err) {
console.error("Failed to toggle dependency", err);
alert("Failed to update prerequisite assignment.");
}
};
const handleGenerateQuiz = () => {
setIsAIQuizModalOpen(true);
};
@@ -368,22 +438,54 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
{isGraded && (
<>
<div className="col-span-full space-y-2">
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2 block">Assessment Category</span>
<select
value={selectedCategoryId}
onChange={(e) => setSelectedCategoryId(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-blue-500 transition-all appearance-none font-bold"
>
<option value="" className="bg-gray-900 border-0">Select Category...</option>
{gradingCategories.map((cat) => (
<option key={cat.id} value={cat.id} className="bg-gray-900 border-0">
{cat.name} ({cat.weight}%)
</option>
))}
</select>
<div className="text-[10px] text-gray-500 italic mt-1 pl-1">
Manage categories in <Link href={`/courses/${params.id}/grading`} className="text-blue-400 hover:underline">Grading Policy</Link>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-4">
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2 block">Assessment Category</span>
<select
value={selectedCategoryId}
onChange={(e) => setSelectedCategoryId(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-blue-500 transition-all appearance-none font-bold"
>
<option value="" className="bg-gray-900 border-0">Select Category...</option>
{gradingCategories.map((cat) => (
<option key={cat.id} value={cat.id} className="bg-gray-900 border-0">
{cat.name} ({cat.weight}%)
</option>
))}
</select>
<div className="text-[10px] text-gray-500 italic mt-1 pl-1">
Manage categories in <Link href={`/courses/${params.id}/grading`} className="text-blue-400 hover:underline">Grading Policy</Link>
</div>
</div>
<div className="space-y-4">
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2 block">Rubric (Optional)</span>
<div className="bg-white/5 border border-white/10 rounded-xl p-4 max-h-48 overflow-y-auto space-y-2">
{courseRubrics.length === 0 ? (
<p className="text-xs text-gray-500 italic p-2 text-center">No rubrics found in this course.</p>
) : (
courseRubrics.map(rubric => {
const isAssigned = assignedRubricIds.includes(rubric.id);
return (
<label key={rubric.id} className="flex items-center justify-between p-2 rounded-lg hover:bg-white/5 transition-all cursor-pointer group">
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={isAssigned}
onChange={() => toggleRubric(rubric.id, isAssigned)}
className="w-4 h-4 rounded border-gray-700 bg-gray-800 text-blue-500 focus:ring-blue-500"
/>
<span className="text-sm font-medium text-gray-200 group-hover:text-blue-400 transition-colors">{rubric.name}</span>
</div>
<span className="text-[10px] font-bold text-gray-600 bg-white/5 px-1.5 py-0.5 rounded">{rubric.total_points} pts</span>
</label>
);
})
)}
</div>
<div className="text-[10px] text-gray-500 italic mt-1 pl-1">
Manage rubrics in <Link href={`/courses/${params.id}/rubrics`} className="text-blue-400 hover:underline">Rubrics Manager</Link>
</div>
</div>
</div>
@@ -419,6 +521,104 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
<p className="text-[10px] text-gray-600 italic">Enables &quot;Check Answer&quot; buttons for individual blocks</p>
</div>
</div>
<div className="pt-8 border-t border-white/5 animate-in fade-in duration-500 delay-150">
<div className="flex items-center gap-2 mb-6 uppercase tracking-[0.2em] text-[10px] font-black text-gray-500">
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 shadow-lg shadow-blue-500/50"></span>
Access & Prerequisites
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-4">
<h4 className="text-sm font-bold text-gray-200">Prerequisites</h4>
<p className="text-xs text-gray-500 leading-relaxed">
Students must complete these lessons before they can access this one.
</p>
<div className="bg-white/5 border border-white/10 rounded-2xl p-4 max-h-60 overflow-y-auto space-y-2">
{allLessons.length === 0 ? (
<p className="text-xs text-gray-500 italic p-4 text-center">No other lessons available.</p>
) : (
allLessons.map(l => {
const dep = dependencies.find(d => d.prerequisite_lesson_id === l.id);
const isAssigned = !!dep;
return (
<div key={l.id} className="space-y-2 p-2 rounded-xl hover:bg-white/5 transition-all group border border-transparent hover:border-white/10">
<label className="flex items-center justify-between cursor-pointer">
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={isAssigned}
onChange={() => toggleDependency(l.id, isAssigned)}
className="w-4 h-4 rounded-md border-gray-700 bg-gray-800 text-blue-500 focus:ring-blue-500 transition-all"
/>
<span className={`text-sm font-medium transition-colors ${isAssigned ? 'text-blue-400' : 'text-gray-400 group-hover:text-gray-200'}`}>
{l.title}
</span>
</div>
{l.is_graded && (
<span className="text-[9px] font-black uppercase tracking-tighter px-2 py-0.5 bg-blue-500/10 text-blue-400 rounded-full border border-blue-500/20">Graded</span>
)}
</label>
{isAssigned && l.is_graded && (
<div className="pl-7 animate-in slide-in-from-left-2 duration-300">
<div className="flex items-center gap-3">
<span className="text-[10px] text-gray-500 font-bold uppercase tracking-widest whitespace-nowrap">Min. Score %</span>
<input
type="number"
min="0"
max="100"
value={dep.min_score_percentage || 0}
onChange={async (e) => {
const minScore = parseFloat(e.target.value);
try {
const updated = await cmsApi.assignDependency(params.lessonId, {
prerequisite_lesson_id: l.id,
min_score_percentage: minScore
});
setDependencies(dependencies.map(d => d.id === updated.id ? updated : d));
} catch (err) {
console.error("Failed to update min score", err);
}
}}
className="w-16 bg-black/40 border border-white/10 rounded-lg px-2 py-1 text-xs text-blue-400 font-bold focus:outline-none focus:border-blue-500"
/>
</div>
</div>
)}
</div>
);
})
)}
</div>
</div>
<div className="flex flex-col justify-center gap-4 px-6 border-l border-white/5">
<div className="flex items-start gap-4 p-4 rounded-2xl bg-indigo-500/5 border border-indigo-500/10">
<div className="p-2 rounded-xl bg-indigo-500/20">
<Layout className="w-5 h-5 text-indigo-400" />
</div>
<div>
<h5 className="text-sm font-bold text-indigo-300">Intelligent Sequences</h5>
<p className="text-[11px] text-indigo-300/60 leading-relaxed mt-1">
Locked lessons will be visible in the student outline with a lock icon 🔒 until they meet all prerequisites.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-4 rounded-2xl bg-blue-500/5 border border-blue-500/10">
<div className="p-2 rounded-xl bg-blue-500/20">
<CheckCircle2 className="w-5 h-5 text-blue-400" />
</div>
<div>
<h5 className="text-sm font-bold text-blue-300">Completion Tracking</h5>
<p className="text-[11px] text-blue-300/60 leading-relaxed mt-1">
If a minimum score is set, students must pass the prerequisite before the next lesson is unlocked.
</p>
</div>
</div>
</div>
</div>
</div>
</>
)}
</div >
@@ -0,0 +1,60 @@
"use client";
import React, { useState } from "react";
import { useParams, useRouter } from "next/navigation";
import CourseEditorLayout from "@/components/CourseEditorLayout";
import RubricList from "@/components/Rubrics/RubricList";
import RubricEditor from "@/components/Rubrics/RubricEditor";
import { ArrowLeft, FileText, Info } from "lucide-react";
export default function RubricsPage() {
const { id } = useParams() as { id: string };
const router = useRouter();
const [editingRubricId, setEditingRubricId] = useState<string | null>(null);
return (
<div className="min-h-screen bg-[#0f1115] text-white p-8">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-12">
<div className="flex items-center gap-4">
<button
onClick={() => router.back()}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
>
<ArrowLeft className="w-6 h-6" />
</button>
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-400 to-indigo-400 bg-clip-text text-transparent">
Rubrics Management
</h1>
<p className="text-gray-400 mt-1">Create and manage evaluation rubrics for your course</p>
</div>
</div>
<div className="hidden md:flex items-center gap-2 bg-blue-500/10 border border-blue-500/20 px-4 py-2 rounded-2xl text-blue-400 text-sm">
<Info className="w-4 h-4" />
<span>Rubrics can be assigned to multiple lessons across the course.</span>
</div>
</div>
<CourseEditorLayout activeTab="rubrics">
<div className="p-8">
{editingRubricId ? (
<RubricEditor
rubricId={editingRubricId}
courseId={id}
onClose={() => setEditingRubricId(null)}
/>
) : (
<RubricList
courseId={id}
onEdit={(rubricId) => setEditingRubricId(rubricId)}
/>
)}
</div>
</CourseEditorLayout>
</div>
</div>
);
}