feat: initial OwlCub Academy e-learning platform

This commit is contained in:
Romain bogdanovic 2026-03-28 18:09:38 +01:00
commit 7a8f6d0cc7
56 changed files with 6132 additions and 0 deletions

20
.env.example Normal file
View File

@ -0,0 +1,20 @@
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/academy"
# NextAuth
NEXTAUTH_SECRET="your-secret-here"
NEXTAUTH_URL="https://academy.owlcub.com"
# Resend (email)
RESEND_API_KEY="re_xxxx"
# S3 / Object Storage
S3_REGION="gra"
S3_ENDPOINT="https://s3.gra.io.cloud.ovh.net"
S3_ACCESS_KEY="your-access-key"
S3_SECRET="your-secret-key"
S3_BUCKET="owlcub-academy"
# Public S3 (for thumbnail URLs in frontend)
NEXT_PUBLIC_S3_ENDPOINT="https://s3.gra.io.cloud.ovh.net"
NEXT_PUBLIC_S3_BUCKET="owlcub-academy"

83
messages/en.json Normal file
View File

@ -0,0 +1,83 @@
{
"nav": {
"courses": "Courses",
"dashboard": "My space",
"admin": "Administration",
"login": "Sign in",
"logout": "Sign out"
},
"home": {
"hero_title": "Build your skills with OwlCub Academy",
"hero_sub": "Certified training in governance, cybersecurity and OwlCub.",
"cta": "Explore courses",
"categories": {
"GOVERNANCE": "Governance",
"CYBER": "Cybersecurity",
"OWLCUB": "OwlCub",
"OTHER": "Other"
},
"levels": {
"BEGINNER": "Beginner",
"INTERMEDIATE": "Intermediate",
"ADVANCED": "Advanced"
}
},
"auth": {
"login_title": "Sign in",
"email_placeholder": "your@email.com",
"send_link": "Send magic link",
"verify_title": "Check your email",
"verify_desc": "A sign-in link has been sent to your email address.",
"back_login": "Back to sign in"
},
"course": {
"enroll": "Enroll",
"enrolled": "Enrolled",
"continue": "Continue",
"start": "Start",
"modules": "Modules",
"lessons": "Lessons",
"quiz": "Quiz",
"complete": "Complete",
"next": "Next",
"prev": "Previous",
"pass_mark": "Pass mark",
"your_score": "Your score",
"passed": "Passed!",
"failed": "Failed",
"retry": "Retry",
"certificate": "Certificate",
"download_cert": "Download certificate"
},
"dashboard": {
"title": "My space",
"in_progress": "In progress",
"completed": "Completed",
"certificates": "Certificates",
"no_enrollments": "You are not enrolled in any course.",
"browse": "Browse courses"
},
"admin": {
"title": "Administration",
"courses": "Courses",
"students": "Students",
"new_course": "New course",
"edit": "Edit",
"delete": "Delete",
"published": "Published",
"draft": "Draft",
"save": "Save",
"cancel": "Cancel",
"add_module": "Add module",
"add_lesson": "Add lesson",
"add_question": "Add question",
"upload_video": "Upload video",
"upload_thumbnail": "Upload thumbnail"
},
"common": {
"loading": "Loading…",
"error": "An error occurred.",
"save_success": "Saved!",
"delete_confirm": "Are you sure?"
}
}

83
messages/es.json Normal file
View File

@ -0,0 +1,83 @@
{
"nav": {
"courses": "Cursos",
"dashboard": "Mi espacio",
"admin": "Administración",
"login": "Iniciar sesión",
"logout": "Cerrar sesión"
},
"home": {
"hero_title": "Desarrolla tus habilidades con OwlCub Academy",
"hero_sub": "Formación certificada en gobernanza, ciberseguridad y OwlCub.",
"cta": "Explorar cursos",
"categories": {
"GOVERNANCE": "Gobernanza",
"CYBER": "Ciberseguridad",
"OWLCUB": "OwlCub",
"OTHER": "Otro"
},
"levels": {
"BEGINNER": "Principiante",
"INTERMEDIATE": "Intermedio",
"ADVANCED": "Avanzado"
}
},
"auth": {
"login_title": "Iniciar sesión",
"email_placeholder": "tu@email.com",
"send_link": "Enviar enlace mágico",
"verify_title": "Revisa tu correo",
"verify_desc": "Se ha enviado un enlace de acceso a tu dirección de correo electrónico.",
"back_login": "Volver al inicio de sesión"
},
"course": {
"enroll": "Inscribirse",
"enrolled": "Inscrito",
"continue": "Continuar",
"start": "Empezar",
"modules": "Módulos",
"lessons": "Lecciones",
"quiz": "Cuestionario",
"complete": "Completar",
"next": "Siguiente",
"prev": "Anterior",
"pass_mark": "Nota mínima",
"your_score": "Tu puntuación",
"passed": "¡Aprobado!",
"failed": "Suspenso",
"retry": "Reintentar",
"certificate": "Certificado",
"download_cert": "Descargar certificado"
},
"dashboard": {
"title": "Mi espacio",
"in_progress": "En progreso",
"completed": "Completados",
"certificates": "Certificados",
"no_enrollments": "No estás inscrito en ningún curso.",
"browse": "Explorar cursos"
},
"admin": {
"title": "Administración",
"courses": "Cursos",
"students": "Estudiantes",
"new_course": "Nuevo curso",
"edit": "Editar",
"delete": "Eliminar",
"published": "Publicado",
"draft": "Borrador",
"save": "Guardar",
"cancel": "Cancelar",
"add_module": "Agregar módulo",
"add_lesson": "Agregar lección",
"add_question": "Agregar pregunta",
"upload_video": "Subir vídeo",
"upload_thumbnail": "Subir miniatura"
},
"common": {
"loading": "Cargando…",
"error": "Ha ocurrido un error.",
"save_success": "¡Guardado!",
"delete_confirm": "¿Estás seguro?"
}
}

83
messages/fr.json Normal file
View File

@ -0,0 +1,83 @@
{
"nav": {
"courses": "Formations",
"dashboard": "Mon espace",
"admin": "Administration",
"login": "Se connecter",
"logout": "Déconnexion"
},
"home": {
"hero_title": "Développez vos compétences avec OwlCub Academy",
"hero_sub": "Formations certifiantes en gouvernance, cybersécurité et OwlCub.",
"cta": "Découvrir les formations",
"categories": {
"GOVERNANCE": "Gouvernance",
"CYBER": "Cybersécurité",
"OWLCUB": "OwlCub",
"OTHER": "Autre"
},
"levels": {
"BEGINNER": "Débutant",
"INTERMEDIATE": "Intermédiaire",
"ADVANCED": "Avancé"
}
},
"auth": {
"login_title": "Connexion",
"email_placeholder": "votre@email.com",
"send_link": "Envoyer le lien magique",
"verify_title": "Vérifiez vos emails",
"verify_desc": "Un lien de connexion a été envoyé à votre adresse email.",
"back_login": "Retour à la connexion"
},
"course": {
"enroll": "S'inscrire",
"enrolled": "Inscrit",
"continue": "Continuer",
"start": "Commencer",
"modules": "Modules",
"lessons": "Leçons",
"quiz": "Quiz",
"complete": "Terminer",
"next": "Suivant",
"prev": "Précédent",
"pass_mark": "Note minimale",
"your_score": "Votre score",
"passed": "Réussi !",
"failed": "Échoué",
"retry": "Réessayer",
"certificate": "Certificat",
"download_cert": "Télécharger le certificat"
},
"dashboard": {
"title": "Mon espace",
"in_progress": "En cours",
"completed": "Terminées",
"certificates": "Certificats",
"no_enrollments": "Vous n'êtes inscrit à aucune formation.",
"browse": "Parcourir les formations"
},
"admin": {
"title": "Administration",
"courses": "Formations",
"students": "Apprenants",
"new_course": "Nouvelle formation",
"edit": "Modifier",
"delete": "Supprimer",
"published": "Publié",
"draft": "Brouillon",
"save": "Enregistrer",
"cancel": "Annuler",
"add_module": "Ajouter un module",
"add_lesson": "Ajouter une leçon",
"add_question": "Ajouter une question",
"upload_video": "Uploader une vidéo",
"upload_thumbnail": "Uploader une miniature"
},
"common": {
"loading": "Chargement…",
"error": "Une erreur est survenue.",
"save_success": "Enregistré !",
"delete_confirm": "Êtes-vous sûr ?"
}
}

10
next.config.ts Normal file
View File

@ -0,0 +1,10 @@
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
export default withNextIntl({
output: "standalone",
images: {
domains: ["s3.gra.io.cloud.ovh.net"],
},
});

37
package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "owlcub-academy",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate deploy"
},
"dependencies": {
"@auth/prisma-adapter": "^2.7.2",
"@aws-sdk/client-s3": "^3.600.0",
"@aws-sdk/s3-request-presigner": "^3.600.0",
"@prisma/client": "^5.22.0",
"clsx": "^2.1.1",
"next": "15.3.0",
"next-auth": "^5.0.0-beta.22",
"next-intl": "^3.26.3",
"pdf-lib": "^1.17.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"resend": "^4.0.0"
},
"devDependencies": {
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"prisma": "^5.22.0",
"tailwindcss": "^3.4.0",
"typescript": "^5"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

225
prisma/schema.prisma Normal file
View File

@ -0,0 +1,225 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─── Enums ───────────────────────────────────────────────────────────────────
enum Role {
ADMIN
LEARNER
}
enum CourseCategory {
GOVERNANCE
CYBER
OWLCUB
OTHER
}
enum CourseLevel {
BEGINNER
INTERMEDIATE
ADVANCED
}
enum LessonType {
VIDEO
TEXT
}
// ─── NextAuth Models ──────────────────────────────────────────────────────────
model User {
id String @id @default(cuid())
email String @unique
name String?
image String?
emailVerified DateTime?
locale String @default("fr")
role Role @default(LEARNER)
createdAt DateTime @default(now())
accounts Account[]
sessions Session[]
enrollments Enrollment[]
lessonProgress LessonProgress[]
quizAttempts QuizAttempt[]
certificates Certificate[]
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
// ─── Academy Models ───────────────────────────────────────────────────────────
model Course {
id String @id @default(cuid())
slug String @unique
category CourseCategory @default(OTHER)
level CourseLevel @default(BEGINNER)
thumbnailUrl String?
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
modules Module[]
enrollments Enrollment[]
certificates Certificate[]
}
model Module {
id String @id @default(cuid())
courseId String
order Int @default(0)
titleFr String
titleEn String
titleEs String
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
lessons Lesson[]
quiz Quiz?
}
model Lesson {
id String @id @default(cuid())
moduleId String
order Int @default(0)
type LessonType @default(TEXT)
videoUrl String?
duration Int?
titleFr String
titleEn String
titleEs String
contentFr String? @db.Text
contentEn String? @db.Text
contentEs String? @db.Text
module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade)
lessonProgress LessonProgress[]
}
model Quiz {
id String @id @default(cuid())
moduleId String @unique
passMark Int @default(80)
module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade)
questions Question[]
attempts QuizAttempt[]
}
model Question {
id String @id @default(cuid())
quizId String
order Int @default(0)
textFr String
textEn String
textEs String
optionsFr String[]
optionsEn String[]
optionsEs String[]
correctIndex Int
quiz Quiz @relation(fields: [quizId], references: [id], onDelete: Cascade)
}
model Enrollment {
id String @id @default(cuid())
userId String
courseId String
enrolledAt DateTime @default(now())
completedAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
@@unique([userId, courseId])
}
model LessonProgress {
id String @id @default(cuid())
userId String
lessonId String
completedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
@@unique([userId, lessonId])
}
model QuizAttempt {
id String @id @default(cuid())
userId String
quizId String
score Int
passed Boolean
answers Int[]
completedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
quiz Quiz @relation(fields: [quizId], references: [id], onDelete: Cascade)
}
model Certificate {
id String @id @default(cuid())
userId String
courseId String
issuedAt DateTime @default(now())
pdfUrl String?
isPaid Boolean @default(false)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
@@unique([userId, courseId])
}

View File

@ -0,0 +1,71 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
interface AdminCourseActionsProps {
courseId: string;
isPublished: boolean;
editHref: string;
t: {
edit: string;
delete: string;
published: string;
draft: string;
};
}
export function AdminCourseActions({ courseId, isPublished, editHref, t }: AdminCourseActionsProps) {
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleTogglePublish = async () => {
setLoading(true);
try {
await fetch(`/api/admin/courses/${courseId}/publish`, { method: "PUT" });
router.refresh();
} finally {
setLoading(false);
}
};
const handleDelete = async () => {
if (!confirm("Supprimer cette formation ? Cette action est irréversible.")) return;
setLoading(true);
try {
await fetch(`/api/admin/courses/${courseId}`, { method: "DELETE" });
router.refresh();
} finally {
setLoading(false);
}
};
return (
<div style={{ display: "flex", gap: 6 }}>
<Link href={editHref} className="btn btn-secondary" style={{ fontSize: 12, padding: "5px 10px" }}>
{t.edit}
</Link>
<button
onClick={handleTogglePublish}
disabled={loading}
className="btn btn-secondary"
style={{
fontSize: 12,
padding: "5px 10px",
color: isPublished ? "#94a3b8" : "#4ade80",
}}
>
{isPublished ? t.draft : t.published}
</button>
<button
onClick={handleDelete}
disabled={loading}
className="btn btn-danger"
style={{ fontSize: 12, padding: "5px 10px" }}
>
{t.delete}
</button>
</div>
);
}

View File

