initial project

This commit is contained in:
corenthin-lebreton 2026-02-26 20:10:14 +01:00
commit 28aa3b0e10
47 changed files with 11112 additions and 0 deletions

3
.env.local.example Normal file
View File

@ -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

129
README.md Normal file
View File

@ -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

View File

@ -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 })
}
}

View File

@ -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 })
}
}

View File

@ -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 })
}
}

View File

@ -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<string> {
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 })
}
}

View File

@ -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 })
}
}

View File

@ -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 })
}
}

View File

@ -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 })
}
}

View File

@ -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 })
}
}

View File

@ -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 })
}
}

View File

@ -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 })
}
}

View File

@ -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 })
}
}

36
app/dashboard/layout.tsx Normal file
View File

@ -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 (
<div className="flex min-h-screen bg-background">
<Sidebar
username={profile?.username ?? user.email?.split('@')[0] ?? 'Utilisateur'}
role={profile?.role ?? 'formateur'}
/>
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
)
}

243
app/dashboard/page.tsx Normal file
View File

@ -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 (
<div className="card p-5 flex-1">
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-text-secondary">{label}</span>
<div className="text-text-muted"><Icon size={18} /></div>
</div>
<div className="flex items-end gap-2">
<span className="text-3xl font-bold text-text-primary">{value}</span>
<span className={cn(
'text-xs font-medium px-2 py-0.5 rounded-full mb-1',
badgePositive ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'
)}>
{badge}
</span>
</div>
</div>
)
}
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 (
<div className="card p-4 flex items-center gap-4 hover:border-border-light transition-colors">
<div className="w-16 h-14 bg-background-elevated rounded-lg flex-shrink-0 overflow-hidden">
<div className="w-full h-full bg-gradient-to-br from-primary/30 to-blue-700/20 flex items-center justify-center">
<FileText size={20} className="text-primary/60" />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-text-primary truncate">{session.title}</h3>
<span className={cn('text-xs px-2 py-0.5 rounded-full border font-medium flex-shrink-0', status.color)}>
{status.label}
</span>
</div>
<p className="text-xs text-text-muted mb-2">Code: #{session.shortCode} · {session.createdAt}</p>
<div className="flex items-center gap-4 text-xs text-text-secondary">
<span className="flex items-center gap-1"><Users size={12} />{session.participants} Participants</span>
{session.status === 'active' && (
<div className="flex items-center gap-1.5 flex-1">
<div className="flex-1 h-1.5 bg-border rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full" style={{ width: `${session.completion}%` }} />
</div>
<span>{session.completion}% Complete</span>
</div>
)}
{session.status === 'completed' && session.avgScore && (
<span className="flex items-center gap-1 text-green-400">
<TrendingUp size={12} />{session.avgScore}% Avg Score
</span>
)}
</div>
</div>
<div className="flex flex-col gap-2 flex-shrink-0">
{session.status === 'active' && (
<>
<Link href={`/dashboard/sessions/${session.id}/live`} className="btn-primary text-xs px-3 py-1.5">
<Monitor size={12} />Monitor
</Link>
<button className="btn-secondary text-xs px-3 py-1.5">Détails</button>
</>
)}
{session.status === 'completed' && (
<Link href={`/dashboard/sessions/${session.id}/live`} className="btn-secondary text-xs px-3 py-1.5">
Voir Rapport
</Link>
)}
</div>
</div>
)
}
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 (
<div className="p-8">
<div className="flex items-start justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-text-primary mb-1">Dashboard Overview</h1>
<p className="text-text-secondary">Bienvenue, {displayName}. Voici ce qui se passe aujourd&apos;hui.</p>
</div>
<div className="flex items-center gap-3">
<NotificationBell />
<div className="w-9 h-9 bg-primary rounded-full flex items-center justify-center text-white font-semibold text-sm">
{displayName.slice(0, 2).toUpperCase()}
</div>
</div>
</div>
<div className="flex gap-4 mb-8">
<KpiCard label="Total Sessions" value={String(totalSessions ?? 0)} badge="+2 cette semaine" badgePositive icon={Monitor} />
<KpiCard label="Étudiants Actifs" value={String(totalParticipants ?? 0)} badge="+12%" badgePositive icon={Users} />
<KpiCard label="Score Moyen" value="78%" badge="+5%" badgePositive icon={TrendingUp} />
<KpiCard label="Taux de Complétion" value="92%" badge="-1%" badgePositive={false} icon={CheckCircle} />
</div>
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-text-primary">Sessions Actives</h2>
<Link href="/dashboard/reports" className="text-sm text-primary hover:text-primary-light transition-colors flex items-center gap-1">
Voir tout <ArrowRight size={14} />
</Link>
</div>
<div className="space-y-3">
{mappedSessions.length > 0 ? (
mappedSessions.map((session: any) => <SessionCard key={session.id} session={session} />)
) : (
<div className="card p-8 text-center">
<Monitor size={32} className="mx-auto text-text-muted mb-3" />
<p className="text-text-secondary mb-4">Aucune session créée pour l&apos;instant</p>
<Link href="/dashboard/sessions/create" className="btn-primary inline-flex">
Créer une session
</Link>
</div>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div className="card p-6">
<h3 className="font-semibold text-text-primary mb-4">Notifications Récentes</h3>
<div className="space-y-4">
{[
{ 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) => (
<div key={i} className="flex items-start gap-3">
<div className="w-8 h-8 bg-background-elevated rounded-lg flex items-center justify-center text-sm flex-shrink-0">
{notif.icon}
</div>
<div>
<p className="text-sm font-medium text-text-primary">{notif.title}</p>
<p className="text-xs text-text-muted">{notif.desc}</p>
</div>
</div>
))}
</div>
</div>
<div className="card p-6 bg-gradient-to-br from-primary to-blue-700 border-0">
<div className="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center mb-4">
<Zap size={20} className="text-white" />
</div>
<h3 className="font-bold text-white text-lg mb-2">Lancer un Quiz Rapide</h3>
<p className="text-blue-100 text-sm mb-6">
Créez instantanément un quiz pour un feedback immédiat de votre classe.
</p>
<Link
href="/dashboard/sessions/create"
className="inline-flex items-center gap-2 bg-white text-primary font-semibold px-4 py-2 rounded-lg hover:bg-blue-50 transition-colors"
>
Démarrer
</Link>
</div>
</div>
</div>
)
}

View File

@ -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<Category[]>(initialCategories)
const [stats, setStats] = useState(initialStats)
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(initialCategories.slice(0, 1).map((c) => c.id))
)
const [search, setSearch] = useState('')
const [uploading, setUploading] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(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<string | null>(null)
const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null)
const [editingCategoryName, setEditingCategoryName] = useState('')
const [savingCategoryId, setSavingCategoryId] = useState<string | null>(null)
const [confirmDeleteCategoryId, setConfirmDeleteCategoryId] = useState<string | null>(null)
const [deletingCategoryId, setDeletingCategoryId] = useState<string | null>(null)
// --- Subchapter CRUD state ---
const [editingSubchapterId, setEditingSubchapterId] = useState<string | null>(null)
const [editingSubchapterName, setEditingSubchapterName] = useState('')
const [savingSubchapterId, setSavingSubchapterId] = useState<string | null>(null)
const [confirmDeleteSubchapterId, setConfirmDeleteSubchapterId] = useState<string | null>(null)
const [deletingSubchapterId, setDeletingSubchapterId] = useState<string | null>(null)
const [addingSubchapterForCategoryId, setAddingSubchapterForCategoryId] = useState<string | null>(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<HTMLInputElement>) => {
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 (
<div className="p-8">
{/* Header */}
<div className="flex items-start justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-text-primary mb-2">Gestion des Quiz</h1>
<p className="text-text-secondary max-w-lg">
Organisez votre curriculum, gérez les catégories et mettez à jour les contenus pédagogiques.
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setShowNewCategoryModal(true)}
className="btn-secondary flex items-center gap-2"
>
<PlusIcon size={16} />
Nouvelle Catégorie
</button>
<label className={cn('btn-primary cursor-pointer', uploading && 'opacity-70 cursor-not-allowed')}>
<Upload size={16} />
{uploading ? 'Import...' : 'Importer un Quiz (JSON)'}
<input
ref={fileInputRef}
type="file"
accept=".json"
className="hidden"
onChange={handleFileUpload}
disabled={uploading}
/>
</label>
</div>
</div>
{uploadError && (
<div className="mb-6 bg-red-500/10 border border-red-500/30 text-red-400 px-4 py-3 rounded-lg text-sm">
{uploadError}
</div>
)}
{/* Stats */}
<div className="grid grid-cols-3 gap-4 mb-8">
{[
{ 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 }) => (
<div key={label} className="card p-5 flex items-center gap-4">
<div className={cn('w-12 h-12 rounded-xl bg-background-elevated flex items-center justify-center', color)}>
<Icon size={22} />
</div>
<div>
<p className="text-text-secondary text-sm">{label}</p>
<p className="text-2xl font-bold text-text-primary">{value}</p>
</div>
</div>
))}
</div>
{/* Categories Section */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-text-primary">Catégories Actives</h2>
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Rechercher un chapitre..."
className="input-field pl-9 py-2 text-sm w-64"
/>
</div>
</div>
<div className="space-y-3">
{filteredCategories.length === 0 ? (
<div className="card p-12 text-center">
<BookOpen size={32} className="mx-auto text-text-muted mb-3" />
<p className="text-text-secondary">Aucune catégorie trouvée</p>
</div>
) : (
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 (
<div key={category.id} className="card overflow-hidden">
{/* Category header */}
<div className="flex items-center justify-between p-4">
<button
onClick={() => !isEditingCat && toggleCategory(category.id)}
className="flex items-center gap-3 flex-1 text-left"
>
<span className="text-xl">{categoryIcons[index % categoryIcons.length]}</span>
{isEditingCat ? (
<input
value={editingCategoryName}
onChange={(e) => setEditingCategoryName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveCategory(category.id)
if (e.key === 'Escape') setEditingCategoryId(null)
}}
onClick={(e) => e.stopPropagation()}
className="input-field text-sm py-1.5 w-56"
autoFocus
/>
) : (
<div>
<p className="font-semibold text-text-primary">{category.name}</p>
<p className="text-xs text-text-muted">
{category.subchapters.length} Chapitre{category.subchapters.length > 1 ? 's' : ''} · {totalQuizCount} Quiz
</p>
</div>
)}
</button>
<div className="flex items-center gap-2">
{isEditingCat ? (
<>
<button
onClick={() => handleSaveCategory(category.id)}
disabled={savingCategoryId === category.id}
className="p-1.5 bg-primary/10 hover:bg-primary/20 rounded-md text-primary transition-colors"
>
{savingCategoryId === category.id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Check size={14} />
)}
</button>
<button
onClick={() => setEditingCategoryId(null)}
className="p-1.5 hover:bg-background-elevated rounded-md text-text-muted hover:text-text-primary transition-colors"
>
<X size={14} />
</button>
</>
) : isConfirmingDelete ? (
<div className="flex items-center gap-2 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-1.5">
<span className="text-xs text-red-400">Supprimer ?</span>
<button
onClick={() => handleDeleteCategory(category.id)}
disabled={isDeletingCat}
className="text-xs text-red-400 hover:text-red-300 font-medium transition-colors"
>
{isDeletingCat ? <Loader2 size={12} className="animate-spin" /> : 'Oui'}
</button>
<button
onClick={() => setConfirmDeleteCategoryId(null)}
className="text-xs text-text-muted hover:text-text-primary transition-colors"
>
Non
</button>
</div>
) : (
<>
<span className="text-xs px-2.5 py-1 rounded-full border bg-green-500/10 text-green-400 border-green-500/20 font-medium">
Publié
</span>
<button
onClick={(e) => { e.stopPropagation(); startEditCategory(category) }}
className="p-1.5 hover:bg-background-elevated rounded-md text-text-muted hover:text-text-primary transition-colors"
title="Renommer"
>
<Edit2 size={14} />
</button>
<button
onClick={(e) => { e.stopPropagation(); setConfirmDeleteCategoryId(category.id); setEditingCategoryId(null) }}
className="p-1.5 hover:bg-red-500/10 rounded-md text-text-muted hover:text-red-400 transition-colors"
title="Supprimer"
>
<Trash2 size={14} />
</button>
<button onClick={() => toggleCategory(category.id)}>
{isExpanded ? (
<ChevronUp size={18} className="text-text-muted" />
) : (
<ChevronDown size={18} className="text-text-muted" />
)}
</button>
</>
)}
</div>
</div>
{/* Subchapters table */}
{isExpanded && (
<div className="border-t border-border">
<table className="w-full">
<thead>
<tr className="border-b border-border">
<th className="text-left text-xs font-medium text-text-muted px-6 py-3 uppercase tracking-wider">Chapitre</th>
<th className="text-left text-xs font-medium text-text-muted px-6 py-3 uppercase tracking-wider">Quiz</th>
<th className="text-left text-xs font-medium text-text-muted px-6 py-3 uppercase tracking-wider">Dernière Modif.</th>
<th className="text-right text-xs font-medium text-text-muted px-6 py-3 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody>
{category.subchapters.map((subchapter) => {
const isEditingSub = editingSubchapterId === subchapter.id
const isConfirmingDeleteSub = confirmDeleteSubchapterId === subchapter.id
const isDeletingSub = deletingSubchapterId === subchapter.id
return (
<tr key={subchapter.id} className="border-b border-border/50 hover:bg-background-elevated/30 transition-colors">
<td className="px-6 py-4 text-sm font-medium text-text-primary">
{isEditingSub ? (
<input
value={editingSubchapterName}
onChange={(e) => 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
)}
</td>
<td className="px-6 py-4 text-sm text-text-secondary">
{subchapter.quizzes.length} Quiz
</td>
<td className="px-6 py-4 text-sm text-text-secondary">
{subchapter.quizzes[0] ? formatDate(subchapter.quizzes[0].updated_at) : '—'}
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-1.5">
{isEditingSub ? (
<>
<button
onClick={() => handleSaveSubchapter(subchapter.id)}
disabled={savingSubchapterId === subchapter.id}
className="p-1.5 bg-primary/10 hover:bg-primary/20 rounded-md text-primary transition-colors"
>
{savingSubchapterId === subchapter.id ? (
<Loader2 size={13} className="animate-spin" />
) : (
<Check size={13} />
)}
</button>
<button
onClick={() => setEditingSubchapterId(null)}
className="p-1.5 hover:bg-background-elevated rounded-md text-text-muted hover:text-text-primary transition-colors"
>
<X size={13} />
</button>
</>
) : isConfirmingDeleteSub ? (
<div className="flex items-center gap-2 bg-red-500/10 border border-red-500/20 rounded-lg px-2.5 py-1">
<span className="text-xs text-red-400">Supprimer ?</span>
<button
onClick={() => handleDeleteSubchapter(subchapter.id)}
disabled={isDeletingSub}
className="text-xs text-red-400 hover:text-red-300 font-medium"
>
{isDeletingSub ? <Loader2 size={11} className="animate-spin" /> : 'Oui'}
</button>
<button
onClick={() => setConfirmDeleteSubchapterId(null)}
className="text-xs text-text-muted hover:text-text-primary"
>
Non
</button>
</div>
) : (
<>
<button
onClick={() => startEditSubchapter(subchapter)}
className="p-1.5 hover:bg-background-elevated rounded-md text-text-muted hover:text-text-primary transition-colors"
title="Renommer"
>
<Edit2 size={14} />
</button>
<button
onClick={() => handlePlaySubchapter(subchapter)}
className="p-1.5 hover:bg-primary/10 rounded-md text-text-muted hover:text-primary transition-colors"
title="Lancer une session"
disabled={subchapter.quizzes.length === 0}
>
<Play size={14} />
</button>
<button
onClick={() => { setConfirmDeleteSubchapterId(subchapter.id); setEditingSubchapterId(null) }}
className="p-1.5 hover:bg-red-500/10 rounded-md text-text-muted hover:text-red-400 transition-colors"
title="Supprimer"
>
<Trash2 size={14} />
</button>
</>
)}
</div>
</td>
</tr>
)
})}
{/* Add subchapter row */}
{addingSubchapterForCategoryId === category.id && (
<tr className="border-b border-border/50 bg-background-elevated/20">
<td className="px-6 py-3" colSpan={4}>
<div className="flex items-center gap-2">
<input
value={newSubchapterName}
onChange={(e) => 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
/>
<button
onClick={() => handleCreateSubchapter(category.id)}
disabled={savingNewSubchapter || !newSubchapterName.trim()}
className="p-1.5 bg-primary/10 hover:bg-primary/20 rounded-md text-primary transition-colors disabled:opacity-40"
>
{savingNewSubchapter ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
</button>
<button
onClick={() => { setAddingSubchapterForCategoryId(null); setNewSubchapterName('') }}
className="p-1.5 hover:bg-background-elevated rounded-md text-text-muted transition-colors"
>
<X size={14} />
</button>
</div>
</td>
</tr>
)}
</tbody>
</table>
<div className="border-t border-border/50 p-3 flex justify-center">
<button
onClick={() => {
setAddingSubchapterForCategoryId(category.id)
setNewSubchapterName('')
setExpandedCategories((prev) => new Set([...prev, category.id]))
}}
className="text-sm text-primary hover:text-primary-light transition-colors flex items-center gap-1"
>
<PlusIcon size={14} />
Ajouter un chapitre
</button>
</div>
</div>
)}
</div>
)
})
)}
</div>
</div>
{/* New Category Modal */}
{showNewCategoryModal && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-background-card border border-border rounded-2xl w-full max-w-md shadow-2xl shadow-black/50">
<div className="flex items-center justify-between p-6 border-b border-border">
<h2 className="text-lg font-bold text-text-primary">Nouvelle Catégorie</h2>
<button
onClick={() => { setShowNewCategoryModal(false); setCreateCategoryError(null); setNewCategoryName(''); setNewCategoryDesc('') }}
className="p-1.5 hover:bg-background-elevated rounded-lg transition-colors"
>
<X size={18} className="text-text-muted" />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm text-text-secondary mb-1.5">Nom de la catégorie *</label>
<input
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreateCategory()}
placeholder="Ex: Développement Web"
className="input-field"
autoFocus
/>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1.5">Description (optionnelle)</label>
<textarea
value={newCategoryDesc}
onChange={(e) => setNewCategoryDesc(e.target.value)}
placeholder="Décrivez cette catégorie..."
className="input-field resize-none h-20"
/>
</div>
{createCategoryError && (
<p className="text-sm text-red-400">{createCategoryError}</p>
)}
</div>
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-border bg-background-secondary/30 rounded-b-2xl">
<button
onClick={() => { setShowNewCategoryModal(false); setCreateCategoryError(null); setNewCategoryName(''); setNewCategoryDesc('') }}
className="btn-secondary"
>
Annuler
</button>
<button
onClick={handleCreateCategory}
disabled={creatingCategory || !newCategoryName.trim()}
className={cn('btn-primary', (creatingCategory || !newCategoryName.trim()) && 'opacity-60 cursor-not-allowed')}
>
{creatingCategory ? (
<><Loader2 size={16} className="animate-spin" /> Création...</>
) : (
<><PlusIcon size={16} /> Créer la catégorie</>
)}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,43 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { createClient } from '@/lib/supabase/server'
import QuizzesClient from './QuizzesClient'
export default async function QuizzesPage() {
const supabase = await createClient()
const db = supabase as any
const { data: { user } } = await supabase.auth.getUser()
const { data: categories } = await db
.from('categories')
.select(`id, name, description, subchapters(id, name, quizzes(id, title, updated_at))`)
.order('name')
const { count: totalQuizzes } = await db
.from('quizzes')
.select('id', { count: 'exact', head: true })
.eq('author_id', user!.id)
const { data: sessionIds } = await db
.from('sessions')
.select('id')
.eq('trainer_id', user!.id)
const ids = (sessionIds ?? []).map((s: any) => s.id)
const { count: totalStudents } = ids.length > 0
? await db
.from('student_participations')
.select('id', { count: 'exact', head: true })
.in('session_id', ids)
: { count: 0 }
return (
<QuizzesClient
initialCategories={categories ?? []}
stats={{
totalQuizzes: totalQuizzes ?? 0,
totalCategories: categories?.length ?? 0,
activeStudents: totalStudents ?? 0,
}}
/>
)
}

