initial project
This commit is contained in:
commit
28aa3b0e10
3
.env.local.example
Normal file
3
.env.local.example
Normal 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
129
README.md
Normal 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
|
||||
44
app/api/categories/[id]/route.ts
Normal file
44
app/api/categories/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
26
app/api/categories/create/route.ts
Normal file
26
app/api/categories/create/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
39
app/api/sessions/[id]/toggle/route.ts
Normal file
39
app/api/sessions/[id]/toggle/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
85
app/api/sessions/create/route.ts
Normal file
85
app/api/sessions/create/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
88
app/api/student/finish/route.ts
Normal file
88
app/api/student/finish/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
67
app/api/student/join/route.ts
Normal file
67
app/api/student/join/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
106
app/api/student/results/route.ts
Normal file
106
app/api/student/results/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
72
app/api/student/submit-answer/route.ts
Normal file
72
app/api/student/submit-answer/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
44
app/api/subchapters/[id]/route.ts
Normal file
44
app/api/subchapters/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
26
app/api/subchapters/create/route.ts
Normal file
26
app/api/subchapters/create/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
110
app/api/upload-quiz/route.ts
Normal file
110
app/api/upload-quiz/route.ts
Normal 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
36
app/dashboard/layout.tsx
Normal 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
243
app/dashboard/page.tsx
Normal 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'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'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>
|
||||
)
|
||||
}
|
||||
719
app/dashboard/quizzes/QuizzesClient.tsx
Normal file
719
app/dashboard/quizzes/QuizzesClient.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
app/dashboard/quizzes/page.tsx
Normal file
43
app/dashboard/quizzes/page.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
66
app/dashboard/reports/page.tsx
Normal file
66
app/dashboard/reports/page.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
294
app/dashboard/sessions/[id]/live/LiveSessionClient.tsx
Normal file
294
app/dashboard/sessions/[id]/live/LiveSessionClient.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
53
app/dashboard/sessions/[id]/live/page.tsx
Normal file
53
app/dashboard/sessions/[id]/live/page.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
459
app/dashboard/sessions/create/CreateSessionClient.tsx
Normal file
459
app/dashboard/sessions/create/CreateSessionClient.tsx
Normal 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'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'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>
|
||||
)
|
||||
}
|
||||
22
app/dashboard/sessions/create/page.tsx
Normal file
22
app/dashboard/sessions/create/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
app/dashboard/settings/page.tsx
Normal file
59
app/dashboard/settings/page.tsx
Normal 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'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
45
app/globals.css
Normal 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
19
app/layout.tsx
Normal 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
211
app/login/page.tsx
Normal 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'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'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
13
app/page.tsx
Normal 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')
|
||||
}
|
||||
}
|
||||
162
app/quiz/[code]/StudentJoinClient.tsx
Normal file
162
app/quiz/[code]/StudentJoinClient.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
309
app/quiz/[code]/exam/ExamClient.tsx
Normal file
309
app/quiz/[code]/exam/ExamClient.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
84
app/quiz/[code]/exam/page.tsx
Normal file
84
app/quiz/[code]/exam/page.tsx
Normal 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
56
app/quiz/[code]/page.tsx
Normal 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 "{code}" n'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 été 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
189
app/quiz/[code]/results/ResultsClient.tsx
Normal file
189
app/quiz/[code]/results/ResultsClient.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
app/quiz/[code]/results/page.tsx
Normal file
33
app/quiz/[code]/results/page.tsx
Normal 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()} />
|
||||
}
|
||||
111
components/dashboard/NotificationBell.tsx
Normal file
111
components/dashboard/NotificationBell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
95
components/dashboard/Sidebar.tsx
Normal file
95
components/dashboard/Sidebar.tsx
Normal 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
277
lib/types/database.ts
Normal 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
15
lib/utils.ts
Normal 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
6
next-env.d.ts
vendored
Normal 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
14
next.config.ts
Normal 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
6400
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal 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
9
postcss.config.mjs
Normal file
@ -0,0 +1,9 @@
|
||||
/** @type {import('postcss').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
51
proxy.ts
Normal file
51
proxy.ts
Normal 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
57
quiz-example.json
Normal 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
48
tailwind.config.ts
Normal 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
41
tsconfig.json
Normal 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
1
tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user