@ -0,0 +1,292 @@
"use client";
import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
interface CourseFormProps {
locale: string;
course?: {
id: string;
titleFr: string;
titleEn: string;
titleEs: string;
descFr: string;
descEn: string;
descEs: string;
slug: string;
category: string;
level: string;
thumbnailUrl?: string | null;
published: boolean;
};
t: {
save: string;
cancel: string;
published: string;
draft: string;
upload_thumbnail: string;
};
}
const CATEGORIES = ["GOVERNANCE", "CYBER", "OWLCUB", "OTHER"];
const LEVELS = ["BEGINNER", "INTERMEDIATE", "ADVANCED"];
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 CourseForm({ locale, course, t }: CourseFormProps) {
const [langTab, setLangTab] = useState<"fr" | "en" | "es">("fr");
const [titleFr, setTitleFr] = useState(course?.titleFr ?? "");
const [titleEn, setTitleEn] = useState(course?.titleEn ?? "");
const [titleEs, setTitleEs] = useState(course?.titleEs ?? "");
const [descFr, setDescFr] = useState(course?.descFr ?? "");
const [descEn, setDescEn] = useState(course?.descEn ?? "");
const [descEs, setDescEs] = useState(course?.descEs ?? "");
const [slug, setSlug] = useState(course?.slug ?? "");
const [category, setCategory] = useState(course?.category ?? "OTHER");
const [level, setLevel] = useState(course?.level ?? "BEGINNER");
const [published, setPublished] = useState(course?.published ?? false);
const [thumbnailUrl, setThumbnailUrl] = useState(course?.thumbnailUrl ?? "");
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState("");
const fileRef = useRef<HTMLInputElement>(null);
const router = useRouter();
const handleTitleFrChange = (v: string) => {
setTitleFr(v);
if (!course) setSlug(slugify(v));
};
const handleThumbnailUpload = 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: "thumbnail" }),
});
const { url, key } = await res.json();
await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": file.type } });
// Construct public URL
const publicUrl = `${process.env.NEXT_PUBLIC_S3_ENDPOINT ?? ""}/${process.env.NEXT_PUBLIC_S3_BUCKET ?? ""}/${key}`;
setThumbnailUrl(publicUrl);
} finally {
setUploading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setError("");
const body = { titleFr, titleEn, titleEs, descFr, descEn, descEs, slug, category, level, published, thumbnailUrl: thumbnailUrl || null };
try {
const res = course
? await fetch(`/api/admin/courses/${course.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
: await fetch("/api/admin/courses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json();
setError(data.error ?? "Erreur");
return;
}
const data = await res.json();
router.push(`/${locale}/admin/courses/${data.id}`);
} finally {
setSaving(false);
}
};
return (
<form onSubmit={handleSubmit}>
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
{/* Lang tabs */}
<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 (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 }}>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>
{/* Meta */}
<div className="card">
<h3 style={{ fontSize: 15, fontWeight: 700, color: "#f1f5f9", marginBottom: 16 }}>Métadonnées</h3>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 14 }}>
<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>
<label style={{ display: "block", fontSize: 13, color: "#94a3b8", marginBottom: 6 }}>Catégorie</label>
<select value={category} onChange={(e) => setCategory(e.target.value)}>
{CATEGORIES.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<label style={{ display: "block", fontSize: 13, color: "#94a3b8", marginBottom: 6 }}>Niveau</label>
<select value={level} onChange={(e) => setLevel(e.target.value)}>
{LEVELS.map((l) => <option key={l} value={l}>{l}</option>)}
</select>
</div>
</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 la formation
</label>
</div>
</div>
{/* Thumbnail */}
<div className="card">
<h3 style={{ fontSize: 15, fontWeight: 700, color: "#f1f5f9", marginBottom: 16 }}>
{t.upload_thumbnail}
</h3>
{thumbnailUrl && (
<div style={{ marginBottom: 12 }}>
<img
src={thumbnailUrl}
alt="Thumbnail"
style={{ width: "100%", maxHeight: 200, objectFit: "cover", borderRadius: 8 }}
/>
</div>
)}
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
<input
type="text"
value={thumbnailUrl}
onChange={(e) => setThumbnailUrl(e.target.value)}
placeholder="URL de la miniature"
style={{ flex: 1 }}
/>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="btn btn-secondary"
>
{uploading ? "…" : "Uploader"}
</button>
<input
ref={fileRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleThumbnailUpload(file);
}}
/>
</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 ? "…" : t.save}
</button>
<button
type="button"
onClick={() => router.back()}
className="btn btn-secondary"
>
{t.cancel}
</button>
</div>
</div>
</form>
);
}

View File

@ -0,0 +1,592 @@
"use client";
import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
interface Lesson {
id: string;
titleFr: string;
titleEn: string;
titleEs: string;
type: string;
videoUrl?: 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[];
correctIndex: number;
order: number;
}
interface Quiz {
id: string;
passMark: number;
questions: Question[];
}
interface Module {
id: string;
titleFr: string;
titleEn: string;
titleEs: string;
order: number;
lessons: Lesson[];
quiz?: Quiz | null;
}
interface ModuleManagerProps {
courseId: string;
modules: Module[];
locale: string;
t: Record<string, string>;
}
export function ModuleManager({ courseId, modules: initialModules, locale, t }: ModuleManagerProps) {
const [modules, setModules] = useState(initialModules);
const [loading, setLoading] = useState(false);
const [expandedModule, setExpandedModule] = useState<string | null>(null);
const router = useRouter();
const addModule = async () => {
const titleFr = prompt("Titre du module (FR) :");
if (!titleFr) return;
setLoading(true);
try {
const res = await fetch(`/api/admin/courses/${courseId}/modules`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ titleFr, titleEn: titleFr, titleEs: titleFr, order: modules.length }),
});
const newModule = await res.json();
setModules((prev) => [...prev, { ...newModule, lessons: [], quiz: null }]);
} finally {
setLoading(false);
}
};
const deleteModule = async (moduleId: string) => {
if (!confirm("Supprimer ce module ?")) return;
setLoading(true);
try {
await fetch(`/api/admin/modules/${moduleId}`, { method: "DELETE" });
setModules((prev) => prev.filter((m) => m.id !== moduleId));
} finally {
setLoading(false);
}
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{modules.map((module, mIndex) => (
<ModuleItem
key={module.id}
module={module}
index={mIndex}
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))
}
t={t}
/>
))}
<button
onClick={addModule}
disabled={loading}
className="btn btn-secondary"
style={{ alignSelf: "flex-start" }}
>
+ {t.add_module}
</button>
</div>
);
}
function ModuleItem({
module,
index,
expanded,
onToggle,
onDelete,
onUpdate,
t,
}: {
module: Module;
index: number;
expanded: boolean;
onToggle: () => void;
onDelete: () => void;
onUpdate: (m: Module) => void;
t: Record<string, string>;
}) {
const [loading, setLoading] = 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 res = await fetch(`/api/admin/modules/${module.id}/lessons`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
titleFr,
titleEn: titleFr,
titleEs: titleFr,
type: ["VIDEO", "TEXT"].includes(type) ? type : "TEXT",
order: module.lessons.length,
}),
});
const newLesson = await res.json();
onUpdate({ ...module, lessons: [...module.lessons, newLesson] });
} finally {
setLoading(false);
}
};
const deleteLesson = async (lessonId: string) => {
if (!confirm("Supprimer cette leçon ?")) return;
setLoading(true);
try {
await fetch(`/api/admin/lessons/${lessonId}`, { method: "DELETE" });
onUpdate({ ...module, lessons: module.lessons.filter((l) => l.id !== lessonId) });
} finally {
setLoading(false);
}
};
const saveQuiz = async (passMark: number, questions: Omit<Question, "id">[]) => {
setLoading(true);
try {
const res = await fetch(`/api/admin/modules/${module.id}/quiz`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ passMark, questions }),
});
const quiz = await res.json();
onUpdate({ ...module, quiz });
} finally {
setLoading(false);
}
};
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,
}}
>
{index + 1}
</span>
<div style={{ flex: 1 }}>
<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" : ""}
</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>
{expanded && (
<div style={{ borderTop: "1px solid rgba(255,255,255,0.08)", padding: "16px" }}>
{/* 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),
})
}
onDelete={() => deleteLesson(lesson.id)}
t={t}
/>
))}
</div>
<button
onClick={addLesson}
disabled={loading}
className="btn btn-secondary"
style={{ fontSize: 12, marginBottom: 16 }}
>
+ {t.add_lesson}
</button>
{/* Quiz section */}
<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",
}}
onClick={() => setQuizExpanded(!quizExpanded)}
>
<span style={{ fontSize: 14, fontWeight: 600, color: "#fbbf24" }}>
Quiz du module {module.quiz ? "✓" : "(non configuré)"}
</span>
<span style={{ color: "#94a3b8" }}>{quizExpanded ? "▲" : "▼"}</span>
</div>
{quizExpanded && (
<QuizEditor quiz={module.quiz} onSave={saveQuiz} t={t} />
)}
</div>
</div>
)}
</div>
);
}
function LessonItem({
lesson,
moduleId,
onUpdate,
onDelete,
t,
}: {
lesson: Lesson;
moduleId: string;
onUpdate: (l: Lesson) => void;
onDelete: () => void;
t: Record<string, string>;
}) {
const [editing, setEditing] = useState(false);
const [uploading, setUploading] = useState(false);
const [titleFr, setTitleFr] = useState(lesson.titleFr);
const [videoUrl, setVideoUrl] = useState(lesson.videoUrl ?? "");
const [contentFr, setContentFr] = useState(lesson.contentFr ?? "");
const [duration, setDuration] = useState(lesson.duration?.toString() ?? "");
const fileRef = useRef<HTMLInputElement>(null);
const handleSave = async () => {
await fetch(`/api/admin/lessons/${lesson.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
titleFr,
titleEn: lesson.titleEn,
titleEs: lesson.titleEs,
videoUrl: videoUrl || null,
contentFr: contentFr || null,
duration: duration ? parseInt(duration) : null,
}),
});
onUpdate({ ...lesson, titleFr, videoUrl: videoUrl || null, contentFr: contentFr || 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 { url, key } = await res.json();
await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": file.type } });
setVideoUrl(key);
} finally {
setUploading(false);
}
};
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>
<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>
</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={{ display: "flex", flexDirection: "column", gap: 10 }}>
<input
type="text"
value={titleFr}
onChange={(e) => setTitleFr(e.target.value)}
placeholder="Titre (FR)"
/>
{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 }}
>
{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);
}}
/>
</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 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>
</div>
</div>
</div>
);
}
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,
})) ?? []
);
const addQuestion = () => {
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 updateQuestion = (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) => {
if (idx !== qi) return q;
const opts = [...q.optionsFr];
opts[oi] = value;
return { ...q, optionsFr: opts };
})
);
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<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}
/>
</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 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>
</div>
<input
type="text"
value={q.textFr}
onChange={(e) => updateQuestion(qi, "textFr", e.target.value)}
placeholder="Question (FR)"
style={{ marginBottom: 8 }}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{q.optionsFr.map((opt, oi) => (
<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="text"
value={opt}
onChange={(e) => updateOption(qi, oi, e.target.value)}
placeholder={`Option ${oi + 1}`}
style={{ flex: 1 }}
/>
</div>
))}
</div>
</div>
))}
<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>
</div>
</div>
);
}

View File

@ -0,0 +1,64 @@
import { redirect, notFound } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { CourseForm } from "../CourseForm";
import { ModuleManager } from "./ModuleManager";
export const dynamic = "force-dynamic";
export default async function EditCoursePage({
params,
}: {
params: Promise<{ locale: string; id: string }>;
}) {
const { locale, id } = await params;
const t = await getTranslations({ locale, namespace: "admin" });
const session = await auth();
if (!session?.user || (session.user as any).role !== "ADMIN") {
redirect(`/${locale}/dashboard`);
}
const course = await db.course.findUnique({
where: { id },
include: {
modules: {
orderBy: { order: "asc" },
include: {
lessons: { orderBy: { order: "asc" } },
quiz: {
include: { questions: { orderBy: { order: "asc" } } },
},
},
},
},
});
if (!course) notFound();
return (
<div style={{ maxWidth: 1000, margin: "0 auto", padding: "40px 24px" }}>
<div style={{ marginBottom: 28 }}>
<Link href={`/${locale}/admin/courses`} style={{ fontSize: 12, color: "#60a5fa" }}>
{t("courses")}
</Link>
<h1 style={{ fontSize: 24, fontWeight: 800, color: "#f1f5f9", marginTop: 4 }}>
{t("edit")} : {course.titleFr}
</h1>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 32 }}>
<CourseForm locale={locale} course={course} t={t as any} />
<div style={{ borderTop: "1px solid rgba(255,255,255,0.08)", paddingTop: 32 }}>
<h2 style={{ fontSize: 20, fontWeight: 700, color: "#f1f5f9", marginBottom: 20 }}>
{t("courses")} Modules & Leçons
</h2>
<ModuleManager courseId={id} modules={course.modules as any} locale={locale} t={t as any} />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,33 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { CourseForm } from "../CourseForm";
export default async function NewCoursePage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "admin" });
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: 28 }}>
<Link href={`/${locale}/admin/courses`} style={{ fontSize: 12, color: "#60a5fa" }}>
{t("courses")}
</Link>
<h1 style={{ fontSize: 24, fontWeight: 800, color: "#f1f5f9", marginTop: 4 }}>
{t("new_course")}
</h1>
</div>
<CourseForm locale={locale} t={t as any} />
</div>
);
}

View File

