From 7a8f6d0cc721518143fa988a6baff3f0873df985 Mon Sep 17 00:00:00 2001 From: Romain bogdanovic Date: Sat, 28 Mar 2026 18:09:38 +0100 Subject: [PATCH] feat: initial OwlCub Academy e-learning platform --- .env.example | 20 + messages/en.json | 83 +++ messages/es.json | 83 +++ messages/fr.json | 83 +++ next.config.ts | 10 + package.json | 37 ++ postcss.config.js | 6 + prisma/schema.prisma | 225 +++++++ .../admin/courses/AdminCourseActions.tsx | 71 +++ src/app/[locale]/admin/courses/CourseForm.tsx | 292 +++++++++ .../admin/courses/[id]/ModuleManager.tsx | 592 ++++++++++++++++++ src/app/[locale]/admin/courses/[id]/page.tsx | 64 ++ src/app/[locale]/admin/courses/new/page.tsx | 33 + src/app/[locale]/admin/courses/page.tsx | 142 +++++ src/app/[locale]/admin/page.tsx | 164 +++++ src/app/[locale]/admin/students/page.tsx | 147 +++++ src/app/[locale]/auth/login/page.tsx | 172 +++++ src/app/[locale]/auth/verify/page.tsx | 71 +++ .../[locale]/courses/[slug]/EnrollButton.tsx | 50 ++ .../[slug]/learn/[lessonId]/LessonContent.tsx | 219 +++++++ .../courses/[slug]/learn/[lessonId]/page.tsx | 298 +++++++++ .../[locale]/courses/[slug]/learn/page.tsx | 63 ++ src/app/[locale]/courses/[slug]/page.tsx | 357 +++++++++++ src/app/[locale]/courses/page.tsx | 202 ++++++ src/app/[locale]/dashboard/page.tsx | 296 +++++++++ src/app/[locale]/layout.tsx | 45 ++ src/app/[locale]/page.tsx | 265 ++++++++ .../api/admin/courses/[id]/modules/route.ts | 33 + .../api/admin/courses/[id]/publish/route.ts | 29 + src/app/api/admin/courses/[id]/route.ts | 60 ++ src/app/api/admin/courses/route.ts | 49 ++ src/app/api/admin/lessons/[id]/route.ts | 50 ++ .../api/admin/modules/[id]/lessons/route.ts | 40 ++ src/app/api/admin/modules/[id]/quiz/route.ts | 51 ++ src/app/api/admin/modules/[id]/route.ts | 22 + src/app/api/auth/[...nextauth]/route.ts | 2 + src/app/api/certificates/[id]/route.ts | 60 ++ src/app/api/enroll/route.ts | 38 ++ src/app/api/progress/route.ts | 125 ++++ src/app/api/quiz/attempt/route.ts | 49 ++ src/app/api/upload/route.ts | 45 ++ src/app/api/video-url/route.ts | 18 + src/app/globals.css | 209 +++++++ src/auth.ts | 50 ++ src/components/CourseCard.tsx | 246 ++++++++ src/components/Nav.tsx | 166 +++++ src/components/ProgressBar.tsx | 41 ++ src/components/QuizComponent.tsx | 212 +++++++ src/components/VideoPlayer.tsx | 124 ++++ src/i18n/request.ts | 5 + src/lib/certificate.ts | 165 +++++ src/lib/db.ts | 9 + src/lib/s3.ts | 48 ++ src/middleware.ts | 43 ++ tailwind.config.ts | 26 + tsconfig.json | 27 + 56 files changed, 6132 insertions(+) create mode 100644 .env.example create mode 100644 messages/en.json create mode 100644 messages/es.json create mode 100644 messages/fr.json create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 prisma/schema.prisma create mode 100644 src/app/[locale]/admin/courses/AdminCourseActions.tsx create mode 100644 src/app/[locale]/admin/courses/CourseForm.tsx create mode 100644 src/app/[locale]/admin/courses/[id]/ModuleManager.tsx create mode 100644 src/app/[locale]/admin/courses/[id]/page.tsx create mode 100644 src/app/[locale]/admin/courses/new/page.tsx create mode 100644 src/app/[locale]/admin/courses/page.tsx create mode 100644 src/app/[locale]/admin/page.tsx create mode 100644 src/app/[locale]/admin/students/page.tsx create mode 100644 src/app/[locale]/auth/login/page.tsx create mode 100644 src/app/[locale]/auth/verify/page.tsx create mode 100644 src/app/[locale]/courses/[slug]/EnrollButton.tsx create mode 100644 src/app/[locale]/courses/[slug]/learn/[lessonId]/LessonContent.tsx create mode 100644 src/app/[locale]/courses/[slug]/learn/[lessonId]/page.tsx create mode 100644 src/app/[locale]/courses/[slug]/learn/page.tsx create mode 100644 src/app/[locale]/courses/[slug]/page.tsx create mode 100644 src/app/[locale]/courses/page.tsx create mode 100644 src/app/[locale]/dashboard/page.tsx create mode 100644 src/app/[locale]/layout.tsx create mode 100644 src/app/[locale]/page.tsx create mode 100644 src/app/api/admin/courses/[id]/modules/route.ts create mode 100644 src/app/api/admin/courses/[id]/publish/route.ts create mode 100644 src/app/api/admin/courses/[id]/route.ts create mode 100644 src/app/api/admin/courses/route.ts create mode 100644 src/app/api/admin/lessons/[id]/route.ts create mode 100644 src/app/api/admin/modules/[id]/lessons/route.ts create mode 100644 src/app/api/admin/modules/[id]/quiz/route.ts create mode 100644 src/app/api/admin/modules/[id]/route.ts create mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/app/api/certificates/[id]/route.ts create mode 100644 src/app/api/enroll/route.ts create mode 100644 src/app/api/progress/route.ts create mode 100644 src/app/api/quiz/attempt/route.ts create mode 100644 src/app/api/upload/route.ts create mode 100644 src/app/api/video-url/route.ts create mode 100644 src/app/globals.css create mode 100644 src/auth.ts create mode 100644 src/components/CourseCard.tsx create mode 100644 src/components/Nav.tsx create mode 100644 src/components/ProgressBar.tsx create mode 100644 src/components/QuizComponent.tsx create mode 100644 src/components/VideoPlayer.tsx create mode 100644 src/i18n/request.ts create mode 100644 src/lib/certificate.ts create mode 100644 src/lib/db.ts create mode 100644 src/lib/s3.ts create mode 100644 src/middleware.ts create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..ff9a4d63 --- /dev/null +++ b/.env.example @@ -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" diff --git a/messages/en.json b/messages/en.json new file mode 100644 index 00000000..4dbcf1ae --- /dev/null +++ b/messages/en.json @@ -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?" + } +} diff --git a/messages/es.json b/messages/es.json new file mode 100644 index 00000000..5a863114 --- /dev/null +++ b/messages/es.json @@ -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?" + } +} diff --git a/messages/fr.json b/messages/fr.json new file mode 100644 index 00000000..93f55054 --- /dev/null +++ b/messages/fr.json @@ -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 ?" + } +} diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 00000000..24493923 --- /dev/null +++ b/next.config.ts @@ -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"], + }, +}); diff --git a/package.json b/package.json new file mode 100644 index 00000000..b16a5c6b --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 00000000..12a703d9 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 00000000..bfcd25a5 --- /dev/null +++ b/prisma/schema.prisma @@ -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]) +} diff --git a/src/app/[locale]/admin/courses/AdminCourseActions.tsx b/src/app/[locale]/admin/courses/AdminCourseActions.tsx new file mode 100644 index 00000000..40b743ce --- /dev/null +++ b/src/app/[locale]/admin/courses/AdminCourseActions.tsx @@ -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 ( +
+ + {t.edit} + + + +
+ ); +} diff --git a/src/app/[locale]/admin/courses/CourseForm.tsx b/src/app/[locale]/admin/courses/CourseForm.tsx new file mode 100644 index 00000000..8b53d7b8 --- /dev/null +++ b/src/app/[locale]/admin/courses/CourseForm.tsx @@ -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(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 ( +
+
+ {/* Lang tabs */} +
+
+ {LANG_TABS.map((l) => ( + + ))} +
+ + {langTab === "fr" && ( +
+
+ + handleTitleFrChange(e.target.value)} required /> +
+
+ +