View File

@ -0,0 +1,66 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { createClient } from '@/lib/supabase/server'
import { BarChart3 } from 'lucide-react'
import Link from 'next/link'
export default async function ReportsPage() {
const supabase = await createClient()
const db = supabase as any
const { data: { user } } = await supabase.auth.getUser()
const { data: sessions } = await db
.from('sessions')
.select(`id, short_code, is_active, school_name, class_name, created_at, quiz:quizzes(title)`)
.eq('trainer_id', user!.id)
.order('created_at', { ascending: false })
return (
<div className="p-8">
<h1 className="text-3xl font-bold text-text-primary mb-2">Rapports</h1>
<p className="text-text-secondary mb-8">Consultez les résultats de vos sessions.</p>
<div className="space-y-3">
{(sessions ?? []).length === 0 ? (
<div className="card p-12 text-center">
<BarChart3 size={32} className="mx-auto text-text-muted mb-3" />
<p className="text-text-secondary">Aucune session créée pour l&apos;instant.</p>
<Link href="/dashboard/sessions/create" className="btn-primary inline-flex mt-4">
Créer une session
</Link>
</div>
) : (
(sessions ?? []).map((session: any) => (
<div key={session.id} className="card p-5 flex items-center gap-4 hover:border-border-light transition-colors">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-text-primary">
{session.quiz?.title ?? 'Quiz'}
</h3>
<span className={`text-xs px-2 py-0.5 rounded-full border font-medium ${
session.is_active
? 'bg-green-500/10 text-green-400 border-green-500/20'
: 'bg-gray-500/10 text-gray-400 border-gray-500/20'
}`}>
{session.is_active ? 'Active' : 'Terminée'}
</span>
</div>
<p className="text-xs text-text-muted">
Code: <span className="font-mono text-primary">{session.short_code}</span>
{session.school_name && ` · ${session.school_name}`}
{session.class_name && ` · ${session.class_name}`}
{' · '}{new Date(session.created_at).toLocaleDateString('fr-FR')}
</p>
</div>
<Link
href={`/dashboard/sessions/${session.id}/live`}
className="btn-secondary text-sm"
>
Voir le rapport
</Link>
</div>
))
)}
</div>
</div>
)
}

