feat: initial OwlCub Academy e-learning platform
This commit is contained in:
commit
7a8f6d0cc7
|
|
@ -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"
|
||||
|
|
@ -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?"
|
||||
}
|
||||
}
|
||||
|
|
@ -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?"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ?"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"],
|
||||
},
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import { handlers } from "@/auth";
|
||||
export const { GET, POST } = handlers;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { getRequestConfig } from "next-intl/server";
|
||||
|
||||
export default getRequestConfig(async ({ locale }) => ({
|
||||
messages: (await import(`../../messages/${locale}.json`)).default,
|
||||
}));
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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|.*\\..*).*)"],
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Reference in New Issue