@ -0,0 +1,142 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { AdminCourseActions } from "./AdminCourseActions";
export const dynamic = "force-dynamic";
const categoryColors: Record<string, string> = {
GOVERNANCE: "#60a5fa",
CYBER: "#f87171",
OWLCUB: "#4ade80",
OTHER: "#94a3b8",
};
export default async function AdminCoursesPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "admin" });
const session = await auth();
if (!session?.user || (session.user as any).role !== "ADMIN") {
redirect(`/${locale}/dashboard`);
}
const courses = await db.course.findMany({
orderBy: { createdAt: "desc" },
include: {
_count: {
select: {
modules: true,
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 }}>
{t("courses")}
</h1>
</div>
<Link href={`/${locale}/admin/courses/new`} className="btn btn-primary">
+ {t("new_course")}
</Link>
</div>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<table>
<thead>
<tr>
<th>Titre (FR)</th>
<th>Catégorie</th>
<th>Niveau</th>
<th>Modules</th>
<th>Inscrits</th>
<th>Statut</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{courses.length === 0 ? (
<tr>
<td colSpan={7} style={{ textAlign: "center", color: "#94a3b8", padding: "48px" }}>
Aucune formation. <Link href={`/${locale}/admin/courses/new`} style={{ color: "#60a5fa" }}>Créer la première.</Link>
</td>
</tr>
) : (
courses.map((course) => (
<tr key={course.id}>
<td>
<div>
<p style={{ fontWeight: 600, color: "#f1f5f9", fontSize: 14 }}>
{course.titleFr}
</p>
<p style={{ fontSize: 11, color: "#475569" }}>{course.slug}</p>
</div>
</td>
<td>
<span
style={{
color: categoryColors[course.category] ?? "#94a3b8",
fontSize: 13,
fontWeight: 600,
}}
>
{course.category}
</span>
</td>
<td style={{ color: "#94a3b8", fontSize: 13 }}>{course.level}</td>
<td style={{ color: "#f1f5f9", fontSize: 14 }}>{course._count.modules}</td>
<td style={{ color: "#f1f5f9", fontSize: 14 }}>{course._count.enrollments}</td>
<td>
<span
style={{
fontSize: 12,
fontWeight: 600,
padding: "3px 10px",
borderRadius: 999,
background: course.published
? "rgba(34,197,94,0.15)"
: "rgba(148,163,184,0.1)",
color: course.published ? "#4ade80" : "#94a3b8",
}}
>
{course.published ? t("published") : t("draft")}
</span>
</td>
<td>
<AdminCourseActions
courseId={course.id}
isPublished={course.published}
editHref={`/${locale}/admin/courses/${course.id}`}
t={{ edit: t("edit"), delete: t("delete"), published: t("published"), draft: t("draft") }}
/>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,164 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
export const dynamic = "force-dynamic";
export default async function AdminDashboardPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "admin" });
const session = await auth();
if (!session?.user || (session.user as any).role !== "ADMIN") {
redirect(`/${locale}/dashboard`);
}
const [totalCourses, publishedCourses, totalStudents, totalEnrollments, totalCompletions] =
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 } } }),
]);
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: "Complétions", value: totalCompletions, sub: "formations terminées", icon: "🎓", href: null, color: "#22c55e" },
];
return (
<div style={{ maxWidth: 1100, margin: "0 auto", padding: "40px 24px" }}>
<div style={{ marginBottom: 36 }}>
<h1 style={{ fontSize: 28, fontWeight: 800, color: "#f1f5f9", marginBottom: 6 }}>
{t("title")}
</h1>
<p style={{ color: "#94a3b8", fontSize: 14 }}>
Tableau de bord d'administration OwlCub Academy
</p>
</div>
{/* Stats */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 16,
marginBottom: 40,
}}
>
{stats.map((stat) => (
<div
key={stat.label}
className="card"
style={{
borderColor: `${stat.color}33`,
position: "relative",
overflow: "hidden",
}}
>
<div
style={{
position: "absolute",
top: -20,
right: -20,
fontSize: 72,
opacity: 0.06,
}}
>
{stat.icon}
</div>
<div style={{ fontSize: 36, marginBottom: 4 }}>{stat.icon}</div>
<div
style={{ fontSize: 36, fontWeight: 800, color: stat.color, marginBottom: 4 }}
>
{stat.value}
</div>
<div style={{ fontSize: 14, fontWeight: 600, color: "#f1f5f9", marginBottom: 2 }}>
{stat.label}
</div>
<div style={{ fontSize: 12, color: "#94a3b8" }}>{stat.sub}</div>
{stat.href && (
<Link
href={stat.href}
style={{
position: "absolute",
bottom: 16,
right: 16,
fontSize: 12,
color: stat.color,
fontWeight: 600,
}}
>
Gérer
</Link>
)}
</div>
))}
</div>
{/* Quick actions */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<div className="card">
<h2 style={{ fontSize: 16, fontWeight: 700, color: "#f1f5f9", marginBottom: 16 }}>
Actions rapides
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<Link
href={`/${locale}/admin/courses/new`}
className="btn btn-primary"
style={{ justifyContent: "center" }}
>
+ {t("new_course")}
</Link>
<Link
href={`/${locale}/admin/courses`}
className="btn btn-secondary"
style={{ justifyContent: "center" }}
>
{t("courses")}
</Link>
<Link
href={`/${locale}/admin/students`}
className="btn btn-secondary"
style={{ justifyContent: "center" }}
>
{t("students")}
</Link>
</div>
</div>
<div className="card">
<h2 style={{ fontSize: 16, fontWeight: 700, color: "#f1f5f9", marginBottom: 16 }}>
Informations
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 12, fontSize: 13, color: "#94a3b8" }}>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span>Taux de complétion</span>
<strong style={{ color: "#f1f5f9" }}>
{totalEnrollments > 0
? `${Math.round((totalCompletions / totalEnrollments) * 100)}%`
: "—"}
</strong>
</div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span>Formations publiées</span>
<strong style={{ color: "#f1f5f9" }}>{publishedCourses}/{totalCourses}</strong>
</div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span>Inscriptions totales</span>
<strong style={{ color: "#f1f5f9" }}>{totalEnrollments}</strong>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,147 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
export const dynamic = "force-dynamic";
export default async function AdminStudentsPage({
params,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<{ q?: string }>;
}) {
const { locale } = await params;
const { q } = await searchParams;
const t = await getTranslations({ locale, namespace: "admin" });
const session = await auth();
if (!session?.user || (session.user as any).role !== "ADMIN") {
redirect(`/${locale}/dashboard`);
}
const where: any = { role: "LEARNER" };
if (q) {
where.OR = [
{ email: { contains: q, mode: "insensitive" } },
{ name: { contains: q, mode: "insensitive" } },
];
}
const students = await db.user.findMany({
where,
orderBy: { createdAt: "desc" },
include: {
_count: {
select: {
enrollments: true,
},
},
enrollments: {
where: { completedAt: { not: null } },
select: { id: true },
},
},
take: 100,
});
return (
<div style={{ maxWidth: 1200, margin: "0 auto", padding: "40px 24px" }}>
<div style={{ marginBottom: 28 }}>
<Link href={`/${locale}/admin`} style={{ fontSize: 12, color: "#60a5fa" }}>
Administration
</Link>
<h1 style={{ fontSize: 24, fontWeight: 800, color: "#f1f5f9", marginTop: 4 }}>
{t("students")}
</h1>
</div>
{/* Search */}
<form method="GET" style={{ marginBottom: 24, display: "flex", gap: 8 }}>
<input
type="text"
name="q"
defaultValue={q}
placeholder="Rechercher par email ou nom…"
style={{ width: 320 }}
/>
<button type="submit" className="btn btn-secondary">
🔍 Rechercher
</button>
</form>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<table>
<thead>
<tr>
<th>Apprenant</th>
<th>Email</th>
<th>Inscriptions</th>
<th>Complétions</th>
<th>Inscrit le</th>
</tr>
</thead>
<tbody>
{students.length === 0 ? (
<tr>
<td colSpan={5} style={{ textAlign: "center", color: "#94a3b8", padding: "48px" }}>
{q ? `Aucun résultat pour "${q}"` : "Aucun apprenant inscrit."}
</td>
</tr>
) : (
students.map((student) => (
<tr key={student.id}>
<td>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
{student.image ? (
<img
src={student.image}
alt=""
style={{ width: 32, height: 32, borderRadius: "50%", objectFit: "cover" }}
/>
) : (
<div
style={{
width: 32,
height: 32,
borderRadius: "50%",
background: "rgba(29,78,216,0.3)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 14,
color: "#60a5fa",
fontWeight: 700,
}}
>
{(student.name ?? student.email ?? "?")[0].toUpperCase()}
</div>
)}
<span style={{ fontSize: 14, fontWeight: 600, color: "#f1f5f9" }}>
{student.name ?? "—"}
</span>
</div>
</td>
<td style={{ color: "#94a3b8", fontSize: 13 }}>{student.email}</td>
<td style={{ color: "#f1f5f9", fontSize: 14 }}>{student._count.enrollments}</td>
<td style={{ color: "#4ade80", fontSize: 14 }}>{student.enrollments.length}</td>
<td style={{ color: "#94a3b8", fontSize: 13 }}>
{student.createdAt.toLocaleDateString("fr-FR")}
</td>
</tr>
))
)}
</tbody>
</table>
{students.length > 0 && (
<div style={{ padding: "12px 16px", borderTop: "1px solid rgba(255,255,255,0.05)", fontSize: 12, color: "#475569" }}>
{students.length} apprenant{students.length !== 1 ? "s" : ""} affiché{students.length !== 1 ? "s" : ""}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,172 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useParams } from "next/navigation";
export default function LoginPage() {
const t = useTranslations("auth");
const params = useParams();
const locale = params.locale as string;
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) return;
setLoading(true);
setError("");
try {
const result = await signIn("resend", {
email,
redirect: false,
callbackUrl: `/${locale}/dashboard`,
});
if (result?.error) {
setError("Une erreur est survenue. Vérifiez votre adresse email.");
} else {
window.location.href = `/${locale}/auth/verify`;
}
} catch {
setError("Une erreur est survenue.");
} finally {
setLoading(false);
}
};
return (
<div
style={{
minHeight: "calc(100vh - 64px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "24px",
}}
>
<div
style={{
width: "100%",
maxWidth: 420,
}}
>
{/* Logo */}
<div style={{ textAlign: "center", marginBottom: 32 }}>
<div
style={{
width: 56,
height: 56,
background: "#1d4ed8",
borderRadius: 14,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 28,
margin: "0 auto 16px",
}}
>
🦉
</div>
<h1 style={{ fontSize: 24, fontWeight: 700, color: "#f1f5f9" }}>
OwlCub Academy
</h1>
<p style={{ fontSize: 14, color: "#94a3b8", marginTop: 6 }}>
{t("login_title")}
</p>
</div>
{/* Form card */}
<div className="card">
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div>
<label
htmlFor="email"
style={{
display: "block",
fontSize: 13,
fontWeight: 600,
color: "#94a3b8",
marginBottom: 6,
}}
>
Adresse email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t("email_placeholder")}
required
autoFocus
style={{ fontSize: 15 }}
/>
</div>
{error && (
<p
style={{
fontSize: 13,
color: "#f87171",
background: "rgba(239,68,68,0.1)",
border: "1px solid rgba(239,68,68,0.3)",
borderRadius: 6,
padding: "10px 12px",
}}
>
{error}
</p>
)}
<button
type="submit"
disabled={loading || !email}
className="btn btn-primary"
style={{
justifyContent: "center",
opacity: loading || !email ? 0.7 : 1,
cursor: loading || !email ? "not-allowed" : "pointer",
padding: "12px",
fontSize: 15,
}}
>
{loading ? (
<span>Envoi en cours</span>
) : (
<>
<span></span>
<span>{t("send_link")}</span>
</>
)}
</button>
</form>
<p
style={{
fontSize: 12,
color: "#64748b",
textAlign: "center",
marginTop: 20,
lineHeight: 1.5,
}}
>
Nous vous enverrons un lien de connexion sécurisé par email. Aucun mot de passe requis.
</p>
</div>
<div style={{ textAlign: "center", marginTop: 20 }}>
<Link
href={`/${locale}/courses`}
style={{ fontSize: 13, color: "#94a3b8" }}
>
Retour aux formations
</Link>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,71 @@
import { getTranslations } from "next-intl/server";
import Link from "next/link";
export default async function VerifyPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "auth" });
return (
<div
style={{
minHeight: "calc(100vh - 64px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "24px",
}}
>
<div style={{ textAlign: "center", maxWidth: 420 }}>
<div
style={{
width: 80,
height: 80,
background: "rgba(29,78,216,0.15)",
border: "1px solid rgba(29,78,216,0.3)",
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 36,
margin: "0 auto 24px",
}}
>
</div>
<h1 style={{ fontSize: 24, fontWeight: 700, color: "#f1f5f9", marginBottom: 12 }}>
{t("verify_title")}
</h1>
<p style={{ fontSize: 15, color: "#94a3b8", lineHeight: 1.6, marginBottom: 32 }}>
{t("verify_desc")}
</p>
<div
style={{
background: "#1a1f2e",
border: "1px solid rgba(255,255,255,0.08)",
borderRadius: 10,
padding: "20px",
marginBottom: 24,
}}
>
<p style={{ fontSize: 13, color: "#64748b", lineHeight: 1.6 }}>
Vérifiez votre boîte de réception (et vos spams). Le lien expirera dans 24 heures.
</p>
</div>
<Link
href={`/${locale}/auth/login`}
style={{
fontSize: 14,
color: "#60a5fa",
fontWeight: 500,
}}
>
{t("back_login")}
</Link>
</div>
</div>
);
}

View File

@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
interface EnrollButtonProps {
courseId: string;
locale: string;
slug: string;
label: string;
}
export function EnrollButton({ courseId, locale, slug, label }: EnrollButtonProps) {
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleEnroll = async () => {
setLoading(true);
try {
const res = await fetch("/api/enroll", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ courseId }),
});
if (res.ok) {
router.push(`/${locale}/courses/${slug}/learn`);
}
} finally {
setLoading(false);
}
};
return (
<button
onClick={handleEnroll}
disabled={loading}
className="btn btn-primary"
style={{
display: "flex",
justifyContent: "center",
padding: "12px",
fontSize: 15,
width: "100%",
opacity: loading ? 0.7 : 1,
}}
>
{loading ? "…" : label}
</button>
);
}

View File