View File

@ -0,0 +1,294 @@
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import {
Users,
CheckCircle,
Clock,
TrendingUp,
Copy,
Check,
Power,
AlertCircle,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import type { RealtimeChannel } from '@supabase/supabase-js'
interface Participation {
id: string
first_name: string
last_name: string
score: number
status: 'in_progress' | 'completed'
started_at: string
completed_at: string | null
}
interface Session {
id: string
short_code: string
is_active: boolean
school_name: string | null
class_name: string | null
total_participants: number
quiz_title: string
}
interface Props {
session: Session
initialParticipations: Participation[]
totalQuestions: number
}
export default function LiveSessionClient({ session, initialParticipations, totalQuestions }: Props) {
const [participations, setParticipations] = useState<Participation[]>(initialParticipations)
const [isActive, setIsActive] = useState(session.is_active)
const [copied, setCopied] = useState(false)
const [togglingSession, setTogglingSession] = useState(false)
const sessionUrl = `${typeof window !== 'undefined' ? window.location.origin : ''}/quiz/${session.short_code}`
const completed = participations.filter((p) => p.status === 'completed')
const inProgress = participations.filter((p) => p.status === 'in_progress')
const avgScore = completed.length > 0
? (completed.reduce((acc, p) => acc + p.score, 0) / completed.length).toFixed(2)
: '0.00'
useEffect(() => {
const supabase = createClient()
let channel: RealtimeChannel
channel = supabase
.channel(`session-${session.id}`)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'student_participations',
filter: `session_id=eq.${session.id}`,
},
(payload) => {
if (payload.eventType === 'INSERT') {
setParticipations((prev) => [payload.new as Participation, ...prev])
} else if (payload.eventType === 'UPDATE') {
setParticipations((prev) =>
prev.map((p) => (p.id === (payload.new as Participation).id ? payload.new as Participation : p))
)
}
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [session.id])
const handleCopy = async () => {
await navigator.clipboard.writeText(sessionUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleToggleSession = async () => {
setTogglingSession(true)
try {
const res = await fetch(`/api/sessions/${session.id}/toggle`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !isActive }),
})
if (res.ok) {
setIsActive(!isActive)
}
} finally {
setTogglingSession(false)
}
}
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
}
return (
<div className="p-8">
{/* Header */}
<div className="flex items-start justify-between mb-6">
<div>
<div className="flex items-center gap-3 mb-1">
<h1 className="text-2xl font-bold text-text-primary">{session.quiz_title}</h1>
<span className={cn(
'text-xs px-2.5 py-1 rounded-full border font-semibold',
isActive
? 'bg-green-500/10 text-green-400 border-green-500/20'
: 'bg-gray-500/10 text-gray-400 border-gray-500/20'
)}>
{isActive ? '● SESSION ACTIVE' : '■ SESSION TERMINÉE'}
</span>
</div>
<p className="text-text-secondary text-sm">
Code: <span className="font-mono font-bold text-primary text-base">{session.short_code}</span>
{session.school_name && ` · ${session.school_name}`}
{session.class_name && ` · ${session.class_name}`}
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={handleCopy}
className={cn(
'flex items-center gap-2 text-sm px-4 py-2 rounded-lg border transition-all',
copied
? 'bg-green-500/10 text-green-400 border-green-500/20'
: 'bg-background-card text-text-secondary border-border hover:border-border-light'
)}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
{copied ? 'Lien copié !' : 'Copier le lien'}
</button>
<button
onClick={handleToggleSession}
disabled={togglingSession}
className={cn(
'flex items-center gap-2 text-sm px-4 py-2 rounded-lg font-medium transition-all',
isActive
? 'bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20'
: 'bg-green-500/10 text-green-400 border border-green-500/20 hover:bg-green-500/20',
togglingSession && 'opacity-60 cursor-not-allowed'
)}
>
<Power size={14} />
{isActive ? 'Terminer la session' : 'Réactiver'}
</button>
</div>
</div>
{!isActive && (
<div className="bg-amber-500/10 border border-amber-500/30 text-amber-400 px-4 py-3 rounded-lg text-sm flex items-center gap-2 mb-6">
<AlertCircle size={16} />
Cette session est terminée. Les étudiants ne peuvent plus accéder au quiz.
</div>
)}
{/* KPIs */}
<div className="grid grid-cols-4 gap-4 mb-8">
<div className="card p-5">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-text-secondary">Participants</span>
<Users size={18} className="text-text-muted" />
</div>
<p className="text-3xl font-bold text-text-primary">{participations.length}</p>
{session.total_participants > 0 && (
<p className="text-xs text-text-muted mt-1">/ {session.total_participants} attendus</p>
)}
</div>
<div className="card p-5">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-text-secondary">En cours</span>
<Clock size={18} className="text-text-muted" />
</div>
<p className="text-3xl font-bold text-amber-400">{inProgress.length}</p>
</div>
<div className="card p-5">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-text-secondary">Terminés</span>
<CheckCircle size={18} className="text-text-muted" />
</div>
<p className="text-3xl font-bold text-green-400">{completed.length}</p>
</div>
<div className="card p-5">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-text-secondary">Score Moyen</span>
<TrendingUp size={18} className="text-text-muted" />
</div>
<p className="text-3xl font-bold text-primary">{avgScore}<span className="text-lg font-normal text-text-muted">/20</span></p>
</div>
</div>
{/* Participants table */}
<div className="card overflow-hidden">
<div className="p-4 border-b border-border flex items-center justify-between">
<h2 className="font-semibold text-text-primary">
Tableau de Classe{' '}
<span className="text-text-muted font-normal text-sm">({participations.length} participants)</span>
</h2>
{isActive && (
<div className="flex items-center gap-1.5 text-green-400 text-xs font-medium">
<span className="w-2 h-2 bg-green-400 rounded-full animate-pulse" />
Mise à jour en temps réel
</div>
)}
</div>
{participations.length === 0 ? (
<div className="p-12 text-center">
<Users size={32} className="mx-auto text-text-muted mb-3" />
<p className="text-text-secondary">En attente des participants...</p>
<p className="text-text-muted text-sm mt-1">
Partagez le code <span className="font-mono font-bold text-primary">{session.short_code}</span> pour que vos étudiants rejoignent.
</p>
</div>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-border">
<th className="text-left text-xs font-medium text-text-muted px-5 py-3 uppercase tracking-wider">Étudiant</th>
<th className="text-left text-xs font-medium text-text-muted px-5 py-3 uppercase tracking-wider">Statut</th>
<th className="text-left text-xs font-medium text-text-muted px-5 py-3 uppercase tracking-wider">Score</th>
<th className="text-left text-xs font-medium text-text-muted px-5 py-3 uppercase tracking-wider">Arrivée</th>
<th className="text-left text-xs font-medium text-text-muted px-5 py-3 uppercase tracking-wider">Fin</th>
</tr>
</thead>
<tbody>
{participations.map((p) => (
<tr key={p.id} className="border-b border-border/50 hover:bg-background-elevated/30 transition-colors">
<td className="px-5 py-3.5">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-semibold">
{p.first_name[0]}{p.last_name[0]}
</div>
<span className="font-medium text-text-primary text-sm">
{p.first_name} {p.last_name}
</span>
</div>
</td>
<td className="px-5 py-3.5">
<span className={cn(
'text-xs px-2.5 py-1 rounded-full border font-medium',
p.status === 'completed'
? 'bg-green-500/10 text-green-400 border-green-500/20'
: 'bg-amber-500/10 text-amber-400 border-amber-500/20'
)}>
{p.status === 'completed' ? 'Terminé' : 'En cours'}
</span>
</td>
<td className="px-5 py-3.5">
{p.status === 'completed' ? (
<span className="font-semibold text-text-primary">
{p.score.toFixed(2)}
<span className="text-text-muted font-normal text-xs">/20</span>
</span>
) : (
<span className="text-text-muted text-sm"></span>
)}
</td>
<td className="px-5 py-3.5 text-sm text-text-secondary">
{formatTime(p.started_at)}
</td>
<td className="px-5 py-3.5 text-sm text-text-secondary">
{p.completed_at ? formatTime(p.completed_at) : '—'}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,53 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { createClient } from '@/lib/supabase/server'
import { notFound, redirect } from 'next/navigation'
import LiveSessionClient from './LiveSessionClient'
export default async function LiveSessionPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const supabase = await createClient()
const db = supabase as any
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const { id } = await params
const { data: session } = await db
.from('sessions')
.select(`id, short_code, is_active, school_name, class_name, total_participants, created_at, quiz:quizzes(id, title)`)
.eq('id', id)
.eq('trainer_id', user.id)
.single()
if (!session) notFound()
const { data: participations } = await db
.from('student_participations')
.select('id, first_name, last_name, score, status, started_at, completed_at')
.eq('session_id', id)
.order('started_at', { ascending: false })
const { count: totalQuestions } = await db
.from('questions')
.select('id', { count: 'exact', head: true })
.eq('quiz_id', session.quiz?.id ?? '')
return (
<LiveSessionClient
session={{
id: session.id,
short_code: session.short_code,
is_active: session.is_active,
school_name: session.school_name,
class_name: session.class_name,
total_participants: session.total_participants,
quiz_title: session.quiz?.title ?? 'Quiz',
}}
initialParticipations={participations ?? []}
totalQuestions={totalQuestions ?? 0}
/>
)
}

View File

@ -0,0 +1,459 @@
'use client'
import { useState, useRef, useEffect, useMemo } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import {
BookOpen,
Users,
Building,
GraduationCap,
Copy,
Check,
Link as LinkIcon,
Search,
ChevronDown,
Hash,
X,
} from 'lucide-react'
import { cn } from '@/lib/utils'
interface Quiz {
id: string
title: string
questions?: { id: string }[]
subchapter?: {
name: string
category?: { name: string }
} | null
}
interface Props {
quizzes: Quiz[]
}
interface CreatedSession {
id: string
short_code: string
url: string
}
// ——— Custom quiz dropdown ———
interface GroupedCategory {
categoryName: string
subchapters: {
subchapterName: string
quizzes: Quiz[]
}[]
}
function QuizDropdown({
quizzes,
value,
onChange,
}: {
quizzes: Quiz[]
value: string
onChange: (id: string) => void
}) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const ref = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
const selected = quizzes.find((q) => q.id === value)
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
useEffect(() => {
if (open) setTimeout(() => searchRef.current?.focus(), 50)
}, [open])
const grouped = useMemo<GroupedCategory[]>(() => {
const q = search.toLowerCase()
const filtered = quizzes.filter(
(quiz) =>
!q ||
quiz.title.toLowerCase().includes(q) ||
quiz.subchapter?.name.toLowerCase().includes(q) ||
quiz.subchapter?.category?.name.toLowerCase().includes(q)
)
const map = new Map<string, Map<string, Quiz[]>>()
for (const quiz of filtered) {
const catName = quiz.subchapter?.category?.name ?? 'Sans catégorie'
const subName = quiz.subchapter?.name ?? 'Sans chapitre'
if (!map.has(catName)) map.set(catName, new Map())
const subMap = map.get(catName)!
if (!subMap.has(subName)) subMap.set(subName, [])
subMap.get(subName)!.push(quiz)
}
return Array.from(map.entries()).map(([categoryName, subMap]) => ({
categoryName,
subchapters: Array.from(subMap.entries()).map(([subchapterName, quizzes]) => ({
subchapterName,
quizzes,
})),
}))
}, [quizzes, search])
const totalFiltered = grouped.reduce(
(acc, g) => acc + g.subchapters.reduce((a, s) => a + s.quizzes.length, 0),
0
)
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={cn(
'input-field w-full flex items-center justify-between text-left transition-all',
open && 'border-primary/60 ring-1 ring-primary/30'
)}
>
<span className={selected ? 'text-text-primary' : 'text-text-muted'}>
{selected ? (
<span className="flex items-center gap-2">
<BookOpen size={14} className="text-primary flex-shrink-0" />
<span className="truncate">{selected.title}</span>
{selected.subchapter && (
<span className="text-text-muted text-xs flex-shrink-0">
{selected.subchapter.category?.name}
</span>
)}
</span>
) : (
'Choisir un modèle de quiz...'
)}
</span>
<ChevronDown
size={16}
className={cn('text-text-muted flex-shrink-0 transition-transform duration-200', open && 'rotate-180')}
/>
</button>
{open && (
<div className="absolute left-0 right-0 top-[calc(100%+6px)] bg-background-card border border-border rounded-xl shadow-2xl shadow-black/50 z-40 overflow-hidden">
{/* Search */}
<div className="p-2 border-b border-border">
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
<input
ref={searchRef}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Rechercher un quiz..."
className="w-full bg-background-elevated border border-border rounded-lg pl-8 pr-8 py-2 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:border-primary/50"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary"
>
<X size={13} />
</button>
)}
</div>
</div>
{/* Options */}
<div className="max-h-64 overflow-y-auto">
{totalFiltered === 0 ? (
<div className="py-8 text-center">
<p className="text-sm text-text-muted">Aucun quiz trouvé</p>
</div>
) : (
grouped.map((group) => (
<div key={group.categoryName}>
{/* Category header */}
<div className="px-3 pt-3 pb-1">
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{group.categoryName}
</p>
</div>
{group.subchapters.map((sub) => (
<div key={sub.subchapterName}>
{/* Subchapter label */}
<div className="px-3 pb-1 pt-0.5">
<p className="text-xs text-text-muted/70 pl-2 border-l border-border">
{sub.subchapterName}
</p>
</div>
{/* Quizzes */}
{sub.quizzes.map((quiz) => (
<button
key={quiz.id}
type="button"
onClick={() => { onChange(quiz.id); setOpen(false); setSearch('') }}
className={cn(
'w-full flex items-center justify-between px-4 py-2.5 hover:bg-background-elevated/80 transition-colors text-left group',
value === quiz.id && 'bg-primary/10'
)}
>
<div className="flex items-center gap-2.5 min-w-0">
{value === quiz.id ? (
<Check size={14} className="text-primary flex-shrink-0" />
) : (
<BookOpen size={14} className="text-text-muted group-hover:text-text-secondary flex-shrink-0 transition-colors" />
)}
<span className={cn(
'text-sm truncate',
value === quiz.id ? 'text-primary font-medium' : 'text-text-primary'
)}>
{quiz.title}
</span>
</div>
{quiz.questions && quiz.questions.length > 0 && (
<span className="flex items-center gap-1 text-xs text-text-muted flex-shrink-0 ml-2">
<Hash size={10} />
{quiz.questions.length} question{quiz.questions.length > 1 ? 's' : ''}
</span>
)}
</button>
))}
</div>
))}
</div>
))
)}
</div>
{quizzes.length > 0 && (
<div className="px-3 py-2 border-t border-border bg-background-secondary/40">
<p className="text-xs text-text-muted">
{totalFiltered} quiz{totalFiltered > 1 ? 's' : ''} disponible{totalFiltered > 1 ? 's' : ''}
</p>
</div>
)}
</div>
)}
</div>
)
}
// ——— Main component ———
export default function CreateSessionClient({ quizzes }: Props) {
const router = useRouter()
const searchParams = useSearchParams()
const preselectedQuizId = searchParams.get('quiz_id') ?? ''
const [quizId, setQuizId] = useState(preselectedQuizId)
const [schoolName, setSchoolName] = useState('')
const [className, setClassName] = useState('')
const [totalParticipants, setTotalParticipants] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [createdSession, setCreatedSession] = useState<CreatedSession | null>(null)
const [copied, setCopied] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!quizId) {
setError('Veuillez sélectionner un quiz')
return
}
setLoading(true)
setError(null)
try {
const res = await fetch('/api/sessions/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
quiz_id: quizId,
school_name: schoolName || null,
class_name: className || null,
total_participants: totalParticipants ? parseInt(totalParticipants) : 0,
}),
})
const data = await res.json()
if (!res.ok) setError(data.error ?? 'Erreur lors de la création de la session')
else setCreatedSession(data.session)
} catch {
setError('Erreur réseau')
} finally {
setLoading(false)
}
}
const handleCopy = async () => {
if (!createdSession) return
const url = `${window.location.origin}/quiz/${createdSession.short_code}`
await navigator.clipboard.writeText(url)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
if (createdSession) {
const sessionUrl = `${typeof window !== 'undefined' ? window.location.origin : ''}/quiz/${createdSession.short_code}`
return (
<div className="p-8">
<div className="max-w-2xl mx-auto">
<div className="card p-8 text-center">
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<Check size={32} className="text-green-400" />
</div>
<h2 className="text-2xl font-bold text-text-primary mb-2">Session créée !</h2>
<p className="text-text-secondary mb-8">
Partagez ce lien avec vos étudiants pour qu&apos;ils rejoignent la session.
</p>
<div className="bg-background-elevated border border-border rounded-xl p-4 mb-6">
<p className="text-xs text-text-muted uppercase tracking-wider mb-2">Code de session</p>
<p className="text-4xl font-mono font-bold text-primary tracking-widest mb-3">
{createdSession.short_code}
</p>
<div className="flex items-center gap-2 bg-background border border-border rounded-lg px-3 py-2">
<LinkIcon size={14} className="text-text-muted flex-shrink-0" />
<span className="text-sm text-text-secondary truncate flex-1">{sessionUrl}</span>
<button
onClick={handleCopy}
className={cn(
'flex items-center gap-1 text-xs px-2 py-1 rounded transition-colors flex-shrink-0',
copied ? 'bg-green-500/20 text-green-400' : 'bg-primary/10 text-primary hover:bg-primary/20'
)}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
{copied ? 'Copié !' : 'Copier'}
</button>
</div>
</div>
<div className="flex gap-3 justify-center">
<Link href={`/dashboard/sessions/${createdSession.id}/live`} className="btn-primary">
<Users size={16} />
Suivre en direct
</Link>
<button
onClick={() => { setCreatedSession(null); setQuizId(''); setSchoolName(''); setClassName(''); setTotalParticipants('') }}
className="btn-secondary"
>
Nouvelle session
</button>
</div>
</div>
</div>
</div>
)
}
return (
<div className="p-8">
<div className="max-w-2xl mx-auto">
<h1 className="text-3xl font-bold text-text-primary mb-2">Configurer une session</h1>
<p className="text-text-secondary mb-8">
Remplissez les informations ci-dessous pour générer un lien d&apos;accès unique pour vos participants.
</p>
<form onSubmit={handleSubmit}>
<div className="card overflow-hidden">
{/* Section 1: Quiz Selection */}
<div className="p-6 border-b border-border">
<div className="flex items-center gap-2 mb-4">
<BookOpen size={18} className="text-primary" />
<h2 className="font-semibold text-text-primary">Sélection du Quiz</h2>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1.5">Quiz à lancer</label>
<QuizDropdown quizzes={quizzes} value={quizId} onChange={setQuizId} />
<p className="text-xs text-text-muted mt-1.5">
Sélectionnez le contenu pédagogique pour cette session.
</p>
</div>
</div>
{/* Section 2: Context */}
<div className="p-6">
<div className="flex items-center gap-2 mb-4">
<Users size={18} className="text-primary" />
<h2 className="font-semibold text-text-primary">Contexte & Participants</h2>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm text-text-secondary mb-1.5">École / Entreprise</label>
<div className="relative">
<Building size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type="text"
value={schoolName}
onChange={(e) => setSchoolName(e.target.value)}
placeholder="Ex: Tech Institute"
className="input-field pl-9"
/>
</div>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1.5">Nom de la classe / Groupe</label>
<div className="relative">
<GraduationCap size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type="text"
value={className}
onChange={(e) => setClassName(e.target.value)}
placeholder="Ex: Promo 2024 - Dev Web"
className="input-field pl-9"
/>
</div>
</div>
</div>
<div className="w-1/2">
<label className="block text-sm text-text-secondary mb-1.5">Nombre de participants attendus</label>
<div className="relative">
<Users size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type="number"
value={totalParticipants}
onChange={(e) => setTotalParticipants(e.target.value)}
placeholder="Ex: 25"
min="1"
className="input-field pl-9"
/>
</div>
</div>
</div>
{/* Footer actions */}
<div className="px-6 py-4 border-t border-border bg-background-secondary/50 flex items-center justify-between">
<Link href="/dashboard" className="btn-secondary">
Annuler
</Link>
<div className="flex items-center gap-3">
{error && <p className="text-sm text-red-400">{error}</p>}
<button
type="submit"
disabled={loading}
className={cn('btn-primary', loading && 'opacity-70 cursor-not-allowed')}
>
{loading ? (
<>
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Génération...
</>
) : (
<>
<LinkIcon size={16} />
Générer le lien
</>
)}
</button>
</div>
</div>
</div>
</form>
</div>
</div>
)
}

