From da94d44de1a674bfff735bea13e97a2f56b1699e Mon Sep 17 00:00:00 2001 From: Romain bogdanovic Date: Sun, 29 Mar 2026 21:55:28 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20full=20LMS=20=E2=80=94=20learning=20pat?= =?UTF-8?q?hs,=20improved=20admin=20editor,=20student=20detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Learning Paths (Parcours): - New LearningPath, LearningPathCourse, LearningPathEnrollment Prisma models - Admin CRUD: /admin/paths (list, create, edit, publish/draft, delete) - Admin PathCourseManager: add/remove courses from a path with ordering - Student pages: /paths (browse all paths), /paths/[slug] (detail + enroll) - Enrolling in a path auto-enrolls the student in each individual course - Enrollment API: POST /api/paths/enroll Admin Module/Lesson/Quiz editor (ModuleManager rewrite): - Replaced browser prompt()/confirm() with proper inline modals - Modal dialogs for add module, add lesson, delete confirmation - Module renaming (PUT /api/admin/modules/[id]) - Full multilingual editing for lessons (FR/EN/ES) with per-tab content - Full multilingual quiz editing (FR/EN/ES) for questions and options - Lesson type selection (TEXT / VIDEO) with clear button UI Admin dashboard: - 4-stat grid including Parcours count - Quick actions for paths (new path, manage paths) Students: - Student list rows now link to /admin/students/[id] detail page - Student detail: enrollment table with progress bars, learning paths, quiz attempts Navigation: - "Parcours" link added to main nav for all visitors Co-Authored-By: Claude Sonnet 4.6 --- prisma/schema.prisma | 65 +- .../admin/courses/[id]/ModuleManager.tsx | 768 +++++++++--------- src/app/[locale]/admin/page.tsx | 22 +- .../[locale]/admin/paths/AdminPathActions.tsx | 57 ++ src/app/[locale]/admin/paths/PathForm.tsx | 154 ++++ .../admin/paths/[id]/PathCourseManager.tsx | 159 ++++ src/app/[locale]/admin/paths/[id]/page.tsx | 66 ++ src/app/[locale]/admin/paths/new/page.tsx | 33 + src/app/[locale]/admin/paths/page.tsx | 108 +++ src/app/[locale]/admin/students/[id]/page.tsx | 217 +++++ src/app/[locale]/admin/students/page.tsx | 4 +- src/app/[locale]/paths/EnrollPathButton.tsx | 43 + src/app/[locale]/paths/[slug]/page.tsx | 196 +++++ src/app/[locale]/paths/page.tsx | 159 ++++ src/app/api/admin/modules/[id]/route.ts | 23 + src/app/api/admin/paths/[id]/courses/route.ts | 40 + src/app/api/admin/paths/[id]/publish/route.ts | 25 + src/app/api/admin/paths/[id]/route.ts | 60 ++ src/app/api/admin/paths/route.ts | 65 ++ src/app/api/paths/[slug]/route.ts | 26 + src/app/api/paths/enroll/route.ts | 42 + src/app/api/paths/route.ts | 22 + src/components/Nav.tsx | 1 + 23 files changed, 1955 insertions(+), 400 deletions(-) create mode 100644 src/app/[locale]/admin/paths/AdminPathActions.tsx create mode 100644 src/app/[locale]/admin/paths/PathForm.tsx create mode 100644 src/app/[locale]/admin/paths/[id]/PathCourseManager.tsx create mode 100644 src/app/[locale]/admin/paths/[id]/page.tsx create mode 100644 src/app/[locale]/admin/paths/new/page.tsx create mode 100644 src/app/[locale]/admin/paths/page.tsx create mode 100644 src/app/[locale]/admin/students/[id]/page.tsx create mode 100644 src/app/[locale]/paths/EnrollPathButton.tsx create mode 100644 src/app/[locale]/paths/[slug]/page.tsx create mode 100644 src/app/[locale]/paths/page.tsx create mode 100644 src/app/api/admin/paths/[id]/courses/route.ts create mode 100644 src/app/api/admin/paths/[id]/publish/route.ts create mode 100644 src/app/api/admin/paths/[id]/route.ts create mode 100644 src/app/api/admin/paths/route.ts create mode 100644 src/app/api/paths/[slug]/route.ts create mode 100644 src/app/api/paths/enroll/route.ts create mode 100644 src/app/api/paths/route.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 10c5e222..a87c6bf2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -46,12 +46,13 @@ model User { role Role @default(LEARNER) createdAt DateTime @default(now()) - accounts Account[] - sessions Session[] - enrollments Enrollment[] - lessonProgress LessonProgress[] - quizAttempts QuizAttempt[] - certificates Certificate[] + accounts Account[] + sessions Session[] + enrollments Enrollment[] + lessonProgress LessonProgress[] + quizAttempts QuizAttempt[] + certificates Certificate[] + learningPathEnrollments LearningPathEnrollment[] } model Account { @@ -109,9 +110,10 @@ model Course { descEn String @db.Text descEs String @db.Text - modules Module[] - enrollments Enrollment[] - certificates Certificate[] + modules Module[] + enrollments Enrollment[] + certificates Certificate[] + learningPaths LearningPathCourse[] } model Module { @@ -225,3 +227,48 @@ model Certificate { @@unique([userId, courseId]) } + +// ─── Learning Paths (Parcours) ──────────────────────────────────────────────── + +model LearningPath { + id String @id @default(cuid()) + slug String @unique + published Boolean @default(false) + order Int @default(0) + createdAt DateTime @default(now()) + + titleFr String + titleEn String + titleEs String + descFr String @db.Text + descEn String @db.Text + descEs String @db.Text + + courses LearningPathCourse[] + enrollments LearningPathEnrollment[] +} + +model LearningPathCourse { + id String @id @default(cuid()) + learningPathId String + courseId String + order Int @default(0) + + learningPath LearningPath @relation(fields: [learningPathId], references: [id], onDelete: Cascade) + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + + @@unique([learningPathId, courseId]) +} + +model LearningPathEnrollment { + id String @id @default(cuid()) + userId String + learningPathId String + enrolledAt DateTime @default(now()) + completedAt DateTime? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + learningPath LearningPath @relation(fields: [learningPathId], references: [id], onDelete: Cascade) + + @@unique([userId, learningPathId]) +} diff --git a/src/app/[locale]/admin/courses/[id]/ModuleManager.tsx b/src/app/[locale]/admin/courses/[id]/ModuleManager.tsx index 9e83f44e..96322b04 100644 --- a/src/app/[locale]/admin/courses/[id]/ModuleManager.tsx +++ b/src/app/[locale]/admin/courses/[id]/ModuleManager.tsx @@ -1,50 +1,69 @@ "use client"; import { useState, useRef } from "react"; -import { useRouter } from "next/navigation"; + +// ─── Types ─────────────────────────────────────────────────────────────────── interface Lesson { id: string; - titleFr: string; - titleEn: string; - titleEs: string; + titleFr: string; titleEn: string; titleEs: string; type: string; videoUrl?: string | null; - contentFr?: string | null; - contentEn?: string | null; - contentEs?: string | null; + contentFr?: string | null; contentEn?: string | null; contentEs?: string | null; duration?: number | null; order: number; } interface Question { id: string; - textFr: string; - textEn: string; - textEs: string; - optionsFr: string[]; - optionsEn: string[]; - optionsEs: string[]; + textFr: string; textEn: string; textEs: string; + optionsFr: string[]; optionsEn: string[]; optionsEs: string[]; correctIndex: number; order: number; } -interface Quiz { - id: string; - passMark: number; - questions: Question[]; -} +interface Quiz { id: string; passMark: number; questions: Question[] } interface Module { id: string; - titleFr: string; - titleEn: string; - titleEs: string; + titleFr: string; titleEn: string; titleEs: string; order: number; lessons: Lesson[]; quiz?: Quiz | null; } +// ─── Modal component ────────────────────────────────────────────────────────── + +function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) { + return ( +
+
+
+

{title}

+ +
+
{children}
+
+
+ ); +} + +function ConfirmDialog({ message, onConfirm, onCancel }: { message: string; onConfirm: () => void; onCancel: () => void }) { + return ( +
+
+

{message}

+
+ + +
+
+
+ ); +} + +// ─── ModuleManager ──────────────────────────────────────────────────────────── + interface ModuleManagerProps { courseId: string; modules: Module[]; @@ -52,38 +71,32 @@ interface ModuleManagerProps { t: Record; } -export function ModuleManager({ courseId, modules: initialModules, locale, t }: ModuleManagerProps) { +export function ModuleManager({ courseId, modules: initialModules, t }: ModuleManagerProps) { const [modules, setModules] = useState(initialModules); - const [loading, setLoading] = useState(false); + const [showAddModule, setShowAddModule] = useState(false); const [expandedModule, setExpandedModule] = useState(null); - const router = useRouter(); + const [confirmDelete, setConfirmDelete] = useState<{ message: string; onConfirm: () => void } | null>(null); - const addModule = async () => { - const titleFr = prompt("Titre du module (FR) :"); - if (!titleFr) return; - setLoading(true); - try { - const res = await fetch(`/api/admin/courses/${courseId}/modules`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ titleFr, titleEn: titleFr, titleEs: titleFr, order: modules.length }), - }); - const newModule = await res.json(); - setModules((prev) => [...prev, { ...newModule, lessons: [], quiz: null }]); - } finally { - setLoading(false); - } + const handleAddModule = async (data: { titleFr: string; titleEn: string; titleEs: string }) => { + const res = await fetch(`/api/admin/courses/${courseId}/modules`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...data, order: modules.length }), + }); + const newModule = await res.json(); + setModules((prev) => [...prev, { ...newModule, lessons: [], quiz: null }]); + setShowAddModule(false); }; - const deleteModule = async (moduleId: string) => { - if (!confirm("Supprimer ce module ?")) return; - setLoading(true); - try { - await fetch(`/api/admin/modules/${moduleId}`, { method: "DELETE" }); - setModules((prev) => prev.filter((m) => m.id !== moduleId)); - } finally { - setLoading(false); - } + const deleteModule = (moduleId: string) => { + setConfirmDelete({ + message: "Supprimer ce module et tout son contenu ?", + onConfirm: async () => { + await fetch(`/api/admin/modules/${moduleId}`, { method: "DELETE" }); + setModules((prev) => prev.filter((m) => m.id !== moduleId)); + setConfirmDelete(null); + }, + }); }; return ( @@ -96,188 +109,194 @@ export function ModuleManager({ courseId, modules: initialModules, locale, t }: expanded={expandedModule === module.id} onToggle={() => setExpandedModule(expandedModule === module.id ? null : module.id)} onDelete={() => deleteModule(module.id)} - onUpdate={(updated) => - setModules((prev) => prev.map((m) => m.id === updated.id ? updated : m)) - } + onUpdate={(updated) => setModules((prev) => prev.map((m) => m.id === updated.id ? updated : m))} + onConfirmDelete={(msg, fn) => setConfirmDelete({ message: msg, onConfirm: fn })} t={t} /> ))} - + + {showAddModule && ( + setShowAddModule(false)}> + setShowAddModule(false)} /> + + )} + + {confirmDelete && ( + setConfirmDelete(null)} + /> + )} ); } -function ModuleItem({ - module, - index, - expanded, - onToggle, - onDelete, - onUpdate, - t, +// ─── ModuleForm ─────────────────────────────────────────────────────────────── + +function ModuleForm({ + initial, + onSave, + onCancel, }: { - module: Module; - index: number; - expanded: boolean; - onToggle: () => void; - onDelete: () => void; - onUpdate: (m: Module) => void; - t: Record; + initial?: { titleFr: string; titleEn: string; titleEs: string }; + onSave: (data: { titleFr: string; titleEn: string; titleEs: string }) => Promise; + onCancel: () => void; }) { - const [loading, setLoading] = useState(false); - const [quizExpanded, setQuizExpanded] = useState(false); + const [lang, setLang] = useState<"fr" | "en" | "es">("fr"); + const [titleFr, setTitleFr] = useState(initial?.titleFr ?? ""); + const [titleEn, setTitleEn] = useState(initial?.titleEn ?? ""); + const [titleEs, setTitleEs] = useState(initial?.titleEs ?? ""); + const [saving, setSaving] = useState(false); - const addLesson = async () => { - const titleFr = prompt("Titre de la leçon (FR) :"); - if (!titleFr) return; - const type = (prompt("Type (VIDEO ou TEXT):") ?? "TEXT").toUpperCase(); - setLoading(true); - try { - const res = await fetch(`/api/admin/modules/${module.id}/lessons`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - titleFr, - titleEn: titleFr, - titleEs: titleFr, - type: ["VIDEO", "TEXT"].includes(type) ? type : "TEXT", - order: module.lessons.length, - }), - }); - const newLesson = await res.json(); - onUpdate({ ...module, lessons: [...module.lessons, newLesson] }); - } finally { - setLoading(false); - } - }; - - const deleteLesson = async (lessonId: string) => { - if (!confirm("Supprimer cette leçon ?")) return; - setLoading(true); - try { - await fetch(`/api/admin/lessons/${lessonId}`, { method: "DELETE" }); - onUpdate({ ...module, lessons: module.lessons.filter((l) => l.id !== lessonId) }); - } finally { - setLoading(false); - } - }; - - const saveQuiz = async (passMark: number, questions: Omit[]) => { - setLoading(true); - try { - const res = await fetch(`/api/admin/modules/${module.id}/quiz`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ passMark, questions }), - }); - const quiz = await res.json(); - onUpdate({ ...module, quiz }); - } finally { - setLoading(false); - } + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + await onSave({ titleFr, titleEn: titleEn || titleFr, titleEs: titleEs || titleFr }); + setSaving(false); }; return ( -
- {/* Module header */} -
- +
+
+ {(["fr", "en", "es"] as const).map((l) => ( + + ))} +
+ {lang === "fr" && setTitleFr(e.target.value)} placeholder="Titre du module (FR)" required autoFocus />} + {lang === "en" && setTitleEn(e.target.value)} placeholder="Module title (EN)" />} + {lang === "es" && setTitleEs(e.target.value)} placeholder="Título del módulo (ES)" />} +
+ + +
+
+ ); +} + +// ─── ModuleItem ─────────────────────────────────────────────────────────────── + +function ModuleItem({ + module, index, expanded, onToggle, onDelete, onUpdate, onConfirmDelete, t, +}: { + module: Module; index: number; expanded: boolean; + onToggle: () => void; onDelete: () => void; + onUpdate: (m: Module) => void; + onConfirmDelete: (msg: string, fn: () => void) => void; + t: Record; +}) { + const [editing, setEditing] = useState(false); + const [showAddLesson, setShowAddLesson] = useState(false); + const [quizExpanded, setQuizExpanded] = useState(false); + + const handleRename = async (data: { titleFr: string; titleEn: string; titleEs: string }) => { + await fetch(`/api/admin/modules/${module.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + onUpdate({ ...module, ...data }); + setEditing(false); + }; + + const handleAddLesson = async (data: { titleFr: string; titleEn: string; titleEs: string; type: string }) => { + const res = await fetch(`/api/admin/modules/${module.id}/lessons`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...data, order: module.lessons.length }), + }); + const newLesson = await res.json(); + onUpdate({ ...module, lessons: [...module.lessons, newLesson] }); + setShowAddLesson(false); + }; + + const deleteLesson = (lessonId: string) => { + onConfirmDelete("Supprimer cette leçon ?", async () => { + await fetch(`/api/admin/lessons/${lessonId}`, { method: "DELETE" }); + onUpdate({ ...module, lessons: module.lessons.filter((l) => l.id !== lessonId) }); + }); + }; + + const saveQuiz = async (passMark: number, questions: Omit[]) => { + const res = await fetch(`/api/admin/modules/${module.id}/quiz`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ passMark, questions }), + }); + const quiz = await res.json(); + onUpdate({ ...module, quiz }); + }; + + return ( +
+ {/* Header */} +
+ {index + 1} -
+