@ -0,0 +1,219 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { VideoPlayer } from "@/components/VideoPlayer";
import { QuizComponent } from "@/components/QuizComponent";
interface LessonContentProps {
lesson: {
id: string;
type: string;
videoUrl?: string | null;
title: string;
content?: string | null;
};
quiz: {
id: string;
passMark: number;
questions: any[];
} | null;
isCompleted: boolean;
navigation: {
prev: string | null;
next: string | null;
};
locale: string;
t: {
complete: string;
next: string;
prev: string;
quiz: string;
};
}
export function LessonContent({
lesson,
quiz,
isCompleted: initialCompleted,
navigation,
locale,
t,
}: LessonContentProps) {
const [completed, setCompleted] = useState(initialCompleted);
const [marking, setMarking] = useState(false);
const [quizPassed, setQuizPassed] = useState(false);
const router = useRouter();
const markComplete = async () => {
if (completed || marking) return;
setMarking(true);
try {
await fetch("/api/progress", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lessonId: lesson.id }),
});
setCompleted(true);
router.refresh();
} finally {
setMarking(false);
}
};
return (
<div style={{ maxWidth: 800 }}>
{/* Lesson title */}
<h1 style={{ fontSize: 24, fontWeight: 700, color: "#f1f5f9", marginBottom: 24, lineHeight: 1.3 }}>
{completed && (
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 24,
height: 24,
background: "#22c55e",
borderRadius: "50%",
fontSize: 12,
color: "#fff",
marginRight: 10,
verticalAlign: "middle",
}}
>
</span>
)}
{lesson.title}
</h1>
{/* Video or Text */}
{lesson.type === "VIDEO" && lesson.videoUrl ? (
<div style={{ marginBottom: 32 }}>
<VideoPlayer
videoKey={lesson.videoUrl}
onComplete={markComplete}
/>
</div>
) : (
lesson.content && (
<div
style={{
background: "#1a1f2e",
border: "1px solid rgba(255,255,255,0.08)",
borderRadius: 12,
padding: "24px 28px",
marginBottom: 32,
fontSize: 15,
color: "#cbd5e1",
lineHeight: 1.8,
whiteSpace: "pre-wrap",
}}
>
{lesson.content}
</div>
)
)}
{/* Mark complete button */}
{!completed && lesson.type !== "VIDEO" && (
<div style={{ marginBottom: 32 }}>
<button
onClick={markComplete}
disabled={marking}
className="btn btn-primary"
style={{ opacity: marking ? 0.7 : 1 }}
>
{marking ? "…" : `${t.complete}`}
</button>
</div>
)}
{/* Completed indicator */}
{completed && (
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
background: "rgba(34,197,94,0.1)",
border: "1px solid rgba(34,197,94,0.3)",
borderRadius: 8,
padding: "8px 16px",
fontSize: 13,
color: "#4ade80",
fontWeight: 600,
marginBottom: 32,
}}
>
Leçon complétée
</div>
)}
{/* Quiz section */}
{quiz && completed && !quizPassed && (
<div style={{ marginBottom: 40 }}>
<h2
style={{
fontSize: 18,
fontWeight: 700,
color: "#f1f5f9",
marginBottom: 20,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<span
style={{
background: "rgba(245,158,11,0.2)",
color: "#fbbf24",
borderRadius: 6,
padding: "4px 10px",
fontSize: 12,
}}
>
{t.quiz}
</span>
Quiz du module
</h2>
<QuizComponent
quizId={quiz.id}
questions={quiz.questions}
passMark={quiz.passMark}
locale={locale}
onPassed={() => {
setQuizPassed(true);
router.refresh();
}}
/>
</div>
)}
{/* Navigation */}
<div
style={{
display: "flex",
justifyContent: "space-between",
paddingTop: 24,
borderTop: "1px solid rgba(255,255,255,0.08)",
marginTop: 20,
}}
>
{navigation.prev ? (
<Link href={navigation.prev} className="btn btn-secondary">
{t.prev}
</Link>
) : (
<div />
)}
{navigation.next && (
<Link href={navigation.next} className="btn btn-primary">
{t.next}
</Link>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,298 @@
import { redirect, notFound } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { LessonContent } from "./LessonContent";
export const dynamic = "force-dynamic";
function getLocaleText(obj: any, locale: string, fr: string, en: string, es: string) {
if (locale === "en") return obj[en] || obj[fr];
if (locale === "es") return obj[es] || obj[fr];
return obj[fr];
}
export default async function LessonPage({
params,
}: {
params: Promise<{ locale: string; slug: string; lessonId: string }>;
}) {
const { locale, slug, lessonId } = await params;
const t = await getTranslations({ locale, namespace: "course" });
const session = await auth();
if (!session?.user) {
redirect(`/${locale}/auth/login`);
}
const userId = (session.user as any).id;
const course = await db.course.findUnique({
where: { slug },
include: {
modules: {
orderBy: { order: "asc" },
include: {
lessons: { orderBy: { order: "asc" } },
quiz: {
include: {
questions: { orderBy: { order: "asc" } },
},
},
},
},
},
});
if (!course) notFound();
// Check enrollment
const enrollment = await db.enrollment.findUnique({
where: { userId_courseId: { userId, courseId: course.id } },
});
if (!enrollment) {
redirect(`/${locale}/courses/${slug}`);
}
// Find current lesson
const allLessons = course.modules.flatMap((m) =>
m.lessons.map((l) => ({ ...l, moduleId: m.id }))
);
const currentLesson = allLessons.find((l) => l.id === lessonId);
if (!currentLesson) notFound();
const currentIndex = allLessons.indexOf(currentLesson);
const prevLesson = currentIndex > 0 ? allLessons[currentIndex - 1] : null;
const nextLesson = currentIndex < allLessons.length - 1 ? allLessons[currentIndex + 1] : null;
// Completed lessons
const completedLessonIds = new Set(
(
await db.lessonProgress.findMany({
where: { userId, lessonId: { in: allLessons.map((l) => l.id) } },
select: { lessonId: true },
})
).map((p) => p.lessonId)
);
// Find current module
const currentModule = course.modules.find((m) =>
m.lessons.some((l) => l.id === lessonId)
)!;
// Check if last lesson in module (for quiz display)
const moduleLessons = currentModule.lessons;
const isLastLessonInModule =
moduleLessons[moduleLessons.length - 1]?.id === lessonId;
// Quiz attempt for this module
let quizPassed = false;
if (currentModule.quiz) {
const attempt = await db.quizAttempt.findFirst({
where: { userId, quizId: currentModule.quiz.id, passed: true },
});
quizPassed = !!attempt;
}
const title = getLocaleText(currentLesson, locale, "titleFr", "titleEn", "titleEs");
const content = getLocaleText(currentLesson, locale, "contentFr", "contentEn", "contentEs");
const courseTitle = getLocaleText(course, locale, "titleFr", "titleEn", "titleEs");
return (
<div style={{ display: "flex", height: "calc(100vh - 64px)", overflow: "hidden" }}>
{/* Sidebar */}
<aside
style={{
width: 280,
flexShrink: 0,
background: "#1a1f2e",
borderRight: "1px solid rgba(255,255,255,0.08)",
overflowY: "auto",
padding: "20px 0",
}}
>
<div style={{ padding: "0 16px 16px", borderBottom: "1px solid rgba(255,255,255,0.08)" }}>
<Link
href={`/${locale}/courses/${slug}`}
style={{ fontSize: 12, color: "#60a5fa" }}
>
Retour
</Link>
<p style={{ fontSize: 13, fontWeight: 700, color: "#f1f5f9", marginTop: 8, lineHeight: 1.4 }}>
{courseTitle}
</p>
</div>
{course.modules.map((module, mIndex) => {
const moduleTitle = getLocaleText(module, locale, "titleFr", "titleEn", "titleEs");
const allModuleLessonsComplete = module.lessons.every((l) =>
completedLessonIds.has(l.id)
);
return (
<div key={module.id} style={{ borderBottom: "1px solid rgba(255,255,255,0.05)" }}>
<div
style={{
padding: "12px 16px",
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<span
style={{
fontSize: 11,
fontWeight: 700,
color: allModuleLessonsComplete ? "#4ade80" : "#94a3b8",
background: allModuleLessonsComplete
? "rgba(34,197,94,0.1)"
: "rgba(255,255,255,0.05)",
borderRadius: 4,
padding: "2px 6px",
}}
>
{mIndex + 1}
</span>
<span style={{ fontSize: 13, fontWeight: 600, color: "#94a3b8" }}>
{moduleTitle}
</span>
</div>
{module.lessons.map((lesson) => {
const lTitle = getLocaleText(lesson, locale, "titleFr", "titleEn", "titleEs");
const isActive = lesson.id === lessonId;
const isComplete = completedLessonIds.has(lesson.id);
return (
<Link
key={lesson.id}
href={`/${locale}/courses/${slug}/learn/${lesson.id}`}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "9px 16px 9px 32px",
background: isActive
? "rgba(29,78,216,0.15)"
: "transparent",
borderLeft: isActive ? "2px solid #1d4ed8" : "2px solid transparent",
transition: "all 0.1s",
}}
>
<span
style={{
width: 18,
height: 18,
borderRadius: "50%",
border: `2px solid ${isComplete ? "#22c55e" : isActive ? "#1d4ed8" : "rgba(255,255,255,0.2)"}`,
background: isComplete ? "#22c55e" : "transparent",
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 9,
color: "#fff",
}}
>
{isComplete && "✓"}
</span>
<span
style={{
fontSize: 12,
color: isActive ? "#f1f5f9" : "#94a3b8",
lineHeight: 1.3,
}}
>
{lTitle}
</span>
<span
style={{
fontSize: 10,
color: "#475569",
marginLeft: "auto",
flexShrink: 0,
}}
>
{lesson.type === "VIDEO" ? "▶" : "📄"}
</span>
</Link>
);
})}
{/* Quiz indicator */}
{module.quiz && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "9px 16px 9px 32px",
}}
>
<span
style={{
width: 18,
height: 18,
borderRadius: "50%",
border: `2px solid ${quizPassed && currentModule.id === module.id ? "#22c55e" : "rgba(245,158,11,0.4)"}`,
background: quizPassed && currentModule.id === module.id ? "#22c55e" : "transparent",
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 9,
color: "#fff",
}}
>
{quizPassed && currentModule.id === module.id && "✓"}
</span>
<span style={{ fontSize: 12, color: "#fbbf24" }}>Quiz</span>
</div>
)}
</div>
);
})}
</aside>
{/* Main content */}
<main style={{ flex: 1, overflowY: "auto", padding: "32px 40px" }}>
<LessonContent
lesson={{
id: currentLesson.id,
type: currentLesson.type,
videoUrl: currentLesson.videoUrl,
title,
content,
}}
quiz={
isLastLessonInModule && currentModule.quiz && !quizPassed
? {
id: currentModule.quiz.id,
passMark: currentModule.quiz.passMark,
questions: currentModule.quiz.questions,
}
: null
}
isCompleted={completedLessonIds.has(lessonId)}
navigation={{
prev: prevLesson
? `/${locale}/courses/${slug}/learn/${prevLesson.id}`
: null,
next: nextLesson
? `/${locale}/courses/${slug}/learn/${nextLesson.id}`
: null,
}}
locale={locale}
t={{
complete: t("complete"),
next: t("next"),
prev: t("prev"),
quiz: t("quiz"),
}}
/>
</main>
</div>
);
}

View File

@ -0,0 +1,63 @@
import { redirect, notFound } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export const dynamic = "force-dynamic";
export default async function LearnRedirectPage({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
const session = await auth();
if (!session?.user) {
redirect(`/${locale}/auth/login`);
}
const userId = (session.user as any).id;
const course = await db.course.findUnique({
where: { slug },
include: {
modules: {
orderBy: { order: "asc" },
include: {
lessons: { orderBy: { order: "asc" } },
},
},
},
});
if (!course) notFound();
// Check enrollment
const enrollment = await db.enrollment.findUnique({
where: { userId_courseId: { userId, courseId: course.id } },
});
if (!enrollment) {
redirect(`/${locale}/courses/${slug}`);
}
// Find the first uncompleted lesson or the first lesson
const allLessons = course.modules.flatMap((m) => m.lessons);
if (allLessons.length === 0) {
redirect(`/${locale}/courses/${slug}`);
}
const completedLessonIds = new Set(
(
await db.lessonProgress.findMany({
where: { userId, lessonId: { in: allLessons.map((l) => l.id) } },
select: { lessonId: true },
})
).map((p) => p.lessonId)
);
const firstUncompleted = allLessons.find((l) => !completedLessonIds.has(l.id));
const targetLesson = firstUncompleted ?? allLessons[0];
redirect(`/${locale}/courses/${slug}/learn/${targetLesson.id}`);
}

View File

@ -0,0 +1,357 @@
import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation";
import Link from "next/link";
import { db } from "@/lib/db";
import { auth } from "@/auth";
import { ProgressBar } from "@/components/ProgressBar";
import { EnrollButton } from "./EnrollButton";
export const dynamic = "force-dynamic";
const levelLabels: Record<string, Record<string, string>> = {
fr: { BEGINNER: "Débutant", INTERMEDIATE: "Intermédiaire", ADVANCED: "Avancé" },
en: { BEGINNER: "Beginner", INTERMEDIATE: "Intermediate", ADVANCED: "Advanced" },
es: { BEGINNER: "Principiante", INTERMEDIATE: "Intermedio", ADVANCED: "Avanzado" },
};
function getLocaleText(
obj: Record<string, string>,
locale: string,
keys: { fr: string; en: string; es: string }
) {
if (locale === "en") return (obj as any)[keys.en] || (obj as any)[keys.fr];
if (locale === "es") return (obj as any)[keys.es] || (obj as any)[keys.fr];
return (obj as any)[keys.fr];
}
export default async function CourseDetailPage({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
const t = await getTranslations({ locale, namespace: "course" });
const course = await db.course.findUnique({
where: { slug },
include: {
modules: {
orderBy: { order: "asc" },
include: {
lessons: { orderBy: { order: "asc" } },
quiz: { select: { id: true } },
},
},
},
});
if (!course || !course.published) {
notFound();
}
const session = await auth();
let enrollment = null;
let progress = 0;
let firstLessonId: string | null = null;
if (session?.user) {
const userId = (session.user as any).id;
enrollment = await db.enrollment.findUnique({
where: { userId_courseId: { userId, courseId: course.id } },
});
// Calculate progress
const allLessons = course.modules.flatMap((m) => m.lessons);
firstLessonId = allLessons[0]?.id ?? null;
if (allLessons.length > 0) {
const completedCount = await db.lessonProgress.count({
where: {
userId,
lessonId: { in: allLessons.map((l) => l.id) },
},
});
progress = Math.round((completedCount / allLessons.length) * 100);
}
}
const title = getLocaleText(course as any, locale, { fr: "titleFr", en: "titleEn", es: "titleEs" });
const desc = getLocaleText(course as any, locale, { fr: "descFr", en: "descEn", es: "descEs" });
const lvlLabel = (levelLabels[locale] ?? levelLabels.fr)[course.level];
const totalLessons = course.modules.reduce((s, m) => s + m.lessons.length, 0);
return (
<div style={{ maxWidth: 1000, margin: "0 auto", padding: "40px 24px" }}>
{/* Breadcrumb */}
<div style={{ marginBottom: 24, fontSize: 13, color: "#94a3b8" }}>
<Link href={`/${locale}/courses`} style={{ color: "#60a5fa" }}>
Formations
</Link>{" "}
/ {title}
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 320px", gap: 32, alignItems: "start" }}>
{/* Main content */}
<div>
{/* Course header */}
<div style={{ marginBottom: 32 }}>
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
<span
style={{
background: "rgba(29,78,216,0.2)",
color: "#60a5fa",
borderRadius: 999,
padding: "3px 12px",
fontSize: 12,
fontWeight: 700,
}}
>
{course.category}
</span>
<span
style={{
background: "rgba(245,158,11,0.15)",
color: "#fbbf24",
borderRadius: 999,
padding: "3px 12px",
fontSize: 12,
fontWeight: 700,
}}
>
{lvlLabel}
</span>
</div>
<h1 style={{ fontSize: 28, fontWeight: 800, color: "#f1f5f9", marginBottom: 16, lineHeight: 1.3 }}>
{title}
</h1>
<p style={{ fontSize: 16, color: "#94a3b8", lineHeight: 1.7 }}>
{desc}
</p>
</div>
{/* Thumbnail */}
{course.thumbnailUrl && (
<div
style={{
borderRadius: 12,
overflow: "hidden",
marginBottom: 32,
aspectRatio: "16/9",
background: "#1a1f2e",
}}
>
<img
src={course.thumbnailUrl}
alt={title}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</div>
)}
{/* Module list */}
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: "#f1f5f9", marginBottom: 16 }}>
{t("modules")} ({course.modules.length})
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{course.modules.map((module, mIndex) => {
const moduleTitle = getLocaleText(module as any, locale, {
fr: "titleFr",
en: "titleEn",
es: "titleEs",
});
return (
<div
key={module.id}
style={{
background: "#1a1f2e",
border: "1px solid rgba(255,255,255,0.08)",
borderRadius: 10,
padding: "16px 20px",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div
style={{
width: 28,
height: 28,
background: "rgba(29,78,216,0.2)",
color: "#60a5fa",
borderRadius: 6,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 12,
fontWeight: 700,
flexShrink: 0,
}}
>
{mIndex + 1}
</div>
<span style={{ fontSize: 15, fontWeight: 600, color: "#f1f5f9" }}>
{moduleTitle}
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<span style={{ fontSize: 12, color: "#94a3b8" }}>
{module.lessons.length} {t("lessons")}
</span>
{module.quiz && (
<span
style={{
fontSize: 11,
fontWeight: 600,
background: "rgba(245,158,11,0.15)",
color: "#fbbf24",
borderRadius: 4,
padding: "2px 8px",
}}
>
{t("quiz")}
</span>
)}
</div>
</div>
{module.lessons.length > 0 && (
<div style={{ marginTop: 10, paddingLeft: 40 }}>
{module.lessons.map((lesson) => {
const lessonTitle = getLocaleText(lesson as any, locale, {
fr: "titleFr",
en: "titleEn",
es: "titleEs",
});
return (
<div
key={lesson.id}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 0",
borderTop: "1px solid rgba(255,255,255,0.04)",
}}
>
<span style={{ fontSize: 12, color: "#475569" }}>
{lesson.type === "VIDEO" ? "▶" : "📄"}
</span>
<span style={{ fontSize: 13, color: "#94a3b8" }}>{lessonTitle}</span>
{lesson.duration && (
<span style={{ fontSize: 12, color: "#475569", marginLeft: "auto" }}>
{Math.floor(lesson.duration / 60)}min
</span>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
</div>
</div>
{/* Sidebar */}
<div style={{ position: "sticky", top: 88 }}>
<div className="card">
{/* Stats */}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 16,
marginBottom: 20,
paddingBottom: 20,
borderBottom: "1px solid rgba(255,255,255,0.08)",
}}
>
<div>
<div style={{ fontSize: 22, fontWeight: 700, color: "#f1f5f9" }}>
{course.modules.length}
</div>
<div style={{ fontSize: 12, color: "#94a3b8" }}>{t("modules")}</div>
</div>
<div>
<div style={{ fontSize: 22, fontWeight: 700, color: "#f1f5f9" }}>
{totalLessons}
</div>
<div style={{ fontSize: 12, color: "#94a3b8" }}>{t("lessons")}</div>
</div>
</div>
{/* Progress if enrolled */}
{enrollment && (
<div style={{ marginBottom: 20 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 13,
color: "#94a3b8",
marginBottom: 8,
}}
>
<span>Progression</span>
<span style={{ fontWeight: 700, color: "#f1f5f9" }}>{progress}%</span>
</div>
<ProgressBar value={progress} height={8} />
{enrollment.completedAt && (
<p
style={{
fontSize: 12,
color: "#4ade80",
marginTop: 8,
display: "flex",
alignItems: "center",
gap: 4,
}}
>
Formation complétée
</p>
)}
</div>
)}
{/* CTA */}
{enrollment ? (
<Link
href={`/${locale}/courses/${slug}/learn`}
className="btn btn-primary"
style={{
display: "flex",
justifyContent: "center",
padding: "12px",
fontSize: 15,
}}
>
{progress > 0 ? t("continue") : t("start")}
</Link>
) : session ? (
<EnrollButton
courseId={course.id}
locale={locale}
slug={slug}
label={t("enroll")}
/>
) : (
<Link
href={`/${locale}/auth/login`}
className="btn btn-primary"
style={{ display: "flex", justifyContent: "center", padding: "12px", fontSize: 15 }}
>
{t("enroll")}
</Link>
)}
<p style={{ fontSize: 12, color: "#475569", textAlign: "center", marginTop: 12 }}>
Accès illimité · Certificat inclus
</p>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,202 @@
import { getTranslations } from "next-intl/server";
import { db } from "@/lib/db";
import { auth } from "@/auth";
import { CourseCard } from "@/components/CourseCard";
import Link from "next/link";
export const dynamic = "force-dynamic";
const categories = ["GOVERNANCE", "CYBER", "OWLCUB", "OTHER"];
const levels = ["BEGINNER", "INTERMEDIATE", "ADVANCED"];
export default async function CoursesPage({
params,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<{ category?: string; level?: string; q?: string }>;
}) {
const { locale } = await params;
const { category, level, q } = await searchParams;
const t = await getTranslations({ locale, namespace: "home" });
const courseT = await getTranslations({ locale, namespace: "course" });
const session = await auth();
const where: any = { published: true };
if (category && categories.includes(category)) where.category = category;
if (level && levels.includes(level)) where.level = level;
if (q) {
where.OR = [
{ titleFr: { contains: q, mode: "insensitive" } },
{ titleEn: { contains: q, mode: "insensitive" } },
{ titleEs: { contains: q, mode: "insensitive" } },
];
}
const courses = await db.course.findMany({
where,
orderBy: { order: "asc" },
include: { _count: { select: { modules: true } } },
});
// Enrolled courses
const enrolledIds = new Set<string>();
if (session?.user) {
const userId = (session.user as any).id;
const enrollments = await db.enrollment.findMany({
where: { userId },
select: { courseId: true },
});
enrollments.forEach((e) => enrolledIds.add(e.courseId));
}
const categoryLabels: Record<string, Record<string, string>> = {
fr: { GOVERNANCE: "Gouvernance", CYBER: "Cybersécurité", OWLCUB: "OwlCub", OTHER: "Autre" },
en: { GOVERNANCE: "Governance", CYBER: "Cybersecurity", OWLCUB: "OwlCub", OTHER: "Other" },
es: { GOVERNANCE: "Gobernanza", CYBER: "Ciberseguridad", OWLCUB: "OwlCub", OTHER: "Otro" },
};
const levelLabels: Record<string, Record<string, string>> = {
fr: { BEGINNER: "Débutant", INTERMEDIATE: "Intermédiaire", ADVANCED: "Avancé" },
en: { BEGINNER: "Beginner", INTERMEDIATE: "Intermediate", ADVANCED: "Advanced" },
es: { BEGINNER: "Principiante", INTERMEDIATE: "Intermedio", ADVANCED: "Avanzado" },
};
const catLabels = categoryLabels[locale] ?? categoryLabels.fr;
const lvlLabels = levelLabels[locale] ?? levelLabels.fr;
return (
<div style={{ maxWidth: 1200, margin: "0 auto", padding: "40px 24px" }}>
<h1 style={{ fontSize: 28, fontWeight: 800, color: "#f1f5f9", marginBottom: 8 }}>
Catalogue des formations
</h1>
<p style={{ color: "#94a3b8", fontSize: 15, marginBottom: 32 }}>
{courses.length} formation{courses.length !== 1 ? "s" : ""} disponible{courses.length !== 1 ? "s" : ""}
</p>
{/* Filters */}
<div
style={{
display: "flex",
gap: 16,
flexWrap: "wrap",
marginBottom: 32,
alignItems: "center",
}}
>
{/* Search */}
<form method="GET" style={{ display: "flex", gap: 8 }}>
{category && <input type="hidden" name="category" value={category} />}
{level && <input type="hidden" name="level" value={level} />}
<input
type="text"
name="q"
defaultValue={q}
placeholder="Rechercher…"
style={{ width: 220, fontSize: 14 }}
/>
<button type="submit" className="btn btn-secondary" style={{ flexShrink: 0 }}>
🔍
</button>
</form>
{/* Category filter */}
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
<Link
href={`/${locale}/courses${level ? `?level=${level}` : ""}`}
style={{
padding: "6px 14px",
borderRadius: 8,
fontSize: 13,
fontWeight: 600,
background: !category ? "#1d4ed8" : "rgba(255,255,255,0.05)",
color: !category ? "#fff" : "#94a3b8",
border: "1px solid",
borderColor: !category ? "#1d4ed8" : "rgba(255,255,255,0.1)",
}}
>
Tout
</Link>
{categories.map((cat) => (
<Link
key={cat}
href={`/${locale}/courses?category=${cat}${level ? `&level=${level}` : ""}`}
style={{
padding: "6px 14px",
borderRadius: 8,
fontSize: 13,
fontWeight: 600,
background: category === cat ? "#1d4ed8" : "rgba(255,255,255,0.05)",
color: category === cat ? "#fff" : "#94a3b8",
border: "1px solid",
borderColor: category === cat ? "#1d4ed8" : "rgba(255,255,255,0.1)",
}}
>
{catLabels[cat]}
</Link>
))}
</div>
{/* Level filter */}
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
{levels.map((lvl) => (
<Link
key={lvl}
href={`/${locale}/courses${category ? `?category=${category}&` : "?"}level=${lvl}`}
style={{
padding: "6px 14px",
borderRadius: 8,
fontSize: 13,
fontWeight: 600,
background: level === lvl ? "rgba(245,158,11,0.2)" : "rgba(255,255,255,0.05)",
color: level === lvl ? "#fbbf24" : "#94a3b8",
border: "1px solid",
borderColor: level === lvl ? "rgba(245,158,11,0.4)" : "rgba(255,255,255,0.1)",
}}
>
{lvlLabels[lvl]}
</Link>
))}
</div>
</div>
{/* Course grid */}
{courses.length === 0 ? (
<div
style={{
textAlign: "center",
padding: "80px 24px",
color: "#94a3b8",
}}
>
<div style={{ fontSize: 48, marginBottom: 16 }}>📚</div>
<p style={{ fontSize: 16, marginBottom: 8 }}>Aucune formation trouvée</p>
<p style={{ fontSize: 14 }}>Essayez de modifier vos filtres</p>
</div>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))",
gap: 20,
}}
>
{courses.map((course) => (
<CourseCard
key={course.id}
locale={locale}
course={course}
isEnrolled={enrolledIds.has(course.id)}
t={{
enroll: courseT("enroll"),
enrolled: courseT("enrolled"),
continue: courseT("continue"),
start: courseT("start"),
}}
/>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,296 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { ProgressBar } from "@/components/ProgressBar";
export const dynamic = "force-dynamic";
function getLocaleText(obj: any, locale: string) {
if (locale === "en") return obj.titleEn || obj.titleFr;
if (locale === "es") return obj.titleEs || obj.titleFr;
return obj.titleFr;
}
export default async function DashboardPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "dashboard" });
const courseT = await getTranslations({ locale, namespace: "course" });
const session = await auth();
if (!session?.user) {
redirect(`/${locale}/auth/login`);
}
const userId = (session.user as any).id;
const enrollments = await db.enrollment.findMany({
where: { userId },
include: {
course: {
include: {
modules: {
include: {
lessons: { select: { id: true } },
},
},
},
},
},
orderBy: { enrolledAt: "desc" },
});
const certificates = await db.certificate.findMany({
where: { userId },
include: { course: true },
orderBy: { issuedAt: "desc" },
});
// Calculate progress for each enrollment
const enrichedEnrollments = await Promise.all(
enrollments.map(async (enrollment) => {
const allLessons = enrollment.course.modules.flatMap((m) => m.lessons);
let progress = 0;
if (allLessons.length > 0) {
const completedCount = await db.lessonProgress.count({
where: {
userId,
lessonId: { in: allLessons.map((l) => l.id) },
},
});
progress = Math.round((completedCount / allLessons.length) * 100);
}
return { ...enrollment, progress };
})
);
const inProgress = enrichedEnrollments.filter((e) => !e.completedAt);
const completed = enrichedEnrollments.filter((e) => e.completedAt);
return (
<div style={{ maxWidth: 1000, margin: "0 auto", padding: "40px 24px" }}>
{/* Header */}
<div style={{ marginBottom: 40 }}>
<h1 style={{ fontSize: 28, fontWeight: 800, color: "#f1f5f9", marginBottom: 6 }}>
{t("title")}
</h1>
<p style={{ color: "#94a3b8", fontSize: 15 }}>
Bienvenue, {session.user.name || session.user.email} 👋
</p>
</div>
{/* Stats */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 16,
marginBottom: 40,
}}
>
{[
{ label: t("in_progress"), value: inProgress.length, icon: "📖", color: "#1d4ed8" },
{ label: t("completed"), value: completed.length, icon: "🎓", color: "#22c55e" },
{ label: t("certificates"), value: certificates.length, icon: "📜", color: "#f59e0b" },
].map((stat) => (
<div
key={stat.label}
className="card"
style={{ textAlign: "center" }}
>
<div style={{ fontSize: 32, marginBottom: 8 }}>{stat.icon}</div>
<div
style={{ fontSize: 32, fontWeight: 800, color: stat.color, marginBottom: 4 }}
>
{stat.value}
</div>
<div style={{ fontSize: 13, color: "#94a3b8" }}>{stat.label}</div>
</div>
))}
</div>
{enrollments.length === 0 ? (
<div
style={{
textAlign: "center",
padding: "60px 24px",
background: "#1a1f2e",
borderRadius: 12,
border: "1px solid rgba(255,255,255,0.08)",
}}
>
<div style={{ fontSize: 48, marginBottom: 16 }}>📚</div>
<p style={{ fontSize: 16, color: "#f1f5f9", marginBottom: 8 }}>{t("no_enrollments")}</p>
<Link href={`/${locale}/courses`} className="btn btn-primary" style={{ marginTop: 16 }}>
{t("browse")}
</Link>
</div>
) : (
<>
{/* In progress */}
{inProgress.length > 0 && (
<section style={{ marginBottom: 40 }}>
<h2 style={{ fontSize: 20, fontWeight: 700, color: "#f1f5f9", marginBottom: 16 }}>
{t("in_progress")}
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{inProgress.map((e) => {
const title = getLocaleText(e.course, locale);
return (
<div
key={e.id}
className="card"
style={{ display: "flex", alignItems: "center", gap: 20 }}
>
<div
style={{
width: 56,
height: 56,
background: e.course.thumbnailUrl
? `url(${e.course.thumbnailUrl}) center/cover`
: "linear-gradient(135deg, #1e3a8a, #1d4ed8)",
borderRadius: 8,
flexShrink: 0,
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 15, fontWeight: 600, color: "#f1f5f9", marginBottom: 8 }}>
{title}
</p>
<ProgressBar value={e.progress} />
<span style={{ fontSize: 12, color: "#94a3b8", marginTop: 4, display: "block" }}>
{e.progress}% complété
</span>
</div>
<Link
href={`/${locale}/courses/${e.course.slug}/learn`}
className="btn btn-primary"
style={{ flexShrink: 0, fontSize: 13 }}
>
{courseT("continue")}
</Link>
</div>
);
})}
</div>
</section>
)}
{/* Completed */}
{completed.length > 0 && (
<section style={{ marginBottom: 40 }}>
<h2 style={{ fontSize: 20, fontWeight: 700, color: "#f1f5f9", marginBottom: 16 }}>
{t("completed")}
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{completed.map((e) => {
const title = getLocaleText(e.course, locale);
const cert = certificates.find((c) => c.courseId === e.course.id);
return (
<div
key={e.id}
className="card"
style={{ display: "flex", alignItems: "center", gap: 20 }}
>
<div
style={{
width: 56,
height: 56,
background: e.course.thumbnailUrl
? `url(${e.course.thumbnailUrl}) center/cover`
: "linear-gradient(135deg, #064e3b, #059669)",
borderRadius: 8,
flexShrink: 0,
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 15, fontWeight: 600, color: "#f1f5f9", marginBottom: 4 }}>
{title}
</p>
<span
style={{
fontSize: 12,
color: "#4ade80",
background: "rgba(34,197,94,0.1)",
borderRadius: 4,
padding: "2px 8px",
}}
>
Complété le{" "}
{e.completedAt?.toLocaleDateString("fr-FR")}
</span>
</div>
{cert && (
<a
href={`/api/certificates/${cert.id}`}
className="btn btn-secondary"
style={{ flexShrink: 0, fontSize: 13 }}
>
📜 {courseT("download_cert")}
</a>
)}
</div>
);
})}
</div>
</section>
)}
{/* Certificates */}
{certificates.length > 0 && (
<section>
<h2 style={{ fontSize: 20, fontWeight: 700, color: "#f1f5f9", marginBottom: 16 }}>
{t("certificates")}
</h2>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 16 }}>
{certificates.map((cert) => {
const title = getLocaleText(cert.course, locale);
return (
<div
key={cert.id}
className="card"
style={{
background: "linear-gradient(135deg, #1a1f2e, #1e2a4a)",
border: "1px solid rgba(29,78,216,0.3)",
}}
>
<div style={{ fontSize: 32, marginBottom: 12 }}>📜</div>
<p style={{ fontSize: 14, fontWeight: 700, color: "#f1f5f9", marginBottom: 4 }}>
{title}
</p>
<p style={{ fontSize: 12, color: "#94a3b8", marginBottom: 16 }}>
Délivré le {cert.issuedAt.toLocaleDateString("fr-FR")}
</p>
{cert.isPaid || true ? (
<a
href={`/api/certificates/${cert.id}`}
className="btn btn-primary"
style={{ fontSize: 13, display: "flex", justifyContent: "center" }}
>
Télécharger le PDF
</a>
) : (
<button
className="btn btn-secondary"
style={{ fontSize: 13, width: "100%", justifyContent: "center", opacity: 0.6 }}
disabled
>
Bientôt disponible
</button>
)}
</div>
);
})}
</div>
</section>
)}
</>
)}
</div>
);
}

View File

@ -0,0 +1,45 @@
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { notFound } from "next/navigation";
import { auth } from "@/auth";
import { Nav } from "@/components/Nav";
import "../globals.css";
const locales = ["fr", "en", "es"];
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!locales.includes(locale)) {
notFound();
}
const messages = await getMessages();
const session = await auth();
const userRole = (session?.user as any)?.role;
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages} locale={locale}>
<Nav
locale={locale}
userRole={userRole}
isLoggedIn={!!session}
/>
<main>{children}</main>
</NextIntlClientProvider>
</body>
</html>
);
}

265
src/app/[locale]/page.tsx Normal file
View File

@ -0,0 +1,265 @@
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { db } from "@/lib/db";
import { CourseCard } from "@/components/CourseCard";
import { auth } from "@/auth";
export default async function HomePage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "home" });
const nav = await getTranslations({ locale, namespace: "nav" });
const courseT = await getTranslations({ locale, namespace: "course" });
const session = await auth();
const courses = await db.course.findMany({
where: { published: true },
orderBy: { order: "asc" },
take: 6,
include: { _count: { select: { modules: true } } },
});
// Get enrolled course IDs for current user
const enrolledIds = new Set<string>();
if (session?.user) {
const userId = (session.user as any).id;
const enrollments = await db.enrollment.findMany({
where: { userId },
select: { courseId: true },
});
enrollments.forEach((e) => enrolledIds.add(e.courseId));
}
const categories = ["GOVERNANCE", "CYBER", "OWLCUB", "OTHER"];
return (
<div>
{/* Hero Section */}
<section
style={{
background:
"linear-gradient(135deg, #0f1117 0%, #1a1f2e 50%, #0f1117 100%)",
padding: "80px 24px",
textAlign: "center",
position: "relative",
overflow: "hidden",
}}
>
{/* Background decoration */}
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 600,
height: 600,
background: "radial-gradient(circle, rgba(29,78,216,0.12) 0%, transparent 70%)",
pointerEvents: "none",
}}
/>
<div style={{ position: "relative", maxWidth: 700, margin: "0 auto" }}>
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
background: "rgba(29,78,216,0.15)",
border: "1px solid rgba(29,78,216,0.3)",
borderRadius: 999,
padding: "6px 16px",
marginBottom: 24,
fontSize: 13,
color: "#60a5fa",
fontWeight: 600,
}}
>
🦉 OwlCub Academy
</div>
<h1
style={{
fontSize: "clamp(32px, 5vw, 52px)",
fontWeight: 800,
color: "#f1f5f9",
lineHeight: 1.2,
marginBottom: 20,
}}
>
{t("hero_title")}
</h1>
<p
style={{
fontSize: 18,
color: "#94a3b8",
marginBottom: 36,
lineHeight: 1.6,
}}
>
{t("hero_sub")}
</p>
<Link
href={`/${locale}/courses`}
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
background: "#1d4ed8",
color: "#fff",
padding: "14px 28px",
borderRadius: 10,
fontWeight: 700,
fontSize: 16,
transition: "all 0.15s",
textDecoration: "none",
}}
>
{t("cta")}
</Link>
</div>
</section>
{/* Category pills */}
<section
style={{
maxWidth: 1200,
margin: "0 auto",
padding: "40px 24px 0",
}}
>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
{categories.map((cat) => {
const catLabel = t(`categories.${cat}`);
return (
<Link
key={cat}
href={`/${locale}/courses?category=${cat}`}
style={{
background: "rgba(255,255,255,0.05)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 8,
padding: "8px 18px",
fontSize: 13,
fontWeight: 600,
color: "#94a3b8",
transition: "all 0.15s",
textDecoration: "none",
}}
>
{catLabel}
</Link>
);
})}
</div>
</section>
{/* Course grid */}
<section
style={{
maxWidth: 1200,
margin: "0 auto",
padding: "32px 24px 64px",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 24,
}}
>
<h2 style={{ fontSize: 22, fontWeight: 700, color: "#f1f5f9" }}>
Formations populaires
</h2>
<Link
href={`/${locale}/courses`}
style={{ fontSize: 13, color: "#60a5fa", fontWeight: 600 }}
>
Voir tout
</Link>
</div>
{courses.length === 0 ? (
<div
style={{
textAlign: "center",
padding: "60px 24px",
color: "#94a3b8",
}}
>
<div style={{ fontSize: 48, marginBottom: 16 }}>📚</div>
<p>Aucune formation disponible pour le moment.</p>
</div>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))",
gap: 20,
}}
>
{courses.map((course) => (
<CourseCard
key={course.id}
locale={locale}
course={course}
isEnrolled={enrolledIds.has(course.id)}
t={{
enroll: courseT("enroll"),
enrolled: courseT("enrolled"),
continue: courseT("continue"),
start: courseT("start"),
}}
/>
))}
</div>
)}
</section>
{/* Stats section */}
<section
style={{
background: "#1a1f2e",
borderTop: "1px solid rgba(255,255,255,0.08)",
borderBottom: "1px solid rgba(255,255,255,0.08)",
padding: "48px 24px",
}}
>
<div
style={{
maxWidth: 800,
margin: "0 auto",
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 32,
textAlign: "center",
}}
>
{[
{ label: "Formations", value: "20+" },
{ label: "Apprenants", value: "500+" },
{ label: "Certificats délivrés", value: "1 000+" },
].map((stat) => (
<div key={stat.label}>
<div
style={{
fontSize: 36,
fontWeight: 800,
color: "#1d4ed8",
marginBottom: 4,
}}
>
{stat.value}
</div>
<div style={{ fontSize: 14, color: "#94a3b8" }}>{stat.label}</div>
</div>
))}
</div>
</section>
</div>
);
}