View File

@ -0,0 +1,22 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Suspense } from 'react'
import { createClient } from '@/lib/supabase/server'
import CreateSessionClient from './CreateSessionClient'
export default async function CreateSessionPage() {
const supabase = await createClient()
const db = supabase as any
const { data: { user } } = await supabase.auth.getUser()
const { data: quizzes } = await db
.from('quizzes')
.select(`id, title, questions(id), subchapter:subchapters(name, category:categories(name))`)
.eq('author_id', user!.id)
.order('title')
return (
<Suspense fallback={<div className="p-8 text-text-secondary">Chargement...</div>}>
<CreateSessionClient quizzes={quizzes ?? []} />
</Suspense>
)
}

View File

@ -0,0 +1,59 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { createClient } from '@/lib/supabase/server'
import { User, Shield, Settings } from 'lucide-react'
export default async function SettingsPage() {
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, role')
.eq('id', user!.id)
.single()
return (
<div className="p-8">
<h1 className="text-3xl font-bold text-text-primary mb-2">Paramètres</h1>
<p className="text-text-secondary mb-8">Gérez votre compte et vos préférences.</p>
<div className="max-w-2xl space-y-6">
<div className="card p-6">
<div className="flex items-center gap-2 mb-4">
<User size={18} className="text-primary" />
<h2 className="font-semibold text-text-primary">Informations du compte</h2>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm text-text-secondary mb-1">Nom d&apos;utilisateur</label>
<input type="text" defaultValue={profile?.username ?? ''} readOnly className="input-field" />
</div>
<div>
<label className="block text-sm text-text-secondary mb-1">Email</label>
<input type="text" defaultValue={user?.email ?? ''} readOnly className="input-field" />
</div>
</div>
</div>
<div className="card p-6">
<div className="flex items-center gap-2 mb-4">
<Shield size={18} className="text-primary" />
<h2 className="font-semibold text-text-primary">Rôle & Permissions</h2>
</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-primary/20 rounded-xl flex items-center justify-center">
<Settings size={18} className="text-primary" />
</div>
<div>
<p className="font-medium text-text-primary capitalize">{profile?.role ?? 'formateur'}</p>
<p className="text-xs text-text-muted">
{profile?.role === 'admin' ? 'Accès complet à toutes les fonctionnalités' : 'Accès formateur standard'}
</p>
</div>
</div>
</div>
</div>
</div>
)
}

