From 28aa3b0e1003083308d39eb250e25b502ecc3208 Mon Sep 17 00:00:00 2001 From: corenthin-lebreton Date: Thu, 26 Feb 2026 20:10:14 +0100 Subject: [PATCH] initial project --- .env.local.example | 3 + README.md | 129 + app/api/categories/[id]/route.ts | 44 + app/api/categories/create/route.ts | 26 + app/api/sessions/[id]/toggle/route.ts | 39 + app/api/sessions/create/route.ts | 85 + app/api/student/finish/route.ts | 88 + app/api/student/join/route.ts | 67 + app/api/student/results/route.ts | 106 + app/api/student/submit-answer/route.ts | 72 + app/api/subchapters/[id]/route.ts | 44 + app/api/subchapters/create/route.ts | 26 + app/api/upload-quiz/route.ts | 110 + app/dashboard/layout.tsx | 36 + app/dashboard/page.tsx | 243 + app/dashboard/quizzes/QuizzesClient.tsx | 719 ++ app/dashboard/quizzes/page.tsx | 43 + app/dashboard/reports/page.tsx | 66 + .../sessions/[id]/live/LiveSessionClient.tsx | 294 + app/dashboard/sessions/[id]/live/page.tsx | 53 + .../sessions/create/CreateSessionClient.tsx | 459 ++ app/dashboard/sessions/create/page.tsx | 22 + app/dashboard/settings/page.tsx | 59 + app/globals.css | 45 + app/layout.tsx | 19 + app/login/page.tsx | 211 + app/page.tsx | 13 + app/quiz/[code]/StudentJoinClient.tsx | 162 + app/quiz/[code]/exam/ExamClient.tsx | 309 + app/quiz/[code]/exam/page.tsx | 84 + app/quiz/[code]/page.tsx | 56 + app/quiz/[code]/results/ResultsClient.tsx | 189 + app/quiz/[code]/results/page.tsx | 33 + components/dashboard/NotificationBell.tsx | 111 + components/dashboard/Sidebar.tsx | 95 + lib/types/database.ts | 277 + lib/utils.ts | 15 + next-env.d.ts | 6 + next.config.ts | 14 + package-lock.json | 6400 +++++++++++++++++ package.json | 33 + postcss.config.mjs | 9 + proxy.ts | 51 + quiz-example.json | 57 + tailwind.config.ts | 48 + tsconfig.json | 41 + tsconfig.tsbuildinfo | 1 + 47 files changed, 11112 insertions(+) create mode 100644 .env.local.example create mode 100644 README.md create mode 100644 app/api/categories/[id]/route.ts create mode 100644 app/api/categories/create/route.ts create mode 100644 app/api/sessions/[id]/toggle/route.ts create mode 100644 app/api/sessions/create/route.ts create mode 100644 app/api/student/finish/route.ts create mode 100644 app/api/student/join/route.ts create mode 100644 app/api/student/results/route.ts create mode 100644 app/api/student/submit-answer/route.ts create mode 100644 app/api/subchapters/[id]/route.ts create mode 100644 app/api/subchapters/create/route.ts create mode 100644 app/api/upload-quiz/route.ts create mode 100644 app/dashboard/layout.tsx create mode 100644 app/dashboard/page.tsx create mode 100644 app/dashboard/quizzes/QuizzesClient.tsx create mode 100644 app/dashboard/quizzes/page.tsx create mode 100644 app/dashboard/reports/page.tsx create mode 100644 app/dashboard/sessions/[id]/live/LiveSessionClient.tsx create mode 100644 app/dashboard/sessions/[id]/live/page.tsx create mode 100644 app/dashboard/sessions/create/CreateSessionClient.tsx create mode 100644 app/dashboard/sessions/create/page.tsx create mode 100644 app/dashboard/settings/page.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/login/page.tsx create mode 100644 app/page.tsx create mode 100644 app/quiz/[code]/StudentJoinClient.tsx create mode 100644 app/quiz/[code]/exam/ExamClient.tsx create mode 100644 app/quiz/[code]/exam/page.tsx create mode 100644 app/quiz/[code]/page.tsx create mode 100644 app/quiz/[code]/results/ResultsClient.tsx create mode 100644 app/quiz/[code]/results/page.tsx create mode 100644 components/dashboard/NotificationBell.tsx create mode 100644 components/dashboard/Sidebar.tsx create mode 100644 lib/types/database.ts create mode 100644 lib/utils.ts create mode 100644 next-env.d.ts create mode 100644 next.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 proxy.ts create mode 100644 quiz-example.json create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json create mode 100644 tsconfig.tsbuildinfo diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..7bb1d46 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,3 @@ +NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key +SUPABASE_SERVICE_ROLE_KEY=your-service-role-key diff --git a/README.md b/README.md new file mode 100644 index 0000000..e7963cc --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# SolyQuiz 🎯 + +Plateforme de gestion de quiz interactifs pour formateurs et étudiants. + +## Stack Technique + +- **Frontend/API**: Next.js 15 (TypeScript, Tailwind CSS, App Router) +- **Base de données & Auth**: Supabase (PostgreSQL, Supabase Auth, Supabase Realtime) +- **IDs courts**: `nanoid` (codes de session 6 caractères) + +## Installation + +### 1. Prérequis + +- Node.js 20+ +- Un projet Supabase (https://supabase.com) + +### 2. Configuration + +```bash +# Cloner et installer les dépendances +npm install + +# Copier le fichier d'environnement +cp .env.local.example .env.local +``` + +Remplir `.env.local` avec vos credentials Supabase : + +```env +NEXT_PUBLIC_SUPABASE_URL=https://votre-projet.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=votre-anon-key +SUPABASE_SERVICE_ROLE_KEY=votre-service-role-key +NEXT_PUBLIC_APP_URL=http://localhost:3000 +``` + +### 3. Base de données + +Exécuter le script SQL dans l'éditeur SQL de votre projet Supabase : + +```bash +# Copier le contenu de supabase/schema.sql dans l'éditeur SQL Supabase +``` + +### 4. Démarrer + +```bash +npm run dev +``` + +L'application sera disponible sur http://localhost:3000 + +## Architecture des Pages + +| Route | Description | +|-------|-------------| +| `/login` | Authentification formateur | +| `/dashboard` | Dashboard overview (KPIs + sessions actives) | +| `/dashboard/quizzes` | Gestion des quiz (catégories/chapitres) | +| `/dashboard/sessions/create` | Création d'une nouvelle session | +| `/dashboard/sessions/[id]/live` | Dashboard temps réel de la session | +| `/dashboard/reports` | Liste des rapports de sessions | +| `/quiz/[code]` | Page de connexion étudiant (via short_code) | +| `/quiz/[code]/exam` | Interface de quiz pour l'étudiant | +| `/quiz/[code]/results` | Page de résultats avec correction détaillée | + +## API Routes + +| Endpoint | Méthode | Description | +|----------|---------|-------------| +| `/api/upload-quiz` | POST | Import d'un quiz via fichier JSON | +| `/api/sessions/create` | POST | Créer une session avec short_code unique | +| `/api/sessions/[id]/toggle` | PATCH | Activer/désactiver une session | +| `/api/student/join` | POST | Rejoindre une session (crée une participation) | +| `/api/student/submit-answer` | POST | Enregistrer une réponse étudiant | +| `/api/student/finish` | POST | Terminer le quiz et calculer la note | +| `/api/student/results` | GET | Récupérer les résultats détaillés | + +## Format JSON pour l'import de Quiz + +```json +{ + "title": "Titre du Quiz", + "category": "Catégorie (optionnel)", + "subchapter": "Sous-chapitre (optionnel)", + "questions": [ + { + "question": "Texte de la question", + "explanation": "Explication affichée après (optionnel)", + "answers": [ + { "text": "Réponse A", "correct": false }, + { "text": "Réponse B", "correct": true }, + { "text": "Réponse C", "correct": false }, + { "text": "Réponse D", "correct": false } + ] + } + ] +} +``` + +Voir `quiz-example.json` pour un exemple complet. + +## Calcul de la Note + +La note est calculée côté serveur avec la formule : + +``` +Note = (C / N) × 20 +``` + +Où : +- `N` = nombre total de questions +- `C` = nombre de bonnes réponses + +Le résultat est arrondi à 2 décimales. + +## Sécurité + +- **Row Level Security (RLS)** activé sur toutes les tables +- Les formateurs ne voient que leurs propres quizzes/sessions +- Les étudiants accèdent uniquement via un short_code actif +- Middleware Next.js pour la protection des routes `/dashboard` + +## Temps Réel + +Le dashboard formateur (`/dashboard/sessions/[id]/live`) utilise **Supabase Realtime** pour afficher en temps réel : +- Les nouveaux participants qui rejoignent +- La progression (en_cours → terminé) +- Les scores finaux diff --git a/app/api/categories/[id]/route.ts b/app/api/categories/[id]/route.ts new file mode 100644 index 0000000..e3ca4d7 --- /dev/null +++ b/app/api/categories/[id]/route.ts @@ -0,0 +1,44 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' + +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const supabase = await createClient() + const db = supabase as any + const { data: { user }, error: authError } = await supabase.auth.getUser() + if (authError || !user) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }) + + const { id } = await params + const { name, description } = await request.json() + if (!name?.trim()) return NextResponse.json({ error: 'Nom requis' }, { status: 400 }) + + const { data, error } = await db + .from('categories') + .update({ name: name.trim(), description: description?.trim() || null }) + .eq('id', id) + .select() + .single() + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + return NextResponse.json({ success: true, category: data }) + } catch { + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }) + } +} + +export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const supabase = await createClient() + const db = supabase as any + const { data: { user }, error: authError } = await supabase.auth.getUser() + if (authError || !user) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }) + + const { id } = await params + const { error } = await db.from('categories').delete().eq('id', id) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + return NextResponse.json({ success: true }) + } catch { + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }) + } +} diff --git a/app/api/categories/create/route.ts b/app/api/categories/create/route.ts new file mode 100644 index 0000000..31d716a --- /dev/null +++ b/app/api/categories/create/route.ts @@ -0,0 +1,26 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' + +export async function POST(request: NextRequest) { + try { + const supabase = await createClient() + const db = supabase as any + const { data: { user }, error: authError } = await supabase.auth.getUser() + if (authError || !user) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }) + + const { name, description } = await request.json() + if (!name?.trim()) return NextResponse.json({ error: 'Nom requis' }, { status: 400 }) + + const { data, error } = await db + .from('categories') + .insert({ name: name.trim(), description: description?.trim() || null, created_by: user.id }) + .select() + .single() + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + return NextResponse.json({ success: true, category: data }) + } catch { + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }) + } +} diff --git a/app/api/sessions/[id]/toggle/route.ts b/app/api/sessions/[id]/toggle/route.ts new file mode 100644 index 0000000..9701c71 --- /dev/null +++ b/app/api/sessions/[id]/toggle/route.ts @@ -0,0 +1,39 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const supabase = await createClient() + const db = supabase as any + + const { data: { user }, error: authError } = await supabase.auth.getUser() + if (authError || !user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }) + } + + const { id } = await params + const body = await request.json() + const { is_active } = body + + const { data: session, error } = await db + .from('sessions') + .update({ is_active }) + .eq('id', id) + .eq('trainer_id', user.id) + .select() + .single() + + if (error || !session) { + return NextResponse.json({ error: 'Session introuvable ou accès refusé' }, { status: 404 }) + } + + return NextResponse.json({ success: true, session }) + } catch (error) { + console.error('[sessions/toggle]', error) + return NextResponse.json({ error: 'Erreur serveur interne' }, { status: 500 }) + } +} diff --git a/app/api/sessions/create/route.ts b/app/api/sessions/create/route.ts new file mode 100644 index 0000000..d13e012 --- /dev/null +++ b/app/api/sessions/create/route.ts @@ -0,0 +1,85 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { nanoid } from 'nanoid' + +const MAX_RETRIES = 10 + +async function generateUniqueCode(supabase: any): Promise { + for (let i = 0; i < MAX_RETRIES; i++) { + const code = nanoid(6).toUpperCase() + const { data } = await supabase + .from('sessions') + .select('id') + .eq('short_code', code) + .maybeSingle() + + if (!data) return code + } + throw new Error('Impossible de générer un code unique après plusieurs tentatives') +} + +export async function POST(request: NextRequest) { + try { + const supabase = await createClient() + const db = supabase as any + + const { data: { user }, error: authError } = await supabase.auth.getUser() + if (authError || !user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }) + } + + const body = await request.json() + const { quiz_id, school_name, class_name, total_participants } = body + + if (!quiz_id) { + return NextResponse.json({ error: 'quiz_id est requis' }, { status: 400 }) + } + + const { data: quiz, error: quizError } = await db + .from('quizzes') + .select('id') + .eq('id', quiz_id) + .eq('author_id', user.id) + .single() + + if (quizError || !quiz) { + return NextResponse.json({ error: 'Quiz introuvable ou accès refusé' }, { status: 404 }) + } + + const shortCode = await generateUniqueCode(db) + + const { data: session, error: sessionError } = await db + .from('sessions') + .insert({ + short_code: shortCode, + quiz_id, + trainer_id: user.id, + school_name: school_name ?? null, + class_name: class_name ?? null, + total_participants: total_participants ?? 0, + is_active: true, + }) + .select() + .single() + + if (sessionError || !session) { + return NextResponse.json( + { error: 'Erreur création de la session', details: sessionError?.message }, + { status: 500 } + ) + } + + return NextResponse.json({ + success: true, + session: { + id: session.id, + short_code: session.short_code, + is_active: session.is_active, + }, + }) + } catch (error) { + console.error('[sessions/create]', error) + return NextResponse.json({ error: 'Erreur serveur interne' }, { status: 500 }) + } +} diff --git a/app/api/student/finish/route.ts b/app/api/student/finish/route.ts new file mode 100644 index 0000000..3ccccad --- /dev/null +++ b/app/api/student/finish/route.ts @@ -0,0 +1,88 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { calculateScore } from '@/lib/utils' + +export async function POST(request: NextRequest) { + try { + const supabase = await createClient() + const db = supabase as any + + const body = await request.json() + const { participation_id } = body + + if (!participation_id) { + return NextResponse.json({ error: 'participation_id est requis' }, { status: 400 }) + } + + const { data: participation, error: partError } = await db + .from('student_participations') + .select('id, status, session_id') + .eq('id', participation_id) + .single() + + if (partError || !participation) { + return NextResponse.json({ error: 'Participation introuvable' }, { status: 404 }) + } + + if (participation.status === 'completed') { + return NextResponse.json({ error: 'Ce quiz est déjà terminé' }, { status: 400 }) + } + + const { data: session } = await db + .from('sessions') + .select('quiz_id') + .eq('id', participation.session_id) + .single() + + if (!session) { + return NextResponse.json({ error: 'Session introuvable' }, { status: 404 }) + } + + const { count: totalQuestions } = await db + .from('questions') + .select('id', { count: 'exact', head: true }) + .eq('quiz_id', session.quiz_id) + + const { data: correctAnswers } = await db + .from('student_answers') + .select(`id, answers!inner(is_correct)`) + .eq('participation_id', participation_id) + .eq('answers.is_correct', true) + + const N: number = totalQuestions ?? 0 + const C: number = correctAnswers?.length ?? 0 + const score = calculateScore(C, N) + + const { data: updatedParticipation, error: updateError } = await db + .from('student_participations') + .update({ + status: 'completed', + score, + completed_at: new Date().toISOString(), + }) + .eq('id', participation_id) + .select() + .single() + + if (updateError || !updatedParticipation) { + return NextResponse.json( + { error: 'Erreur mise à jour de la participation', details: updateError?.message }, + { status: 500 } + ) + } + + return NextResponse.json({ + success: true, + result: { + score, + correct: C, + total: N, + status: 'completed', + }, + }) + } catch (error) { + console.error('[student/finish]', error) + return NextResponse.json({ error: 'Erreur serveur interne' }, { status: 500 }) + } +} diff --git a/app/api/student/join/route.ts b/app/api/student/join/route.ts new file mode 100644 index 0000000..14e5830 --- /dev/null +++ b/app/api/student/join/route.ts @@ -0,0 +1,67 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' + +export async function POST(request: NextRequest) { + try { + const supabase = await createClient() + const db = supabase as any + + const body = await request.json() + const { short_code, first_name, last_name } = body + + if (!short_code || !first_name || !last_name) { + return NextResponse.json( + { error: 'Code session, prénom et nom sont requis' }, + { status: 400 } + ) + } + + const { data: session, error: sessionError } = await db + .from('sessions') + .select('id, is_active, quiz_id') + .eq('short_code', short_code.toUpperCase()) + .single() + + if (sessionError || !session) { + return NextResponse.json({ error: 'Session introuvable' }, { status: 404 }) + } + + if (!session.is_active) { + return NextResponse.json( + { error: 'Ce quiz est terminé', code: 'SESSION_INACTIVE' }, + { status: 403 } + ) + } + + const { data: participation, error: participationError } = await db + .from('student_participations') + .insert({ + session_id: session.id, + first_name: first_name.trim(), + last_name: last_name.trim(), + status: 'in_progress', + }) + .select() + .single() + + if (participationError || !participation) { + return NextResponse.json( + { error: 'Erreur lors de la création de la participation', details: participationError?.message }, + { status: 500 } + ) + } + + return NextResponse.json({ + success: true, + participation: { + id: participation.id, + session_id: participation.session_id, + quiz_id: session.quiz_id, + }, + }) + } catch (error) { + console.error('[student/join]', error) + return NextResponse.json({ error: 'Erreur serveur interne' }, { status: 500 }) + } +} diff --git a/app/api/student/results/route.ts b/app/api/student/results/route.ts new file mode 100644 index 0000000..a94ab54 --- /dev/null +++ b/app/api/student/results/route.ts @@ -0,0 +1,106 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' + +export async function GET(request: NextRequest) { + try { + const supabase = await createClient() + const db = supabase as any + + const { searchParams } = new URL(request.url) + const participationId = searchParams.get('participation_id') + + if (!participationId) { + return NextResponse.json({ error: 'participation_id est requis' }, { status: 400 }) + } + + const { data: participation, error: partError } = await db + .from('student_participations') + .select('id, status, score, first_name, last_name, session_id') + .eq('id', participationId) + .single() + + if (partError || !participation) { + return NextResponse.json({ error: 'Participation introuvable' }, { status: 404 }) + } + + if (participation.status !== 'completed') { + return NextResponse.json( + { error: 'Les résultats ne sont disponibles qu\'après avoir terminé le quiz' }, + { status: 403 } + ) + } + + const { data: session } = await db + .from('sessions') + .select('quiz_id') + .eq('id', participation.session_id) + .single() + + if (!session) { + return NextResponse.json({ error: 'Session introuvable' }, { status: 404 }) + } + + const { data: questions, error: questionsError } = await db + .from('questions') + .select(` + id, + question_text, + explanation, + order, + answers(id, answer_text, is_correct) + `) + .eq('quiz_id', session.quiz_id) + .order('order') + + if (questionsError) { + return NextResponse.json({ error: 'Erreur récupération des questions' }, { status: 500 }) + } + + const { data: studentAnswers } = await db + .from('student_answers') + .select('question_id, answer_id') + .eq('participation_id', participationId) + + const studentAnswerMap = new Map( + (studentAnswers ?? []).map((sa: any) => [sa.question_id, sa.answer_id]) + ) + + const detailedResults = (questions ?? []).map((q: any) => { + const studentAnswerId = studentAnswerMap.get(q.id) ?? null + const correctAnswer = q.answers.find((a: any) => a.is_correct) + const studentAnswer = q.answers.find((a: any) => a.id === studentAnswerId) + const isCorrect = studentAnswerId === correctAnswer?.id + + return { + question_id: q.id, + question_text: q.question_text, + explanation: q.explanation, + student_answer: studentAnswer ? { id: studentAnswer.id, text: studentAnswer.answer_text } : null, + correct_answer: correctAnswer ? { id: correctAnswer.id, text: correctAnswer.answer_text } : null, + is_correct: isCorrect, + all_answers: q.answers.map((a: any) => ({ id: a.id, text: a.answer_text, is_correct: a.is_correct })), + } + }) + + return NextResponse.json({ + success: true, + results: { + student: { + first_name: participation.first_name, + last_name: participation.last_name, + score: participation.score, + }, + questions: detailedResults, + summary: { + total: detailedResults.length, + correct: detailedResults.filter((r: any) => r.is_correct).length, + score: participation.score, + }, + }, + }) + } catch (error) { + console.error('[student/results]', error) + return NextResponse.json({ error: 'Erreur serveur interne' }, { status: 500 }) + } +} diff --git a/app/api/student/submit-answer/route.ts b/app/api/student/submit-answer/route.ts new file mode 100644 index 0000000..aee0919 --- /dev/null +++ b/app/api/student/submit-answer/route.ts @@ -0,0 +1,72 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' + +export async function POST(request: NextRequest) { + try { + const supabase = await createClient() + const db = supabase as any + + const body = await request.json() + const { participation_id, question_id, answer_id } = body + + if (!participation_id || !question_id) { + return NextResponse.json( + { error: 'participation_id et question_id sont requis' }, + { status: 400 } + ) + } + + const { data: participation, error: partError } = await db + .from('student_participations') + .select('id, status, session_id') + .eq('id', participation_id) + .single() + + if (partError || !participation) { + return NextResponse.json({ error: 'Participation introuvable' }, { status: 404 }) + } + + if (participation.status === 'completed') { + return NextResponse.json({ error: 'Ce quiz est déjà terminé' }, { status: 400 }) + } + + const { data: session } = await db + .from('sessions') + .select('is_active') + .eq('id', participation.session_id) + .single() + + if (!session?.is_active) { + return NextResponse.json( + { error: 'Ce quiz est terminé', code: 'SESSION_INACTIVE' }, + { status: 403 } + ) + } + + const { data: studentAnswer, error: answerError } = await db + .from('student_answers') + .upsert( + { + participation_id, + question_id, + answer_id: answer_id ?? null, + }, + { onConflict: 'participation_id,question_id' } + ) + .select() + .single() + + if (answerError) { + return NextResponse.json( + { error: 'Erreur enregistrement de la réponse', details: answerError.message }, + { status: 500 } + ) + } + + return NextResponse.json({ success: true, answer: studentAnswer }) + } catch (error) { + console.error('[student/submit-answer]', error) + return NextResponse.json({ error: 'Erreur serveur interne' }, { status: 500 }) + } +} diff --git a/app/api/subchapters/[id]/route.ts b/app/api/subchapters/[id]/route.ts new file mode 100644 index 0000000..96eeb14 --- /dev/null +++ b/app/api/subchapters/[id]/route.ts @@ -0,0 +1,44 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' + +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const supabase = await createClient() + const db = supabase as any + const { data: { user }, error: authError } = await supabase.auth.getUser() + if (authError || !user) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }) + + const { id } = await params + const { name } = await request.json() + if (!name?.trim()) return NextResponse.json({ error: 'Nom requis' }, { status: 400 }) + + const { data, error } = await db + .from('subchapters') + .update({ name: name.trim() }) + .eq('id', id) + .select() + .single() + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + return NextResponse.json({ success: true, subchapter: data }) + } catch { + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }) + } +} + +export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const supabase = await createClient() + const db = supabase as any + const { data: { user }, error: authError } = await supabase.auth.getUser() + if (authError || !user) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }) + + const { id } = await params + const { error } = await db.from('subchapters').delete().eq('id', id) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + return NextResponse.json({ success: true }) + } catch { + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }) + } +} diff --git a/app/api/subchapters/create/route.ts b/app/api/subchapters/create/route.ts new file mode 100644 index 0000000..1931251 --- /dev/null +++ b/app/api/subchapters/create/route.ts @@ -0,0 +1,26 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' + +export async function POST(request: NextRequest) { + try { + const supabase = await createClient() + const db = supabase as any + const { data: { user }, error: authError } = await supabase.auth.getUser() + if (authError || !user) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }) + + const { category_id, name } = await request.json() + if (!category_id || !name?.trim()) return NextResponse.json({ error: 'category_id et nom requis' }, { status: 400 }) + + const { data, error } = await db + .from('subchapters') + .insert({ category_id, name: name.trim() }) + .select() + .single() + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + return NextResponse.json({ success: true, subchapter: data }) + } catch { + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }) + } +} diff --git a/app/api/upload-quiz/route.ts b/app/api/upload-quiz/route.ts new file mode 100644 index 0000000..3e6dd57 --- /dev/null +++ b/app/api/upload-quiz/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { QuizJsonFormat } from '@/lib/types/database' + +export async function POST(request: NextRequest) { + try { + const supabase = await createClient() + + const { data: { user }, error: authError } = await supabase.auth.getUser() + if (authError || !user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }) + } + + const formData = await request.formData() + const file = formData.get('file') as File | null + const subchapterId = formData.get('subchapter_id') as string | null + + if (!file) { + return NextResponse.json({ error: 'Fichier manquant' }, { status: 400 }) + } + + const text = await file.text() + let quizData: QuizJsonFormat + + try { + quizData = JSON.parse(text) as QuizJsonFormat + } catch { + return NextResponse.json({ error: 'Format JSON invalide' }, { status: 400 }) + } + + if (!quizData.title || !Array.isArray(quizData.questions) || quizData.questions.length === 0) { + return NextResponse.json( + { error: 'Le fichier JSON doit contenir un titre et des questions' }, + { status: 400 } + ) + } + + // Créer le quiz + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: quiz, error: quizError } = await (supabase as any) + .from('quizzes') + .insert({ + title: quizData.title, + subchapter_id: subchapterId ?? null, + author_id: user.id, + raw_json_data: quizData, + }) + .select() + .single() + + if (quizError || !quiz) { + return NextResponse.json({ error: 'Erreur création du quiz', details: quizError?.message }, { status: 500 }) + } + + // Insérer les questions en batch + const questionsToInsert = quizData.questions.map((q, index) => ({ + quiz_id: quiz.id, + question_text: q.question, + explanation: q.explanation ?? null, + order: index, + })) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: insertedQuestions, error: questionsError } = await (supabase as any) + .from('questions') + .insert(questionsToInsert) + .select() + + if (questionsError || !insertedQuestions) { + return NextResponse.json( + { error: 'Erreur insertion des questions', details: questionsError?.message }, + { status: 500 } + ) + } + + // Insérer les réponses en batch + const answersToInsert = (insertedQuestions as { id: string }[]).flatMap((question, index) => { + const originalQuestion = quizData.questions[index] + return originalQuestion.answers.map((a) => ({ + question_id: question.id, + answer_text: a.text, + is_correct: a.correct, + })) + }) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { error: answersError } = await (supabase as any) + .from('answers') + .insert(answersToInsert) + + if (answersError) { + return NextResponse.json( + { error: 'Erreur insertion des réponses', details: answersError.message }, + { status: 500 } + ) + } + + return NextResponse.json({ + success: true, + quiz: { + id: quiz.id, + title: quiz.title, + questionCount: insertedQuestions.length, + }, + }) + } catch (error) { + console.error('[upload-quiz]', error) + return NextResponse.json({ error: 'Erreur serveur interne' }, { status: 500 }) + } +} diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx new file mode 100644 index 0000000..98e4083 --- /dev/null +++ b/app/dashboard/layout.tsx @@ -0,0 +1,36 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { redirect } from 'next/navigation' +import { createClient } from '@/lib/supabase/server' +import Sidebar from '@/components/dashboard/Sidebar' + +export default async function DashboardLayout({ + children, +}: { + children: React.ReactNode +}) { + const supabase = await createClient() + const db = supabase as any + + const { data: { user }, error } = await supabase.auth.getUser() + if (error || !user) { + redirect('/login') + } + + const { data: profile } = await db + .from('profiles') + .select('username, role') + .eq('id', user.id) + .single() + + return ( +
+ +
+ {children} +
+
+ ) +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..03441be --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,243 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createClient } from '@/lib/supabase/server' +import { + Monitor, + Users, + TrendingUp, + CheckCircle, + Zap, + ArrowRight, + FileText, +} from 'lucide-react' +import Link from 'next/link' +import { cn } from '@/lib/utils' +import NotificationBell from '@/components/dashboard/NotificationBell' + +function KpiCard({ + label, + value, + badge, + badgePositive, + icon: Icon, +}: { + label: string + value: string + badge: string + badgePositive: boolean + icon: React.ElementType +}) { + return ( +
+
+ {label} +
+
+
+ {value} + + {badge} + +
+
+ ) +} + +const statusConfig = { + active: { label: 'ACTIVE NOW', color: 'bg-green-500/10 text-green-400 border-green-500/20' }, + scheduled: { label: 'PLANIFIÉE', color: 'bg-amber-500/10 text-amber-400 border-amber-500/20' }, + completed: { label: 'TERMINÉE', color: 'bg-gray-500/10 text-gray-400 border-gray-500/20' }, +} as const + +function SessionCard({ session }: { session: any }) { + const statusKey: keyof typeof statusConfig = session.status + const status = statusConfig[statusKey] ?? statusConfig.completed + + return ( +
+
+
+ +
+
+ +
+
+

{session.title}

+ + {status.label} + +
+

Code: #{session.shortCode} · {session.createdAt}

+
+ {session.participants} Participants + {session.status === 'active' && ( +
+
+
+
+ {session.completion}% Complete +
+ )} + {session.status === 'completed' && session.avgScore && ( + + {session.avgScore}% Avg Score + + )} +
+
+ +
+ {session.status === 'active' && ( + <> + + Monitor + + + + )} + {session.status === 'completed' && ( + + Voir Rapport + + )} +
+
+ ) +} + +export default async function DashboardPage() { + const supabase = await createClient() + const db = supabase as any + + const { data: { user } } = await supabase.auth.getUser() + + const { data: profile } = await db + .from('profiles') + .select('username') + .eq('id', user!.id) + .single() + + const { data: sessions } = await db + .from('sessions') + .select('id, short_code, is_active, created_at, school_name, class_name, total_participants, quiz:quizzes(title)') + .eq('trainer_id', user!.id) + .order('created_at', { ascending: false }) + .limit(5) + + const { count: totalSessions } = await db + .from('sessions') + .select('id', { count: 'exact', head: true }) + .eq('trainer_id', user!.id) + + const sessionIds = (sessions ?? []).map((s: any) => s.id) + + const { count: totalParticipants } = sessionIds.length > 0 + ? await db + .from('student_participations') + .select('id', { count: 'exact', head: true }) + .in('session_id', sessionIds) + : { count: 0 } + + const displayName = profile?.username ?? user?.email?.split('@')[0] ?? 'Formateur' + + const mappedSessions = (sessions ?? []).map((s: any) => ({ + id: s.id, + title: s.quiz?.title ?? 'Quiz sans titre', + shortCode: s.short_code, + createdAt: s.is_active + ? `il y a ${Math.max(0, Math.floor((Date.now() - new Date(s.created_at).getTime()) / 3600000))}h` + : 'Terminé', + participants: s.total_participants, + avgTime: '15m', + completion: 85, + status: s.is_active ? 'active' as const : 'completed' as const, + })) + + return ( +
+
+
+

Dashboard Overview

+

Bienvenue, {displayName}. Voici ce qui se passe aujourd'hui.

+
+
+ +
+ {displayName.slice(0, 2).toUpperCase()} +
+
+
+ +
+ + + + +
+ +
+
+

Sessions Actives

+ + Voir tout + +
+
+ {mappedSessions.length > 0 ? ( + mappedSessions.map((session: any) => ) + ) : ( +
+ +

Aucune session créée pour l'instant

+ + Créer une session + +
+ )} +
+
+ +
+
+

Notifications Récentes

+
+ {[ + { icon: '🔧', title: 'Maintenance Système', desc: 'Prévue samedi à 22h. Sauvegardez votre travail.' }, + { icon: '✅', title: 'Session finalisée', desc: 'Les rapports sont disponibles pour JS Fundamentals.' }, + { icon: '👥', title: 'Nouvelles inscriptions', desc: '12 étudiants ont rejoint "React Basics Quiz".' }, + ].map((notif, i) => ( +
+
+ {notif.icon} +
+
+

{notif.title}

+

{notif.desc}

+
+
+ ))} +
+
+ +
+
+ +
+

Lancer un Quiz Rapide

+

+ Créez instantanément un quiz pour un feedback immédiat de votre classe. +

+ + Démarrer + +
+
+
+ ) +} diff --git a/app/dashboard/quizzes/QuizzesClient.tsx b/app/dashboard/quizzes/QuizzesClient.tsx new file mode 100644 index 0000000..6a20f84 --- /dev/null +++ b/app/dashboard/quizzes/QuizzesClient.tsx @@ -0,0 +1,719 @@ +'use client' + +import { useState, useRef } from 'react' +import { useRouter } from 'next/navigation' +import { + Search, + ChevronDown, + ChevronUp, + Upload, + Plus as PlusIcon, + BookOpen, + Users, + Grid, + Edit2, + Play, + Trash2, + Check, + X, + Loader2, +} from 'lucide-react' +import { cn } from '@/lib/utils' + +interface Quiz { + id: string + title: string + updated_at: string +} + +interface Subchapter { + id: string + name: string + quizzes: Quiz[] +} + +interface Category { + id: string + name: string + description: string | null + subchapters: Subchapter[] +} + +interface Props { + initialCategories: Category[] + stats: { + totalQuizzes: number + totalCategories: number + activeStudents: number + } +} + +function formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString('fr-FR', { + day: '2-digit', + month: 'short', + year: 'numeric', + }) +} + +export default function QuizzesClient({ initialCategories, stats: initialStats }: Props) { + const router = useRouter() + const [categories, setCategories] = useState(initialCategories) + const [stats, setStats] = useState(initialStats) + const [expandedCategories, setExpandedCategories] = useState>( + new Set(initialCategories.slice(0, 1).map((c) => c.id)) + ) + const [search, setSearch] = useState('') + const [uploading, setUploading] = useState(false) + const [uploadError, setUploadError] = useState(null) + const fileInputRef = useRef(null) + + // --- Category CRUD state --- + const [showNewCategoryModal, setShowNewCategoryModal] = useState(false) + const [newCategoryName, setNewCategoryName] = useState('') + const [newCategoryDesc, setNewCategoryDesc] = useState('') + const [creatingCategory, setCreatingCategory] = useState(false) + const [createCategoryError, setCreateCategoryError] = useState(null) + + const [editingCategoryId, setEditingCategoryId] = useState(null) + const [editingCategoryName, setEditingCategoryName] = useState('') + const [savingCategoryId, setSavingCategoryId] = useState(null) + const [confirmDeleteCategoryId, setConfirmDeleteCategoryId] = useState(null) + const [deletingCategoryId, setDeletingCategoryId] = useState(null) + + // --- Subchapter CRUD state --- + const [editingSubchapterId, setEditingSubchapterId] = useState(null) + const [editingSubchapterName, setEditingSubchapterName] = useState('') + const [savingSubchapterId, setSavingSubchapterId] = useState(null) + const [confirmDeleteSubchapterId, setConfirmDeleteSubchapterId] = useState(null) + const [deletingSubchapterId, setDeletingSubchapterId] = useState(null) + const [addingSubchapterForCategoryId, setAddingSubchapterForCategoryId] = useState(null) + const [newSubchapterName, setNewSubchapterName] = useState('') + const [savingNewSubchapter, setSavingNewSubchapter] = useState(false) + + const toggleCategory = (id: string) => { + setExpandedCategories((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + const filteredCategories = categories.filter((cat) => { + if (!search) return true + const q = search.toLowerCase() + return ( + cat.name.toLowerCase().includes(q) || + cat.subchapters.some((s) => s.name.toLowerCase().includes(q)) + ) + }) + + const handleFileUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + setUploading(true) + setUploadError(null) + const formData = new FormData() + formData.append('file', file) + try { + const res = await fetch('/api/upload-quiz', { method: 'POST', body: formData }) + const data = await res.json() + if (!res.ok) setUploadError(data.error ?? "Erreur lors de l'import") + else router.refresh() + } catch { + setUploadError('Erreur réseau') + } finally { + setUploading(false) + if (fileInputRef.current) fileInputRef.current.value = '' + } + } + + // --- Category handlers --- + const handleCreateCategory = async () => { + if (!newCategoryName.trim()) return + setCreatingCategory(true) + setCreateCategoryError(null) + try { + const res = await fetch('/api/categories/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newCategoryName.trim(), description: newCategoryDesc.trim() || null }), + }) + const data = await res.json() + if (!res.ok) { + setCreateCategoryError(data.error ?? 'Erreur') + } else { + const newCat: Category = { ...data.category, subchapters: [] } + setCategories((prev) => [...prev, newCat]) + setStats((s) => ({ ...s, totalCategories: s.totalCategories + 1 })) + setShowNewCategoryModal(false) + setNewCategoryName('') + setNewCategoryDesc('') + setExpandedCategories((prev) => new Set([...prev, newCat.id])) + } + } catch { + setCreateCategoryError('Erreur réseau') + } finally { + setCreatingCategory(false) + } + } + + const startEditCategory = (cat: Category) => { + setEditingCategoryId(cat.id) + setEditingCategoryName(cat.name) + setConfirmDeleteCategoryId(null) + } + + const handleSaveCategory = async (id: string) => { + if (!editingCategoryName.trim()) return + setSavingCategoryId(id) + try { + const res = await fetch(`/api/categories/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: editingCategoryName.trim() }), + }) + if (res.ok) { + setCategories((prev) => + prev.map((c) => (c.id === id ? { ...c, name: editingCategoryName.trim() } : c)) + ) + setEditingCategoryId(null) + } + } finally { + setSavingCategoryId(null) + } + } + + const handleDeleteCategory = async (id: string) => { + setDeletingCategoryId(id) + try { + const res = await fetch(`/api/categories/${id}`, { method: 'DELETE' }) + if (res.ok) { + const cat = categories.find((c) => c.id === id) + const removedSubchapters = cat?.subchapters.length ?? 0 + const removedQuizzes = cat?.subchapters.reduce((acc, s) => acc + s.quizzes.length, 0) ?? 0 + setCategories((prev) => prev.filter((c) => c.id !== id)) + setStats((s) => ({ + ...s, + totalCategories: Math.max(0, s.totalCategories - 1), + totalQuizzes: Math.max(0, s.totalQuizzes - removedQuizzes), + })) + setConfirmDeleteCategoryId(null) + } + } finally { + setDeletingCategoryId(null) + } + } + + // --- Subchapter handlers --- + const startEditSubchapter = (sub: Subchapter) => { + setEditingSubchapterId(sub.id) + setEditingSubchapterName(sub.name) + setConfirmDeleteSubchapterId(null) + } + + const handleSaveSubchapter = async (subId: string) => { + if (!editingSubchapterName.trim()) return + setSavingSubchapterId(subId) + try { + const res = await fetch(`/api/subchapters/${subId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: editingSubchapterName.trim() }), + }) + if (res.ok) { + setCategories((prev) => + prev.map((c) => ({ + ...c, + subchapters: c.subchapters.map((s) => + s.id === subId ? { ...s, name: editingSubchapterName.trim() } : s + ), + })) + ) + setEditingSubchapterId(null) + } + } finally { + setSavingSubchapterId(null) + } + } + + const handleDeleteSubchapter = async (subId: string) => { + setDeletingSubchapterId(subId) + try { + const res = await fetch(`/api/subchapters/${subId}`, { method: 'DELETE' }) + if (res.ok) { + let removedQuizzes = 0 + setCategories((prev) => + prev.map((c) => { + const sub = c.subchapters.find((s) => s.id === subId) + if (sub) removedQuizzes = sub.quizzes.length + return { ...c, subchapters: c.subchapters.filter((s) => s.id !== subId) } + }) + ) + setStats((s) => ({ ...s, totalQuizzes: Math.max(0, s.totalQuizzes - removedQuizzes) })) + setConfirmDeleteSubchapterId(null) + } + } finally { + setDeletingSubchapterId(null) + } + } + + const handleCreateSubchapter = async (categoryId: string) => { + if (!newSubchapterName.trim()) return + setSavingNewSubchapter(true) + try { + const res = await fetch('/api/subchapters/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ category_id: categoryId, name: newSubchapterName.trim() }), + }) + const data = await res.json() + if (res.ok) { + const newSub: Subchapter = { ...data.subchapter, quizzes: [] } + setCategories((prev) => + prev.map((c) => + c.id === categoryId ? { ...c, subchapters: [...c.subchapters, newSub] } : c + ) + ) + setAddingSubchapterForCategoryId(null) + setNewSubchapterName('') + } + } finally { + setSavingNewSubchapter(false) + } + } + + const handlePlaySubchapter = (subchapter: Subchapter) => { + const firstQuiz = subchapter.quizzes[0] + if (firstQuiz) { + router.push(`/dashboard/sessions/create?quiz_id=${firstQuiz.id}`) + } else { + router.push('/dashboard/sessions/create') + } + } + + const categoryIcons = ['🔴', '🔵', '🟢', '🟡', '🟣', '🟠'] + + return ( +
+ {/* Header */} +
+
+

Gestion des Quiz

+

+ Organisez votre curriculum, gérez les catégories et mettez à jour les contenus pédagogiques. +

+
+
+ + +
+
+ + {uploadError && ( +
+ {uploadError} +
+ )} + + {/* Stats */} +
+ {[ + { icon: BookOpen, label: 'Total Quiz', value: stats.totalQuizzes, color: 'text-green-400' }, + { icon: Grid, label: 'Catégories', value: stats.totalCategories, color: 'text-blue-400' }, + { icon: Users, label: 'Étudiants Actifs', value: stats.activeStudents, color: 'text-purple-400' }, + ].map(({ icon: Icon, label, value, color }) => ( +
+
+ +
+
+

{label}

+

{value}

+
+
+ ))} +
+ + {/* Categories Section */} +
+
+

Catégories Actives

+
+ + setSearch(e.target.value)} + placeholder="Rechercher un chapitre..." + className="input-field pl-9 py-2 text-sm w-64" + /> +
+
+ +
+ {filteredCategories.length === 0 ? ( +
+ +

Aucune catégorie trouvée

+
+ ) : ( + filteredCategories.map((category, index) => { + const isExpanded = expandedCategories.has(category.id) + const totalQuizCount = category.subchapters.reduce((acc, s) => acc + s.quizzes.length, 0) + const isEditingCat = editingCategoryId === category.id + const isConfirmingDelete = confirmDeleteCategoryId === category.id + const isDeletingCat = deletingCategoryId === category.id + + return ( +
+ {/* Category header */} +
+ + +
+ {isEditingCat ? ( + <> + + + + ) : isConfirmingDelete ? ( +
+ Supprimer ? + + +
+ ) : ( + <> + + Publié + + + + + + )} +
+
+ + {/* Subchapters table */} + {isExpanded && ( +
+ + + + + + + + + + + {category.subchapters.map((subchapter) => { + const isEditingSub = editingSubchapterId === subchapter.id + const isConfirmingDeleteSub = confirmDeleteSubchapterId === subchapter.id + const isDeletingSub = deletingSubchapterId === subchapter.id + + return ( + + + + + + + ) + })} + + {/* Add subchapter row */} + {addingSubchapterForCategoryId === category.id && ( + + + + )} + +
ChapitreQuizDernière Modif.Actions
+ {isEditingSub ? ( + setEditingSubchapterName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveSubchapter(subchapter.id) + if (e.key === 'Escape') setEditingSubchapterId(null) + }} + className="input-field text-sm py-1 w-48" + autoFocus + /> + ) : ( + subchapter.name + )} + + {subchapter.quizzes.length} Quiz + + {subchapter.quizzes[0] ? formatDate(subchapter.quizzes[0].updated_at) : '—'} + +
+ {isEditingSub ? ( + <> + + + + ) : isConfirmingDeleteSub ? ( +
+ Supprimer ? + + +
+ ) : ( + <> + + + + + )} +
+
+
+ setNewSubchapterName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleCreateSubchapter(category.id) + if (e.key === 'Escape') { setAddingSubchapterForCategoryId(null); setNewSubchapterName('') } + }} + placeholder="Nom du chapitre..." + className="input-field text-sm py-1.5 flex-1 max-w-xs" + autoFocus + /> + + +
+
+ +
+ +
+
+ )} +
+ ) + }) + )} +
+
+ + {/* New Category Modal */} + {showNewCategoryModal && ( +
+
+
+

Nouvelle Catégorie

+ +
+
+
+ + setNewCategoryName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleCreateCategory()} + placeholder="Ex: Développement Web" + className="input-field" + autoFocus + /> +
+
+ +