View File

@ -0,0 +1,33 @@
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 POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
if (!(await requireAdmin())) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id: courseId } = await params;
const { titleFr, titleEn, titleEs, order } = await req.json();
const module = await db.module.create({
data: {
courseId,
titleFr: titleFr || "Module sans titre",
titleEn: titleEn || titleFr || "Untitled Module",
titleEs: titleEs || titleFr || "Módulo sin título",
order: order ?? 0,
},
});
return NextResponse.json(module);
}

View File

@ -0,0 +1,29 @@
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(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
if (!(await requireAdmin())) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
const course = await db.course.findUnique({ where: { id }, select: { published: true } });
if (!course) return NextResponse.json({ error: "Not found" }, { status: 404 });
const updated = await db.course.update({
where: { id },
data: { published: !course.published },
});
return NextResponse.json(updated);
}

View File

@ -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 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 { titleFr, titleEn, titleEs, descFr, descEn, descEs, slug, category, level, published, thumbnailUrl } = body;
try {
const course = await db.course.update({
where: { id },
data: {
titleFr,
titleEn: titleEn || titleFr,
titleEs: titleEs || titleFr,
descFr: descFr || "",
descEn: descEn || "",
descEs: descEs || "",
slug,
category,
level,
published,
thumbnailUrl: thumbnailUrl ?? null,
},
});
return NextResponse.json(course);
} catch (err: any) {
if (err.code === "P2002") {
return NextResponse.json({ error: "Slug already exists" }, { status: 409 });
}
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
if (!(await requireAdmin())) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
await db.course.delete({ where: { id } });
return NextResponse.json({ ok: true });
}