45
app/globals.css Normal file
View File

@ -0,0 +1,45 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-text-primary font-sans antialiased;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
@apply bg-background-secondary;
}
::-webkit-scrollbar-thumb {
@apply bg-border-light rounded-full;
}
}
@layer components {
.btn-primary {
@apply bg-primary hover:bg-primary-hover text-white font-medium px-4 py-2 rounded-lg transition-colors duration-200 flex items-center gap-2;
}
.btn-secondary {
@apply bg-transparent border border-border-light hover:bg-background-elevated text-text-primary font-medium px-4 py-2 rounded-lg transition-colors duration-200;
}
.card {
@apply bg-background-card border border-border rounded-xl;
}
.input-field {
@apply bg-background-elevated border border-border text-text-primary placeholder-text-muted rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all;
}
.sidebar-link {
@apply flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-secondary hover:text-text-primary hover:bg-background-elevated transition-all duration-200 text-sm font-medium;
}
.sidebar-link-active {
@apply bg-primary/10 text-primary;
}
}

19
app/layout.tsx Normal file
View File

@ -0,0 +1,19 @@
import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = {
title: 'SolyQuiz - Plateforme de Quiz',
description: 'Créez et gérez des sessions de quiz interactives pour vos étudiants',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="fr">
<body>{children}</body>
</html>
)
}

211
app/login/page.tsx Normal file
View File

@ -0,0 +1,211 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { Eye, EyeOff, HelpCircle, Shield } from 'lucide-react'
import { cn } from '@/lib/utils'
export default function LoginPage() {
const router = useRouter()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [rememberMe, setRememberMe] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
const supabase = createClient()
// Supabase Auth utilise l'email — on convertit le username en email fictif
// ou on utilise l'email directement selon la configuration
const email = username.includes('@') ? username : `${username}@solyquiz.local`
const { error: authError } = await supabase.auth.signInWithPassword({
email,
password,
})
if (authError) {
setError('Identifiants incorrects. Vérifiez votre nom d\'utilisateur et mot de passe.')
setLoading(false)
return
}
router.push('/dashboard')
router.refresh()
}
return (
<div className="min-h-screen bg-background flex flex-col">
{/* Header */}
<header className="flex items-center justify-between px-10 py-5 border-b border-border">
<div className="flex items-center gap-3">
<div className="w-9 h-9 bg-primary rounded-lg flex items-center justify-center shadow-lg shadow-primary/30">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z" fill="white"/>
</svg>
</div>
<span className="text-xl font-bold text-text-primary">SolyQuiz</span>
</div>
<button className="text-text-secondary hover:text-text-primary transition-colors">
<HelpCircle size={20} />
</button>
</header>
{/* Background decorations */}
<div className="flex-1 relative overflow-hidden flex items-center justify-center">
<div className="absolute w-96 h-96 rounded-full bg-primary/5 blur-3xl -left-20 top-20" />
<div className="absolute w-96 h-96 rounded-full bg-blue-500/5 blur-3xl right-20 bottom-20" />
{/* Login Card */}
<div className="relative z-10 w-full max-w-md mx-4">
<div className="card overflow-hidden shadow-2xl shadow-black/40">
{/* Card header gradient */}
<div className="relative h-32 bg-gradient-to-br from-primary/80 to-blue-600/60 flex items-center justify-center overflow-hidden">
<div className="absolute w-48 h-48 rounded-full bg-white/10 blur-2xl -right-10 -top-10" />
<div className="absolute w-40 h-40 rounded-full bg-white/5 blur-xl -left-10 bottom-0" />
<div className="relative z-10 w-16 h-18 bg-white/10 backdrop-blur-sm border border-white/20 rounded-xl flex items-center justify-center p-3">
<svg width="36" height="44" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z" fill="white"/>
</svg>
</div>
</div>
{/* Card body */}
<div className="p-8">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-text-primary mb-1">Espace Formateur</h1>
<p className="text-text-secondary text-sm">
Connectez-vous pour gérer vos quiz et évaluations.
</p>
</div>
<form onSubmit={handleLogin} className="space-y-5">
{/* Username */}
<div>
<label className="block text-xs font-medium text-text-secondary uppercase tracking-wider mb-1.5 ml-1">
Nom d&apos;utilisateur
</label>
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="ex: formateur.dupont"
className="input-field pl-10"
required
/>
</div>
</div>
{/* Password */}
<div>
<label className="block text-xs font-medium text-text-secondary uppercase tracking-wider mb-1.5 ml-1">
Mot de passe
</label>
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="input-field pl-10 pr-10"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-secondary transition-colors"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
{/* Remember me + forgot password */}
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="w-4 h-4 rounded border-border bg-background-elevated text-primary"
/>
<span className="text-sm text-text-secondary">Rester connecté</span>
</label>
<button type="button" className="text-sm text-primary hover:text-primary-light transition-colors">
Mot de passe oublié ?
</button>
</div>
{/* Error message */}
{error && (
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm px-4 py-3 rounded-lg">
{error}
</div>
)}
{/* Submit */}
<button
type="submit"
disabled={loading}
className={cn(
'btn-primary w-full justify-center py-3',
loading && 'opacity-70 cursor-not-allowed'
)}
>
{loading ? (
<>
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Connexion...
</>
) : (
'Se connecter'
)}
</button>
</form>
</div>
{/* Card footer */}
<div className="border-t border-border px-8 py-4">
<p className="text-center text-xs text-text-muted">
En continuant, vous acceptez les{' '}
<button className="text-primary hover:text-primary-light transition-colors">
Conditions d&apos;utilisation
</button>{' '}
de SolyQuiz.
</p>
</div>
</div>
{/* Secure connection note */}
<div className="flex items-center justify-center gap-1.5 mt-4">
<Shield size={14} className="text-text-muted" />
<span className="text-xs text-text-muted">Connexion sécurisée par SSL</span>
</div>
</div>
</div>
</div>
)
}

13
app/page.tsx Normal file
View File

@ -0,0 +1,13 @@
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
export default async function HomePage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (user) {
redirect('/dashboard')
} else {
redirect('/login')
}
}

View File