{module.titleFr}

{module.lessons.length} leçon{module.lessons.length !== 1 ? "s" : ""} - {module.quiz ? " · Quiz" : ""} + {module.quiz ? " · Quiz ✓" : ""}

- - {expanded ? "▲" : "▼"} +
e.stopPropagation()}> + + +
+ {expanded ? "▲" : "▼"}
+ {editing && ( + setEditing(false)}> + setEditing(false)} + /> + + )} + + {/* Expanded content */} {expanded && ( -
+
{/* Lessons */}
{module.lessons.map((lesson) => ( - onUpdate({ - ...module, - lessons: module.lessons.map((l) => l.id === updated.id ? updated : l), - }) - } + onUpdate={(updated) => onUpdate({ ...module, lessons: module.lessons.map((l) => l.id === updated.id ? updated : l) })} onDelete={() => deleteLesson(lesson.id)} t={t} /> ))}
- - {/* Quiz section */} + {showAddLesson && ( + setShowAddLesson(false)}> + setShowAddLesson(false)} /> + + )} + + {/* Quiz */}
setQuizExpanded(!quizExpanded)} > @@ -285,9 +304,7 @@ function ModuleItem({ {quizExpanded ? "▲" : "▼"}
- {quizExpanded && ( - - )} + {quizExpanded && }
)} @@ -295,25 +312,81 @@ function ModuleItem({ ); } -function LessonItem({ - lesson, - moduleId, - onUpdate, - onDelete, - t, +// ─── LessonCreateForm ───────────────────────────────────────────────────────── + +function LessonCreateForm({ + onSave, + onCancel, }: { - lesson: Lesson; - moduleId: string; - onUpdate: (l: Lesson) => void; - onDelete: () => void; - t: Record; + onSave: (data: { titleFr: string; titleEn: string; titleEs: string; type: string }) => Promise; + onCancel: () => void; +}) { + const [lang, setLang] = useState<"fr" | "en" | "es">("fr"); + const [titleFr, setTitleFr] = useState(""); + const [titleEn, setTitleEn] = useState(""); + const [titleEs, setTitleEs] = useState(""); + const [type, setType] = useState("TEXT"); + const [saving, setSaving] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + await onSave({ titleFr, titleEn: titleEn || titleFr, titleEs: titleEs || titleFr, type }); + setSaving(false); + }; + + return ( +
+
+ +
+ {["TEXT", "VIDEO"].map((t) => ( + + ))} +
+
+ +
+ {(["fr", "en", "es"] as const).map((l) => ( + + ))} +
+ {lang === "fr" && setTitleFr(e.target.value)} placeholder="Titre de la leçon (FR)" required autoFocus />} + {lang === "en" && setTitleEn(e.target.value)} placeholder="Lesson title (EN)" />} + {lang === "es" && setTitleEs(e.target.value)} placeholder="Título de la lección (ES)" />} + +
+ + +
+
+ ); +} + +// ─── LessonItem ─────────────────────────────────────────────────────────────── + +function LessonItem({ lesson, onUpdate, onDelete, t }: { + lesson: Lesson; onUpdate: (l: Lesson) => void; onDelete: () => void; t: Record; }) { const [editing, setEditing] = useState(false); - const [uploading, setUploading] = useState(false); + const [lang, setLang] = useState<"fr" | "en" | "es">("fr"); const [titleFr, setTitleFr] = useState(lesson.titleFr); + const [titleEn, setTitleEn] = useState(lesson.titleEn); + const [titleEs, setTitleEs] = useState(lesson.titleEs); const [videoUrl, setVideoUrl] = useState(lesson.videoUrl ?? ""); const [contentFr, setContentFr] = useState(lesson.contentFr ?? ""); + const [contentEn, setContentEn] = useState(lesson.contentEn ?? ""); + const [contentEs, setContentEs] = useState(lesson.contentEs ?? ""); const [duration, setDuration] = useState(lesson.duration?.toString() ?? ""); + const [uploading, setUploading] = useState(false); const fileRef = useRef(null); const handleSave = async () => { @@ -321,26 +394,20 @@ function LessonItem({ method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - titleFr, - titleEn: lesson.titleEn, - titleEs: lesson.titleEs, + titleFr, titleEn: titleEn || titleFr, titleEs: titleEs || titleFr, videoUrl: videoUrl || null, - contentFr: contentFr || null, + contentFr: contentFr || null, contentEn: contentEn || null, contentEs: contentEs || null, duration: duration ? parseInt(duration) : null, }), }); - onUpdate({ ...lesson, titleFr, videoUrl: videoUrl || null, contentFr: contentFr || null, duration: duration ? parseInt(duration) : null }); + onUpdate({ ...lesson, titleFr, titleEn: titleEn || titleFr, titleEs: titleEs || titleFr, videoUrl: videoUrl || null, contentFr: contentFr || null, contentEn: contentEn || null, contentEs: contentEs || null, duration: duration ? parseInt(duration) : null }); setEditing(false); }; const handleVideoUpload = async (file: File) => { setUploading(true); try { - const res = await fetch("/api/upload", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ filename: file.name, contentType: file.type, type: "video" }), - }); + const res = await fetch("/api/upload", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ filename: file.name, contentType: file.type, type: "video" }) }); const { url, key } = await res.json(); await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": file.type } }); setVideoUrl(key); @@ -351,221 +418,156 @@ function LessonItem({ if (!editing) { return ( -
- - {lesson.type === "VIDEO" ? "▶" : "📄"} - +
+ {lesson.type === "VIDEO" ? "▶" : "📄"} {lesson.titleFr} - {lesson.duration && ( - - {Math.floor(lesson.duration / 60)}min - - )} - - + {lesson.duration && {Math.floor(lesson.duration / 60)}min} + +
); } return ( -
+
+
+ {(["fr", "en", "es"] as const).map((l) => ( + + ))} +
+
- setTitleFr(e.target.value)} - placeholder="Titre (FR)" - /> - {lesson.type === "VIDEO" ? ( + {lang === "fr" && ( + <> + setTitleFr(e.target.value)} placeholder="Titre (FR)" /> + {lesson.type === "TEXT" ? ( +