View File

@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
async function requireAdmin(req: NextRequest) {
const session = await auth();
if (!session?.user || (session.user as any).role !== "ADMIN") {
return null;
}
return session;
}
export async function POST(req: NextRequest) {
const session = await requireAdmin(req);
if (!session) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await req.json();
const { titleFr, titleEn, titleEs, descFr, descEn, descEs, slug, category, level, published, thumbnailUrl } = body;
if (!titleFr || !slug) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
try {
const course = await db.course.create({
data: {
titleFr,
titleEn: titleEn || titleFr,
titleEs: titleEs || titleFr,
descFr: descFr || "",
descEn: descEn || "",
descEs: descEs || "",
slug,
category: category || "OTHER",
level: level || "BEGINNER",
published: published || false,
thumbnailUrl: thumbnailUrl || null,
},
});
return NextResponse.json(course);
} catch (err: any) {
if (err.code === "P2002") {
return NextResponse.json({ error: "Slug already exists" }, { status: 409 });
}
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}

View File

@ -0,0 +1,50 @@
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(
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 lesson = await db.lesson.update({
where: { id },
data: {
titleFr: body.titleFr,
titleEn: body.titleEn || body.titleFr,
titleEs: body.titleEs || body.titleFr,
videoUrl: body.videoUrl ?? null,
contentFr: body.contentFr ?? null,
contentEn: body.contentEn ?? null,
contentEs: body.contentEs ?? null,
duration: body.duration ? parseInt(body.duration) : null,
},
});
return NextResponse.json(lesson);
}
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
if (!(await requireAdmin())) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
await db.lesson.delete({ where: { id } });
return NextResponse.json({ ok: true });
}

View File

@ -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;
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
if (!(await requireAdmin())) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id: moduleId } = await params;
const { titleFr, titleEn, titleEs, type, videoUrl, contentFr, contentEn, contentEs, duration, order } =
await req.json();
const lesson = await db.lesson.create({
data: {
moduleId,
titleFr: titleFr || "Leçon sans titre",
titleEn: titleEn || titleFr || "Untitled Lesson",
titleEs: titleEs || titleFr || "Lección sin título",
type: type === "VIDEO" ? "VIDEO" : "TEXT",
videoUrl: videoUrl || null,
contentFr: contentFr || null,
contentEn: contentEn || null,
contentEs: contentEs || null,
duration: duration ? parseInt(duration) : null,
order: order ?? 0,
},
});
return NextResponse.json(lesson);
}

View File

@ -0,0 +1,51 @@
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(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
if (!(await requireAdmin())) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id: moduleId } = await params;
const { passMark, questions } = await req.json();
// Delete existing quiz if any
const existing = await db.quiz.findUnique({ where: { moduleId } });
if (existing) {
await db.question.deleteMany({ where: { quizId: existing.id } });
await db.quiz.delete({ where: { id: existing.id } });
}
// Create new quiz with questions
const quiz = await db.quiz.create({
data: {
moduleId,
passMark: passMark ?? 80,
questions: {
create: questions.map((q: any, idx: number) => ({
textFr: q.textFr || "",
textEn: q.textEn || q.textFr || "",
textEs: q.textEs || q.textFr || "",
optionsFr: q.optionsFr || [],
optionsEn: q.optionsEn || q.optionsFr || [],
optionsEs: q.optionsEs || q.optionsFr || [],
correctIndex: q.correctIndex ?? 0,
order: q.order ?? idx,
})),
},
},
include: { questions: { orderBy: { order: "asc" } } },
});
return NextResponse.json(quiz);
}

View File

@ -0,0 +1,22 @@
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 DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
if (!(await requireAdmin())) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
await db.module.delete({ where: { id } });
return NextResponse.json({ ok: true });
}

View File

@ -0,0 +1,2 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

View File

@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { generateCertificate } from "@/lib/certificate";
import { uploadBuffer, getVideoUrl } from "@/lib/s3";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const userId = (session.user as any).id;
const userRole = (session.user as any).role;
const certificate = await db.certificate.findUnique({
where: { id },
include: {
user: true,
course: true,
},
});
if (!certificate) {
return NextResponse.json({ error: "Certificate not found" }, { status: 404 });
}
// Only owner or admin can access
if (certificate.userId !== userId && userRole !== "ADMIN") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
let pdfKey = certificate.pdfUrl;
// Generate PDF if not already created
if (!pdfKey) {
const pdfBytes = await generateCertificate({
learnerName: certificate.user.name ?? certificate.user.email ?? "Apprenant",
courseTitle: certificate.course.titleFr,
issuedAt: certificate.issuedAt,
});
pdfKey = `certificates/${certificate.userId}/${certificate.courseId}.pdf`;
await uploadBuffer(pdfKey, Buffer.from(pdfBytes), "application/pdf");
await db.certificate.update({
where: { id },
data: { pdfUrl: pdfKey },
});
}
// Get presigned download URL
const downloadUrl = await getVideoUrl(pdfKey);
return NextResponse.redirect(downloadUrl, 302);
}

View File

@ -0,0 +1,38 @@
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: "Unauthorized" }, { status: 401 });
}
const userId = (session.user as any).id;
const { courseId } = await req.json();
if (!courseId) {
return NextResponse.json({ error: "Missing courseId" }, { status: 400 });
}
// Check course exists and is published
const course = await db.course.findUnique({ where: { id: courseId } });
if (!course || !course.published) {
return NextResponse.json({ error: "Course not found" }, { status: 404 });
}
// Check not already enrolled
const existing = await db.enrollment.findUnique({
where: { userId_courseId: { userId, courseId } },
});
if (existing) {
return NextResponse.json(existing);
}
const enrollment = await db.enrollment.create({
data: { userId, courseId },
});
return NextResponse.json(enrollment);
}

View File

@ -0,0 +1,125 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { generateCertificate } from "@/lib/certificate";
import { uploadBuffer } from "@/lib/s3";
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = (session.user as any).id;
const { lessonId } = await req.json();
if (!lessonId) {
return NextResponse.json({ error: "Missing lessonId" }, { status: 400 });
}
// Upsert lesson progress
await db.lessonProgress.upsert({
where: { userId_lessonId: { userId, lessonId } },
create: { userId, lessonId },
update: { completedAt: new Date() },
});
// Find course for this lesson
const lesson = await db.lesson.findUnique({
where: { id: lessonId },
include: {
module: {
include: {
course: {
include: {
modules: {
include: {
lessons: { select: { id: true } },
quiz: { select: { id: true } },
},
},
},
},
},
},
},
});
if (!lesson) {
return NextResponse.json({ error: "Lesson not found" }, { status: 404 });
}
const course = lesson.module.course;
// Check enrollment
const enrollment = await db.enrollment.findUnique({
where: { userId_courseId: { userId, courseId: course.id } },
});
if (!enrollment || enrollment.completedAt) {
return NextResponse.json({ ok: true });
}
// Check if all lessons completed
const allLessons = course.modules.flatMap((m) => m.lessons);
const completedCount = await db.lessonProgress.count({
where: { userId, lessonId: { in: allLessons.map((l) => l.id) } },
});
if (completedCount < allLessons.length) {
return NextResponse.json({ ok: true });
}
// Check if all module quizzes passed
const modulesWithQuizzes = course.modules.filter((m) => m.quiz);
if (modulesWithQuizzes.length > 0) {
const passedQuizIds = await db.quizAttempt.findMany({
where: {
userId,
quizId: { in: modulesWithQuizzes.map((m) => m.quiz!.id) },
passed: true,
},
select: { quizId: true },
});
const passedIds = new Set(passedQuizIds.map((a) => a.quizId));
const allPassed = modulesWithQuizzes.every((m) => passedIds.has(m.quiz!.id));
if (!allPassed) {
return NextResponse.json({ ok: true });
}
}
// Mark course as complete
await db.enrollment.update({
where: { userId_courseId: { userId, courseId: course.id } },
data: { completedAt: new Date() },
});
// Generate certificate
const user = await db.user.findUnique({ where: { id: userId } });
const certKey = `certificates/${userId}/${course.id}.pdf`;
try {
const pdfBytes = await generateCertificate({
learnerName: user?.name ?? user?.email ?? "Apprenant",
courseTitle: course.titleFr,
issuedAt: new Date(),
});
await uploadBuffer(certKey, Buffer.from(pdfBytes), "application/pdf");
await db.certificate.upsert({
where: { userId_courseId: { userId, courseId: course.id } },
create: { userId, courseId: course.id, pdfUrl: certKey, isPaid: false },
update: { pdfUrl: certKey },
});
} catch (err) {
// Certificate generation failed, create record without pdfUrl
await db.certificate.upsert({
where: { userId_courseId: { userId, courseId: course.id } },
create: { userId, courseId: course.id, isPaid: false },
update: {},
});
}
return NextResponse.json({ ok: true, courseCompleted: true });
}

View File

@ -0,0 +1,49 @@
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: "Unauthorized" }, { status: 401 });
}
const userId = (session.user as any).id;
const { quizId, answers } = await req.json();
if (!quizId || !Array.isArray(answers)) {
return NextResponse.json({ error: "Missing fields" }, { status: 400 });
}
const quiz = await db.quiz.findUnique({
where: { id: quizId },
include: { questions: { orderBy: { order: "asc" } } },
});
if (!quiz) {
return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
}
// Compute score
let correct = 0;
quiz.questions.forEach((question, idx) => {
if (answers[idx] === question.correctIndex) correct++;
});
const total = quiz.questions.length;
const score = total > 0 ? Math.round((correct / total) * 100) : 0;
const passed = score >= quiz.passMark;
// Save attempt
const attempt = await db.quizAttempt.create({
data: {
userId,
quizId,
score,
passed,
answers,
},
});
return NextResponse.json({ score, passed, attemptId: attempt.id });
}

View File

@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { getUploadUrl } from "@/lib/s3";
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { filename, contentType, type } = await req.json();
const userRole = (session.user as any).role;
const userId = (session.user as any).id;
// Non-certificate uploads require ADMIN
if (type !== "certificate" && userRole !== "ADMIN") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (!filename || !contentType) {
return NextResponse.json({ error: "Missing filename or contentType" }, { status: 400 });
}
const ext = filename.split(".").pop() ?? "bin";
const timestamp = Date.now();
let key: string;
switch (type) {
case "video":
key = `videos/${timestamp}-${filename}`;
break;
case "thumbnail":
key = `thumbnails/${timestamp}-${filename}`;
break;
case "certificate":
key = `certificates/${userId}/${timestamp}.${ext}`;
break;
default:
key = `uploads/${timestamp}-${filename}`;
}
const url = await getUploadUrl(key, contentType);
return NextResponse.json({ url, key });
}