@ -0,0 +1,162 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { User, UserCheck } from 'lucide-react'
import { cn } from '@/lib/utils'
interface Props {
sessionCode: string
quizTitle: string
schoolName: string | null
className: string | null
sessionId: string
}
export default function StudentJoinClient({ sessionCode, quizTitle, schoolName, className }: Props) {
const router = useRouter()
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleJoin = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
try {
const res = await fetch('/api/student/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
short_code: sessionCode,
first_name: firstName.trim(),
last_name: lastName.trim(),
}),
})
const data = await res.json()
if (!res.ok) {
if (data.code === 'SESSION_INACTIVE') {
setError('Ce quiz est terminé. Vous ne pouvez plus rejoindre cette session.')
} else {
setError(data.error ?? 'Erreur lors de la connexion')
}
} else {
router.push(`/quiz/${sessionCode}/exam?pid=${data.participation.id}`)
}
} catch {
setError('Erreur réseau. Vérifiez votre connexion.')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-background flex flex-col">
{/* Header */}
<header className="flex items-center justify-between px-10 py-5 border-b border-border">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center shadow-md shadow-primary/30">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z" fill="white"/>
</svg>
</div>
<span className="text-lg font-bold text-text-primary">SolyQuiz</span>
</div>
</header>
<div className="flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="card overflow-hidden shadow-2xl shadow-black/40">
{/* Gradient header */}
<div className="relative h-28 bg-gradient-to-br from-primary/80 to-blue-700/60 flex items-center justify-center overflow-hidden">
<div className="absolute w-40 h-40 rounded-full bg-white/10 blur-xl -right-5 -top-5" />
<div className="relative z-10 text-center">
<div className="w-12 h-12 bg-white/15 backdrop-blur-sm border border-white/20 rounded-xl flex items-center justify-center mx-auto mb-1">
<UserCheck size={24} className="text-white" />
</div>
</div>
</div>
<div className="p-8">
<div className="text-center mb-6">
<h1 className="text-xl font-bold text-text-primary mb-1">{quizTitle}</h1>
<div className="flex items-center justify-center gap-3 text-xs text-text-muted">
{schoolName && <span>📍 {schoolName}</span>}
{className && <span>🎓 {className}</span>}
<span className="font-mono bg-background-elevated px-2 py-0.5 rounded text-primary font-semibold">
#{sessionCode}
</span>
</div>
</div>
<form onSubmit={handleJoin} className="space-y-4">
<div>
<label className="block text-xs font-medium text-text-secondary uppercase tracking-wider mb-1.5 ml-1">
Prénom
</label>
<div className="relative">
<User size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Votre prénom"
className="input-field pl-9"
required
autoFocus
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-text-secondary uppercase tracking-wider mb-1.5 ml-1">
Nom
</label>
<div className="relative">
<User size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Votre nom de famille"
className="input-field pl-9"
required
/>
</div>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm px-4 py-3 rounded-lg">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className={cn('btn-primary w-full justify-center py-3', loading && 'opacity-70 cursor-not-allowed')}
>
{loading ? (
<>
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Connexion...
</>
) : (
'Commencer le quiz'
)}
</button>
</form>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,309 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { ChevronLeft, ChevronRight, Send, AlertCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
interface Answer {
id: string
text: string
}
interface Question {
id: string
text: string
order: number
answers: Answer[]
selectedAnswerId: string | null
}
interface Props {
sessionCode: string
participationId: string
studentName: string
questions: Question[]
}
export default function ExamClient({ sessionCode, participationId, studentName, questions: initialQuestions }: Props) {
const router = useRouter()
const [questions, setQuestions] = useState(initialQuestions)
const [currentIndex, setCurrentIndex] = useState(0)
const [submitting, setSubmitting] = useState(false)
const [finishing, setFinishing] = useState(false)
const [error, setError] = useState<string | null>(null)
const currentQuestion = questions[currentIndex]
const progress = Math.round(((currentIndex + 1) / questions.length) * 100)
const answeredCount = questions.filter((q) => q.selectedAnswerId !== null).length
const handleSelectAnswer = async (answerId: string) => {
if (submitting) return
const previousAnswerId = currentQuestion.selectedAnswerId
setQuestions((prev) =>
prev.map((q, i) =>
i === currentIndex ? { ...q, selectedAnswerId: answerId } : q
)
)
setSubmitting(true)
setError(null)
try {
const res = await fetch('/api/student/submit-answer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
participation_id: participationId,
question_id: currentQuestion.id,
answer_id: answerId,
}),
})
const data = await res.json()
if (!res.ok) {
// Rollback
setQuestions((prev) =>
prev.map((q, i) =>
i === currentIndex ? { ...q, selectedAnswerId: previousAnswerId } : q
)
)
if (data.code === 'SESSION_INACTIVE') {
setError('Ce quiz a été terminé par le formateur.')
} else {
setError(data.error ?? 'Erreur enregistrement')
}
}
} catch {
setQuestions((prev) =>
prev.map((q, i) =>
i === currentIndex ? { ...q, selectedAnswerId: previousAnswerId } : q
)
)
setError('Erreur réseau')
} finally {
setSubmitting(false)
}
}
const handleFinish = async () => {
setFinishing(true)
setError(null)
try {
const res = await fetch('/api/student/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ participation_id: participationId }),
})
const data = await res.json()
if (res.ok) {
router.push(`/quiz/${sessionCode}/results?pid=${participationId}`)
} else {
setError(data.error ?? 'Erreur lors de la finalisation')
setFinishing(false)
}
} catch {
setError('Erreur réseau')
setFinishing(false)
}
}
const isLastQuestion = currentIndex === questions.length - 1
const allAnswered = answeredCount === questions.length
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b border-border bg-background-secondary px-8 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z" fill="white"/>
</svg>
</div>
<span className="font-bold text-text-primary">SolyQuiz</span>
</div>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2 bg-background-card border border-border rounded-lg px-3 py-1.5">
<span className="text-xs text-text-muted">Session</span>
<span className="font-mono font-bold text-primary text-sm">{sessionCode}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-primary/20 rounded-full flex items-center justify-center text-primary text-xs font-bold">
{studentName.split(' ').map((n) => n[0]).join('')}
</div>
<span className="text-sm text-text-secondary">{studentName}</span>
</div>
</div>
</header>
<div className="max-w-4xl mx-auto px-4 py-8">
{/* Progress */}
<div className="mb-8">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2 text-text-secondary">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="2" width="6" height="6" rx="1" /><path d="M12 8v4l3 3" /><circle cx="12" cy="14" r="7" />
</svg>
<span className="text-sm font-medium">
Question <span className="text-text-primary font-bold">{currentIndex + 1}</span> sur {questions.length}
</span>
</div>
<span className="text-sm font-semibold text-text-primary">{progress}%</span>
</div>
<div className="h-2 bg-background-elevated rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
</div>
{/* Question layout */}
<div className="flex gap-6">
{/* Image placeholder */}
<div className="w-72 flex-shrink-0">
<div className="h-80 rounded-xl overflow-hidden bg-gradient-to-br from-background-elevated to-background-card border border-border flex items-end p-4 relative">
<div className="absolute inset-0 opacity-20 bg-gradient-to-br from-primary to-blue-700" />
<div className="relative z-10">
<span className="text-xs bg-primary/80 text-white px-2 py-0.5 rounded mb-1 block w-fit">
Question {currentIndex + 1}
</span>
<p className="text-sm font-semibold text-text-primary line-clamp-2">
{currentQuestion.text}
</p>
</div>
</div>
</div>
{/* Question & Answers */}
<div className="flex-1">
<div className="card p-6 mb-4">
<h2 className="text-lg font-semibold text-text-primary mb-1">
Question {currentIndex + 1}
</h2>
<p className="text-text-secondary mb-6">{currentQuestion.text}</p>
<div className="space-y-3">
{currentQuestion.answers.map((answer) => {
const isSelected = currentQuestion.selectedAnswerId === answer.id
return (
<button
key={answer.id}
onClick={() => handleSelectAnswer(answer.id)}
disabled={submitting}
className={cn(
'w-full flex items-center gap-3 p-4 rounded-lg border text-left transition-all',
isSelected
? 'border-primary bg-primary/10 text-text-primary'
: 'border-border bg-background-elevated hover:border-border-light text-text-secondary hover:text-text-primary',
submitting && 'cursor-not-allowed opacity-70'
)}
>
<div className={cn(
'w-5 h-5 rounded-full border-2 flex-shrink-0 flex items-center justify-center transition-all',
isSelected ? 'border-primary bg-primary' : 'border-border-light'
)}>
{isSelected && (
<div className="w-2 h-2 bg-white rounded-full" />
)}
</div>
<span className="text-sm">{answer.text}</span>
</button>
)
})}
</div>
</div>
{error && (
<div className="flex items-center gap-2 bg-red-500/10 border border-red-500/30 text-red-400 text-sm px-4 py-3 rounded-lg mb-4">
<AlertCircle size={14} />
{error}
</div>
)}
{/* Navigation */}
<div className="flex items-center justify-between">
<button
onClick={() => setCurrentIndex((i) => Math.max(0, i - 1))}
disabled={currentIndex === 0}
className={cn(
'flex items-center gap-2 text-sm px-4 py-2.5 rounded-lg border transition-all',
currentIndex === 0
? 'border-border text-text-muted cursor-not-allowed opacity-40'
: 'border-border-light text-text-secondary hover:text-text-primary hover:border-border-light bg-background-card hover:bg-background-elevated'
)}
>
<ChevronLeft size={16} />
Précédent
</button>
{isLastQuestion ? (
<button
onClick={handleFinish}
disabled={finishing || !allAnswered}
className={cn(
'flex items-center gap-2 text-sm px-6 py-2.5 rounded-lg font-semibold transition-all',
allAnswered && !finishing
? 'bg-green-600 hover:bg-green-700 text-white'
: 'bg-background-elevated text-text-muted cursor-not-allowed',
finishing && 'opacity-70'
)}
>
{finishing ? (
<>
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Finalisation...
</>
) : (
<>
<Send size={14} />
{allAnswered ? 'Terminer le quiz' : `Répondre à toutes les questions (${answeredCount}/${questions.length})`}
</>
)}
</button>
) : (
<button
onClick={() => setCurrentIndex((i) => Math.min(questions.length - 1, i + 1))}
className="btn-primary text-sm px-6 py-2.5"
>
Suivant
<ChevronRight size={16} />
</button>
)}
</div>
{/* Question dots */}
<div className="flex items-center gap-1.5 justify-center mt-6 flex-wrap">
{questions.map((q, i) => (
<button
key={q.id}
onClick={() => setCurrentIndex(i)}
className={cn(
'w-6 h-6 rounded-full text-xs font-medium transition-all',
i === currentIndex
? 'bg-primary text-white scale-110'
: q.selectedAnswerId
? 'bg-primary/30 text-primary hover:bg-primary/50'
: 'bg-background-elevated text-text-muted hover:bg-background-card'
)}
>
{i + 1}
</button>
))}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,84 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import ExamClient from './ExamClient'
export default async function ExamPage({
params,
searchParams,
}: {
params: Promise<{ code: string }>
searchParams: Promise<{ pid?: string }>
}) {
const { code } = await params
const { pid: participationId } = await searchParams
if (!participationId) {
redirect(`/quiz/${code}`)
}
const supabase = await createClient()
const db = supabase as any
const { data: session } = await db
.from('sessions')
.select('id, is_active, quiz_id')
.eq('short_code', code.toUpperCase())
.single()
if (!session) redirect(`/quiz/${code}`)
if (!session.is_active) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center max-w-md mx-auto px-4">
<div className="text-6xl mb-4">🏁</div>
<h1 className="text-2xl font-bold text-text-primary mb-3">Ce quiz est terminé</h1>
<p className="text-text-secondary">Le formateur a clôturé cette session.</p>
</div>
</div>
)
}
const { data: participation } = await db
.from('student_participations')
.select('id, status, score, first_name, last_name')
.eq('id', participationId)
.single()
if (!participation) redirect(`/quiz/${code}`)
if (participation.status === 'completed') {
redirect(`/quiz/${code}/results?pid=${participationId}`)
}
const { data: questions } = await db
.from('questions')
.select(`id, question_text, order, answers(id, answer_text)`)
.eq('quiz_id', session.quiz_id)
.order('order')
const { data: existingAnswers } = await db
.from('student_answers')
.select('question_id, answer_id')
.eq('participation_id', participationId)
const answersMap = new Map(
(existingAnswers ?? []).map((a: any) => [a.question_id, a.answer_id])
)
return (
<ExamClient
sessionCode={code.toUpperCase()}
participationId={participationId}
studentName={`${participation.first_name} ${participation.last_name}`}
questions={(questions ?? []).map((q: any) => ({
id: q.id,
text: q.question_text,
order: q.order,
answers: q.answers.map((a: any) => ({ id: a.id, text: a.answer_text })),
selectedAnswerId: answersMap.get(q.id) ?? null,
}))}
/>
)
}

56
app/quiz/[code]/page.tsx Normal file
View File

@ -0,0 +1,56 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { createClient } from '@/lib/supabase/server'
import StudentJoinClient from './StudentJoinClient'
export default async function QuizJoinPage({
params,
}: {
params: Promise<{ code: string }>
}) {
const { code } = await params
const supabase = await createClient()
const db = supabase as any
const { data: session } = await db
.from('sessions')
.select(`id, short_code, is_active, school_name, class_name, quiz:quizzes(title)`)
.eq('short_code', code.toUpperCase())
.single()
if (!session) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<div className="text-6xl mb-4">🔍</div>
<h1 className="text-2xl font-bold text-text-primary mb-2">Session introuvable</h1>
<p className="text-text-secondary">Le code de session &quot;{code}&quot; n&apos;existe pas.</p>
</div>
</div>
)
}
if (!session.is_active) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center max-w-md mx-auto px-4">
<div className="text-6xl mb-4">🏁</div>
<h1 className="text-2xl font-bold text-text-primary mb-3">Ce quiz est terminé</h1>
<p className="text-text-secondary">
La session <span className="font-mono font-bold text-primary">{session.short_code}</span> a é clôturée par le formateur.
Les nouvelles participations ne sont plus acceptées.
</p>
</div>
</div>
)
}
return (
<StudentJoinClient
sessionCode={session.short_code}
quizTitle={session.quiz?.title ?? 'Quiz'}
schoolName={session.school_name}
className={session.class_name}
sessionId={session.id}
/>
)
}

