feat: full LMS — learning paths, improved admin editor, student detail
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 <noreply@anthropic.com>
This commit is contained in:
parent
8f78cbf25d
commit
da94d44de1
|
|
@ -52,6 +52,7 @@ model User {
|
|||
lessonProgress LessonProgress[]
|
||||
quizAttempts QuizAttempt[]
|
||||
certificates Certificate[]
|
||||
learningPathEnrollments LearningPathEnrollment[]
|
||||
}
|
||||
|
||||
model Account {
|
||||
|
|
@ -112,6 +113,7 @@ model Course {
|
|||
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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div style={{ position: "fixed", inset: 0, zIndex: 100, background: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}>
|
||||
<div style={{ background: "#1a1f2e", border: "1px solid rgba(255,255,255,0.12)", borderRadius: 12, width: "100%", maxWidth: 560, maxHeight: "90vh", overflowY: "auto" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "16px 20px", borderBottom: "1px solid rgba(255,255,255,0.08)" }}>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 700, color: "#f1f5f9" }}>{title}</h3>
|
||||
<button onClick={onClose} style={{ background: "none", border: "none", color: "#94a3b8", cursor: "pointer", fontSize: 20, lineHeight: 1 }}>×</button>
|
||||
</div>
|
||||
<div style={{ padding: 20 }}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmDialog({ message, onConfirm, onCancel }: { message: string; onConfirm: () => void; onCancel: () => void }) {
|
||||
return (
|
||||
<div style={{ position: "fixed", inset: 0, zIndex: 110, background: "rgba(0,0,0,0.7)", display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}>
|
||||
<div style={{ background: "#1a1f2e", border: "1px solid rgba(239,68,68,0.3)", borderRadius: 10, padding: 24, maxWidth: 380, width: "100%" }}>
|
||||
<p style={{ color: "#f1f5f9", fontSize: 15, marginBottom: 20 }}>{message}</p>
|
||||
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
|
||||
<button onClick={onCancel} className="btn btn-secondary" style={{ fontSize: 13 }}>Annuler</button>
|
||||
<button onClick={onConfirm} className="btn btn-danger" style={{ fontSize: 13 }}>Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── ModuleManager ────────────────────────────────────────────────────────────
|
||||
|
||||
interface ModuleManagerProps {
|
||||
courseId: string;
|
||||
modules: Module[];
|
||||
|
|
@ -52,38 +71,32 @@ interface ModuleManagerProps {
|
|||
t: Record<string, string>;
|
||||
}
|
||||
|
||||
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<string | null>(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 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({ titleFr, titleEn: titleFr, titleEs: titleFr, order: modules.length }),
|
||||
body: JSON.stringify({ ...data, order: modules.length }),
|
||||
});
|
||||
const newModule = await res.json();
|
||||
setModules((prev) => [...prev, { ...newModule, lessons: [], quiz: null }]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
setShowAddModule(false);
|
||||
};
|
||||
|
||||
const deleteModule = async (moduleId: string) => {
|
||||
if (!confirm("Supprimer ce module ?")) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
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));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
setConfirmDelete(null);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -96,83 +109,124 @@ 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}
|
||||
/>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={addModule}
|
||||
disabled={loading}
|
||||
className="btn btn-secondary"
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
<button onClick={() => setShowAddModule(true)} className="btn btn-secondary" style={{ alignSelf: "flex-start" }}>
|
||||
+ {t.add_module}
|
||||
</button>
|
||||
|
||||
{showAddModule && (
|
||||
<Modal title="Ajouter un module" onClose={() => setShowAddModule(false)}>
|
||||
<ModuleForm onSave={handleAddModule} onCancel={() => setShowAddModule(false)} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{confirmDelete && (
|
||||
<ConfirmDialog
|
||||
message={confirmDelete.message}
|
||||
onConfirm={confirmDelete.onConfirm}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
initial?: { titleFr: string; titleEn: string; titleEs: string };
|
||||
onSave: (data: { titleFr: string; titleEn: string; titleEs: string }) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
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 handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
await onSave({ titleFr, titleEn: titleEn || titleFr, titleEs: titleEs || titleFr });
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<div style={{ display: "flex", gap: 6, marginBottom: 4 }}>
|
||||
{(["fr", "en", "es"] as const).map((l) => (
|
||||
<button key={l} type="button" onClick={() => setLang(l)}
|
||||
style={{ padding: "4px 12px", borderRadius: 6, fontSize: 12, fontWeight: 600, border: "1px solid", cursor: "pointer", background: lang === l ? "#1d4ed8" : "transparent", borderColor: lang === l ? "#1d4ed8" : "rgba(255,255,255,0.1)", color: lang === l ? "#fff" : "#94a3b8", textTransform: "uppercase" }}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{lang === "fr" && <input type="text" value={titleFr} onChange={(e) => setTitleFr(e.target.value)} placeholder="Titre du module (FR)" required autoFocus />}
|
||||
{lang === "en" && <input type="text" value={titleEn} onChange={(e) => setTitleEn(e.target.value)} placeholder="Module title (EN)" />}
|
||||
{lang === "es" && <input type="text" value={titleEs} onChange={(e) => setTitleEs(e.target.value)} placeholder="Título del módulo (ES)" />}
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button type="submit" disabled={saving || !titleFr} className="btn btn-primary" style={{ fontSize: 13, opacity: !titleFr ? 0.6 : 1 }}>
|
||||
{saving ? "…" : initial ? "Enregistrer" : "Créer"}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} className="btn btn-secondary" style={{ fontSize: 13 }}>Annuler</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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<string, string>;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [showAddLesson, setShowAddLesson] = useState(false);
|
||||
const [quizExpanded, setQuizExpanded] = 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 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({
|
||||
titleFr,
|
||||
titleEn: titleFr,
|
||||
titleEs: titleFr,
|
||||
type: ["VIDEO", "TEXT"].includes(type) ? type : "TEXT",
|
||||
order: module.lessons.length,
|
||||
}),
|
||||
body: JSON.stringify({ ...data, order: module.lessons.length }),
|
||||
});
|
||||
const newLesson = await res.json();
|
||||
onUpdate({ ...module, lessons: [...module.lessons, newLesson] });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
setShowAddLesson(false);
|
||||
};
|
||||
|
||||
const deleteLesson = async (lessonId: string) => {
|
||||
if (!confirm("Supprimer cette leçon ?")) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
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) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const saveQuiz = async (passMark: number, questions: Omit<Question, "id">[]) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/modules/${module.id}/quiz`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
|
@ -180,104 +234,69 @@ function ModuleItem({
|
|||
});
|
||||
const quiz = await res.json();
|
||||
onUpdate({ ...module, quiz });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "#1a1f2e",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
borderRadius: 10,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Module header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "14px 16px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
background: "rgba(29,78,216,0.2)",
|
||||
color: "#60a5fa",
|
||||
borderRadius: 6,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
<div style={{ background: "#1a1f2e", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 10, overflow: "hidden" }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "14px 16px", cursor: "pointer" }} onClick={onToggle}>
|
||||
<span style={{ width: 26, height: 26, background: "rgba(29,78,216,0.2)", color: "#60a5fa", borderRadius: 6, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 12, fontWeight: 700, flexShrink: 0 }}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontSize: 14, fontWeight: 600, color: "#f1f5f9" }}>{module.titleFr}</p>
|
||||
<p style={{ fontSize: 12, color: "#94a3b8" }}>
|
||||
{module.lessons.length} leçon{module.lessons.length !== 1 ? "s" : ""}
|
||||
{module.quiz ? " · Quiz" : ""}
|
||||
{module.quiz ? " · Quiz ✓" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
||||
className="btn btn-danger"
|
||||
style={{ fontSize: 12, padding: "4px 10px" }}
|
||||
>
|
||||
{t.delete}
|
||||
</button>
|
||||
<span style={{ color: "#94a3b8", fontSize: 16 }}>{expanded ? "▲" : "▼"}</span>
|
||||
<div style={{ display: "flex", gap: 6 }} onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={() => setEditing(true)} className="btn btn-secondary" style={{ fontSize: 12, padding: "4px 10px" }}>Renommer</button>
|
||||
<button onClick={onDelete} className="btn btn-danger" style={{ fontSize: 12, padding: "4px 10px" }}>{t.delete}</button>
|
||||
</div>
|
||||
<span style={{ color: "#94a3b8", fontSize: 16, flexShrink: 0 }}>{expanded ? "▲" : "▼"}</span>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<Modal title="Renommer le module" onClose={() => setEditing(false)}>
|
||||
<ModuleForm
|
||||
initial={{ titleFr: module.titleFr, titleEn: module.titleEn, titleEs: module.titleEs }}
|
||||
onSave={handleRename}
|
||||
onCancel={() => setEditing(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<div style={{ borderTop: "1px solid rgba(255,255,255,0.08)", padding: "16px" }}>
|
||||
<div style={{ borderTop: "1px solid rgba(255,255,255,0.08)", padding: 16 }}>
|
||||
{/* Lessons */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 12 }}>
|
||||
{module.lessons.map((lesson) => (
|
||||
<LessonItem
|
||||
key={lesson.id}
|
||||
lesson={lesson}
|
||||
moduleId={module.id}
|
||||
onUpdate={(updated) =>
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={addLesson}
|
||||
disabled={loading}
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: 12, marginBottom: 16 }}
|
||||
>
|
||||
<button onClick={() => setShowAddLesson(true)} className="btn btn-secondary" style={{ fontSize: 12, marginBottom: 16 }}>
|
||||
+ {t.add_lesson}
|
||||
</button>
|
||||
|
||||
{/* Quiz section */}
|
||||
{showAddLesson && (
|
||||
<Modal title="Ajouter une leçon" onClose={() => setShowAddLesson(false)}>
|
||||
<LessonCreateForm onSave={handleAddLesson} onCancel={() => setShowAddLesson(false)} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Quiz */}
|
||||
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)", paddingTop: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 8,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8, cursor: "pointer" }}
|
||||
onClick={() => setQuizExpanded(!quizExpanded)}
|
||||
>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: "#fbbf24" }}>
|
||||
|
|
@ -285,9 +304,7 @@ function ModuleItem({
|
|||
</span>
|
||||
<span style={{ color: "#94a3b8" }}>{quizExpanded ? "▲" : "▼"}</span>
|
||||
</div>
|
||||
{quizExpanded && (
|
||||
<QuizEditor quiz={module.quiz} onSave={saveQuiz} t={t} />
|
||||
)}
|
||||
{quizExpanded && <QuizEditor quiz={module.quiz} onSave={saveQuiz} t={t} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -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<string, string>;
|
||||
onSave: (data: { titleFr: string; titleEn: string; titleEs: string; type: string }) => Promise<void>;
|
||||
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 (
|
||||
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, color: "#94a3b8", marginBottom: 6 }}>Type de leçon</label>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{["TEXT", "VIDEO"].map((t) => (
|
||||
<button key={t} type="button" onClick={() => setType(t)}
|
||||
style={{ flex: 1, padding: "8px", borderRadius: 6, fontSize: 13, fontWeight: 600, border: "1px solid", cursor: "pointer", background: type === t ? (t === "VIDEO" ? "rgba(29,78,216,0.2)" : "rgba(34,197,94,0.1)") : "transparent", borderColor: type === t ? (t === "VIDEO" ? "#1d4ed8" : "#22c55e") : "rgba(255,255,255,0.1)", color: type === t ? (t === "VIDEO" ? "#60a5fa" : "#4ade80") : "#94a3b8" }}>
|
||||
{t === "VIDEO" ? "▶ Vidéo" : "📄 Texte"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
{(["fr", "en", "es"] as const).map((l) => (
|
||||
<button key={l} type="button" onClick={() => setLang(l)}
|
||||
style={{ padding: "4px 12px", borderRadius: 6, fontSize: 12, fontWeight: 600, border: "1px solid", cursor: "pointer", background: lang === l ? "#1d4ed8" : "transparent", borderColor: lang === l ? "#1d4ed8" : "rgba(255,255,255,0.1)", color: lang === l ? "#fff" : "#94a3b8", textTransform: "uppercase" }}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{lang === "fr" && <input type="text" value={titleFr} onChange={(e) => setTitleFr(e.target.value)} placeholder="Titre de la leçon (FR)" required autoFocus />}
|
||||
{lang === "en" && <input type="text" value={titleEn} onChange={(e) => setTitleEn(e.target.value)} placeholder="Lesson title (EN)" />}
|
||||
{lang === "es" && <input type="text" value={titleEs} onChange={(e) => setTitleEs(e.target.value)} placeholder="Título de la lección (ES)" />}
|
||||
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button type="submit" disabled={saving || !titleFr} className="btn btn-primary" style={{ fontSize: 13, opacity: !titleFr ? 0.6 : 1 }}>
|
||||
{saving ? "…" : "Créer la leçon"}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} className="btn btn-secondary" style={{ fontSize: 13 }}>Annuler</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── LessonItem ───────────────────────────────────────────────────────────────
|
||||
|
||||
function LessonItem({ lesson, onUpdate, onDelete, t }: {
|
||||
lesson: Lesson; onUpdate: (l: Lesson) => void; onDelete: () => void; t: Record<string, string>;
|
||||
}) {
|
||||
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<HTMLInputElement>(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 (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 12px",
|
||||
background: "rgba(255,255,255,0.03)",
|
||||
borderRadius: 6,
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "#475569" }}>
|
||||
{lesson.type === "VIDEO" ? "▶" : "📄"}
|
||||
</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 12px", background: "rgba(255,255,255,0.03)", borderRadius: 6, border: "1px solid rgba(255,255,255,0.06)" }}>
|
||||
<span style={{ fontSize: 12, color: "#475569" }}>{lesson.type === "VIDEO" ? "▶" : "📄"}</span>
|
||||
<span style={{ fontSize: 13, color: "#cbd5e1", flex: 1 }}>{lesson.titleFr}</span>
|
||||
{lesson.duration && (
|
||||
<span style={{ fontSize: 11, color: "#475569" }}>
|
||||
{Math.floor(lesson.duration / 60)}min
|
||||
</span>
|
||||
)}
|
||||
<button onClick={() => setEditing(true)} className="btn btn-secondary" style={{ fontSize: 11, padding: "3px 8px" }}>
|
||||
{t.edit}
|
||||
</button>
|
||||
<button onClick={onDelete} className="btn btn-danger" style={{ fontSize: 11, padding: "3px 8px" }}>
|
||||
{t.delete}
|
||||
</button>
|
||||
{lesson.duration && <span style={{ fontSize: 11, color: "#475569" }}>{Math.floor(lesson.duration / 60)}min</span>}
|
||||
<button onClick={() => setEditing(true)} className="btn btn-secondary" style={{ fontSize: 11, padding: "3px 8px" }}>{t.edit}</button>
|
||||
<button onClick={onDelete} className="btn btn-danger" style={{ fontSize: 11, padding: "3px 8px" }}>{t.delete}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 14,
|
||||
background: "rgba(29,78,216,0.05)",
|
||||
border: "1px solid rgba(29,78,216,0.2)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: 16, background: "rgba(29,78,216,0.05)", border: "1px solid rgba(29,78,216,0.2)", borderRadius: 8 }}>
|
||||
<div style={{ display: "flex", gap: 6, marginBottom: 12 }}>
|
||||
{(["fr", "en", "es"] as const).map((l) => (
|
||||
<button key={l} type="button" onClick={() => setLang(l)}
|
||||
style={{ padding: "4px 12px", borderRadius: 6, fontSize: 12, fontWeight: 600, border: "1px solid", cursor: "pointer", background: lang === l ? "#1d4ed8" : "transparent", borderColor: lang === l ? "#1d4ed8" : "rgba(255,255,255,0.1)", color: lang === l ? "#fff" : "#94a3b8", textTransform: "uppercase" }}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={titleFr}
|
||||
onChange={(e) => setTitleFr(e.target.value)}
|
||||
placeholder="Titre (FR)"
|
||||
/>
|
||||
{lesson.type === "VIDEO" ? (
|
||||
{lang === "fr" && (
|
||||
<>
|
||||
<input type="text" value={titleFr} onChange={(e) => setTitleFr(e.target.value)} placeholder="Titre (FR)" />
|
||||
{lesson.type === "TEXT" ? (
|
||||
<textarea value={contentFr} onChange={(e) => setContentFr(e.target.value)} placeholder="Contenu (FR)" rows={5} />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{lang === "en" && (
|
||||
<>
|
||||
<input type="text" value={titleEn} onChange={(e) => setTitleEn(e.target.value)} placeholder="Title (EN)" />
|
||||
{lesson.type === "TEXT" ? (
|
||||
<textarea value={contentEn} onChange={(e) => setContentEn(e.target.value)} placeholder="Content (EN)" rows={5} />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{lang === "es" && (
|
||||
<>
|
||||
<input type="text" value={titleEs} onChange={(e) => setTitleEs(e.target.value)} placeholder="Título (ES)" />
|
||||
{lesson.type === "TEXT" ? (
|
||||
<textarea value={contentEs} onChange={(e) => setContentEs(e.target.value)} placeholder="Contenido (ES)" rows={5} />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{lesson.type === "VIDEO" && (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={videoUrl}
|
||||
onChange={(e) => setVideoUrl(e.target.value)}
|
||||
placeholder="Clé S3 de la vidéo"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
<input type="text" value={videoUrl} onChange={(e) => setVideoUrl(e.target.value)} placeholder="Clé S3 de la vidéo" style={{ flex: 1 }} />
|
||||
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploading} className="btn btn-secondary" style={{ fontSize: 12 }}>
|
||||
{uploading ? "…" : t.upload_video}
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleVideoUpload(file);
|
||||
}}
|
||||
/>
|
||||
<input ref={fileRef} type="file" accept="video/*" style={{ display: "none" }} onChange={(e) => { const f = e.target.files?.[0]; if (f) handleVideoUpload(f); }} />
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
value={contentFr}
|
||||
onChange={(e) => setContentFr(e.target.value)}
|
||||
placeholder="Contenu (FR)"
|
||||
rows={4}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
type="number"
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(e.target.value)}
|
||||
placeholder="Durée (secondes)"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 12, color: "#94a3b8", marginBottom: 4 }}>Durée (secondes)</label>
|
||||
<input type="number" value={duration} onChange={(e) => setDuration(e.target.value)} placeholder="ex: 360" style={{ width: 140 }} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button onClick={handleSave} className="btn btn-primary" style={{ fontSize: 12 }}>
|
||||
{t.save}
|
||||
</button>
|
||||
<button onClick={() => setEditing(false)} className="btn btn-secondary" style={{ fontSize: 12 }}>
|
||||
{t.cancel}
|
||||
</button>
|
||||
<button onClick={handleSave} className="btn btn-primary" style={{ fontSize: 12 }}>{t.save}</button>
|
||||
<button onClick={() => setEditing(false)} className="btn btn-secondary" style={{ fontSize: 12 }}>{t.cancel}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuizEditor({
|
||||
quiz,
|
||||
onSave,
|
||||
t,
|
||||
}: {
|
||||
// ─── QuizEditor ───────────────────────────────────────────────────────────────
|
||||
|
||||
function QuizEditor({ quiz, onSave, t }: {
|
||||
quiz?: Quiz | null;
|
||||
onSave: (passMark: number, questions: Omit<Question, "id">[]) => void;
|
||||
t: Record<string, string>;
|
||||
}) {
|
||||
const [passMark, setPassMark] = useState(quiz?.passMark ?? 80);
|
||||
const [questions, setQuestions] = useState<Omit<Question, "id">[]>(
|
||||
quiz?.questions.map((q) => ({
|
||||
textFr: q.textFr,
|
||||
textEn: q.textEn,
|
||||
textEs: q.textEs,
|
||||
optionsFr: q.optionsFr,
|
||||
optionsEn: q.optionsEn,
|
||||
optionsEs: q.optionsEs,
|
||||
correctIndex: q.correctIndex,
|
||||
order: q.order,
|
||||
})) ?? []
|
||||
quiz?.questions.map((q) => ({ textFr: q.textFr, textEn: q.textEn, textEs: q.textEs, optionsFr: q.optionsFr, optionsEn: q.optionsEn, optionsEs: q.optionsEs, correctIndex: q.correctIndex, order: q.order })) ?? []
|
||||
);
|
||||
const [lang, setLang] = useState<"fr" | "en" | "es">("fr");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const addQuestion = () => {
|
||||
setQuestions((prev) => [
|
||||
...prev,
|
||||
{
|
||||
textFr: "",
|
||||
textEn: "",
|
||||
textEs: "",
|
||||
optionsFr: ["", "", "", ""],
|
||||
optionsEn: ["", "", "", ""],
|
||||
optionsEs: ["", "", "", ""],
|
||||
correctIndex: 0,
|
||||
order: prev.length,
|
||||
},
|
||||
]);
|
||||
setQuestions((prev) => [...prev, { textFr: "", textEn: "", textEs: "", optionsFr: ["", "", "", ""], optionsEn: ["", "", "", ""], optionsEs: ["", "", "", ""], correctIndex: 0, order: prev.length }]);
|
||||
};
|
||||
|
||||
const removeQuestion = (i: number) => {
|
||||
setQuestions((prev) => prev.filter((_, idx) => idx !== i));
|
||||
};
|
||||
const removeQuestion = (i: number) => setQuestions((prev) => prev.filter((_, idx) => idx !== i));
|
||||
|
||||
const updateQuestion = (i: number, field: string, value: any) => {
|
||||
setQuestions((prev) =>
|
||||
prev.map((q, idx) => (idx === i ? { ...q, [field]: value } : q))
|
||||
);
|
||||
};
|
||||
const updateField = (i: number, field: string, value: any) =>
|
||||
setQuestions((prev) => prev.map((q, idx) => idx === i ? { ...q, [field]: value } : q));
|
||||
|
||||
const updateOption = (qi: number, oi: number, value: string) => {
|
||||
setQuestions((prev) =>
|
||||
prev.map((q, idx) => {
|
||||
const updateOption = (qi: number, oi: number, field: "optionsFr" | "optionsEn" | "optionsEs", value: string) => {
|
||||
setQuestions((prev) => prev.map((q, idx) => {
|
||||
if (idx !== qi) return q;
|
||||
const opts = [...q.optionsFr];
|
||||
const opts = [...q[field]];
|
||||
opts[oi] = value;
|
||||
return { ...q, optionsFr: opts };
|
||||
})
|
||||
);
|
||||
return { ...q, [field]: opts };
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
await onSave(passMark, questions);
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const textField = lang === "fr" ? "textFr" : lang === "en" ? "textEn" : "textEs";
|
||||
const optsField = lang === "fr" ? "optionsFr" : lang === "en" ? "optionsEn" : "optionsEs";
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<label style={{ fontSize: 13, color: "#94a3b8" }}>Note minimale (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={passMark}
|
||||
onChange={(e) => setPassMark(parseInt(e.target.value))}
|
||||
style={{ width: 80 }}
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
<input type="number" value={passMark} onChange={(e) => setPassMark(parseInt(e.target.value))} style={{ width: 80 }} min={0} max={100} />
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 4 }}>
|
||||
{(["fr", "en", "es"] as const).map((l) => (
|
||||
<button key={l} type="button" onClick={() => setLang(l)}
|
||||
style={{ padding: "4px 10px", borderRadius: 6, fontSize: 11, fontWeight: 600, border: "1px solid", cursor: "pointer", background: lang === l ? "#1d4ed8" : "transparent", borderColor: lang === l ? "#1d4ed8" : "rgba(255,255,255,0.1)", color: lang === l ? "#fff" : "#94a3b8", textTransform: "uppercase" }}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{questions.map((q, qi) => (
|
||||
<div
|
||||
key={qi}
|
||||
style={{
|
||||
background: "rgba(245,158,11,0.05)",
|
||||
border: "1px solid rgba(245,158,11,0.2)",
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
}}
|
||||
>
|
||||
<div key={qi} style={{ background: "rgba(245,158,11,0.05)", border: "1px solid rgba(245,158,11,0.2)", borderRadius: 8, padding: 14 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 10 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: "#fbbf24" }}>
|
||||
Question {qi + 1}
|
||||
</span>
|
||||
<button onClick={() => removeQuestion(qi)} className="btn btn-danger" style={{ fontSize: 11, padding: "2px 8px" }}>
|
||||
{t.delete}
|
||||
</button>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: "#fbbf24" }}>Question {qi + 1}</span>
|
||||
<button onClick={() => removeQuestion(qi)} className="btn btn-danger" style={{ fontSize: 11, padding: "2px 8px" }}>{t.delete}</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={q.textFr}
|
||||
onChange={(e) => updateQuestion(qi, "textFr", e.target.value)}
|
||||
placeholder="Question (FR)"
|
||||
value={(q as any)[textField]}
|
||||
onChange={(e) => updateField(qi, textField, e.target.value)}
|
||||
placeholder={`Question (${lang.toUpperCase()})`}
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{q.optionsFr.map((opt, oi) => (
|
||||
{(q as any)[optsField].map((opt: string, oi: number) => (
|
||||
<div key={oi} style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||||
<input
|
||||
type="radio"
|
||||
name={`correct-${qi}`}
|
||||
checked={q.correctIndex === oi}
|
||||
onChange={() => updateQuestion(qi, "correctIndex", oi)}
|
||||
style={{ accentColor: "#22c55e" }}
|
||||
/>
|
||||
<input type="radio" name={`correct-${qi}`} checked={q.correctIndex === oi} onChange={() => updateField(qi, "correctIndex", oi)} style={{ accentColor: "#22c55e" }} />
|
||||
<input
|
||||
type="text"
|
||||
value={opt}
|
||||
onChange={(e) => updateOption(qi, oi, e.target.value)}
|
||||
onChange={(e) => updateOption(qi, oi, optsField as any, e.target.value)}
|
||||
placeholder={`Option ${oi + 1}`}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
|
|
@ -576,15 +578,9 @@ function QuizEditor({
|
|||
))}
|
||||
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button onClick={addQuestion} className="btn btn-secondary" style={{ fontSize: 12 }}>
|
||||
+ {t.add_question}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSave(passMark, questions)}
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
{t.save} Quiz
|
||||
<button onClick={addQuestion} className="btn btn-secondary" style={{ fontSize: 12 }}>+ {t.add_question}</button>
|
||||
<button onClick={handleSave} disabled={saving} className="btn btn-primary" style={{ fontSize: 12 }}>
|
||||
{saving ? "…" : `${t.save} Quiz`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -20,18 +20,20 @@ export default async function AdminDashboardPage({
|
|||
redirect(`/${locale}/dashboard`);
|
||||
}
|
||||
|
||||
const [totalCourses, publishedCourses, totalStudents, totalEnrollments, totalCompletions] =
|
||||
const [totalCourses, publishedCourses, totalStudents, totalEnrollments, totalCompletions, totalPaths] =
|
||||
await Promise.all([
|
||||
db.course.count(),
|
||||
db.course.count({ where: { published: true } }),
|
||||
db.user.count({ where: { role: "LEARNER" } }),
|
||||
db.enrollment.count(),
|
||||
db.enrollment.count({ where: { completedAt: { not: null } } }),
|
||||
db.learningPath.count(),
|
||||
]);
|
||||
|
||||
const stats = [
|
||||
{ label: "Formations totales", value: totalCourses, sub: `${publishedCourses} publiées`, icon: "📚", href: `/${locale}/admin/courses`, color: "#1d4ed8" },
|
||||
{ label: "Apprenants", value: totalStudents, sub: `${totalEnrollments} inscriptions`, icon: "👥", href: `/${locale}/admin/students`, color: "#8b5cf6" },
|
||||
{ label: "Parcours", value: totalPaths, sub: "programmes complets", icon: "🗺️", href: `/${locale}/admin/paths`, color: "#8b5cf6" },
|
||||
{ label: "Apprenants", value: totalStudents, sub: `${totalEnrollments} inscriptions`, icon: "👥", href: `/${locale}/admin/students`, color: "#06b6d4" },
|
||||
{ label: "Complétions", value: totalCompletions, sub: "formations terminées", icon: "🎓", href: null, color: "#22c55e" },
|
||||
];
|
||||
|
||||
|
|
@ -50,7 +52,7 @@ export default async function AdminDashboardPage({
|
|||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gridTemplateColumns: "repeat(4, 1fr)",
|
||||
gap: 16,
|
||||
marginBottom: 40,
|
||||
}}
|
||||
|
|
@ -126,6 +128,20 @@ export default async function AdminDashboardPage({
|
|||
>
|
||||
{t("courses")}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${locale}/admin/paths/new`}
|
||||
className="btn btn-secondary"
|
||||
style={{ justifyContent: "center" }}
|
||||
>
|
||||
+ Nouveau parcours
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${locale}/admin/paths`}
|
||||
className="btn btn-secondary"
|
||||
style={{ justifyContent: "center" }}
|
||||
>
|
||||
🗺️ Parcours
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${locale}/admin/students`}
|
||||
className="btn btn-secondary"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
export function AdminPathActions({
|
||||
pathId,
|
||||
isPublished,
|
||||
editHref,
|
||||
}: {
|
||||
pathId: string;
|
||||
isPublished: boolean;
|
||||
editHref: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const togglePublish = async () => {
|
||||
setLoading(true);
|
||||
await fetch(`/api/admin/paths/${pathId}/publish`, { method: "PUT" });
|
||||
router.refresh();
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm("Supprimer ce parcours définitivement ?")) return;
|
||||
setLoading(true);
|
||||
await fetch(`/api/admin/paths/${pathId}`, { method: "DELETE" });
|
||||
router.refresh();
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
<Link href={editHref} className="btn btn-secondary" style={{ fontSize: 12, padding: "4px 10px" }}>
|
||||
Modifier
|
||||
</Link>
|
||||
<button
|
||||
onClick={togglePublish}
|
||||
disabled={loading}
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: 12, padding: "4px 10px", color: isPublished ? "#fbbf24" : "#4ade80" }}
|
||||
>
|
||||
{isPublished ? "Dépublier" : "Publier"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
className="btn btn-danger"
|
||||
style={{ fontSize: 12, padding: "4px 10px" }}
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface PathFormProps {
|
||||
locale: string;
|
||||
path?: {
|
||||
id: string;
|
||||
titleFr: string;
|
||||
titleEn: string;
|
||||
titleEs: string;
|
||||
descFr: string;
|
||||
descEn: string;
|
||||
descEs: string;
|
||||
slug: string;
|
||||
published: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const LANG_TABS = ["fr", "en", "es"] as const;
|
||||
|
||||
function slugify(text: string) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[éèêë]/g, "e")
|
||||
.replace(/[àâä]/g, "a")
|
||||
.replace(/[îï]/g, "i")
|
||||
.replace(/[ôö]/g, "o")
|
||||
.replace(/[ùûü]/g, "u")
|
||||
.replace(/[ç]/g, "c")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
export function PathForm({ locale, path }: PathFormProps) {
|
||||
const [langTab, setLangTab] = useState<"fr" | "en" | "es">("fr");
|
||||
const [titleFr, setTitleFr] = useState(path?.titleFr ?? "");
|
||||
const [titleEn, setTitleEn] = useState(path?.titleEn ?? "");
|
||||
const [titleEs, setTitleEs] = useState(path?.titleEs ?? "");
|
||||
const [descFr, setDescFr] = useState(path?.descFr ?? "");
|
||||
const [descEn, setDescEn] = useState(path?.descEn ?? "");
|
||||
const [descEs, setDescEs] = useState(path?.descEs ?? "");
|
||||
const [slug, setSlug] = useState(path?.slug ?? "");
|
||||
const [published, setPublished] = useState(path?.published ?? false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
const handleTitleFrChange = (v: string) => {
|
||||
setTitleFr(v);
|
||||
if (!path) setSlug(slugify(v));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError("");
|
||||
|
||||
const body = { titleFr, titleEn: titleEn || titleFr, titleEs: titleEs || titleFr, descFr, descEn: descEn || descFr, descEs: descEs || descFr, slug, published };
|
||||
|
||||
const res = path
|
||||
? await fetch(`/api/admin/paths/${path.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
|
||||
: await fetch("/api/admin/paths", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
|
||||
setSaving(false);
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
setError(data.error ?? "Erreur");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
router.push(`/${locale}/admin/paths/${data.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
<div className="card">
|
||||
<div style={{ display: "flex", gap: 4, marginBottom: 20 }}>
|
||||
{LANG_TABS.map((l) => (
|
||||
<button key={l} type="button" onClick={() => setLangTab(l)}
|
||||
style={{ padding: "6px 16px", borderRadius: 6, fontSize: 13, fontWeight: 600, border: "1px solid", cursor: "pointer", background: langTab === l ? "#1d4ed8" : "transparent", borderColor: langTab === l ? "#1d4ed8" : "rgba(255,255,255,0.1)", color: langTab === l ? "#fff" : "#94a3b8", textTransform: "uppercase" }}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{langTab === "fr" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, color: "#94a3b8", marginBottom: 6 }}>Titre du parcours (FR) *</label>
|
||||
<input type="text" value={titleFr} onChange={(e) => handleTitleFrChange(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, color: "#94a3b8", marginBottom: 6 }}>Description (FR) *</label>
|
||||
<textarea value={descFr} onChange={(e) => setDescFr(e.target.value)} rows={5} required />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{langTab === "en" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, color: "#94a3b8", marginBottom: 6 }}>Path title (EN)</label>
|
||||
<input type="text" value={titleEn} onChange={(e) => setTitleEn(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, color: "#94a3b8", marginBottom: 6 }}>Description (EN)</label>
|
||||
<textarea value={descEn} onChange={(e) => setDescEn(e.target.value)} rows={5} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{langTab === "es" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, color: "#94a3b8", marginBottom: 6 }}>Título (ES)</label>
|
||||
<input type="text" value={titleEs} onChange={(e) => setTitleEs(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, color: "#94a3b8", marginBottom: 6 }}>Descripción (ES)</label>
|
||||
<textarea value={descEs} onChange={(e) => setDescEs(e.target.value)} rows={5} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ fontSize: 15, fontWeight: 700, color: "#f1f5f9", marginBottom: 16 }}>Métadonnées</h3>
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: 13, color: "#94a3b8", marginBottom: 6 }}>Slug *</label>
|
||||
<input type="text" value={slug} onChange={(e) => setSlug(e.target.value)} required />
|
||||
</div>
|
||||
<div style={{ marginTop: 14, display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<input type="checkbox" id="published" checked={published} onChange={(e) => setPublished(e.target.checked)} style={{ width: 16, height: 16, accentColor: "#1d4ed8" }} />
|
||||
<label htmlFor="published" style={{ fontSize: 14, color: "#f1f5f9", cursor: "pointer" }}>Publier le parcours</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p style={{ color: "#f87171", fontSize: 13, background: "rgba(239,68,68,0.1)", padding: "10px 14px", borderRadius: 6, border: "1px solid rgba(239,68,68,0.3)" }}>{error}</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: 10 }}>
|
||||
<button type="submit" disabled={saving} className="btn btn-primary">
|
||||
{saving ? "…" : path ? "Enregistrer" : "Créer le parcours"}
|
||||
</button>
|
||||
<button type="button" onClick={() => router.back()} className="btn btn-secondary">Annuler</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface Course {
|
||||
id: string;
|
||||
titleFr: string;
|
||||
published: boolean;
|
||||
}
|
||||
|
||||
interface PathCourse {
|
||||
id: string;
|
||||
courseId: string;
|
||||
order: number;
|
||||
course: Course;
|
||||
}
|
||||
|
||||
export function PathCourseManager({
|
||||
pathId,
|
||||
pathCourses: initial,
|
||||
allCourses,
|
||||
}: {
|
||||
pathId: string;
|
||||
pathCourses: PathCourse[];
|
||||
allCourses: Course[];
|
||||
}) {
|
||||
const [pathCourses, setPathCourses] = useState(initial);
|
||||
const [selectedCourseId, setSelectedCourseId] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const addedIds = new Set(pathCourses.map((pc) => pc.courseId));
|
||||
const availableCourses = allCourses.filter((c) => !addedIds.has(c.id));
|
||||
|
||||
const addCourse = async () => {
|
||||
if (!selectedCourseId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/paths/${pathId}/courses`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ courseId: selectedCourseId }),
|
||||
});
|
||||
const lpc = await res.json();
|
||||
setPathCourses((prev) => [...prev, lpc]);
|
||||
setSelectedCourseId("");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeCourse = async (courseId: string) => {
|
||||
if (!confirm("Retirer ce cours du parcours ?")) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await fetch(`/api/admin/paths/${pathId}/courses`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ courseId }),
|
||||
});
|
||||
setPathCourses((prev) => prev.filter((pc) => pc.courseId !== courseId));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{pathCourses.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "24px",
|
||||
textAlign: "center",
|
||||
background: "#1a1f2e",
|
||||
borderRadius: 8,
|
||||
border: "1px dashed rgba(255,255,255,0.1)",
|
||||
color: "#64748b",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
Aucune formation dans ce parcours.
|
||||
</div>
|
||||
) : (
|
||||
pathCourses.map((pc, i) => (
|
||||
<div
|
||||
key={pc.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
padding: "12px 16px",
|
||||
background: "#1a1f2e",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
background: "rgba(29,78,216,0.2)",
|
||||
color: "#60a5fa",
|
||||
borderRadius: 6,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<p style={{ fontSize: 14, fontWeight: 600, color: "#f1f5f9" }}>{pc.course.titleFr}</p>
|
||||
{!pc.course.published && (
|
||||
<span style={{ fontSize: 11, color: "#94a3b8" }}>Brouillon</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeCourse(pc.courseId)}
|
||||
disabled={loading}
|
||||
className="btn btn-danger"
|
||||
style={{ fontSize: 12, padding: "4px 10px" }}
|
||||
>
|
||||
Retirer
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{availableCourses.length > 0 && (
|
||||
<div style={{ display: "flex", gap: 10, marginTop: 8 }}>
|
||||
<select
|
||||
value={selectedCourseId}
|
||||
onChange={(e) => setSelectedCourseId(e.target.value)}
|
||||
style={{ flex: 1, fontSize: 14 }}
|
||||
>
|
||||
<option value="">— Ajouter une formation —</option>
|
||||
{availableCourses.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.titleFr}{!c.published ? " (brouillon)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={addCourse}
|
||||
disabled={loading || !selectedCourseId}
|
||||
className="btn btn-primary"
|
||||
style={{ opacity: !selectedCourseId ? 0.6 : 1 }}
|
||||
>
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { redirect, notFound } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
import { PathForm } from "../PathForm";
|
||||
import { PathCourseManager } from "./PathCourseManager";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EditPathPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
}) {
|
||||
const { locale, id } = await params;
|
||||
setRequestLocale(locale);
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user || (session.user as any).role !== "ADMIN") {
|
||||
redirect(`/${locale}/dashboard`);
|
||||
}
|
||||
|
||||
const [path, allCourses] = await Promise.all([
|
||||
db.learningPath.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
courses: {
|
||||
include: { course: true },
|
||||
orderBy: { order: "asc" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.course.findMany({ orderBy: { titleFr: "asc" }, select: { id: true, titleFr: true, published: true } }),
|
||||
]);
|
||||
|
||||
if (!path) notFound();
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 900, margin: "0 auto", padding: "40px 24px" }}>
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<Link href={`/${locale}/admin/paths`} style={{ fontSize: 12, color: "#60a5fa" }}>
|
||||
← Parcours
|
||||
</Link>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 800, color: "#f1f5f9", marginTop: 4 }}>
|
||||
{path.titleFr}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 32 }}>
|
||||
<PathForm locale={locale} path={path} />
|
||||
|
||||
<div>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, color: "#f1f5f9", marginBottom: 16 }}>
|
||||
Formations du parcours
|
||||
</h2>
|
||||
<PathCourseManager
|
||||
pathId={path.id}
|
||||
pathCourses={path.courses.map((lpc) => ({ id: lpc.id, courseId: lpc.courseId, order: lpc.order, course: lpc.course }))}
|
||||
allCourses={allCourses}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
import { PathForm } from "../PathForm";
|
||||
|
||||
export default async function NewPathPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user || (session.user as any).role !== "ADMIN") {
|
||||
redirect(`/${locale}/dashboard`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 900, margin: "0 auto", padding: "40px 24px" }}>
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<Link href={`/${locale}/admin/paths`} style={{ fontSize: 12, color: "#60a5fa" }}>
|
||||
← Parcours
|
||||
</Link>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 800, color: "#f1f5f9", marginTop: 4 }}>
|
||||
Nouveau parcours
|
||||
</h1>
|
||||
</div>
|
||||
<PathForm locale={locale} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
import { AdminPathActions } from "./AdminPathActions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminPathsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user || (session.user as any).role !== "ADMIN") {
|
||||
redirect(`/${locale}/dashboard`);
|
||||
}
|
||||
|
||||
const paths = await db.learningPath.findMany({
|
||||
orderBy: { order: "asc" },
|
||||
include: {
|
||||
courses: true,
|
||||
_count: { select: { enrollments: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 1200, margin: "0 auto", padding: "40px 24px" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 32 }}>
|
||||
<div>
|
||||
<Link href={`/${locale}/admin`} style={{ fontSize: 12, color: "#60a5fa" }}>
|
||||
← Administration
|
||||
</Link>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 800, color: "#f1f5f9", marginTop: 4 }}>
|
||||
Parcours de formation
|
||||
</h1>
|
||||
</div>
|
||||
<Link href={`/${locale}/admin/paths/new`} className="btn btn-primary">
|
||||
+ Nouveau parcours
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Titre (FR)</th>
|
||||
<th>Formations</th>
|
||||
<th>Inscrits</th>
|
||||
<th>Statut</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paths.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ textAlign: "center", color: "#94a3b8", padding: "48px" }}>
|
||||
Aucun parcours.{" "}
|
||||
<Link href={`/${locale}/admin/paths/new`} style={{ color: "#60a5fa" }}>
|
||||
Créer le premier.
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paths.map((path) => (
|
||||
<tr key={path.id}>
|
||||
<td>
|
||||
<div>
|
||||
<p style={{ fontWeight: 600, color: "#f1f5f9", fontSize: 14 }}>{path.titleFr}</p>
|
||||
<p style={{ fontSize: 11, color: "#475569" }}>{path.slug}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ color: "#f1f5f9", fontSize: 14 }}>{path.courses.length} cours</td>
|
||||
<td style={{ color: "#f1f5f9", fontSize: 14 }}>{path._count.enrollments}</td>
|
||||
<td>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
padding: "3px 10px",
|
||||
borderRadius: 999,
|
||||
background: path.published ? "rgba(34,197,94,0.15)" : "rgba(148,163,184,0.1)",
|
||||
color: path.published ? "#4ade80" : "#94a3b8",
|
||||
}}
|
||||
>
|
||||
{path.published ? "Publié" : "Brouillon"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<AdminPathActions
|
||||
pathId={path.id}
|
||||
isPublished={path.published}
|
||||
editHref={`/${locale}/admin/paths/${path.id}`}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
import { redirect, notFound } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
import { ProgressBar } from "@/components/ProgressBar";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function StudentDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
}) {
|
||||
const { locale, id } = await params;
|
||||
setRequestLocale(locale);
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user || (session.user as any).role !== "ADMIN") {
|
||||
redirect(`/${locale}/dashboard`);
|
||||
}
|
||||
|
||||
const student = await db.user.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
enrollments: {
|
||||
include: {
|
||||
course: {
|
||||
include: {
|
||||
modules: { include: { lessons: { select: { id: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { enrolledAt: "desc" },
|
||||
},
|
||||
certificates: { include: { course: true }, orderBy: { issuedAt: "desc" } },
|
||||
quizAttempts: { include: { quiz: { include: { module: { include: { course: true } } } } }, orderBy: { completedAt: "desc" } },
|
||||
learningPathEnrollments: { include: { learningPath: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!student) notFound();
|
||||
|
||||
// Compute progress for each enrollment
|
||||
const enriched = await Promise.all(
|
||||
student.enrollments.map(async (e) => {
|
||||
const allLessons = e.course.modules.flatMap((m) => m.lessons);
|
||||
let progress = 0;
|
||||
if (allLessons.length > 0) {
|
||||
const done = await db.lessonProgress.count({
|
||||
where: { userId: student.id, lessonId: { in: allLessons.map((l) => l.id) } },
|
||||
});
|
||||
progress = Math.round((done / allLessons.length) * 100);
|
||||
}
|
||||
return { ...e, progress, totalLessons: allLessons.length };
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 1000, margin: "0 auto", padding: "40px 24px" }}>
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<Link href={`/${locale}/admin/students`} style={{ fontSize: 12, color: "#60a5fa" }}>
|
||||
← Étudiants
|
||||
</Link>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16, marginTop: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
background: "linear-gradient(135deg, #1e3a8a, #1d4ed8)",
|
||||
borderRadius: 12,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: "#fff",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{(student.name || student.email)[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 800, color: "#f1f5f9" }}>
|
||||
{student.name || "—"}
|
||||
</h1>
|
||||
<p style={{ fontSize: 14, color: "#64748b" }}>{student.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 12, marginBottom: 32 }}>
|
||||
{[
|
||||
{ label: "Formations inscrites", value: student.enrollments.length, color: "#1d4ed8" },
|
||||
{ label: "Formations terminées", value: enriched.filter((e) => e.completedAt).length, color: "#22c55e" },
|
||||
{ label: "Certificats", value: student.certificates.length, color: "#f59e0b" },
|
||||
{ label: "Parcours", value: student.learningPathEnrollments.length, color: "#8b5cf6" },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="card" style={{ textAlign: "center" }}>
|
||||
<div style={{ fontSize: 28, fontWeight: 800, color: s.color, marginBottom: 4 }}>{s.value}</div>
|
||||
<div style={{ fontSize: 12, color: "#94a3b8" }}>{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 32 }}>
|
||||
{/* Enrollments */}
|
||||
<section>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, color: "#f1f5f9", marginBottom: 14 }}>Formations</h2>
|
||||
{enriched.length === 0 ? (
|
||||
<p style={{ color: "#64748b", fontSize: 14 }}>Aucune inscription.</p>
|
||||
) : (
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Formation</th>
|
||||
<th>Progression</th>
|
||||
<th>Leçons</th>
|
||||
<th>Statut</th>
|
||||
<th>Inscrit le</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{enriched.map((e) => (
|
||||
<tr key={e.id}>
|
||||
<td>
|
||||
<Link href={`/${locale}/admin/courses/${e.course.id}`} style={{ color: "#f1f5f9", fontWeight: 600, fontSize: 14 }}>
|
||||
{e.course.titleFr}
|
||||
</Link>
|
||||
</td>
|
||||
<td style={{ width: 160 }}>
|
||||
<ProgressBar value={e.progress} />
|
||||
<span style={{ fontSize: 11, color: "#94a3b8" }}>{e.progress}%</span>
|
||||
</td>
|
||||
<td style={{ color: "#94a3b8", fontSize: 13 }}>{e.totalLessons}</td>
|
||||
<td>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
padding: "2px 8px",
|
||||
borderRadius: 999,
|
||||
background: e.completedAt ? "rgba(34,197,94,0.15)" : "rgba(148,163,184,0.1)",
|
||||
color: e.completedAt ? "#4ade80" : "#94a3b8",
|
||||
}}
|
||||
>
|
||||
{e.completedAt ? "Terminé" : "En cours"}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ color: "#64748b", fontSize: 13 }}>
|
||||
{e.enrolledAt.toLocaleDateString("fr-FR")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Learning paths */}
|
||||
{student.learningPathEnrollments.length > 0 && (
|
||||
<section>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, color: "#f1f5f9", marginBottom: 14 }}>Parcours</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{student.learningPathEnrollments.map((lpe) => (
|
||||
<div key={lpe.id} className="card" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: "#f1f5f9" }}>{lpe.learningPath.titleFr}</span>
|
||||
<span style={{ fontSize: 12, color: "#64748b" }}>Inscrit le {lpe.enrolledAt.toLocaleDateString("fr-FR")}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Recent quiz attempts */}
|
||||
{student.quizAttempts.length > 0 && (
|
||||
<section>
|
||||
<h2 style={{ fontSize: 18, fontWeight: 700, color: "#f1f5f9", marginBottom: 14 }}>Tentatives de quiz</h2>
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Formation · Module</th>
|
||||
<th>Score</th>
|
||||
<th>Résultat</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{student.quizAttempts.slice(0, 20).map((qa) => (
|
||||
<tr key={qa.id}>
|
||||
<td style={{ fontSize: 13, color: "#cbd5e1" }}>
|
||||
{qa.quiz.module.course.titleFr} · {qa.quiz.module.titleFr}
|
||||
</td>
|
||||
<td style={{ fontSize: 14, fontWeight: 700, color: qa.passed ? "#4ade80" : "#f87171" }}>
|
||||
{qa.score}%
|
||||
</td>
|
||||
<td>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, padding: "2px 8px", borderRadius: 999, background: qa.passed ? "rgba(34,197,94,0.15)" : "rgba(239,68,68,0.15)", color: qa.passed ? "#4ade80" : "#f87171" }}>
|
||||
{qa.passed ? "Réussi" : "Échoué"}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontSize: 12, color: "#64748b" }}>{qa.completedAt.toLocaleDateString("fr-FR")}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -95,7 +95,7 @@ export default async function AdminStudentsPage({
|
|||
students.map((student) => (
|
||||
<tr key={student.id}>
|
||||
<td>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<Link href={`/${locale}/admin/students/${student.id}`} style={{ display: "flex", alignItems: "center", gap: 10, textDecoration: "none" }}>
|
||||
{student.image ? (
|
||||
<img
|
||||
src={student.image}
|
||||
|
|
@ -123,7 +123,7 @@ export default async function AdminStudentsPage({
|
|||
<span style={{ fontSize: 14, fontWeight: 600, color: "#f1f5f9" }}>
|
||||
{student.name ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td style={{ color: "#94a3b8", fontSize: 13 }}>{student.email}</td>
|
||||
<td style={{ color: "#f1f5f9", fontSize: 14 }}>{student._count.enrollments}</td>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function EnrollPathButton({
|
||||
pathId,
|
||||
locale,
|
||||
isLoggedIn,
|
||||
}: {
|
||||
pathId: string;
|
||||
locale: string;
|
||||
isLoggedIn: boolean;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleEnroll = async () => {
|
||||
if (!isLoggedIn) {
|
||||
router.push(`/${locale}/auth/login`);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
await fetch("/api/paths/enroll", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ learningPathId: pathId }),
|
||||
});
|
||||
router.refresh();
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleEnroll}
|
||||
disabled={loading}
|
||||
className="btn btn-primary"
|
||||
style={{ width: "100%", justifyContent: "center", fontSize: 14, opacity: loading ? 0.7 : 1 }}
|
||||
>
|
||||
{loading ? "…" : isLoggedIn ? "S'inscrire gratuitement" : "Se connecter pour s'inscrire"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { EnrollPathButton } from "../EnrollPathButton";
|
||||
import { ProgressBar } from "@/components/ProgressBar";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function getLocaleText(obj: any, locale: string, field: string) {
|
||||
if (locale === "en") return obj[`${field}En`] || obj[`${field}Fr`];
|
||||
if (locale === "es") return obj[`${field}Es`] || obj[`${field}Fr`];
|
||||
return obj[`${field}Fr`];
|
||||
}
|
||||
|
||||
const levelLabel: Record<string, string> = {
|
||||
BEGINNER: "Débutant",
|
||||
INTERMEDIATE: "Intermédiaire",
|
||||
ADVANCED: "Avancé",
|
||||
};
|
||||
|
||||
export default async function PathDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
}) {
|
||||
const { locale, slug } = await params;
|
||||
setRequestLocale(locale);
|
||||
const session = await auth();
|
||||
const userId = (session?.user as any)?.id as string | undefined;
|
||||
|
||||
const path = await db.learningPath.findUnique({
|
||||
where: { slug, published: true },
|
||||
include: {
|
||||
courses: {
|
||||
include: {
|
||||
course: {
|
||||
include: {
|
||||
modules: { include: { lessons: { select: { id: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { order: "asc" },
|
||||
},
|
||||
_count: { select: { enrollments: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!path) notFound();
|
||||
|
||||
const isEnrolled = userId
|
||||
? !!(await db.learningPathEnrollment.findUnique({ where: { userId_learningPathId: { userId, learningPathId: path.id } } }))
|
||||
: false;
|
||||
|
||||
// Get enrollment + progress for each course
|
||||
const courseProgress: Record<string, { enrolled: boolean; progress: number; completedAt: Date | null }> = {};
|
||||
if (userId) {
|
||||
for (const lpc of path.courses) {
|
||||
const enrollment = await db.enrollment.findUnique({ where: { userId_courseId: { userId, courseId: lpc.courseId } } });
|
||||
const allLessons = lpc.course.modules.flatMap((m) => m.lessons);
|
||||
let progress = 0;
|
||||
if (allLessons.length > 0 && enrollment) {
|
||||
const done = await db.lessonProgress.count({ where: { userId, lessonId: { in: allLessons.map((l) => l.id) } } });
|
||||
progress = Math.round((done / allLessons.length) * 100);
|
||||
}
|
||||
courseProgress[lpc.courseId] = { enrolled: !!enrollment, progress, completedAt: enrollment?.completedAt ?? null };
|
||||
}
|
||||
}
|
||||
|
||||
const title = getLocaleText(path, locale, "title");
|
||||
const desc = getLocaleText(path, locale, "desc");
|
||||
const totalLessons = path.courses.reduce((sum, lpc) => sum + lpc.course.modules.reduce((s, m) => s + m.lessons.length, 0), 0);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 900, margin: "0 auto", padding: "40px 24px" }}>
|
||||
<Link href={`/${locale}/paths`} style={{ fontSize: 12, color: "#60a5fa" }}>
|
||||
← Parcours
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ marginTop: 16, marginBottom: 40, display: "grid", gridTemplateColumns: "1fr auto", gap: 32, alignItems: "start" }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: 30, fontWeight: 800, color: "#f1f5f9", marginBottom: 12 }}>{title}</h1>
|
||||
<p style={{ color: "#94a3b8", fontSize: 15, lineHeight: 1.7, marginBottom: 20 }}>{desc}</p>
|
||||
<div style={{ display: "flex", gap: 20, fontSize: 14, color: "#64748b" }}>
|
||||
<span>📚 {path.courses.length} formation{path.courses.length !== 1 ? "s" : ""}</span>
|
||||
<span>🎬 {totalLessons} leçons</span>
|
||||
<span>👥 {path._count.enrollments} inscrits</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ minWidth: 180 }}>
|
||||
{isEnrolled ? (
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(34,197,94,0.1)",
|
||||
border: "1px solid rgba(34,197,94,0.3)",
|
||||
borderRadius: 8,
|
||||
padding: "12px 20px",
|
||||
textAlign: "center",
|
||||
fontSize: 14,
|
||||
color: "#4ade80",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
✓ Inscrit à ce parcours
|
||||
</div>
|
||||
) : (
|
||||
<EnrollPathButton pathId={path.id} locale={locale} isLoggedIn={!!session} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Course list */}
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, color: "#f1f5f9", marginBottom: 16 }}>Formations incluses</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{path.courses.map((lpc, i) => {
|
||||
const courseTitle = getLocaleText(lpc.course, locale, "title");
|
||||
const courseDesc = getLocaleText(lpc.course, locale, "desc");
|
||||
const lessonCount = lpc.course.modules.reduce((s, m) => s + m.lessons.length, 0);
|
||||
const cp = courseProgress[lpc.courseId];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={lpc.id}
|
||||
className="card"
|
||||
style={{ display: "grid", gridTemplateColumns: "auto 1fr auto", gap: 16, alignItems: "center" }}
|
||||
>
|
||||
{/* Step number */}
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
background: cp?.completedAt ? "rgba(34,197,94,0.2)" : "rgba(29,78,216,0.2)",
|
||||
color: cp?.completedAt ? "#4ade80" : "#60a5fa",
|
||||
borderRadius: 10,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 16,
|
||||
fontWeight: 800,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{cp?.completedAt ? "✓" : i + 1}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
|
||||
<p style={{ fontSize: 16, fontWeight: 600, color: "#f1f5f9" }}>{courseTitle}</p>
|
||||
<span style={{ fontSize: 11, color: "#94a3b8", background: "rgba(255,255,255,0.05)", borderRadius: 4, padding: "2px 6px" }}>
|
||||
{levelLabel[lpc.course.level] ?? lpc.course.level}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: "#64748b", marginBottom: cp ? 8 : 0, lineHeight: 1.5 }}>
|
||||
{courseDesc?.slice(0, 120)}{courseDesc?.length > 120 ? "…" : ""}
|
||||
</p>
|
||||
{cp?.enrolled && (
|
||||
<>
|
||||
<ProgressBar value={cp.progress} />
|
||||
<span style={{ fontSize: 12, color: "#94a3b8", display: "block", marginTop: 4 }}>{cp.progress}% complété</span>
|
||||
</>
|
||||
)}
|
||||
<p style={{ fontSize: 12, color: "#475569", marginTop: 4 }}>
|
||||
{lpc.course.modules.length} module{lpc.course.modules.length !== 1 ? "s" : ""} · {lessonCount} leçon{lessonCount !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
<div>
|
||||
{cp?.enrolled ? (
|
||||
<Link
|
||||
href={`/${locale}/courses/${lpc.course.slug}/learn`}
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: 13, whiteSpace: "nowrap" }}
|
||||
>
|
||||
{cp.completedAt ? "Revoir" : cp.progress > 0 ? "Continuer →" : "Commencer →"}
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
href={`/${locale}/courses/${lpc.course.slug}`}
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: 13, whiteSpace: "nowrap" }}
|
||||
>
|
||||
Voir la formation
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
import { EnrollPathButton } from "./EnrollPathButton";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function getLocaleText(obj: any, locale: string, field: string) {
|
||||
if (locale === "en") return obj[`${field}En`] || obj[`${field}Fr`];
|
||||
if (locale === "es") return obj[`${field}Es`] || obj[`${field}Fr`];
|
||||
return obj[`${field}Fr`];
|
||||
}
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
BEGINNER: "#4ade80",
|
||||
INTERMEDIATE: "#fbbf24",
|
||||
ADVANCED: "#f87171",
|
||||
};
|
||||
|
||||
export default async function PathsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const session = await auth();
|
||||
const userId = (session?.user as any)?.id as string | undefined;
|
||||
|
||||
const paths = await db.learningPath.findMany({
|
||||
where: { published: true },
|
||||
orderBy: { order: "asc" },
|
||||
include: {
|
||||
courses: {
|
||||
include: {
|
||||
course: {
|
||||
include: { modules: { include: { lessons: { select: { id: true } } } } },
|
||||
},
|
||||
},
|
||||
orderBy: { order: "asc" },
|
||||
},
|
||||
_count: { select: { enrollments: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Check which paths the user is enrolled in
|
||||
const enrolledPathIds = userId
|
||||
? new Set(
|
||||
(await db.learningPathEnrollment.findMany({ where: { userId }, select: { learningPathId: true } })).map(
|
||||
(e) => e.learningPathId
|
||||
)
|
||||
)
|
||||
: new Set<string>();
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 1100, margin: "0 auto", padding: "40px 24px" }}>
|
||||
<div style={{ marginBottom: 40 }}>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 800, color: "#f1f5f9", marginBottom: 8 }}>
|
||||
Parcours de formation
|
||||
</h1>
|
||||
<p style={{ color: "#94a3b8", fontSize: 15 }}>
|
||||
Des programmes complets pour maîtriser la GRC et l'application OwlCub.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{paths.length === 0 ? (
|
||||
<div style={{ textAlign: "center", padding: "60px 24px", background: "#1a1f2e", borderRadius: 12, border: "1px solid rgba(255,255,255,0.08)" }}>
|
||||
<p style={{ color: "#64748b", fontSize: 16 }}>Aucun parcours disponible pour l'instant.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
||||
{paths.map((path) => {
|
||||
const title = getLocaleText(path, locale, "title");
|
||||
const desc = getLocaleText(path, locale, "desc");
|
||||
const totalLessons = path.courses.reduce((sum, lpc) => sum + lpc.course.modules.reduce((s, m) => s + m.lessons.length, 0), 0);
|
||||
const isEnrolled = enrolledPathIds.has(path.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={path.id}
|
||||
className="card"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr auto",
|
||||
gap: 24,
|
||||
background: "linear-gradient(135deg, #1a1f2e, #1e2a4a)",
|
||||
border: "1px solid rgba(29,78,216,0.25)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 10 }}>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, color: "#f1f5f9" }}>{title}</h2>
|
||||
{isEnrolled && (
|
||||
<span style={{ fontSize: 11, fontWeight: 600, background: "rgba(34,197,94,0.15)", color: "#4ade80", border: "1px solid rgba(34,197,94,0.3)", borderRadius: 4, padding: "2px 8px" }}>
|
||||
Inscrit
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p style={{ color: "#94a3b8", fontSize: 14, marginBottom: 16, lineHeight: 1.6 }}>{desc}</p>
|
||||
|
||||
{/* Course list */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginBottom: 16 }}>
|
||||
{path.courses.map((lpc, i) => {
|
||||
const courseTitle = getLocaleText(lpc.course, locale, "title");
|
||||
const lessonCount = lpc.course.modules.reduce((s, m) => s + m.lessons.length, 0);
|
||||
return (
|
||||
<div key={lpc.id} style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span style={{ width: 22, height: 22, background: "rgba(29,78,216,0.3)", color: "#60a5fa", borderRadius: 4, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 11, fontWeight: 700, flexShrink: 0 }}>
|
||||
{i + 1}
|
||||
</span>
|
||||
<span style={{ fontSize: 13, color: "#cbd5e1" }}>{courseTitle}</span>
|
||||
<span style={{ fontSize: 11, color: "#475569" }}>· {lessonCount} leçon{lessonCount !== 1 ? "s" : ""}</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: levelColors[lpc.course.level] ?? "#94a3b8" }}>
|
||||
{lpc.course.level}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 16, fontSize: 13, color: "#64748b" }}>
|
||||
<span>📚 {path.courses.length} formation{path.courses.length !== 1 ? "s" : ""}</span>
|
||||
<span>🎬 {totalLessons} leçons</span>
|
||||
<span>👥 {path._count.enrollments} inscrits</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10, alignItems: "flex-end", justifyContent: "center", minWidth: 160 }}>
|
||||
{isEnrolled ? (
|
||||
<Link
|
||||
href={`/${locale}/courses/${path.courses[0]?.course.slug ?? ""}/learn`}
|
||||
className="btn btn-primary"
|
||||
style={{ width: "100%", justifyContent: "center", fontSize: 14 }}
|
||||
>
|
||||
Continuer →
|
||||
</Link>
|
||||
) : (
|
||||
<EnrollPathButton
|
||||
pathId={path.id}
|
||||
locale={locale}
|
||||
isLoggedIn={!!session}
|
||||
/>
|
||||
)}
|
||||
<Link
|
||||
href={`/${locale}/paths/${path.slug}`}
|
||||
style={{ fontSize: 13, color: "#60a5fa", textAlign: "center", width: "100%" }}
|
||||
>
|
||||
Voir le détail
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,29 @@ async function requireAdmin() {
|
|||
return session;
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
if (!(await requireAdmin())) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
|
||||
const updated = await db.module.update({
|
||||
where: { id },
|
||||
data: {
|
||||
titleFr: body.titleFr,
|
||||
titleEn: body.titleEn ?? body.titleFr,
|
||||
titleEs: body.titleEs ?? body.titleFr,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== "ADMIN") return null;
|
||||
return session;
|
||||
}
|
||||
|
||||
// Add a course to the learning path
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await requireAdmin();
|
||||
if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const { id: learningPathId } = await params;
|
||||
const { courseId } = await req.json();
|
||||
|
||||
// Get current max order
|
||||
const maxOrder = await db.learningPathCourse.count({ where: { learningPathId } });
|
||||
|
||||
const lpc = await db.learningPathCourse.create({
|
||||
data: { learningPathId, courseId, order: maxOrder },
|
||||
include: { course: true },
|
||||
});
|
||||
|
||||
return NextResponse.json(lpc);
|
||||
}
|
||||
|
||||
// Remove a course from the learning path
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await requireAdmin();
|
||||
if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const { id: learningPathId } = await params;
|
||||
const { courseId } = await req.json();
|
||||
|
||||
await db.learningPathCourse.deleteMany({ where: { learningPathId, courseId } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== "ADMIN") return null;
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function PUT(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await requireAdmin();
|
||||
if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const { id } = await params;
|
||||
const current = await db.learningPath.findUnique({ where: { id }, select: { published: true } });
|
||||
if (!current) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const updated = await db.learningPath.update({
|
||||
where: { id },
|
||||
data: { published: !current.published },
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== "ADMIN") return null;
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function GET(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await requireAdmin();
|
||||
if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const { id } = await params;
|
||||
const path = await db.learningPath.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
courses: {
|
||||
include: { course: true },
|
||||
orderBy: { order: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!path) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
return NextResponse.json(path);
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await requireAdmin();
|
||||
if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
|
||||
const path = await db.learningPath.update({
|
||||
where: { id },
|
||||
data: {
|
||||
titleFr: body.titleFr,
|
||||
titleEn: body.titleEn,
|
||||
titleEs: body.titleEs,
|
||||
descFr: body.descFr,
|
||||
descEn: body.descEn,
|
||||
descEs: body.descEs,
|
||||
slug: body.slug,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(path);
|
||||
}
|
||||
|
||||
export async function DELETE(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await requireAdmin();
|
||||
if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const { id } = await params;
|
||||
await db.learningPath.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await auth();
|
||||
if (!session?.user || (session.user as any).role !== "ADMIN") return null;
|
||||
return session;
|
||||
}
|
||||
|
||||
function slugify(text: string) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[éèêë]/g, "e")
|
||||
.replace(/[àâä]/g, "a")
|
||||
.replace(/[îï]/g, "i")
|
||||
.replace(/[ôö]/g, "o")
|
||||
.replace(/[ùûü]/g, "u")
|
||||
.replace(/[ç]/g, "c")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const session = await requireAdmin();
|
||||
if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const paths = await db.learningPath.findMany({
|
||||
orderBy: { order: "asc" },
|
||||
include: {
|
||||
courses: { include: { course: { select: { id: true, titleFr: true } } }, orderBy: { order: "asc" } },
|
||||
_count: { select: { enrollments: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(paths);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await requireAdmin();
|
||||
if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
const body = await req.json();
|
||||
const { titleFr, titleEn, titleEs, descFr, descEn, descEs, slug } = body;
|
||||
|
||||
if (!titleFr || !descFr) {
|
||||
return NextResponse.json({ error: "Titre et description FR requis" }, { status: 400 });
|
||||
}
|
||||
|
||||
const finalSlug = slug || slugify(titleFr);
|
||||
|
||||
const path = await db.learningPath.create({
|
||||
data: {
|
||||
titleFr,
|
||||
titleEn: titleEn || titleFr,
|
||||
titleEs: titleEs || titleFr,
|
||||
descFr,
|
||||
descEn: descEn || descFr,
|
||||
descEs: descEs || descFr,
|
||||
slug: finalSlug,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(path);
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function GET(_: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
|
||||
const path = await db.learningPath.findUnique({
|
||||
where: { slug, published: true },
|
||||
include: {
|
||||
courses: {
|
||||
include: {
|
||||
course: {
|
||||
include: {
|
||||
modules: { include: { lessons: { select: { id: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { order: "asc" },
|
||||
},
|
||||
_count: { select: { enrollments: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!path) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
return NextResponse.json(path);
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const userId = (session.user as any).id as string;
|
||||
const { learningPathId } = await req.json();
|
||||
|
||||
// Check path exists and is published
|
||||
const path = await db.learningPath.findUnique({
|
||||
where: { id: learningPathId, published: true },
|
||||
include: {
|
||||
courses: {
|
||||
include: { course: true },
|
||||
orderBy: { order: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!path) return NextResponse.json({ error: "Parcours introuvable" }, { status: 404 });
|
||||
|
||||
// Enroll in the path
|
||||
await db.learningPathEnrollment.upsert({
|
||||
where: { userId_learningPathId: { userId, learningPathId } },
|
||||
create: { userId, learningPathId },
|
||||
update: {},
|
||||
});
|
||||
|
||||
// Also auto-enroll in each course of the path
|
||||
for (const lpc of path.courses) {
|
||||
await db.enrollment.upsert({
|
||||
where: { userId_courseId: { userId, courseId: lpc.courseId } },
|
||||
create: { userId, courseId: lpc.courseId },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
const paths = await db.learningPath.findMany({
|
||||
where: { published: true },
|
||||
orderBy: { order: "asc" },
|
||||
include: {
|
||||
courses: {
|
||||
include: {
|
||||
course: {
|
||||
select: { id: true, titleFr: true, titleEn: true, titleEs: true, level: true, thumbnailUrl: true },
|
||||
},
|
||||
},
|
||||
orderBy: { order: "asc" },
|
||||
},
|
||||
_count: { select: { enrollments: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(paths);
|
||||
}
|
||||
|
|
@ -78,6 +78,7 @@ export function Nav({ locale, userRole, isLoggedIn }: NavProps) {
|
|||
{/* Nav links */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<NavLink href={localePath("/courses")} label={t("courses")} />
|
||||
<NavLink href={localePath("/paths")} label="Parcours" />
|
||||
{isLoggedIn && (
|
||||
<NavLink href={localePath("/dashboard")} label={t("dashboard")} />
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in New Issue