View File

@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { getVideoUrl } from "@/lib/s3";
export async function GET(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const key = req.nextUrl.searchParams.get("key");
if (!key) {
return NextResponse.json({ error: "Missing key" }, { status: 400 });
}
const url = await getVideoUrl(key);
return NextResponse.json({ url });
}

209
src/app/globals.css Normal file
View File

@ -0,0 +1,209 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--bg: #0f1117;
--card-bg: #1a1f2e;
--card-border: rgba(255, 255, 255, 0.1);
--primary: #1d4ed8;
--primary-hover: #1e40af;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--success: #22c55e;
--error: #ef4444;
--warning: #f59e0b;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth;
}
body {
background-color: var(--bg);
color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
min-height: 100vh;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg);
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
/* Focus styles */
*:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
/* Links */
a {
color: inherit;
text-decoration: none;
}
/* Card utility */
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 12px;
padding: 24px;
}
/* Button base */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.15s ease;
border: none;
text-decoration: none;
}
.btn-primary {
background: var(--primary);
color: #fff;
}
.btn-primary:hover {
background: var(--primary-hover);
}
.btn-secondary {
background: transparent;
color: var(--text-primary);
border: 1px solid var(--card-border);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.05);
}
.btn-danger {
background: var(--error);
color: #fff;
}
.btn-danger:hover {
background: #dc2626;
}
/* Form inputs */
input[type="text"],
input[type="email"],
input[type="number"],
select,
textarea {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--card-border);
border-radius: 8px;
color: var(--text-primary);
padding: 10px 14px;
font-size: 14px;
width: 100%;
transition: border-color 0.15s ease;
}
input[type="text"]:focus,
input[type="email"]:focus,
input[type="number"]:focus,
select:focus,
textarea:focus {
border-color: var(--primary);
outline: none;
}
select option {
background: #1a1f2e;
color: var(--text-primary);
}
/* Badge */
.badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.badge-blue {
background: rgba(29, 78, 216, 0.2);
color: #60a5fa;
}
.badge-green {
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
}
.badge-yellow {
background: rgba(245, 158, 11, 0.2);
color: #fbbf24;
}
.badge-red {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}
.badge-gray {
background: rgba(148, 163, 184, 0.15);
color: #94a3b8;
}
/* Table */
table {
width: 100%;
border-collapse: collapse;
}
th {
text-align: left;
padding: 12px 16px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
border-bottom: 1px solid var(--card-border);
}
td {
padding: 14px 16px;
font-size: 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
tr:hover td {
background: rgba(255, 255, 255, 0.02);
}

50
src/auth.ts Normal file
View File

@ -0,0 +1,50 @@
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import Resend from "next-auth/providers/resend";
import { db } from "@/lib/db";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(db),
providers: [
Resend({
from: "OwlCub Academy <noreply@owlcub.com>",
sendVerificationRequest: async ({ identifier: email, url, provider }) => {
const { Resend: ResendClient } = await import("resend");
const resend = new ResendClient(process.env.RESEND_API_KEY!);
await resend.emails.send({
from: provider.from as string,
to: email,
subject: "Connexion à OwlCub Academy",
html: `
<div style="font-family: -apple-system, sans-serif; max-width: 480px; margin: 0 auto; padding: 32px 24px; background: #0f1117; color: #f1f5f9;">
<div style="background: #1d4ed8; padding: 16px 24px; border-radius: 8px 8px 0 0; margin-bottom: 0;">
<h2 style="color: #fff; font-size: 20px; margin: 0;">OwlCub Academy</h2>
</div>
<div style="background: #1a1f2e; padding: 32px 24px; border-radius: 0 0 8px 8px;">
<p style="color: #94a3b8; margin-bottom: 24px; font-size: 16px;">Cliquez sur le lien ci-dessous pour vous connecter :</p>
<a href="${url}" style="display: inline-block; background: #1d4ed8; color: #fff; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 16px;">Se connecter</a>
<p style="color: #475569; font-size: 12px; margin-top: 24px;">Ce lien expire dans 24 heures. Si vous n'avez pas demandé ce lien, ignorez cet email.</p>
</div>
</div>
`,
});
},
}),
],
session: { strategy: "database" },
pages: {
signIn: "/auth/login",
verifyRequest: "/auth/verify",
newUser: "/dashboard",
},
callbacks: {
session({ session, user }) {
if (session.user) {
(session.user as any).id = user.id;
(session.user as any).role = (user as any).role;
(session.user as any).locale = (user as any).locale;
}
return session;
},
},
});

View File

@ -0,0 +1,246 @@
import Link from "next/link";
import { ProgressBar } from "./ProgressBar";
interface CourseCardProps {
locale: string;
course: {
id: string;
slug: string;
category: string;
level: string;
thumbnailUrl?: string | null;
titleFr: string;
titleEn: string;
titleEs: string;
descFr: string;
descEn: string;
descEs: string;
_count?: { modules?: number };
};
progress?: number; // 0-100
isEnrolled?: boolean;
t?: {
enroll: string;
enrolled: string;
continue: string;
start: string;
};
}
const categoryColors: Record<string, { bg: string; text: string }> = {
GOVERNANCE: { bg: "rgba(29,78,216,0.2)", text: "#60a5fa" },
CYBER: { bg: "rgba(239,68,68,0.2)", text: "#f87171" },
OWLCUB: { bg: "rgba(34,197,94,0.2)", text: "#4ade80" },
OTHER: { bg: "rgba(148,163,184,0.15)", text: "#94a3b8" },
};
const levelColors: Record<string, { bg: string; text: string }> = {
BEGINNER: { bg: "rgba(34,197,94,0.15)", text: "#4ade80" },
INTERMEDIATE: { bg: "rgba(245,158,11,0.15)", text: "#fbbf24" },
ADVANCED: { bg: "rgba(239,68,68,0.15)", text: "#f87171" },
};
const categoryLabels: Record<string, Record<string, string>> = {
fr: { GOVERNANCE: "Gouvernance", CYBER: "Cybersécurité", OWLCUB: "OwlCub", OTHER: "Autre" },
en: { GOVERNANCE: "Governance", CYBER: "Cybersecurity", OWLCUB: "OwlCub", OTHER: "Other" },
es: { GOVERNANCE: "Gobernanza", CYBER: "Ciberseguridad", OWLCUB: "OwlCub", OTHER: "Otro" },
};
const levelLabels: Record<string, Record<string, string>> = {
fr: { BEGINNER: "Débutant", INTERMEDIATE: "Intermédiaire", ADVANCED: "Avancé" },
en: { BEGINNER: "Beginner", INTERMEDIATE: "Intermediate", ADVANCED: "Advanced" },
es: { BEGINNER: "Principiante", INTERMEDIATE: "Intermedio", ADVANCED: "Avanzado" },
};
function getTitle(course: CourseCardProps["course"], locale: string) {
if (locale === "en") return course.titleEn || course.titleFr;
if (locale === "es") return course.titleEs || course.titleFr;
return course.titleFr;
}
function getDesc(course: CourseCardProps["course"], locale: string) {
if (locale === "en") return course.descEn || course.descFr;
if (locale === "es") return course.descEs || course.descFr;
return course.descFr;
}
export function CourseCard({
locale,
course,
progress,
isEnrolled,
t,
}: CourseCardProps) {
const title = getTitle(course, locale);
const desc = getDesc(course, locale);
const catColor = categoryColors[course.category] ?? categoryColors.OTHER;
const lvlColor = levelColors[course.level] ?? levelColors.BEGINNER;
const catLabel = (categoryLabels[locale] ?? categoryLabels.fr)[course.category] ?? course.category;
const lvlLabel = (levelLabels[locale] ?? levelLabels.fr)[course.level] ?? course.level;
const actionLabel = isEnrolled
? (progress ?? 0) > 0
? t?.continue ?? "Continuer"
: t?.start ?? "Commencer"
: t?.enroll ?? "S'inscrire";
return (
<div
style={{
background: "#1a1f2e",
border: "1px solid rgba(255,255,255,0.08)",
borderRadius: 12,
overflow: "hidden",
display: "flex",
flexDirection: "column",
transition: "border-color 0.2s, transform 0.2s",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.borderColor =
"rgba(29,78,216,0.5)";
(e.currentTarget as HTMLElement).style.transform = "translateY(-2px)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.borderColor =
"rgba(255,255,255,0.08)";
(e.currentTarget as HTMLElement).style.transform = "translateY(0)";
}}
>
{/* Thumbnail */}
<div
style={{
height: 160,
background: course.thumbnailUrl
? `url(${course.thumbnailUrl}) center/cover no-repeat`
: "linear-gradient(135deg, #1e3a8a 0%, #1d4ed8 100%)",
position: "relative",
}}
>
{!course.thumbnailUrl && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 48,
opacity: 0.4,
}}
>
🦉
</div>
)}
{/* Badges overlay */}
<div
style={{
position: "absolute",
top: 10,
left: 10,
display: "flex",
gap: 6,
}}
>
<span
style={{
background: catColor.bg,
color: catColor.text,
borderRadius: 999,
padding: "3px 10px",
fontSize: 11,
fontWeight: 700,
backdropFilter: "blur(4px)",
}}
>
{catLabel}
</span>
<span
style={{
background: lvlColor.bg,
color: lvlColor.text,
borderRadius: 999,
padding: "3px 10px",
fontSize: 11,
fontWeight: 700,
backdropFilter: "blur(4px)",
}}
>
{lvlLabel}
</span>
</div>
</div>
{/* Content */}
<div
style={{
padding: "16px",
flex: 1,
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<h3
style={{
fontSize: 15,
fontWeight: 700,
color: "#f1f5f9",
lineHeight: 1.4,
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{title}
</h3>
<p
style={{
fontSize: 13,
color: "#94a3b8",
lineHeight: 1.5,
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
flex: 1,
}}
>
{desc}
</p>
{/* Progress bar if enrolled */}
{isEnrolled && progress !== undefined && (
<div style={{ marginTop: 4 }}>
<ProgressBar value={progress} />
<span style={{ fontSize: 11, color: "#94a3b8", marginTop: 4, display: "block" }}>
{progress}% complété
</span>
</div>
)}
{/* CTA */}
<Link
href={`/${locale}/courses/${course.slug}${isEnrolled ? "/learn" : ""}`}
style={{
marginTop: 8,
display: "block",
textAlign: "center",
background: isEnrolled ? "rgba(29,78,216,0.15)" : "#1d4ed8",
color: isEnrolled ? "#60a5fa" : "#fff",
border: isEnrolled
? "1px solid rgba(29,78,216,0.4)"
: "1px solid transparent",
borderRadius: 8,
padding: "9px 16px",
fontSize: 13,
fontWeight: 600,
transition: "all 0.15s",
}}
>
{actionLabel}
</Link>
</div>
</div>
);
}

166
src/components/Nav.tsx Normal file
View File

@ -0,0 +1,166 @@
"use client";
import { useTranslations } from "next-intl";
import { usePathname, useRouter } from "next/navigation";
import { signOut } from "next-auth/react";
import Link from "next/link";
interface NavProps {
locale: string;
userRole?: string;
isLoggedIn?: boolean;
}
export function Nav({ locale, userRole, isLoggedIn }: NavProps) {
const t = useTranslations("nav");
const pathname = usePathname();
const localePath = (path: string) => `/${locale}${path}`;
const handleLocaleChange = (newLocale: string) => {
// Replace locale segment in pathname
const segments = pathname.split("/");
segments[1] = newLocale;
window.location.href = segments.join("/");
};
return (
<nav
style={{
background: "#1a1f2e",
borderBottom: "1px solid rgba(255,255,255,0.08)",
position: "sticky",
top: 0,
zIndex: 50,
}}
>
<div
style={{
maxWidth: 1200,
margin: "0 auto",
padding: "0 24px",
height: 64,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 24,
}}
>
{/* Logo */}
<Link
href={localePath("/")}
style={{
display: "flex",
alignItems: "center",
gap: 10,
fontWeight: 700,
fontSize: 18,
color: "#f1f5f9",
}}
>
<div
style={{
width: 32,
height: 32,
background: "#1d4ed8",
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 16,
}}
>
🦉
</div>
<span>OwlCub Academy</span>
</Link>
{/* Nav links */}
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<NavLink href={localePath("/courses")} label={t("courses")} />
{isLoggedIn && (
<NavLink href={localePath("/dashboard")} label={t("dashboard")} />
)}
{userRole === "ADMIN" && (
<NavLink href={localePath("/admin")} label={t("admin")} />
)}
</div>
{/* Right side */}
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{/* Locale switcher */}
<div style={{ display: "flex", gap: 4 }}>
{(["fr", "en", "es"] as const).map((l) => (
<button
key={l}
onClick={() => handleLocaleChange(l)}
style={{
background: l === locale ? "rgba(29,78,216,0.25)" : "transparent",
border: "1px solid",
borderColor:
l === locale ? "#1d4ed8" : "rgba(255,255,255,0.1)",
color: l === locale ? "#60a5fa" : "#94a3b8",
borderRadius: 6,
padding: "4px 10px",
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
textTransform: "uppercase",
letterSpacing: "0.05em",
transition: "all 0.15s",
}}
>
{l}
</button>
))}
</div>
{/* Auth button */}
{isLoggedIn ? (
<button
onClick={() => signOut({ callbackUrl: `/${locale}` })}
className="btn btn-secondary"
style={{ fontSize: 13 }}
>
{t("logout")}
</button>
) : (
<Link
href={localePath("/auth/login")}
className="btn btn-primary"
style={{ fontSize: 13 }}
>
{t("login")}
</Link>
)}
</div>
</div>
</nav>
);
}
function NavLink({ href, label }: { href: string; label: string }) {
return (
<Link
href={href}
style={{
color: "#94a3b8",
fontSize: 14,
fontWeight: 500,
padding: "6px 12px",
borderRadius: 6,
transition: "all 0.15s",
}}
onMouseEnter={(e) => {
(e.target as HTMLElement).style.color = "#f1f5f9";
(e.target as HTMLElement).style.background = "rgba(255,255,255,0.05)";
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.color = "#94a3b8";
(e.target as HTMLElement).style.background = "transparent";
}}
>
{label}
</Link>
);
}

View File

@ -0,0 +1,41 @@
interface ProgressBarProps {
value: number; // 0-100
height?: number;
showLabel?: boolean;
}
export function ProgressBar({ value, height = 6, showLabel = false }: ProgressBarProps) {
const clamped = Math.min(100, Math.max(0, value));
return (
<div style={{ width: "100%" }}>
<div
style={{
width: "100%",
height,
background: "rgba(255,255,255,0.08)",
borderRadius: height / 2,
overflow: "hidden",
}}
>
<div
style={{
width: `${clamped}%`,
height: "100%",
background:
clamped === 100
? "#22c55e"
: "linear-gradient(90deg, #1d4ed8, #3b82f6)",
borderRadius: height / 2,
transition: "width 0.4s ease",
}}
/>
</div>
{showLabel && (
<span style={{ fontSize: 12, color: "#94a3b8", marginTop: 4, display: "block" }}>
{clamped}%
</span>
)}
</div>
);
}

View File

@ -0,0 +1,212 @@
"use client";
import { useState } from "react";
interface Question {
id: string;
order: number;
textFr: string;
textEn: string;
textEs: string;
optionsFr: string[];
optionsEn: string[];
optionsEs: string[];
correctIndex: number;
}
interface QuizComponentProps {
quizId: string;
questions: Question[];
passMark: number;
locale: string;
onPassed?: () => void;
}
function getQuestionText(q: Question, locale: string) {
if (locale === "en") return q.textEn || q.textFr;
if (locale === "es") return q.textEs || q.textFr;
return q.textFr;
}
function getOptions(q: Question, locale: string) {
if (locale === "en") return q.optionsEn?.length ? q.optionsEn : q.optionsFr;
if (locale === "es") return q.optionsEs?.length ? q.optionsEs : q.optionsFr;
return q.optionsFr;
}
const labels = {
fr: { submit: "Valider", retry: "Réessayer", score: "Votre score", passed: "Réussi !", failed: "Échoué", passMark: "Note minimale" },
en: { submit: "Submit", retry: "Retry", score: "Your score", passed: "Passed!", failed: "Failed", passMark: "Pass mark" },
es: { submit: "Enviar", retry: "Reintentar", score: "Tu puntuación", passed: "¡Aprobado!", failed: "Suspenso", passMark: "Nota mínima" },
};
export function QuizComponent({ quizId, questions, passMark, locale, onPassed }: QuizComponentProps) {
const [answers, setAnswers] = useState<Record<number, number>>({});
const [result, setResult] = useState<{ score: number; passed: boolean } | null>(null);
const [submitting, setSubmitting] = useState(false);
const l = (labels[locale as keyof typeof labels] ?? labels.fr);
const sortedQuestions = [...questions].sort((a, b) => a.order - b.order);
const handleSelect = (qIndex: number, optionIndex: number) => {
if (result) return;
setAnswers((prev) => ({ ...prev, [qIndex]: optionIndex }));
};
const handleSubmit = async () => {
if (Object.keys(answers).length < questions.length) return;
setSubmitting(true);
try {
const answersArray = sortedQuestions.map((_, i) => answers[i] ?? -1);
const res = await fetch("/api/quiz/attempt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ quizId, answers: answersArray }),
});
const data = await res.json();
setResult(data);
if (data.passed) {
onPassed?.();
}
} catch {
// ignore
} finally {
setSubmitting(false);
}
};
const handleRetry = () => {
setAnswers({});
setResult(null);
};
const allAnswered = Object.keys(answers).length === questions.length;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
{/* Questions */}
{sortedQuestions.map((q, qIndex) => {
const options = getOptions(q, locale);
const text = getQuestionText(q, locale);
const selectedOption = answers[qIndex];
return (
<div
key={q.id}
style={{
background: "#1a1f2e",
border: "1px solid rgba(255,255,255,0.08)",
borderRadius: 10,
padding: 20,
}}
>
<p style={{ fontSize: 15, fontWeight: 600, color: "#f1f5f9", marginBottom: 16 }}>
<span style={{ color: "#94a3b8", marginRight: 8 }}>{qIndex + 1}.</span>
{text}
</p>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{options.map((option, optIndex) => {
const isSelected = selectedOption === optIndex;
const isCorrect = result && optIndex === q.correctIndex;
const isWrong = result && isSelected && optIndex !== q.correctIndex;
let borderColor = "rgba(255,255,255,0.1)";
let bg = "rgba(255,255,255,0.03)";
if (isSelected && !result) { borderColor = "#1d4ed8"; bg = "rgba(29,78,216,0.1)"; }
if (isCorrect) { borderColor = "#22c55e"; bg = "rgba(34,197,94,0.1)"; }
if (isWrong) { borderColor = "#ef4444"; bg = "rgba(239,68,68,0.1)"; }
return (
<button
key={optIndex}
onClick={() => handleSelect(qIndex, optIndex)}
disabled={!!result}
style={{
background: bg,
border: `1px solid ${borderColor}`,
borderRadius: 8,
padding: "10px 14px",
color: "#f1f5f9",
fontSize: 14,
textAlign: "left",
cursor: result ? "default" : "pointer",
display: "flex",
alignItems: "center",
gap: 10,
transition: "all 0.15s",
}}
>
<span
style={{
width: 20,
height: 20,
borderRadius: "50%",
border: `2px solid ${isSelected ? "#1d4ed8" : "rgba(255,255,255,0.2)"}`,
background: isSelected ? "#1d4ed8" : "transparent",
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 10,
}}
>
{isSelected && !result && "●"}
{isCorrect && "✓"}
{isWrong && "✗"}
</span>
{option}
</button>
);
})}
</div>
</div>
);
})}
{/* Result */}
{result && (
<div
style={{
background: result.passed ? "rgba(34,197,94,0.1)" : "rgba(239,68,68,0.1)",
border: `1px solid ${result.passed ? "#22c55e" : "#ef4444"}`,
borderRadius: 10,
padding: 20,
textAlign: "center",
}}
>
<div style={{ fontSize: 36, marginBottom: 8 }}>
{result.passed ? "🎉" : "😔"}
</div>
<p style={{ fontSize: 20, fontWeight: 700, color: result.passed ? "#4ade80" : "#f87171" }}>
{result.passed ? l.passed : l.failed}
</p>
<p style={{ fontSize: 14, color: "#94a3b8", marginTop: 4 }}>
{l.score}: <strong style={{ color: "#f1f5f9" }}>{result.score}%</strong>
{" · "}{l.passMark}: <strong style={{ color: "#f1f5f9" }}>{passMark}%</strong>
</p>
{!result.passed && (
<button onClick={handleRetry} className="btn btn-secondary" style={{ marginTop: 16 }}>
{l.retry}
</button>
)}
</div>
)}
{/* Submit */}
{!result && (
<button
onClick={handleSubmit}
disabled={!allAnswered || submitting}
className="btn btn-primary"
style={{
opacity: allAnswered ? 1 : 0.5,
cursor: allAnswered ? "pointer" : "not-allowed",
alignSelf: "flex-start",
}}
>
{submitting ? "…" : l.submit}
</button>
)}
</div>
);
}