View File

@ -0,0 +1,189 @@
'use client'
import { CheckCircle, XCircle, Trophy, Target } from 'lucide-react'
import { cn } from '@/lib/utils'
interface QuestionResult {
question_id: string
question_text: string
explanation: string | null
student_answer: { id: string; text: string } | null
correct_answer: { id: string; text: string } | null
is_correct: boolean
all_answers: { id: string; text: string; is_correct: boolean }[]
}
interface Results {
student: {
first_name: string
last_name: string
score: number
}
questions: QuestionResult[]
summary: {
total: number
correct: number
score: number
}
}
interface Props {
results: Results
sessionCode: string
}
function getScoreColor(score: number): string {
if (score >= 14) return 'text-green-400'
if (score >= 10) return 'text-amber-400'
return 'text-red-400'
}
function getScoreMessage(score: number): string {
if (score >= 18) return 'Excellent ! Performance remarquable !'
if (score >= 14) return 'Très bien ! Vous maîtrisez le sujet.'
if (score >= 10) return 'Bien. Quelques points à revoir.'
return 'À retravailler. N\'hésitez pas à reprendre le cours.'
}
export default function ResultsClient({ results, sessionCode }: Props) {
const { student, questions, summary } = results
const percentage = Math.round((summary.correct / summary.total) * 100)
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b border-border bg-background-secondary px-8 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z" fill="white"/>
</svg>
</div>
<span className="font-bold text-text-primary">SolyQuiz</span>
</div>
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-primary/20 rounded-full flex items-center justify-center text-primary text-xs font-bold">
{student.first_name[0]}{student.last_name[0]}
</div>
<span className="text-sm text-text-secondary">{student.first_name} {student.last_name}</span>
</div>
</header>
<div className="max-w-3xl mx-auto px-4 py-10">
{/* Score Card */}
<div className="card p-8 mb-8 text-center bg-gradient-to-br from-background-card to-background-elevated relative overflow-hidden">
<div className="absolute w-64 h-64 rounded-full bg-primary/5 blur-3xl -right-10 -top-10" />
<div className="relative z-10">
<div className="w-16 h-16 bg-primary/20 rounded-full flex items-center justify-center mx-auto mb-4">
<Trophy size={28} className="text-primary" />
</div>
<h1 className="text-2xl font-bold text-text-primary mb-1">Quiz terminé !</h1>
<p className="text-text-secondary mb-6">
{student.first_name} {student.last_name} · Session #{sessionCode}
</p>
<div className="flex items-end justify-center gap-2 mb-3">
<span className={cn('text-6xl font-bold', getScoreColor(summary.score))}>
{summary.score.toFixed(2)}
</span>
<span className="text-2xl text-text-muted mb-2">/20</span>
</div>
<p className={cn('text-sm font-medium mb-6', getScoreColor(summary.score))}>
{getScoreMessage(summary.score)}
</p>
<div className="flex items-center justify-center gap-8 text-sm">
<div className="flex items-center gap-2 text-green-400">
<CheckCircle size={16} />
<span><strong>{summary.correct}</strong> correctes</span>
</div>
<div className="flex items-center gap-2 text-red-400">
<XCircle size={16} />
<span><strong>{summary.total - summary.correct}</strong> incorrectes</span>
</div>
<div className="flex items-center gap-2 text-text-secondary">
<Target size={16} />
<span><strong>{percentage}%</strong> de réussite</span>
</div>
</div>
</div>
</div>
{/* Detailed Results */}
<h2 className="text-lg font-semibold text-text-primary mb-4">
Correction détaillée
</h2>
<div className="space-y-4">
{questions.map((q, index) => (
<div
key={q.question_id}
className={cn(
'card p-5 border-l-4',
q.is_correct ? 'border-l-green-500' : 'border-l-red-500'
)}
>
<div className="flex items-start gap-3 mb-4">
<div className={cn(
'w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5',
q.is_correct ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
)}>
{q.is_correct ? <CheckCircle size={16} /> : <XCircle size={16} />}
</div>
<div className="flex-1">
<p className="text-xs text-text-muted mb-1">Question {index + 1}</p>
<p className="font-medium text-text-primary">{q.question_text}</p>
</div>
</div>
<div className="ml-10 space-y-2">
{q.all_answers.map((answer) => {
const isStudentAnswer = q.student_answer?.id === answer.id
const isCorrectAnswer = q.correct_answer?.id === answer.id
return (
<div
key={answer.id}
className={cn(
'flex items-center gap-2 p-3 rounded-lg text-sm border',
isCorrectAnswer
? 'bg-green-500/10 border-green-500/30 text-green-300'
: isStudentAnswer && !q.is_correct
? 'bg-red-500/10 border-red-500/30 text-red-300'
: 'bg-background-elevated border-border text-text-secondary'
)}
>
<div className={cn(
'w-4 h-4 rounded-full border flex-shrink-0',
isCorrectAnswer
? 'border-green-400 bg-green-400'
: isStudentAnswer && !q.is_correct
? 'border-red-400 bg-red-400'
: 'border-border-light'
)} />
<span>{answer.text}</span>
{isCorrectAnswer && (
<span className="ml-auto text-xs text-green-400 font-medium"> Bonne réponse</span>
)}
{isStudentAnswer && !q.is_correct && (
<span className="ml-auto text-xs text-red-400 font-medium"> Votre réponse</span>
)}
</div>
)
})}
{q.explanation && (
<div className="mt-3 p-3 bg-blue-500/5 border border-blue-500/20 rounded-lg">
<p className="text-xs text-blue-400 font-medium mb-1">💡 Explication</p>
<p className="text-xs text-text-secondary">{q.explanation}</p>
</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,33 @@
import { notFound, redirect } from 'next/navigation'
import ResultsClient from './ResultsClient'
export default async function ResultsPage({
params,
searchParams,
}: {
params: Promise<{ code: string }>
searchParams: Promise<{ pid?: string }>
}) {
const { code } = await params
const { pid: participationId } = await searchParams
if (!participationId) redirect(`/quiz/${code}`)
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000'
const res = await fetch(
`${baseUrl}/api/student/results?participation_id=${participationId}`,
{ cache: 'no-store' }
)
if (!res.ok) {
const data = await res.json()
if (data.error?.includes('terminé')) {
redirect(`/quiz/${code}/exam?pid=${participationId}`)
}
notFound()
}
const data = await res.json()
return <ResultsClient results={data.results} sessionCode={code.toUpperCase()} />
}

View File

@ -0,0 +1,111 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { Bell, X } from 'lucide-react'
import { cn } from '@/lib/utils'
interface Notification {
id: string
icon: string
title: string
desc: string
time: string
read: boolean
}
const INITIAL_NOTIFICATIONS: Notification[] = [
{ id: '1', icon: '✅', title: 'Session finalisée', desc: 'Rapports disponibles pour JS Fundamentals.', time: 'il y a 2h', read: false },
{ id: '2', icon: '👥', title: 'Nouvelles inscriptions', desc: '12 étudiants ont rejoint "React Basics Quiz".', time: 'il y a 4h', read: false },
{ id: '3', icon: '🔧', title: 'Maintenance prévue', desc: 'Samedi à 22h. Pensez à sauvegarder votre travail.', time: 'il y a 1j', read: true },
]
export default function NotificationBell() {
const [open, setOpen] = useState(false)
const [notifications, setNotifications] = useState<Notification[]>(INITIAL_NOTIFICATIONS)
const ref = useRef<HTMLDivElement>(null)
const unreadCount = notifications.filter((n) => !n.read).length
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [])
const markRead = (id: string) =>
setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n)))
const markAllRead = () => setNotifications((prev) => prev.map((n) => ({ ...n, read: true })))
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((v) => !v)}
className="relative p-2.5 bg-background-card border border-border rounded-lg hover:border-border-light transition-colors"
>
<Bell size={18} className="text-text-secondary" />
{unreadCount > 0 && (
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-primary rounded-full animate-pulse" />
)}
</button>
{open && (
<div className="absolute right-0 top-12 w-80 bg-background-card border border-border rounded-xl shadow-2xl shadow-black/50 z-50 overflow-hidden animate-in fade-in slide-in-from-top-2 duration-150">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-text-primary text-sm">Notifications</h3>
{unreadCount > 0 && (
<span className="bg-primary/20 text-primary text-xs px-2 py-0.5 rounded-full font-medium">
{unreadCount}
</span>
)}
</div>
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button
onClick={markAllRead}
className="text-xs text-primary hover:text-primary-light transition-colors"
>
Tout lire
</button>
)}
<button
onClick={() => setOpen(false)}
className="p-1 hover:bg-background-elevated rounded transition-colors"
>
<X size={14} className="text-text-muted" />
</button>
</div>
</div>
<div className="max-h-80 overflow-y-auto">
{notifications.map((notif) => (
<div
key={notif.id}
onClick={() => markRead(notif.id)}
className={cn(
'flex items-start gap-3 px-4 py-3 hover:bg-background-elevated/50 transition-colors cursor-pointer border-b border-border/50 last:border-0',
!notif.read && 'bg-primary/5'
)}
>
<div className="w-8 h-8 bg-background-elevated rounded-lg flex items-center justify-center text-sm flex-shrink-0">
{notif.icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-0.5">
<p className="text-sm font-medium text-text-primary truncate">{notif.title}</p>
{!notif.read && <div className="w-1.5 h-1.5 bg-primary rounded-full flex-shrink-0" />}
</div>
<p className="text-xs text-text-muted leading-relaxed">{notif.desc}</p>
<p className="text-xs text-text-muted/50 mt-1">{notif.time}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,95 @@
'use client'
import { usePathname, useRouter } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import {
LayoutDashboard,
BookOpen,
Plus,
BarChart3,
Settings,
LogOut,
} from 'lucide-react'
import { cn } from '@/lib/utils'
interface SidebarProps {
username: string
role: string
}
const navLinks = [
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/dashboard/quizzes', label: 'Mes Quizzes', icon: BookOpen },
{ href: '/dashboard/sessions/create', label: 'Créer Session', icon: Plus },
{ href: '/dashboard/reports', label: 'Rapports', icon: BarChart3 },
{ href: '/dashboard/settings', label: 'Paramètres', icon: Settings },
]
export default function Sidebar({ username, role }: SidebarProps) {
const pathname = usePathname()
const router = useRouter()
const handleLogout = async () => {
const supabase = createClient()
await supabase.auth.signOut()
router.push('/login')
router.refresh()
}
return (
<aside className="w-64 bg-background-secondary border-r border-border flex flex-col h-screen sticky top-0">
{/* Brand */}
<div className="p-6 border-b border-border">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-primary rounded-xl flex items-center justify-center shadow-lg shadow-primary/30 flex-shrink-0">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z" fill="white"/>
</svg>
</div>
<div>
<p className="font-bold text-text-primary leading-tight">SolyQuiz</p>
<p className="text-xs text-text-muted capitalize">{role === 'admin' ? 'Admin Portal' : 'Trainer Portal'}</p>
</div>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
{navLinks.map(({ href, label, icon: Icon }) => {
const isActive = pathname === href || (href !== '/dashboard' && pathname.startsWith(href))
return (
<Link
key={href}
href={href}
className={cn('sidebar-link', isActive && 'sidebar-link-active')}
>
<Icon size={18} />
<span>{label}</span>
</Link>
)
})}
</nav>
{/* User profile + logout */}
<div className="border-t border-border p-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-9 h-9 bg-primary/20 rounded-full flex items-center justify-center text-primary font-semibold text-sm flex-shrink-0">
{username.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{username}</p>
<p className="text-xs text-text-muted truncate">{username}@solyquiz.local</p>
</div>
</div>
<button
onClick={handleLogout}
className="sidebar-link w-full text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<LogOut size={18} />
<span>Déconnexion</span>
</button>
</div>
</aside>
)
}

277
lib/types/database.ts Normal file
View File

@ -0,0 +1,277 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export type Database = {
__InternalSupabase: {
PostgrestVersion: '12'
}
public: {
Tables: {
profiles: {
Row: {
id: string
role: 'admin' | 'formateur'
username: string
created_at: string
updated_at: string
}
Insert: {
id: string
role?: 'admin' | 'formateur'
username: string
created_at?: string
updated_at?: string
}
Update: {
id?: string
role?: 'admin' | 'formateur'
username?: string
updated_at?: string
}
Relationships: []
}
categories: {
Row: {
id: string
name: string
description: string | null
created_by: string | null
created_at: string
updated_at: string
}
Insert: {
id?: string
name: string
description?: string | null
created_by?: string | null
created_at?: string
updated_at?: string
}
Update: {
name?: string
description?: string | null
updated_at?: string
}
Relationships: []
}
subchapters: {
Row: {
id: string
category_id: string
name: string
created_at: string
updated_at: string
}
Insert: {
id?: string
category_id: string
name: string
created_at?: string
updated_at?: string
}
Update: {
category_id?: string
name?: string
updated_at?: string
}
Relationships: []
}
quizzes: {
Row: {
id: string
subchapter_id: string | null
author_id: string | null
title: string
raw_json_data: Json | null
created_at: string
updated_at: string
}
Insert: {
id?: string
subchapter_id?: string | null
author_id?: string | null
title: string
raw_json_data?: Json | null
created_at?: string
updated_at?: string
}
Update: {
subchapter_id?: string | null
title?: string
raw_json_data?: Json | null
updated_at?: string
}
Relationships: []
}
questions: {
Row: {
id: string
quiz_id: string
question_text: string
explanation: string | null
order: number
created_at: string
}
Insert: {
id?: string
quiz_id: string
question_text: string
explanation?: string | null
order?: number
created_at?: string
}
Update: {
question_text?: string
explanation?: string | null
order?: number
}
Relationships: []
}
answers: {
Row: {
id: string
question_id: string
answer_text: string
is_correct: boolean
created_at: string
}
Insert: {
id?: string
question_id: string
answer_text: string
is_correct?: boolean
created_at?: string
}
Update: {
answer_text?: string
is_correct?: boolean
}
Relationships: []
}
sessions: {
Row: {
id: string
short_code: string
quiz_id: string
trainer_id: string
school_name: string | null
class_name: string | null
total_participants: number
is_active: boolean
created_at: string
updated_at: string
}
Insert: {
id?: string
short_code: string
quiz_id: string
trainer_id: string
school_name?: string | null
class_name?: string | null
total_participants?: number
is_active?: boolean
created_at?: string
updated_at?: string
}
Update: {
school_name?: string | null
class_name?: string | null
total_participants?: number
is_active?: boolean
updated_at?: string
}
Relationships: []
}
student_participations: {
Row: {
id: string
session_id: string
first_name: string
last_name: string
score: number
status: 'in_progress' | 'completed'
started_at: string
completed_at: string | null
}
Insert: {
id?: string
session_id: string
first_name: string
last_name: string
score?: number
status?: 'in_progress' | 'completed'
started_at?: string
completed_at?: string | null
}
Update: {
score?: number
status?: 'in_progress' | 'completed'
completed_at?: string | null
}
Relationships: []
}
student_answers: {
Row: {
id: string
participation_id: string
question_id: string
answer_id: string | null
answered_at: string
}
Insert: {
id?: string
participation_id: string
question_id: string
answer_id?: string | null
answered_at?: string
}
Update: {
answer_id?: string | null
}
Relationships: []
}
}
Views: Record<string, never>
Functions: Record<string, never>
Enums: Record<string, never>
CompositeTypes: Record<string, never>
}
}
// Types utilitaires pour l'application
export type Profile = Database['public']['Tables']['profiles']['Row']
export type Category = Database['public']['Tables']['categories']['Row']
export type Subchapter = Database['public']['Tables']['subchapters']['Row']
export type Quiz = Database['public']['Tables']['quizzes']['Row']
export type Question = Database['public']['Tables']['questions']['Row']
export type Answer = Database['public']['Tables']['answers']['Row']
export type Session = Database['public']['Tables']['sessions']['Row']
export type StudentParticipation = Database['public']['Tables']['student_participations']['Row']
export type StudentAnswer = Database['public']['Tables']['student_answers']['Row']
// Types enrichis pour les jointures courantes
export type QuestionWithAnswers = Question & { answers: Answer[] }
export type QuizWithQuestions = Quiz & { questions: QuestionWithAnswers[] }
export type CategoryWithSubchapters = Category & {
subchapters: (Subchapter & { quizzes: Quiz[] })[]
}
// Format attendu pour le fichier JSON d'import de quiz
export interface QuizJsonFormat {
title: string
category?: string
subchapter?: string
questions: {
question: string
explanation?: string
answers: {
text: string
correct: boolean
}[]
}[]
}

15
lib/utils.ts Normal file
View File

@ -0,0 +1,15 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function calculateScore(correct: number, total: number): number {
if (total === 0) return 0
return Math.round((correct / total) * 20 * 100) / 100
}
export function formatScore(score: number): string {
return score.toFixed(2)
}

6
next-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

14
next.config.ts Normal file
View File

@ -0,0 +1,14 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "**.supabase.co",
},
],
},
};
export default nextConfig;

6400
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "solyquiz",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.47.10",
"clsx": "^2.1.1",
"lucide-react": "^0.474.0",
"nanoid": "^5.0.9",
"next": "^16.1.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.6.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.0.1",
"eslint": "^9",
"eslint-config-next": "15.1.6",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}

9
postcss.config.mjs Normal file
View File

@ -0,0 +1,9 @@
/** @type {import('postcss').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

51
proxy.ts Normal file
View File

@ -0,0 +1,51 @@
import { NextResponse, type NextRequest } from 'next/server'
import { createServerClient, type CookieOptions } from '@supabase/ssr'
export async function proxy(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet: { name: string; value: string; options?: CookieOptions }[]) {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
supabaseResponse = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
const {
data: { user },
} = await supabase.auth.getUser()
if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
if (user && request.nextUrl.pathname === '/login') {
const url = request.nextUrl.clone()
url.pathname = '/dashboard'
return NextResponse.redirect(url)
}
return supabaseResponse
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}

57
quiz-example.json Normal file
View File

@ -0,0 +1,57 @@
{
"title": "Introduction à Ansible",
"category": "Ansible",
"subchapter": "Introduction",
"questions": [
{
"question": "Qu'est-ce qu'Ansible ?",
"explanation": "Ansible est un outil d'automatisation IT open-source qui permet de configurer des systèmes, déployer des logiciels et orchestrer des tâches IT plus avancées.",
"answers": [
{ "text": "Un langage de programmation", "correct": false },
{ "text": "Un outil d'automatisation IT", "correct": true },
{ "text": "Un système de gestion de base de données", "correct": false },
{ "text": "Un serveur web", "correct": false }
]
},
{
"question": "Quel fichier Ansible décrit les tâches à exécuter ?",
"explanation": "Un Playbook Ansible est un fichier YAML qui décrit une série de tâches à exécuter sur des hôtes distants.",
"answers": [
{ "text": "Inventory file", "correct": false },
{ "text": "Playbook", "correct": true },
{ "text": "Role", "correct": false },
{ "text": "Module", "correct": false }
]
},
{
"question": "Dans quel format sont écrits les Playbooks Ansible ?",
"explanation": "Les Playbooks Ansible sont écrits en YAML (Yet Another Markup Language), un format lisible par l'humain.",
"answers": [
{ "text": "JSON", "correct": false },
{ "text": "XML", "correct": false },
{ "text": "YAML", "correct": true },
{ "text": "TOML", "correct": false }
]
},
{
"question": "Comment Ansible se connecte-t-il aux nœuds gérés ?",
"explanation": "Ansible utilise SSH (Secure Shell) pour se connecter aux nœuds Linux/Unix gérés, sans nécessiter d'agent sur les nœuds cibles.",
"answers": [
{ "text": "Via un agent installé sur chaque nœud", "correct": false },
{ "text": "Via SSH", "correct": true },
{ "text": "Via HTTP", "correct": false },
{ "text": "Via FTP", "correct": false }
]
},
{
"question": "Qu'est-ce qu'un inventaire Ansible ?",
"explanation": "L'inventaire Ansible est un fichier qui liste les hôtes et les groupes d'hôtes sur lesquels Ansible peut exécuter des tâches.",
"answers": [
{ "text": "Une liste de modules disponibles", "correct": false },
{ "text": "Un fichier de configuration global", "correct": false },
{ "text": "La liste des hôtes gérés", "correct": true },
{ "text": "Un catalogue de rôles", "correct": false }
]
}
]
}

48
tailwind.config.ts Normal file
View File

@ -0,0 +1,48 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
// Design system SolyQuiz - thème sombre navy
background: {
DEFAULT: "#0D0F1A",
secondary: "#131629",
card: "#1A1D2E",
elevated: "#1F2236",
},
primary: {
DEFAULT: "#2563EB",
hover: "#1D4ED8",
light: "#3B82F6",
},
border: {
DEFAULT: "#2A2D3E",
light: "#3A3D50",
},
text: {
primary: "#F1F5F9",
secondary: "#94A3B8",
muted: "#64748B",
},
status: {
active: "#22C55E",
scheduled: "#F59E0B",
completed: "#6B7280",
error: "#EF4444",
},
},
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
},
},
},
plugins: [],
};
export default config;

41
tsconfig.json Normal file
View File

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

1
tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long