View File

@ -0,0 +1,124 @@
"use client";
import { useEffect, useState, useRef } from "react";
interface VideoPlayerProps {
videoKey: string; // S3 key
onComplete?: () => void;
}
export function VideoPlayer({ videoKey, onComplete }: VideoPlayerProps) {
const [url, setUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const completedRef = useRef(false);
useEffect(() => {
setLoading(true);
setError(false);
completedRef.current = false;
fetch(`/api/video-url?key=${encodeURIComponent(videoKey)}`)
.then((r) => r.json())
.then((data) => {
if (data.url) {
setUrl(data.url);
} else {
setError(true);
}
})
.catch(() => setError(true))
.finally(() => setLoading(false));
}, [videoKey]);
const handleTimeUpdate = () => {
const video = videoRef.current;
if (!video || completedRef.current) return;
// Mark complete when 90% watched
if (video.currentTime / video.duration >= 0.9) {
completedRef.current = true;
onComplete?.();
}
};
if (loading) {
return (
<div
style={{
width: "100%",
aspectRatio: "16/9",
background: "#0f1117",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#94a3b8",
fontSize: 14,
}}
>
<div
style={{
width: 32,
height: 32,
border: "3px solid rgba(255,255,255,0.1)",
borderTopColor: "#1d4ed8",
borderRadius: "50%",
animation: "spin 0.8s linear infinite",
}}
/>
</div>
);
}
if (error || !url) {
return (
<div
style={{
width: "100%",
aspectRatio: "16/9",
background: "#0f1117",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#94a3b8",
fontSize: 14,
flexDirection: "column",
gap: 8,
}}
>
<span style={{ fontSize: 32 }}></span>
<span>Impossible de charger la vidéo</span>
</div>
);
}
return (
<div style={{ width: "100%", borderRadius: 12, overflow: "hidden" }}>
<video
ref={videoRef}
src={url}
controls
onTimeUpdate={handleTimeUpdate}
onEnded={() => {
if (!completedRef.current) {
completedRef.current = true;
onComplete?.();
}
}}
style={{
width: "100%",
aspectRatio: "16/9",
background: "#000",
display: "block",
}}
/>
<style>{`
@keyframes spin {
to { transform: rotate(360deg); }
}
`}</style>
</div>
);
}

5
src/i18n/request.ts Normal file
View File

@ -0,0 +1,5 @@
import { getRequestConfig } from "next-intl/server";
export default getRequestConfig(async ({ locale }) => ({
messages: (await import(`../../messages/${locale}.json`)).default,
}));

165
src/lib/certificate.ts Normal file
View File

@ -0,0 +1,165 @@
import { PDFDocument, rgb, StandardFonts } from "pdf-lib";
export async function generateCertificate(opts: {
learnerName: string;
courseTitle: string;
issuedAt: Date;
}): Promise<Uint8Array> {
const { learnerName, courseTitle, issuedAt } = opts;
const pdfDoc = await PDFDocument.create();
// A4 landscape: 842 x 595 pts
const page = pdfDoc.addPage([842, 595]);
const { width, height } = page.getSize();
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const fontRegular = await pdfDoc.embedFont(StandardFonts.Helvetica);
// White background
page.drawRectangle({
x: 0,
y: 0,
width,
height,
color: rgb(1, 1, 1),
});
// Decorative border (1pt navy, 20px inset)
const borderInset = 20;
const navyColor = rgb(0.11, 0.31, 0.53);
page.drawRectangle({
x: borderInset,
y: borderInset,
width: width - borderInset * 2,
height: height - borderInset * 2,
borderColor: navyColor,
borderWidth: 1.5,
color: rgb(1, 1, 1),
});
// Blue header bar (60px from top)
const headerHeight = 70;
const blueColor = rgb(0.114, 0.306, 0.847); // #1d4ed8
page.drawRectangle({
x: borderInset,
y: height - borderInset - headerHeight,
width: width - borderInset * 2,
height: headerHeight,
color: blueColor,
});
// "OwlCub Academy" in header
const headerText = "OwlCub Academy";
const headerFontSize = 24;
const headerTextWidth = fontBold.widthOfTextAtSize(headerText, headerFontSize);
page.drawText(headerText, {
x: (width - headerTextWidth) / 2,
y: height - borderInset - headerHeight / 2 - headerFontSize / 3,
size: headerFontSize,
font: fontBold,
color: rgb(1, 1, 1),
});
// "CERTIFICAT DE RÉUSSITE"
const certTitle = "CERTIFICAT DE REUSSITE";
const certTitleSize = 32;
const certTitleWidth = fontBold.widthOfTextAtSize(certTitle, certTitleSize);
page.drawText(certTitle, {
x: (width - certTitleWidth) / 2,
y: height - borderInset - headerHeight - 80,
size: certTitleSize,
font: fontBold,
color: navyColor,
});
// Decorative line under title
page.drawLine({
start: { x: width / 2 - 120, y: height - borderInset - headerHeight - 95 },
end: { x: width / 2 + 120, y: height - borderInset - headerHeight - 95 },
thickness: 2,
color: blueColor,
});
// "Ce certificat est décerné à"
const subtextSize = 14;
const subtext = "Ce certificat est decerne a";
const subtextWidth = fontRegular.widthOfTextAtSize(subtext, subtextSize);
page.drawText(subtext, {
x: (width - subtextWidth) / 2,
y: height - borderInset - headerHeight - 140,
size: subtextSize,
font: fontRegular,
color: rgb(0.58, 0.64, 0.72), // #94a3b8
});
// Learner name
const nameSize = 28;
const nameWidth = fontBold.widthOfTextAtSize(learnerName, nameSize);
page.drawText(learnerName, {
x: (width - nameWidth) / 2,
y: height - borderInset - headerHeight - 190,
size: nameSize,
font: fontBold,
color: rgb(0.07, 0.09, 0.15), // near black
});
// "pour avoir complété avec succès la formation"
const completedText = "pour avoir complete avec succes la formation";
const completedSize = 13;
const completedWidth = fontRegular.widthOfTextAtSize(completedText, completedSize);
page.drawText(completedText, {
x: (width - completedWidth) / 2,
y: height - borderInset - headerHeight - 230,
size: completedSize,
font: fontRegular,
color: rgb(0.58, 0.64, 0.72),
});
// Course title
const courseTitleSize = 18;
// Truncate if too long
const displayCourseTitle =
courseTitle.length > 60 ? courseTitle.substring(0, 57) + "..." : courseTitle;
const courseTitleWidth = fontBold.widthOfTextAtSize(
displayCourseTitle,
courseTitleSize
);
page.drawText(displayCourseTitle, {
x: (width - courseTitleWidth) / 2,
y: height - borderInset - headerHeight - 265,
size: courseTitleSize,
font: fontBold,
color: navyColor,
});
// Date (bottom right)
const dateStr = issuedAt.toLocaleDateString("fr-FR", {
year: "numeric",
month: "long",
day: "numeric",
});
const dateSize = 11;
const dateText = `Délivré le ${dateStr}`;
const dateWidth = fontRegular.widthOfTextAtSize(dateText, dateSize);
page.drawText(dateText, {
x: width - borderInset - 30 - dateWidth,
y: borderInset + 30,
size: dateSize,
font: fontRegular,
color: rgb(0.58, 0.64, 0.72),
});
// Small owl icon placeholder (decorative circle)
page.drawEllipse({
x: width / 2,
y: borderInset + 45,
xScale: 18,
yScale: 18,
color: blueColor,
opacity: 0.15,
});
const pdfBytes = await pdfDoc.save();
return pdfBytes;
}

9
src/lib/db.ts Normal file
View File

@ -0,0 +1,9 @@
import { PrismaClient } from "@prisma/client";
declare global {
var prisma: PrismaClient | undefined;
}
export const db = global.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") global.prisma = db;

48
src/lib/s3.ts Normal file
View File

@ -0,0 +1,48 @@
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({
region: process.env.S3_REGION!,
endpoint: process.env.S3_ENDPOINT!,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET!,
},
forcePathStyle: true,
});
export async function getUploadUrl(key: string, contentType: string) {
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
ContentType: contentType,
});
return getSignedUrl(s3, command, { expiresIn: 3600 });
}
export async function getVideoUrl(key: string) {
const command = new GetObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
});
return getSignedUrl(s3, command, { expiresIn: 7200 });
}
export async function uploadBuffer(
key: string,
buffer: Buffer,
contentType: string
) {
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
Body: buffer,
ContentType: contentType,
});
await s3.send(command);
return key;
}

43
src/middleware.ts Normal file
View File

@ -0,0 +1,43 @@
import createMiddleware from "next-intl/middleware";
import { auth } from "@/auth";
import { NextRequest, NextResponse } from "next/server";
const intlMiddleware = createMiddleware({
locales: ["fr", "en", "es"],
defaultLocale: "fr",
localePrefix: "always",
});
export default async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
// Auth check for protected routes
const protectedPatterns = ["/dashboard", "/admin", "/courses/[^/]+/learn"];
const isProtected = protectedPatterns.some((p) =>
new RegExp(`^/(fr|en|es)${p}`).test(pathname)
);
if (isProtected) {
const session = await auth();
if (!session) {
const locale = pathname.split("/")[1] || "fr";
return NextResponse.redirect(
new URL(`/${locale}/auth/login`, req.url)
);
}
if (pathname.includes("/admin")) {
if ((session.user as any)?.role !== "ADMIN") {
const locale = pathname.split("/")[1] || "fr";
return NextResponse.redirect(
new URL(`/${locale}/dashboard`, req.url)
);
}
}
}
return intlMiddleware(req);
}
export const config = {
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
};

26
tailwind.config.ts Normal file
View File

@ -0,0 +1,26 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "#0f1117",
card: "#1a1f2e",
"card-border": "rgba(255,255,255,0.1)",
primary: "#1d4ed8",
"text-primary": "#f1f5f9",
"text-secondary": "#94a3b8",
success: "#22c55e",
error: "#ef4444",
},
},
},
plugins: [],
};
export default config;

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}