ajout gestion utilisateurs, refacto création quiz, gestion des utilisateurs externes

This commit is contained in:
corenthin-lebreton 2026-02-26 22:04:18 +01:00
parent 28aa3b0e10
commit a376e5d5de
32 changed files with 2096 additions and 366 deletions

View File

@ -0,0 +1,66 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/server'
async function assertAdmin() {
const supabase = await createClient()
const db = supabase as any
const { data: { user }, error } = await supabase.auth.getUser()
if (error || !user) return null
const { data: profile } = await db.from('profiles').select('role').eq('id', user.id).single()
if (profile?.role !== 'admin') return null
return user
}
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const caller = await assertAdmin()
if (!caller) return NextResponse.json({ error: 'Accès refusé' }, { status: 403 })
const { id } = await params
const { role } = await request.json()
if (!['admin', 'formateur'].includes(role)) {
return NextResponse.json({ error: 'Rôle invalide' }, { status: 400 })
}
// Ne pas modifier son propre rôle
if (id === caller.id) {
return NextResponse.json({ error: 'Vous ne pouvez pas modifier votre propre rôle' }, { status: 400 })
}
const admin = createAdminClient()
const { error: updateError } = await (admin as any)
.from('profiles')
.update({ role })
.eq('id', id)
if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 })
return NextResponse.json({ success: true })
} catch {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const caller = await assertAdmin()
if (!caller) return NextResponse.json({ error: 'Accès refusé' }, { status: 403 })
const { id } = await params
if (id === caller.id) {
return NextResponse.json({ error: 'Vous ne pouvez pas supprimer votre propre compte' }, { status: 400 })
}
// auth.admin.deleteUser est défaillant sur ce projet — on passe par une fonction SQL SECURITY DEFINER
const admin = createAdminClient() as any
const { error: deleteError } = await admin.rpc('admin_delete_user', { user_id: id })
if (deleteError) return NextResponse.json({ error: deleteError.message }, { status: 500 })
return NextResponse.json({ success: true })
} catch (err) {
console.error('[admin/users/delete]', err)
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}

View File

@ -0,0 +1,65 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/server'
async function assertAdmin() {
const supabase = await createClient()
const db = supabase as any
const { data: { user }, error } = await supabase.auth.getUser()
if (error || !user) return null
const { data: profile } = await db.from('profiles').select('role').eq('id', user.id).single()
if (profile?.role !== 'admin') return null
return user
}
export async function POST(request: NextRequest) {
try {
const caller = await assertAdmin()
if (!caller) return NextResponse.json({ error: 'Accès refusé' }, { status: 403 })
const { email, password, username, role } = await request.json()
if (!email?.trim() || !password?.trim() || !username?.trim()) {
return NextResponse.json({ error: 'Email, mot de passe et nom d\'utilisateur sont requis' }, { status: 400 })
}
if (!['admin', 'formateur'].includes(role)) {
return NextResponse.json({ error: 'Rôle invalide' }, { status: 400 })
}
if (password.length < 8) {
return NextResponse.json({ error: 'Le mot de passe doit contenir au moins 8 caractères' }, { status: 400 })
}
const admin = createAdminClient()
// Créer l'utilisateur Supabase Auth (le trigger handle_new_user créera le profil)
const { data: newUser, error: createError } = await admin.auth.admin.createUser({
email: email.trim(),
password,
email_confirm: true,
user_metadata: {
username: username.trim(),
role,
},
})
if (createError) {
const msg = createError.message.includes('already')
? 'Un compte avec cet email existe déjà'
: createError.message
return NextResponse.json({ error: msg }, { status: 400 })
}
return NextResponse.json({
success: true,
user: {
id: newUser.user.id,
email: newUser.user.email,
username: username.trim(),
role,
},
})
} catch {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}

View File

@ -0,0 +1,54 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/server'
async function assertAdmin() {
const supabase = await createClient()
const db = supabase as any
const { data: { user }, error } = await supabase.auth.getUser()
if (error || !user) return null
const { data: profile } = await db.from('profiles').select('role').eq('id', user.id).single()
if (profile?.role !== 'admin') return null
return user
}
export async function GET() {
try {
const caller = await assertAdmin()
if (!caller) return NextResponse.json({ error: 'Accès refusé' }, { status: 403 })
const admin = createAdminClient()
const adminDb = admin as any
// Récupérer tous les utilisateurs via la fonction SECURITY DEFINER
const { data: authUsers, error: authError } = await adminDb.rpc('get_all_users_admin')
if (authError) return NextResponse.json({ error: authError.message }, { status: 500 })
// Récupérer tous les profils
const { data: profiles, error: profilesError } = await adminDb
.from('profiles')
.select('id, username, role, created_at')
.order('created_at', { ascending: false })
if (profilesError) return NextResponse.json({ error: profilesError.message }, { status: 500 })
// Merger par user ID
const profileMap = new Map((profiles ?? []).map((p: any) => [p.id, p]))
const users = (authUsers ?? []).map((authUser: any) => {
const profile: any = profileMap.get(authUser.id) ?? {}
return {
id: authUser.id,
email: authUser.email,
username: profile.username ?? authUser.email?.split('@')[0] ?? '—',
role: profile.role ?? 'formateur',
created_at: profile.created_at ?? authUser.created_at,
last_sign_in: authUser.last_sign_in_at ?? null,
}
})
return NextResponse.json({ users })
} catch {
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
}
}

View File

@ -0,0 +1,73 @@
/* 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 { answer_text, is_correct } = await request.json()
// Récupérer la réponse avec sa question et le quiz pour vérifier l'ownership
const { data: answer, error: fetchError } = await db
.from('answers')
.select('id, question_id, question:questions!inner(id, quiz:quizzes!inner(author_id))')
.eq('id', id)
.single()
if (fetchError || !answer || answer.question?.quiz?.author_id !== user.id) {
return NextResponse.json({ error: 'Réponse introuvable ou accès refusé' }, { status: 404 })
}
const questionId = answer.question_id
const updates: Record<string, any> = {}
if (answer_text !== undefined) {
if (!answer_text.trim()) {
return NextResponse.json({ error: 'Texte de la réponse requis' }, { status: 400 })
}
updates.answer_text = answer_text.trim()
}
if (is_correct === true) {
// Remettre toutes les autres réponses de cette question à false
await db
.from('answers')
.update({ is_correct: false })
.eq('question_id', questionId)
.neq('id', id)
updates.is_correct = true
}
if (Object.keys(updates).length === 0) {
return NextResponse.json({ error: 'Aucune modification fournie' }, { status: 400 })
}
const { data, error } = await db
.from('answers')
.update(updates)
.eq('id', id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true, answer: data })
} catch (error) {
console.error('[answers/patch]', error)
return NextResponse.json({ error: 'Erreur serveur interne' }, { status: 500 })
}
}

View File

@ -0,0 +1,55 @@
/* 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 { question_text, explanation } = await request.json()
if (!question_text?.trim()) {
return NextResponse.json({ error: 'Texte de la question requis' }, { status: 400 })
}
// Vérifier que la question appartient à un quiz de l'utilisateur
const { data: question, error: fetchError } = await db
.from('questions')
.select('id, quiz:quizzes!inner(author_id)')
.eq('id', id)
.single()
if (fetchError || !question || question.quiz?.author_id !== user.id) {
return NextResponse.json({ error: 'Question introuvable ou accès refusé' }, { status: 404 })
}
const { data, error } = await db
.from('questions')
.update({
question_text: question_text.trim(),
explanation: explanation?.trim() || null,
})
.eq('id', id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true, question: data })
} catch (error) {
console.error('[questions/patch]', error)
return NextResponse.json({ error: 'Erreur serveur interne' }, { status: 500 })
}
}

View File

@ -0,0 +1,85 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
export async function GET(
_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 { data: quiz, error } = await db
.from('quizzes')
.select(`
id, title, updated_at,
subchapter:subchapters(id, name, category:categories(id, name)),
questions(
id, question_text, explanation, order,
answers(id, answer_text, is_correct)
)
`)
.eq('id', id)
.eq('author_id', user.id)
.single()
if (error || !quiz) {
return NextResponse.json({ error: 'Quiz introuvable ou accès refusé' }, { status: 404 })
}
// Trier les questions par ordre
quiz.questions?.sort((a: any, b: any) => a.order - b.order)
return NextResponse.json({ quiz })
} catch (error) {
console.error('[quizzes/get]', error)
return NextResponse.json({ error: 'Erreur serveur interne' }, { status: 500 })
}
}
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 { title } = await request.json()
if (!title?.trim()) {
return NextResponse.json({ error: 'Titre requis' }, { status: 400 })
}
const { data, error } = await db
.from('quizzes')
.update({ title: title.trim() })
.eq('id', id)
.eq('author_id', user.id)
.select()
.single()
if (error || !data) {
return NextResponse.json({ error: 'Quiz introuvable ou accès refusé' }, { status: 404 })
}
return NextResponse.json({ success: true, quiz: data })
} catch (error) {
console.error('[quizzes/patch]', error)
return NextResponse.json({ error: 'Erreur serveur interne' }, { status: 500 })
}
}

View File

@ -0,0 +1,60 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server'
import { createClient, createAdminClient } from '@/lib/supabase/server'
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Authentification via le client SSR (cookie)
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 { id } = await params
// Utiliser le client admin pour contourner le RLS (pas de policy DELETE sur sessions)
const db = createAdminClient() as any
// Vérifier manuellement que la session appartient au formateur connecté
const { data: session, error: fetchError } = await db
.from('sessions')
.select('id, is_active, trainer_id')
.eq('id', id)
.single()
if (fetchError || !session) {
return NextResponse.json({ error: 'Session introuvable' }, { status: 404 })
}
if (session.trainer_id !== user.id) {
return NextResponse.json({ error: 'Accès refusé' }, { status: 403 })
}
if (session.is_active) {
return NextResponse.json(
{ error: "Impossible de supprimer une session active. Terminez-la d'abord." },
{ status: 400 }
)
}
const { error: deleteError } = await db
.from('sessions')
.delete()
.eq('id', id)
if (deleteError) {
return NextResponse.json(
{ error: 'Erreur lors de la suppression', details: deleteError.message },
{ status: 500 }
)
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('[sessions/delete]', error)
return NextResponse.json({ error: 'Erreur serveur interne' }, { status: 500 })
}
}

View File

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { createClient, createAdminClient } from '@/lib/supabase/server'
import { calculateScore } from '@/lib/utils'
export async function PATCH(
request: NextRequest,
@ -8,8 +9,6 @@ export async function PATCH(
) {
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 })
@ -19,19 +18,64 @@ export async function PATCH(
const body = await request.json()
const { is_active } = body
const db = supabase as any
const { data: session, error } = await db
.from('sessions')
.update({ is_active })
.eq('id', id)
.eq('trainer_id', user.id)
.select()
.select('id, quiz_id')
.single()
if (error || !session) {
return NextResponse.json({ error: 'Session introuvable ou accès refusé' }, { status: 404 })
}
return NextResponse.json({ success: true, session })
// Quand on ferme la session, forcer la complétion des étudiants encore en cours
if (!is_active) {
const admin = createAdminClient() as any
// Récupérer toutes les participations encore en cours
const { data: pending } = await admin
.from('student_participations')
.select('id')
.eq('session_id', id)
.eq('status', 'in_progress')
if (pending && pending.length > 0) {
// Compter le nombre total de questions du quiz pour calculer le score
const { count: totalQuestions } = await admin
.from('questions')
.select('id', { count: 'exact', head: true })
.eq('quiz_id', session.quiz_id)
const total = totalQuestions ?? 0
for (const participation of pending) {
// Récupérer les réponses données et vérifier lesquelles sont correctes
const { data: correctAnswers } = await admin
.from('student_answers')
.select('answer_id, answers!inner(is_correct)')
.eq('participation_id', participation.id)
.eq('answers.is_correct', true)
const correct = correctAnswers?.length ?? 0
const score = calculateScore(correct, total)
await admin
.from('student_participations')
.update({
status: 'completed',
score,
completed_at: new Date().toISOString(),
})
.eq('id', participation.id)
}
}
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('[sessions/toggle]', error)
return NextResponse.json({ error: 'Erreur serveur interne' }, { status: 500 })

View File

@ -1,12 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } 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 db = createAdminClient() as any
const body = await request.json()
const { participation_id } = body

View File

@ -1,11 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/server'
export async function POST(request: NextRequest) {
try {
const supabase = await createClient()
const db = supabase as any
const db = createAdminClient() as any
const body = await request.json()
const { short_code, first_name, last_name } = body
@ -19,7 +18,7 @@ export async function POST(request: NextRequest) {
const { data: session, error: sessionError } = await db
.from('sessions')
.select('id, is_active, quiz_id')
.select('id, is_active, quiz_id, total_participants')
.eq('short_code', short_code.toUpperCase())
.single()
@ -34,6 +33,20 @@ export async function POST(request: NextRequest) {
)
}
if (session.total_participants > 0) {
const { count } = await db
.from('student_participations')
.select('id', { count: 'exact', head: true })
.eq('session_id', session.id)
if (count !== null && count >= session.total_participants) {
return NextResponse.json(
{ error: `Cette session est complète (${session.total_participants} participant${session.total_participants > 1 ? 's' : ''} maximum).`, code: 'SESSION_FULL' },
{ status: 403 }
)
}
}
const { data: participation, error: participationError } = await db
.from('student_participations')
.insert({

View File

@ -1,11 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/server'
export async function GET(request: NextRequest) {
try {
const supabase = await createClient()
const db = supabase as any
const db = createAdminClient() as any
const { searchParams } = new URL(request.url)
const participationId = searchParams.get('participation_id')

View File

@ -1,11 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/server'
export async function POST(request: NextRequest) {
try {
const supabase = await createClient()
const db = supabase as any
const db = createAdminClient() as any
const body = await request.json()
const { participation_id, question_id, answer_id } = body

View File

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { QuizJsonFormat } from '@/lib/types/database'
@ -5,6 +6,7 @@ import { QuizJsonFormat } from '@/lib/types/database'
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) {
@ -18,6 +20,9 @@ export async function POST(request: NextRequest) {
if (!file) {
return NextResponse.json({ error: 'Fichier manquant' }, { status: 400 })
}
if (!subchapterId) {
return NextResponse.json({ error: 'subchapter_id requis' }, { status: 400 })
}
const text = await file.text()
let quizData: QuizJsonFormat
@ -35,13 +40,34 @@ export async function POST(request: NextRequest) {
)
}
// Créer le quiz
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: quiz, error: quizError } = await (supabase as any)
// Vérifier que le chapitre existe
const { data: subchapter, error: subError } = await db
.from('subchapters')
.select('id')
.eq('id', subchapterId)
.single()
if (subError || !subchapter) {
return NextResponse.json({ error: 'Chapitre introuvable' }, { status: 404 })
}
// Si ce chapitre a déjà un quiz → le supprimer (cascade supprime questions + réponses)
const { data: existingQuiz } = await db
.from('quizzes')
.select('id')
.eq('subchapter_id', subchapterId)
.maybeSingle()
if (existingQuiz) {
await db.from('quizzes').delete().eq('id', existingQuiz.id)
}
// Créer le nouveau quiz
const { data: quiz, error: quizError } = await db
.from('quizzes')
.insert({
title: quizData.title,
subchapter_id: subchapterId ?? null,
subchapter_id: subchapterId,
author_id: user.id,
raw_json_data: quizData,
})
@ -60,8 +86,7 @@ export async function POST(request: NextRequest) {
order: index,
}))
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: insertedQuestions, error: questionsError } = await (supabase as any)
const { data: insertedQuestions, error: questionsError } = await db
.from('questions')
.insert(questionsToInsert)
.select()
@ -83,10 +108,7 @@ export async function POST(request: NextRequest) {
}))
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { error: answersError } = await (supabase as any)
.from('answers')
.insert(answersToInsert)
const { error: answersError } = await db.from('answers').insert(answersToInsert)
if (answersError) {
return NextResponse.json(
@ -100,7 +122,9 @@ export async function POST(request: NextRequest) {
quiz: {
id: quiz.id,
title: quiz.title,
subchapter_id: subchapterId,
questionCount: insertedQuestions.length,
replaced: !!existingQuiz,
},
})
} catch (error) {

View File

@ -0,0 +1,324 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import {
Users,
Shield,
Plus,
Trash2,
ChevronDown,
Check,
Loader2,
Search,
X,
UserCircle,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import Link from 'next/link'
interface User {
id: string
email: string
username: string
role: 'admin' | 'formateur'
created_at: string
last_sign_in: string | null
}
interface Props {
initialUsers: User[]
currentUserId: string
}
function formatDate(dateStr: string | null) {
if (!dateStr) return 'Jamais'
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: '2-digit',
month: 'short',
year: 'numeric',
})
}
const roleConfig = {
admin: { label: 'Admin', color: 'bg-purple-500/10 text-purple-400 border-purple-500/20' },
formateur: { label: 'Formateur', color: 'bg-blue-500/10 text-blue-400 border-blue-500/20' },
}
export default function UsersClient({ initialUsers, currentUserId }: Props) {
const router = useRouter()
const [users, setUsers] = useState<User[]>(initialUsers)
const [search, setSearch] = useState('')
const [filterRole, setFilterRole] = useState<'all' | 'admin' | 'formateur'>('all')
const [changingRoleId, setChangingRoleId] = useState<string | null>(null)
const [roleDropdownId, setRoleDropdownId] = useState<string | null>(null)
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const filtered = users.filter((u) => {
const q = search.toLowerCase()
const matchSearch = !q || u.email.toLowerCase().includes(q) || u.username.toLowerCase().includes(q)
const matchRole = filterRole === 'all' || u.role === filterRole
return matchSearch && matchRole
})
const handleChangeRole = async (userId: string, newRole: 'admin' | 'formateur') => {
setChangingRoleId(userId)
setRoleDropdownId(null)
setError(null)
try {
const res = await fetch(`/api/admin/users/${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: newRole }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error)
} else {
setUsers((prev) => prev.map((u) => (u.id === userId ? { ...u, role: newRole } : u)))
}
} finally {
setChangingRoleId(null)
}
}
const handleDelete = async (userId: string) => {
setDeletingId(userId)
setError(null)
try {
const res = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' })
const data = await res.json()
if (!res.ok) {
setError(data.error)
} else {
setUsers((prev) => prev.filter((u) => u.id !== userId))
setConfirmDeleteId(null)
}
} finally {
setDeletingId(null)
}
}
const adminCount = users.filter((u) => u.role === 'admin').length
const formateurCount = users.filter((u) => u.role === 'formateur').length
return (
<div className="p-4 md:p-8">
{/* Header */}
<div className="flex items-start justify-between mb-6 md:mb-8 gap-3">
<div>
<h1 className="text-2xl md:text-3xl font-bold text-text-primary mb-1 md:mb-2">Gestion des Utilisateurs</h1>
<p className="text-text-secondary text-sm hidden sm:block">
Créez et gérez les comptes formateurs et administrateurs de la plateforme.
</p>
</div>
<Link href="/dashboard/admin/users/create" className="btn-primary flex items-center gap-2 flex-shrink-0 text-sm">
<Plus size={16} />
<span className="hidden sm:inline">Créer un compte</span>
<span className="sm:hidden">Créer</span>
</Link>
</div>
{error && (
<div className="mb-6 bg-red-500/10 border border-red-500/30 text-red-400 px-4 py-3 rounded-lg text-sm flex items-center justify-between">
{error}
<button onClick={() => setError(null)}><X size={14} /></button>
</div>
)}
{/* Stats */}
<div className="grid grid-cols-3 gap-3 md:gap-4 mb-6 md:mb-8">
{[
{ icon: Users, label: 'Total', value: users.length, color: 'text-text-secondary' },
{ icon: Shield, label: 'Admins', value: adminCount, color: 'text-purple-400' },
{ icon: UserCircle, label: 'Formateurs', value: formateurCount, color: 'text-blue-400' },
].map(({ icon: Icon, label, value, color }) => (
<div key={label} className="card p-3 md:p-5 flex items-center gap-3 md:gap-4">
<div className={cn('w-9 h-9 md:w-12 md:h-12 rounded-xl bg-background-elevated flex items-center justify-center flex-shrink-0', color)}>
<Icon size={18} />
</div>
<div>
<p className="text-text-secondary text-xs md:text-sm">{label}</p>
<p className="text-xl md:text-2xl font-bold text-text-primary">{value}</p>
</div>
</div>
))}
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 mb-5">
<div className="relative flex-1 sm:max-w-xs">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Rechercher un utilisateur..."
className="input-field pl-9 py-2 text-sm w-full"
/>
</div>
<div className="flex items-center gap-1 bg-background-card border border-border rounded-lg p-1">
{(['all', 'admin', 'formateur'] as const).map((r) => (
<button
key={r}
onClick={() => setFilterRole(r)}
className={cn(
'px-3 py-1.5 text-sm rounded-md transition-colors',
filterRole === r
? 'bg-primary/20 text-primary font-medium'
: 'text-text-muted hover:text-text-primary'
)}
>
{r === 'all' ? 'Tous' : r === 'admin' ? 'Admins' : 'Formateurs'}
</button>
))}
</div>
</div>
{/* Table */}
<div className="card overflow-hidden overflow-x-auto">
<table className="w-full min-w-[600px]">
<thead>
<tr className="border-b border-border">
<th className="text-left text-xs font-medium text-text-muted px-4 md:px-6 py-3 uppercase tracking-wider">Utilisateur</th>
<th className="text-left text-xs font-medium text-text-muted px-4 md:px-6 py-3 uppercase tracking-wider hidden sm:table-cell">Email</th>
<th className="text-left text-xs font-medium text-text-muted px-4 md:px-6 py-3 uppercase tracking-wider">Rôle</th>
<th className="text-left text-xs font-medium text-text-muted px-4 md:px-6 py-3 uppercase tracking-wider hidden md:table-cell">Créé le</th>
<th className="text-left text-xs font-medium text-text-muted px-4 md:px-6 py-3 uppercase tracking-wider hidden lg:table-cell">Dernière connexion</th>
<th className="text-right text-xs font-medium text-text-muted px-4 md:px-6 py-3 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-text-muted">
Aucun utilisateur trouvé
</td>
</tr>
) : (
filtered.map((user) => {
const isSelf = user.id === currentUserId
const roleCfg = roleConfig[user.role] ?? roleConfig.formateur
const isConfirmingDelete = confirmDeleteId === user.id
const isDeleting = deletingId === user.id
const isChanging = changingRoleId === user.id
return (
<tr key={user.id} className="border-b border-border/50 hover:bg-background-elevated/20 transition-colors">
{/* Avatar + Username */}
<td className="px-4 md:px-6 py-3 md:py-4">
<div className="flex items-center gap-2 md:gap-3">
<div className="w-8 h-8 md:w-9 md:h-9 bg-primary/20 rounded-full flex items-center justify-center text-primary font-semibold text-sm flex-shrink-0">
{user.username.slice(0, 2).toUpperCase()}
</div>
<div>
<p className="text-sm font-medium text-text-primary">{user.username}</p>
{isSelf && <p className="text-xs text-primary/70">Vous</p>}
</div>
</div>
</td>
{/* Email */}
<td className="px-4 md:px-6 py-3 md:py-4 text-sm text-text-secondary hidden sm:table-cell">{user.email}</td>
{/* Rôle (dropdown) */}
<td className="px-4 md:px-6 py-3 md:py-4">
<div className="relative">
<button
disabled={isSelf || isChanging}
onClick={() => setRoleDropdownId(roleDropdownId === user.id ? null : user.id)}
className={cn(
'flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border font-medium transition-colors',
roleCfg.color,
!isSelf && !isChanging && 'hover:opacity-80 cursor-pointer',
isSelf && 'cursor-default opacity-70'
)}
>
{isChanging ? (
<Loader2 size={11} className="animate-spin" />
) : (
roleCfg.label
)}
{!isSelf && !isChanging && <ChevronDown size={11} />}
</button>
{roleDropdownId === user.id && (
<div className="absolute left-0 top-8 z-20 bg-background-card border border-border rounded-lg shadow-xl overflow-hidden w-36">
{(['formateur', 'admin'] as const).map((r) => (
<button
key={r}
onClick={() => handleChangeRole(user.id, r)}
className={cn(
'w-full flex items-center justify-between px-3 py-2 text-sm hover:bg-background-elevated transition-colors',
user.role === r ? 'text-primary font-medium' : 'text-text-primary'
)}
>
{r === 'admin' ? 'Admin' : 'Formateur'}
{user.role === r && <Check size={13} className="text-primary" />}
</button>
))}
</div>
)}
</div>
</td>
{/* Created at */}
<td className="px-4 md:px-6 py-3 md:py-4 text-sm text-text-secondary hidden md:table-cell">{formatDate(user.created_at)}</td>
{/* Last sign in */}
<td className="px-4 md:px-6 py-3 md:py-4 text-sm text-text-secondary hidden lg:table-cell">{formatDate(user.last_sign_in)}</td>
{/* Actions */}
<td className="px-4 md:px-6 py-3 md:py-4">
<div className="flex items-center justify-end gap-2">
{isConfirmingDelete ? (
<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={() => handleDelete(user.id)}
disabled={isDeleting}
className="text-xs text-red-400 hover:text-red-300 font-medium"
>
{isDeleting ? <Loader2 size={11} className="animate-spin" /> : 'Oui'}
</button>
<button
onClick={() => setConfirmDeleteId(null)}
className="text-xs text-text-muted hover:text-text-primary"
>
Non
</button>
</div>
) : (
<button
disabled={isSelf}
onClick={() => { setConfirmDeleteId(user.id); setRoleDropdownId(null) }}
className={cn(
'p-1.5 rounded-md transition-colors',
isSelf
? 'text-text-muted/30 cursor-not-allowed'
: 'text-text-muted hover:text-red-400 hover:bg-red-500/10'
)}
title={isSelf ? 'Vous ne pouvez pas vous supprimer' : 'Supprimer'}
>
<Trash2 size={15} />
</button>
)}
</div>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
{/* Clic en dehors ferme les dropdowns */}
{roleDropdownId && (
<div className="fixed inset-0 z-10" onClick={() => setRoleDropdownId(null)} />
)}
</div>
)
}

View File

@ -0,0 +1,256 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import {
Mail,
Lock,
User,
Shield,
Eye,
EyeOff,
Loader2,
ArrowLeft,
RefreshCw,
Check,
} from 'lucide-react'
import { cn } from '@/lib/utils'
function generatePassword() {
const chars = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789!@#$'
return Array.from({ length: 12 }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
}
export default function CreateUserPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState(generatePassword())
const [role, setRole] = useState<'formateur' | 'admin'>('formateur')
const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
try {
const res = await fetch('/api/admin/users/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, username, password, role }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error)
} else {
setSuccess(true)
setTimeout(() => router.push('/dashboard/admin/users'), 1500)
}
} catch {
setError('Erreur réseau')
} finally {
setLoading(false)
}
}
if (success) {
return (
<div className="p-8 flex items-center justify-center min-h-[60vh]">
<div className="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-xl font-bold text-text-primary mb-2">Compte créé !</h2>
<p className="text-text-secondary">Redirection en cours...</p>
</div>
</div>
)
}
return (
<div className="p-4 md:p-8">
<div className="max-w-xl mx-auto">
{/* Header */}
<div className="mb-8">
<Link
href="/dashboard/admin/users"
className="flex items-center gap-1.5 text-sm text-text-muted hover:text-text-primary transition-colors mb-4"
>
<ArrowLeft size={15} />
Retour à la liste
</Link>
<h1 className="text-3xl font-bold text-text-primary mb-2">Créer un compte</h1>
<p className="text-text-secondary">
Le nouveau membre pourra se connecter immédiatement avec ses identifiants.
</p>
</div>
<form onSubmit={handleSubmit}>
<div className="card overflow-hidden">
{/* Identité */}
<div className="p-6 border-b border-border">
<div className="flex items-center gap-2 mb-5">
<User size={18} className="text-primary" />
<h2 className="font-semibold text-text-primary">Identité</h2>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm text-text-secondary mb-1.5">
Nom d&apos;utilisateur *
</label>
<div className="relative">
<User size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Ex: jean.dupont"
className="input-field pl-9"
required
/>
</div>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1.5">
Adresse email *
</label>
<div className="relative">
<Mail size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Ex: jean.dupont@solyti.fr"
className="input-field pl-9"
required
/>
</div>
</div>
</div>
</div>
{/* Rôle */}
<div className="p-6 border-b border-border">
<div className="flex items-center gap-2 mb-5">
<Shield size={18} className="text-primary" />
<h2 className="font-semibold text-text-primary">Rôle</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{([
{
value: 'formateur',
label: 'Formateur',
desc: 'Gère ses quiz et sessions',
icon: '👨‍🏫',
},
{
value: 'admin',
label: 'Administrateur',
desc: 'Accès complet + gestion des comptes',
icon: '🛡️',
},
] as const).map((r) => (
<button
key={r.value}
type="button"
onClick={() => setRole(r.value)}
className={cn(
'text-left p-4 rounded-xl border-2 transition-all',
role === r.value
? 'border-primary bg-primary/10'
: 'border-border bg-background-elevated/30 hover:border-border-light'
)}
>
<span className="text-2xl mb-2 block">{r.icon}</span>
<p className={cn(
'font-semibold text-sm',
role === r.value ? 'text-primary' : 'text-text-primary'
)}>
{r.label}
</p>
<p className="text-xs text-text-muted mt-0.5">{r.desc}</p>
</button>
))}
</div>
</div>
{/* Mot de passe */}
<div className="p-6">
<div className="flex items-center gap-2 mb-5">
<Lock size={18} className="text-primary" />
<h2 className="font-semibold text-text-primary">Mot de passe temporaire</h2>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1.5">
Mot de passe *{' '}
<span className="text-text-muted">(min. 8 caractères)</span>
</label>
<div className="relative">
<Lock size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input-field pl-9 pr-20"
required
minLength={8}
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
<button
type="button"
onClick={() => setPassword(generatePassword())}
className="p-1.5 hover:bg-background-elevated rounded-md text-text-muted hover:text-text-primary transition-colors"
title="Générer un nouveau mot de passe"
>
<RefreshCw size={14} />
</button>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="p-1.5 hover:bg-background-elevated rounded-md text-text-muted hover:text-text-primary transition-colors"
>
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</div>
<p className="text-xs text-text-muted mt-1.5">
Communiquez ce mot de passe à l&apos;utilisateur. Il pourra le modifier depuis ses paramètres.
</p>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-border bg-background-secondary/50 flex items-center justify-between">
<Link href="/dashboard/admin/users" className="btn-secondary">
Annuler
</Link>
<div className="flex items-center gap-3">
{error && <p className="text-sm text-red-400 max-w-xs text-right">{error}</p>}
<button
type="submit"
disabled={loading}
className={cn('btn-primary', loading && 'opacity-70 cursor-not-allowed')}
>
{loading ? (
<><Loader2 size={16} className="animate-spin" /> Création...</>
) : (
'Créer le compte'
)}
</button>
</div>
</div>
</div>
</form>
</div>
</div>
)
}

View File

@ -0,0 +1,47 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/server'
import UsersClient from './UsersClient'
export default async function AdminUsersPage() {
const supabase = await createClient()
const db = supabase as any
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: callerProfile } = await db
.from('profiles')
.select('role')
.eq('id', user.id)
.single()
if (callerProfile?.role !== 'admin') redirect('/dashboard')
// Utilise le client admin pour bypass RLS
const admin = createAdminClient()
const adminDb = admin as any
// Récupérer les données auth via la fonction SECURITY DEFINER (évite listUsers défaillant)
const [authResult, profilesResult] = await Promise.all([
adminDb.rpc('get_all_users_admin'),
adminDb.from('profiles').select('id, username, role, created_at').order('created_at', { ascending: false }),
])
const profileMap = new Map((profilesResult.data ?? []).map((p: any) => [p.id, p]))
const users = (authResult.data ?? []).map((authUser: any) => {
const profile: any = profileMap.get(authUser.id) ?? {}
return {
id: authUser.id,
email: authUser.email ?? '',
username: profile.username ?? authUser.email?.split('@')[0] ?? '—',
role: profile.role ?? 'formateur',
created_at: profile.created_at ?? authUser.created_at,
last_sign_in: authUser.last_sign_in_at ?? null,
}
})
return <UsersClient initialUsers={users} currentUserId={user.id} />
}

View File

@ -26,9 +26,10 @@ export default async function DashboardLayout({
<div className="flex min-h-screen bg-background">
<Sidebar
username={profile?.username ?? user.email?.split('@')[0] ?? 'Utilisateur'}
email={user.email ?? ''}
role={profile?.role ?? 'formateur'}
/>
<main className="flex-1 overflow-auto">
<main className="flex-1 overflow-auto pt-14 lg:pt-0">
{children}
</main>
</div>

View File

@ -27,7 +27,7 @@ function KpiCard({
icon: React.ElementType
}) {
return (
<div className="card p-5 flex-1">
<div className="card p-4 md: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>
@ -56,8 +56,8 @@ function SessionCard({ session }: { session: any }) {
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="card p-3 md:p-4 flex items-center gap-3 md:gap-4 hover:border-border-light transition-colors">
<div className="hidden sm:block 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>
@ -132,13 +132,16 @@ export default async function DashboardPage() {
.select('id', { count: 'exact', head: true })
.eq('trainer_id', user!.id)
const sessionIds = (sessions ?? []).map((s: any) => s.id)
const activeSessionIds = (sessions ?? [])
.filter((s: any) => s.is_active)
.map((s: any) => s.id)
const { count: totalParticipants } = sessionIds.length > 0
const { count: totalParticipants } = activeSessionIds.length > 0
? await db
.from('student_participations')
.select('id', { count: 'exact', head: true })
.in('session_id', sessionIds)
.in('session_id', activeSessionIds)
.eq('status', 'in_progress')
: { count: 0 }
const displayName = profile?.username ?? user?.email?.split('@')[0] ?? 'Formateur'
@ -157,11 +160,11 @@ export default async function DashboardPage() {
}))
return (
<div className="p-8">
<div className="flex items-start justify-between mb-8">
<div className="p-4 md:p-8">
<div className="flex items-start justify-between mb-6 md:mb-8">
<div>
<h1 className="text-3xl font-bold text-text-primary mb-1">Dashboard Overview</h1>
<p className="text-text-secondary">Bienvenue, {displayName}. Voici ce qui se passe aujourd&apos;hui.</p>
<h1 className="text-2xl md:text-3xl font-bold text-text-primary mb-1">Dashboard Overview</h1>
<p className="text-text-secondary text-sm md:text-base">Bienvenue, {displayName}. Voici ce qui se passe aujourd&apos;hui.</p>
</div>
<div className="flex items-center gap-3">
<NotificationBell />
@ -171,9 +174,9 @@ export default async function DashboardPage() {
</div>
</div>
<div className="flex gap-4 mb-8">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 md:gap-4 mb-6 md: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="Étudiants en cours" value={String(totalParticipants ?? 0)} badge="sessions actives" 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>
@ -200,7 +203,7 @@ export default async function DashboardPage() {
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md: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">

View File

@ -17,6 +17,8 @@ import {
Check,
X,
Loader2,
FileJson,
RefreshCw,
} from 'lucide-react'
import { cn } from '@/lib/utils'
@ -64,24 +66,26 @@ export default function QuizzesClient({ initialCategories, stats: initialStats }
new Set(initialCategories.slice(0, 1).map((c) => c.id))
)
const [search, setSearch] = useState('')
const [uploading, setUploading] = useState(false)
// Upload state — clé = subchapter_id
const [uploadingSubchapterId, setUploadingSubchapterId] = useState<string | null>(null)
const [uploadError, setUploadError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [pendingSubchapterId, setPendingSubchapterId] = useState<string | null>(null)
// --- Category CRUD state ---
// Category CRUD
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 ---
// Subchapter CRUD
const [editingSubchapterId, setEditingSubchapterId] = useState<string | null>(null)
const [editingSubchapterName, setEditingSubchapterName] = useState('')
const [savingSubchapterId, setSavingSubchapterId] = useState<string | null>(null)
@ -109,27 +113,63 @@ export default function QuizzesClient({ initialCategories, stats: initialStats }
)
})
// ── Upload quiz pour un chapitre spécifique ──
const triggerUpload = (subchapterId: string) => {
setPendingSubchapterId(subchapterId)
setUploadError(null)
fileInputRef.current?.click()
}
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
if (!file || !pendingSubchapterId) return
const subchapterId = pendingSubchapterId
setUploadingSubchapterId(subchapterId)
setUploadError(null)
const formData = new FormData()
formData.append('file', file)
formData.append('subchapter_id', subchapterId)
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()
if (!res.ok) {
setUploadError(data.error ?? "Erreur lors de l'import")
} else {
// Mettre à jour le chapitre dans le state local
const newQuiz: Quiz = {
id: data.quiz.id,
title: data.quiz.title,
updated_at: new Date().toISOString(),
}
setCategories((prev) =>
prev.map((cat) => ({
...cat,
subchapters: cat.subchapters.map((sub) =>
sub.id === subchapterId
? { ...sub, quizzes: [newQuiz] }
: sub
),
}))
)
if (data.quiz.replaced) {
// Compte inchangé (remplacement)
} else {
setStats((s) => ({ ...s, totalQuizzes: s.totalQuizzes + 1 }))
}
}
} catch {
setUploadError('Erreur réseau')
} finally {
setUploading(false)
setUploadingSubchapterId(null)
setPendingSubchapterId(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
// --- Category handlers ---
// ── Category handlers ──
const handleCreateCategory = async () => {
if (!newCategoryName.trim()) return
setCreatingCategory(true)
@ -159,12 +199,6 @@ export default function QuizzesClient({ initialCategories, stats: initialStats }
}
}
const startEditCategory = (cat: Category) => {
setEditingCategoryId(cat.id)
setEditingCategoryName(cat.name)
setConfirmDeleteCategoryId(null)
}
const handleSaveCategory = async (id: string) => {
if (!editingCategoryName.trim()) return
setSavingCategoryId(id)
@ -191,7 +225,6 @@ export default function QuizzesClient({ initialCategories, stats: initialStats }
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) => ({
@ -206,13 +239,7 @@ export default function QuizzesClient({ initialCategories, stats: initialStats }
}
}
// --- Subchapter handlers ---
const startEditSubchapter = (sub: Subchapter) => {
setEditingSubchapterId(sub.id)
setEditingSubchapterName(sub.name)
setConfirmDeleteSubchapterId(null)
}
// ── Subchapter handlers ──
const handleSaveSubchapter = async (subId: string) => {
if (!editingSubchapterName.trim()) return
setSavingSubchapterId(subId)
@ -284,87 +311,75 @@ export default function QuizzesClient({ initialCategories, stats: initialStats }
}
}
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">
<div className="p-4 md:p-8">
{/* Input file caché — partagé, déclenché par triggerUpload() */}
<input
ref={fileInputRef}
type="file"
accept=".json"
className="hidden"
onChange={handleFileUpload}
/>
{/* Header */}
<div className="flex items-start justify-between mb-8">
<div className="flex items-start justify-between mb-6 md:mb-8 gap-3">
<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.
<h1 className="text-2xl md:text-3xl font-bold text-text-primary mb-1 md:mb-2">Gestion des Quiz</h1>
<p className="text-text-secondary text-sm max-w-lg hidden sm:block">
Organisez votre curriculum, gérez les catégories et importez les quiz dans chaque chapitre.
</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>
<button
onClick={() => setShowNewCategoryModal(true)}
className="btn-secondary flex items-center gap-2 flex-shrink-0 text-sm"
>
<PlusIcon size={16} />
<span className="hidden sm:inline">Nouvelle </span>Catégorie
</button>
</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">
<div className="mb-6 bg-red-500/10 border border-red-500/30 text-red-400 px-4 py-3 rounded-lg text-sm flex items-center justify-between">
{uploadError}
<button onClick={() => setUploadError(null)}><X size={14} /></button>
</div>
)}
{/* Stats */}
<div className="grid grid-cols-3 gap-4 mb-8">
<div className="grid grid-cols-3 gap-3 md:gap-4 mb-6 md: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 key={label} className="card p-3 md:p-5 flex items-center gap-3 md:gap-4">
<div className={cn('w-9 h-9 md:w-12 md:h-12 rounded-xl bg-background-elevated flex items-center justify-center flex-shrink-0', color)}>
<Icon size={18} className="md:hidden" />
<Icon size={22} className="hidden md:block" />
</div>
<div>
<p className="text-text-secondary text-sm">{label}</p>
<p className="text-2xl font-bold text-text-primary">{value}</p>
<p className="text-text-secondary text-xs md:text-sm">{label}</p>
<p className="text-xl md:text-2xl font-bold text-text-primary">{value}</p>
</div>
</div>
))}
</div>
{/* Categories Section */}
{/* Categories */}
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between mb-4 gap-3">
<h2 className="text-lg font-semibold text-text-primary">Catégories Actives</h2>
<div className="relative">
<div className="relative w-full sm:w-64">
<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"
className="input-field pl-9 py-2 text-sm w-full"
/>
</div>
</div>
@ -422,61 +437,31 @@ export default function QuizzesClient({ initialCategories, stats: initialStats }
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} />
)}
{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"
>
<button onClick={() => setEditingCategoryId(null)} className="p-1.5 hover:bg-background-elevated rounded-md text-text-muted 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"
>
<button onClick={() => handleDeleteCategory(category.id)} disabled={isDeletingCat} className="text-xs text-red-400 hover:text-red-300 font-medium">
{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>
<button onClick={() => setConfirmDeleteCategoryId(null)} className="text-xs text-text-muted hover:text-text-primary">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"
>
<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(); setEditingCategoryId(category.id); setEditingCategoryName(category.name); setConfirmDeleteCategoryId(null) }} 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"
>
<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" />
)}
{isExpanded ? <ChevronUp size={18} className="text-text-muted" /> : <ChevronDown size={18} className="text-text-muted" />}
</button>
</>
)}
@ -485,8 +470,8 @@ export default function QuizzesClient({ initialCategories, stats: initialStats }
{/* Subchapters table */}
{isExpanded && (
<div className="border-t border-border">
<table className="w-full">
<div className="border-t border-border overflow-x-auto">
<table className="w-full min-w-[600px]">
<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>
@ -500,9 +485,12 @@ export default function QuizzesClient({ initialCategories, stats: initialStats }
const isEditingSub = editingSubchapterId === subchapter.id
const isConfirmingDeleteSub = confirmDeleteSubchapterId === subchapter.id
const isDeletingSub = deletingSubchapterId === subchapter.id
const isUploading = uploadingSubchapterId === subchapter.id
const quiz = subchapter.quizzes[0] ?? null
return (
<tr key={subchapter.id} className="border-b border-border/50 hover:bg-background-elevated/30 transition-colors">
{/* Nom du chapitre */}
<td className="px-6 py-4 text-sm font-medium text-text-primary">
{isEditingSub ? (
<input
@ -512,79 +500,112 @@ export default function QuizzesClient({ initialCategories, stats: initialStats }
if (e.key === 'Enter') handleSaveSubchapter(subchapter.id)
if (e.key === 'Escape') setEditingSubchapterId(null)
}}
className="input-field text-sm py-1 w-48"
className="input-field text-sm py-1 w-40 md:w-48"
autoFocus
/>
) : (
subchapter.name
)}
</td>
<td className="px-6 py-4 text-sm text-text-secondary">
{subchapter.quizzes.length} Quiz
{/* Quiz associé */}
<td className="px-6 py-4 text-sm">
{quiz ? (
<span className="font-medium text-text-primary truncate max-w-[200px] block">{quiz.title}</span>
) : (
<span className="text-text-muted italic">Aucun quiz</span>
)}
</td>
{/* Date */}
<td className="px-6 py-4 text-sm text-text-secondary">
{subchapter.quizzes[0] ? formatDate(subchapter.quizzes[0].updated_at) : '—'}
{quiz ? formatDate(quiz.updated_at) : '—'}
</td>
{/* Actions */}
<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 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"
>
<button onClick={() => setEditingSubchapterId(null)} className="p-1.5 hover:bg-background-elevated rounded-md text-text-muted 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"
>
<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>
<button onClick={() => setConfirmDeleteSubchapterId(null)} className="text-xs text-text-muted hover:text-text-primary">Non</button>
</div>
) : (
<>
{/* Renommer le chapitre */}
<button
onClick={() => startEditSubchapter(subchapter)}
onClick={() => { setEditingSubchapterId(subchapter.id); setEditingSubchapterName(subchapter.name); setConfirmDeleteSubchapterId(null) }}
className="p-1.5 hover:bg-background-elevated rounded-md text-text-muted hover:text-text-primary transition-colors"
title="Renommer"
title="Renommer le chapitre"
>
<Edit2 size={14} />
</button>
{/* Éditer le quiz (si existant) */}
{quiz && (
<button
onClick={() => router.push(`/dashboard/quizzes/${quiz.id}/edit`)}
className="p-1.5 hover:bg-primary/10 rounded-md text-text-muted hover:text-primary transition-colors"
title="Éditer le quiz"
>
<BookOpen size={14} />
</button>
)}
{/* Importer / Remplacer le quiz */}
<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}
onClick={() => triggerUpload(subchapter.id)}
disabled={isUploading}
className={cn(
'p-1.5 rounded-md transition-colors',
quiz
? 'hover:bg-amber-500/10 text-text-muted hover:text-amber-400'
: 'hover:bg-green-500/10 text-text-muted hover:text-green-400',
isUploading && 'opacity-50 cursor-not-allowed'
)}
title={quiz ? 'Remplacer le quiz (JSON)' : 'Importer un quiz (JSON)'}
>
{isUploading ? (
<Loader2 size={14} className="animate-spin" />
) : quiz ? (
<RefreshCw size={14} />
) : (
<FileJson size={14} />
)}
</button>
{/* Lancer une session */}
<button
onClick={() => quiz && router.push(`/dashboard/sessions/create?quiz_id=${quiz.id}`)}
disabled={!quiz}
className={cn(
'p-1.5 rounded-md transition-colors',
quiz
? 'hover:bg-primary/10 text-text-muted hover:text-primary'
: 'text-text-muted/30 cursor-not-allowed'
)}
title={quiz ? 'Lancer une session' : 'Importez un quiz pour lancer une session'}
>
<Play size={14} />
</button>
{/* Supprimer le chapitre */}
<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"
title="Supprimer le chapitre"
>
<Trash2 size={14} />
</button>
@ -596,7 +617,7 @@ export default function QuizzesClient({ initialCategories, stats: initialStats }
)
})}
{/* Add subchapter row */}
{/* Ajouter un chapitre */}
{addingSubchapterForCategoryId === category.id && (
<tr className="border-b border-border/50 bg-background-elevated/20">
<td className="px-6 py-3" colSpan={4}>
@ -612,17 +633,10 @@ export default function QuizzesClient({ initialCategories, stats: initialStats }
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"
>
<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"
>
<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>
@ -634,11 +648,7 @@ export default function QuizzesClient({ initialCategories, stats: initialStats }
<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]))
}}
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} />
@ -654,61 +664,41 @@ export default function QuizzesClient({ initialCategories, stats: initialStats }
</div>
</div>
{/* New Category Modal */}
{/* Légende des icônes */}
<div className="mt-6 flex items-center gap-6 text-xs text-text-muted">
<div className="flex items-center gap-1.5"><Edit2 size={12} /> Renommer</div>
<div className="flex items-center gap-1.5"><BookOpen size={12} /> Éditer le quiz</div>
<div className="flex items-center gap-1.5"><FileJson size={12} /> Importer JSON</div>
<div className="flex items-center gap-1.5"><RefreshCw size={12} /> Remplacer (JSON)</div>
<div className="flex items-center gap-1.5"><Play size={12} /> Lancer session</div>
<div className="flex items-center gap-1.5"><Trash2 size={12} /> Supprimer</div>
</div>
{/* Modale nouvelle catégorie */}
{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"
>
<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
/>
<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"
/>
<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>
)}
{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 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>

View File

@ -0,0 +1,376 @@
'use client'
import { useState, useCallback, useRef } from 'react'
import { useRouter } from 'next/navigation'
import {
ArrowLeft,
CheckCircle2,
XCircle,
ChevronDown,
ChevronUp,
Loader2,
Save,
BookOpen,
AlertCircle,
} from 'lucide-react'
import { cn } from '@/lib/utils'
interface Answer {
id: string
answer_text: string
is_correct: boolean
}
interface Question {
id: string
question_text: string
explanation: string | null
order: number
answers: Answer[]
}
interface Quiz {
id: string
title: string
updated_at: string
subchapter: {
id: string
name: string
category: { id: string; name: string }
} | null
questions: Question[]
}
interface Props {
quiz: Quiz
}
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
export default function QuizEditorClient({ quiz: initialQuiz }: Props) {
const router = useRouter()
const [quiz, setQuiz] = useState<Quiz>(initialQuiz)
const [expandedQuestions, setExpandedQuestions] = useState<Set<string>>(
new Set(initialQuiz.questions.slice(0, 3).map((q) => q.id))
)
const [saveStatus, setSaveStatus] = useState<Record<string, SaveStatus>>({})
const [titleSaving, setTitleSaving] = useState(false)
const [titleValue, setTitleValue] = useState(initialQuiz.title)
const debounceTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
const setSave = (key: string, status: SaveStatus) =>
setSaveStatus((prev) => ({ ...prev, [key]: status }))
const toggleQuestion = (id: string) => {
setExpandedQuestions((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
// ── Sauvegarde titre du quiz ──
const handleSaveTitle = async () => {
if (!titleValue.trim() || titleValue === quiz.title) return
setTitleSaving(true)
try {
const res = await fetch(`/api/quizzes/${quiz.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: titleValue.trim() }),
})
if (res.ok) {
setQuiz((prev) => ({ ...prev, title: titleValue.trim() }))
}
} finally {
setTitleSaving(false)
}
}
// ── Sauvegarde question avec debounce ──
const saveQuestion = useCallback(async (questionId: string, text: string, explanation: string | null) => {
setSave(`q-${questionId}`, 'saving')
try {
const res = await fetch(`/api/questions/${questionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question_text: text, explanation }),
})
setSave(`q-${questionId}`, res.ok ? 'saved' : 'error')
if (res.ok) {
setTimeout(() => setSave(`q-${questionId}`, 'idle'), 2000)
}
} catch {
setSave(`q-${questionId}`, 'error')
}
}, [])
const handleQuestionChange = (questionId: string, field: 'question_text' | 'explanation', value: string) => {
setQuiz((prev) => ({
...prev,
questions: prev.questions.map((q) =>
q.id === questionId ? { ...q, [field]: value } : q
),
}))
const key = `q-${questionId}`
if (debounceTimers.current[key]) clearTimeout(debounceTimers.current[key])
debounceTimers.current[key] = setTimeout(() => {
const question = quiz.questions.find((q) => q.id === questionId)
if (!question) return
const newText = field === 'question_text' ? value : question.question_text
const newExp = field === 'explanation' ? value : question.explanation
saveQuestion(questionId, newText, newExp)
}, 600)
}
// ── Sauvegarde texte réponse avec debounce ──
const saveAnswerText = useCallback(async (answerId: string, text: string) => {
setSave(`a-${answerId}`, 'saving')
try {
const res = await fetch(`/api/answers/${answerId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answer_text: text }),
})
setSave(`a-${answerId}`, res.ok ? 'saved' : 'error')
if (res.ok) setTimeout(() => setSave(`a-${answerId}`, 'idle'), 2000)
} catch {
setSave(`a-${answerId}`, 'error')
}
}, [])
const handleAnswerTextChange = (questionId: string, answerId: string, value: string) => {
setQuiz((prev) => ({
...prev,
questions: prev.questions.map((q) =>
q.id === questionId
? { ...q, answers: q.answers.map((a) => (a.id === answerId ? { ...a, answer_text: value } : a)) }
: q
),
}))
const key = `a-${answerId}`
if (debounceTimers.current[key]) clearTimeout(debounceTimers.current[key])
debounceTimers.current[key] = setTimeout(() => saveAnswerText(answerId, value), 600)
}
// ── Changer la bonne réponse ──
const handleSetCorrect = async (questionId: string, answerId: string) => {
const question = quiz.questions.find((q) => q.id === questionId)
if (!question) return
const currentCorrect = question.answers.find((a) => a.is_correct)
if (currentCorrect?.id === answerId) return // déjà correcte
setSave(`correct-${questionId}`, 'saving')
// Mise à jour optimiste
setQuiz((prev) => ({
...prev,
questions: prev.questions.map((q) =>
q.id === questionId
? {
...q,
answers: q.answers.map((a) => ({ ...a, is_correct: a.id === answerId })),
}
: q
),
}))
try {
const res = await fetch(`/api/answers/${answerId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_correct: true }),
})
setSave(`correct-${questionId}`, res.ok ? 'saved' : 'error')
if (res.ok) setTimeout(() => setSave(`correct-${questionId}`, 'idle'), 2000)
else {
// Rollback en cas d'erreur
setQuiz(initialQuiz)
}
} catch {
setSave(`correct-${questionId}`, 'error')
setQuiz(initialQuiz)
}
}
const getStatusIcon = (key: string) => {
const status = saveStatus[key]
if (status === 'saving') return <Loader2 size={12} className="animate-spin text-text-muted" />
if (status === 'saved') return <CheckCircle2 size={12} className="text-green-400" />
if (status === 'error') return <AlertCircle size={12} className="text-red-400" />
return null
}
return (
<div className="p-4 md:p-8 max-w-4xl mx-auto">
{/* Header */}
<div className="mb-6 md:mb-8">
<button
onClick={() => router.push('/dashboard/quizzes')}
className="flex items-center gap-1.5 text-sm text-text-muted hover:text-text-primary transition-colors mb-5"
>
<ArrowLeft size={15} />
Retour aux quiz
</button>
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
<div className="flex-1">
{/* Fil d'ariane */}
{quiz.subchapter && (
<p className="text-xs text-text-muted mb-2">
{quiz.subchapter.category?.name} {quiz.subchapter.name}
</p>
)}
{/* Titre éditable */}
<div className="flex items-center gap-3">
<input
value={titleValue}
onChange={(e) => setTitleValue(e.target.value)}
onBlur={handleSaveTitle}
onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle()}
className="text-2xl font-bold text-text-primary bg-transparent border-b-2 border-transparent hover:border-border focus:border-primary outline-none transition-colors py-1 flex-1"
placeholder="Titre du quiz"
/>
{titleSaving && <Loader2 size={16} className="animate-spin text-text-muted flex-shrink-0" />}
</div>
<p className="text-sm text-text-muted mt-1">
{quiz.questions.length} question{quiz.questions.length > 1 ? 's' : ''} · Les modifications sont sauvegardées automatiquement
</p>
</div>
<div className="flex items-center gap-2 text-xs text-text-muted bg-background-card border border-border px-3 py-1.5 rounded-lg flex-shrink-0">
<Save size={12} />
Auto-save activé
</div>
</div>
</div>
{/* Questions */}
<div className="space-y-4">
{quiz.questions.map((question, qIndex) => {
const isExpanded = expandedQuestions.has(question.id)
const correctAnswer = question.answers.find((a) => a.is_correct)
return (
<div key={question.id} className="card overflow-hidden">
{/* Question header */}
<button
onClick={() => toggleQuestion(question.id)}
className="w-full flex items-center justify-between p-5 text-left hover:bg-background-elevated/30 transition-colors"
>
<div className="flex items-center gap-4 flex-1 min-w-0">
<span className="flex-shrink-0 w-7 h-7 rounded-full bg-primary/20 text-primary text-xs font-bold flex items-center justify-center">
{qIndex + 1}
</span>
<p className="text-sm font-medium text-text-primary truncate">{question.question_text}</p>
<div className="flex items-center gap-1 flex-shrink-0">
{getStatusIcon(`q-${question.id}`)}
{getStatusIcon(`correct-${question.id}`)}
</div>
</div>
<div className="flex items-center gap-3 flex-shrink-0 ml-4">
{correctAnswer && (
<span className="hidden sm:flex items-center gap-1 text-xs text-green-400 bg-green-500/10 border border-green-500/20 px-2 py-0.5 rounded-full">
<CheckCircle2 size={10} />
{correctAnswer.answer_text.length > 25 ? correctAnswer.answer_text.slice(0, 25) + '…' : correctAnswer.answer_text}
</span>
)}
{isExpanded ? <ChevronUp size={16} className="text-text-muted" /> : <ChevronDown size={16} className="text-text-muted" />}
</div>
</button>
{/* Question body */}
{isExpanded && (
<div className="border-t border-border p-5 space-y-5">
{/* Texte de la question */}
<div>
<label className="block text-xs font-medium text-text-muted uppercase tracking-wider mb-2">
Question
</label>
<textarea
value={question.question_text}
onChange={(e) => handleQuestionChange(question.id, 'question_text', e.target.value)}
className="input-field text-sm resize-none h-20 w-full"
placeholder="Texte de la question..."
/>
</div>
{/* Explication */}
<div>
<label className="block text-xs font-medium text-text-muted uppercase tracking-wider mb-2 flex items-center gap-2">
<BookOpen size={11} />
Explication (optionnelle)
</label>
<textarea
value={question.explanation ?? ''}
onChange={(e) => handleQuestionChange(question.id, 'explanation', e.target.value)}
className="input-field text-sm resize-none h-16 w-full text-text-secondary"
placeholder="Explication affichée après la réponse..."
/>
</div>
{/* Réponses */}
<div>
<label className="block text-xs font-medium text-text-muted uppercase tracking-wider mb-3">
Réponses cliquer sur le cercle pour définir la bonne réponse
</label>
<div className="space-y-2">
{question.answers.map((answer) => (
<div
key={answer.id}
className={cn(
'flex items-center gap-3 p-3 rounded-lg border transition-all',
answer.is_correct
? 'border-green-500/40 bg-green-500/5'
: 'border-border bg-background-elevated/30'
)}
>
{/* Toggle bonne réponse */}
<button
onClick={() => handleSetCorrect(question.id, answer.id)}
className={cn(
'flex-shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-all',
answer.is_correct
? 'border-green-500 bg-green-500'
: 'border-border-light hover:border-green-400'
)}
title={answer.is_correct ? 'Bonne réponse' : 'Définir comme bonne réponse'}
>
{answer.is_correct && <div className="w-2 h-2 bg-white rounded-full" />}
</button>
{/* Texte de la réponse */}
<input
value={answer.answer_text}
onChange={(e) => handleAnswerTextChange(question.id, answer.id, e.target.value)}
className={cn(
'flex-1 bg-transparent text-sm outline-none border-b border-transparent hover:border-border focus:border-primary transition-colors py-0.5',
answer.is_correct ? 'text-green-300 font-medium' : 'text-text-primary'
)}
placeholder="Texte de la réponse..."
/>
{/* Indicateur correct/incorrect */}
<div className="flex items-center gap-1.5 flex-shrink-0">
{getStatusIcon(`a-${answer.id}`)}
{answer.is_correct ? (
<CheckCircle2 size={14} className="text-green-400" />
) : (
<XCircle size={14} className="text-text-muted/40" />
)}
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,39 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { redirect, notFound } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import QuizEditorClient from './QuizEditorClient'
export default async function QuizEditPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const supabase = await createClient()
const db = supabase as any
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) redirect('/login')
const { data: quiz, error } = await db
.from('quizzes')
.select(`
id, title, updated_at,
subchapter:subchapters(id, name, category:categories(id, name)),
questions(
id, question_text, explanation, order,
answers(id, answer_text, is_correct)
)
`)
.eq('id', id)
.eq('author_id', user.id)
.single()
if (error || !quiz) notFound()
// Trier les questions par ordre
quiz.questions?.sort((a: any, b: any) => a.order - b.order)
return <QuizEditorClient quiz={quiz} />
}

View File

@ -15,9 +15,9 @@ export default async function ReportsPage() {
.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="p-4 md:p-8">
<h1 className="text-2xl md:text-3xl font-bold text-text-primary mb-2">Rapports</h1>
<p className="text-text-secondary mb-6 md:mb-8">Consultez les résultats de vos sessions.</p>
<div className="space-y-3">
{(sessions ?? []).length === 0 ? (

View File

@ -1,6 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import {
Users,
@ -11,6 +12,7 @@ import {
Check,
Power,
AlertCircle,
Trash2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import type { RealtimeChannel } from '@supabase/supabase-js'
@ -42,10 +44,14 @@ interface Props {
}
export default function LiveSessionClient({ session, initialParticipations, totalQuestions }: Props) {
const router = useRouter()
const [participations, setParticipations] = useState<Participation[]>(initialParticipations)
const [isActive, setIsActive] = useState(session.is_active)
const [copied, setCopied] = useState(false)
const [togglingSession, setTogglingSession] = useState(false)
const [deletingSession, setDeletingSession] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [deleteError, setDeleteError] = useState<string | null>(null)
const sessionUrl = `${typeof window !== 'undefined' ? window.location.origin : ''}/quiz/${session.short_code}`
@ -108,17 +114,36 @@ export default function LiveSessionClient({ session, initialParticipations, tota
}
}
const handleDeleteSession = async () => {
setDeletingSession(true)
setDeleteError(null)
try {
const res = await fetch(`/api/sessions/${session.id}`, { method: 'DELETE' })
if (res.ok) {
window.location.href = '/dashboard'
} else {
const data = await res.json()
setDeleteError(data.error ?? 'Erreur lors de la suppression')
setDeletingSession(false)
}
} catch {
setDeleteError('Erreur réseau. Vérifiez votre connexion.')
setDeletingSession(false)
}
// Ne pas fermer la modale ici — elle reste ouverte pour afficher l'erreur éventuelle
}
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
}
return (
<div className="p-8">
<div className="p-4 md:p-8">
{/* Header */}
<div className="flex items-start justify-between mb-6">
<div className="flex flex-col sm:flex-row sm:items-start justify-between mb-6 gap-4">
<div>
<div className="flex items-center gap-3 mb-1">
<h1 className="text-2xl font-bold text-text-primary">{session.quiz_title}</h1>
<div className="flex flex-wrap items-center gap-2 md:gap-3 mb-1">
<h1 className="text-xl md: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
@ -135,18 +160,18 @@ export default function LiveSessionClient({ session, initialParticipations, tota
</p>
</div>
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-2 md:gap-3">
<button
onClick={handleCopy}
className={cn(
'flex items-center gap-2 text-sm px-4 py-2 rounded-lg border transition-all',
'flex items-center gap-2 text-sm px-3 md: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'}
<span className="hidden sm:inline">{copied ? 'Lien copié !' : 'Copier le lien'}</span>
</button>
<button
onClick={handleToggleSession}
@ -162,6 +187,15 @@ export default function LiveSessionClient({ session, initialParticipations, tota
<Power size={14} />
{isActive ? 'Terminer la session' : 'Réactiver'}
</button>
{!isActive && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="flex items-center gap-2 text-sm px-4 py-2 rounded-lg font-medium transition-all bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20"
>
<Trash2 size={14} />
Supprimer
</button>
)}
</div>
</div>
@ -173,43 +207,97 @@ export default function LiveSessionClient({ session, initialParticipations, tota
)}
{/* KPIs */}
<div className="grid grid-cols-4 gap-4 mb-8">
<div className="card p-5">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 md:gap-4 mb-6 md:mb-8">
<div className="card p-4 md: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" />
<span className="text-xs md:text-sm text-text-secondary">Participants</span>
<Users size={16} className="text-text-muted" />
</div>
<p className="text-3xl font-bold text-text-primary">{participations.length}</p>
<p className="text-2xl md: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="card p-4 md: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" />
<span className="text-xs md:text-sm text-text-secondary">En cours</span>
<Clock size={16} className="text-text-muted" />
</div>
<p className="text-3xl font-bold text-amber-400">{inProgress.length}</p>
<p className="text-2xl md:text-3xl font-bold text-amber-400">{inProgress.length}</p>
</div>
<div className="card p-5">
<div className="card p-4 md: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" />
<span className="text-xs md:text-sm text-text-secondary">Terminés</span>
<CheckCircle size={16} className="text-text-muted" />
</div>
<p className="text-3xl font-bold text-green-400">{completed.length}</p>
<p className="text-2xl md:text-3xl font-bold text-green-400">{completed.length}</p>
</div>
<div className="card p-5">
<div className="card p-4 md: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" />
<span className="text-xs md:text-sm text-text-secondary">Score Moyen</span>
<TrendingUp size={16} 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>
<p className="text-2xl md:text-3xl font-bold text-primary">{avgScore}<span className="text-base md:text-lg font-normal text-text-muted">/20</span></p>
</div>
</div>
{/* Modale de confirmation de suppression */}
{showDeleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-background-card border border-border rounded-xl p-6 w-full max-w-sm shadow-2xl">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-red-500/10 flex items-center justify-center">
<Trash2 size={18} className="text-red-400" />
</div>
<div>
<h3 className="font-semibold text-text-primary">Supprimer la session</h3>
<p className="text-xs text-text-muted">Action irréversible</p>
</div>
</div>
<p className="text-sm text-text-secondary mb-4">
Êtes-vous sûr de vouloir supprimer la session{' '}
<span className="font-mono font-bold text-primary">{session.short_code}</span> ?
Toutes les participations et réponses associées seront définitivement perdues.
</p>
{deleteError && (
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-xs px-3 py-2 rounded-lg mb-4">
{deleteError}
</div>
)}
<div className="flex gap-3">
<button
onClick={() => setShowDeleteConfirm(false)}
disabled={deletingSession}
className="flex-1 px-4 py-2 text-sm rounded-lg border border-border text-text-secondary hover:bg-background-elevated transition-all"
>
Annuler
</button>
<button
onClick={handleDeleteSession}
disabled={deletingSession}
className={cn(
'flex-1 flex items-center justify-center gap-2 px-4 py-2 text-sm rounded-lg font-medium bg-red-500 text-white hover:bg-red-600 transition-all',
deletingSession && 'opacity-60 cursor-not-allowed'
)}
>
{deletingSession ? (
<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>
) : (
<Trash2 size={14} />
)}
Supprimer
</button>
</div>
</div>
</div>
)}
{/* Participants table */}
<div className="card overflow-hidden">
<div className="p-4 border-b border-border flex items-center justify-between">
@ -226,7 +314,7 @@ export default function LiveSessionClient({ session, initialParticipations, tota
</div>
{participations.length === 0 ? (
<div className="p-12 text-center">
<div className="p-8 md: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">
@ -234,59 +322,61 @@ export default function LiveSessionClient({ session, initialParticipations, tota
</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>
<div className="overflow-x-auto">
<table className="w-full min-w-[500px]">
<thead>
<tr className="border-b border-border">
<th className="text-left text-xs font-medium text-text-muted px-4 md:px-5 py-3 uppercase tracking-wider">Étudiant</th>
<th className="text-left text-xs font-medium text-text-muted px-4 md:px-5 py-3 uppercase tracking-wider">Statut</th>
<th className="text-left text-xs font-medium text-text-muted px-4 md:px-5 py-3 uppercase tracking-wider">Score</th>
<th className="text-left text-xs font-medium text-text-muted px-4 md:px-5 py-3 uppercase tracking-wider hidden sm:table-cell">Arrivée</th>
<th className="text-left text-xs font-medium text-text-muted px-4 md:px-5 py-3 uppercase tracking-wider hidden sm:table-cell">Fin</th>
</tr>
))}
</tbody>
</table>
</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-4 md:px-5 py-3.5">
<div className="flex items-center gap-2 md:gap-3">
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-semibold flex-shrink-0">
{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-4 md:px-5 py-3.5">
<span className={cn(
'text-xs px-2.5 py-1 rounded-full border font-medium whitespace-nowrap',
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-4 md: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-4 md:px-5 py-3.5 text-sm text-text-secondary hidden sm:table-cell">
{formatTime(p.started_at)}
</td>
<td className="px-4 md:px-5 py-3.5 text-sm text-text-secondary hidden sm:table-cell">
{p.completed_at ? formatTime(p.completed_at) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>

View File

@ -345,10 +345,10 @@ export default function CreateSessionClient({ quizzes }: Props) {
}
return (
<div className="p-8">
<div className="p-4 md: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">
<h1 className="text-2xl md:text-3xl font-bold text-text-primary mb-2">Configurer une session</h1>
<p className="text-text-secondary mb-6 md:mb-8 text-sm md:text-base">
Remplissez les informations ci-dessous pour générer un lien d&apos;accès unique pour vos participants.
</p>
@ -377,7 +377,7 @@ export default function CreateSessionClient({ quizzes }: Props) {
<h2 className="font-semibold text-text-primary">Contexte & Participants</h2>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="grid grid-cols-1 sm: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">
@ -406,7 +406,7 @@ export default function CreateSessionClient({ quizzes }: Props) {
</div>
</div>
<div className="w-1/2">
<div className="w-full sm: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" />
@ -423,7 +423,7 @@ export default function CreateSessionClient({ quizzes }: Props) {
</div>
{/* Footer actions */}
<div className="px-6 py-4 border-t border-border bg-background-secondary/50 flex items-center justify-between">
<div className="px-4 md:px-6 py-4 border-t border-border bg-background-secondary/50 flex items-center justify-between gap-3">
<Link href="/dashboard" className="btn-secondary">
Annuler
</Link>

View File

@ -14,9 +14,9 @@ export default async function SettingsPage() {
.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="p-4 md:p-8">
<h1 className="text-2xl md:text-3xl font-bold text-text-primary mb-2">Paramètres</h1>
<p className="text-text-secondary mb-6 md: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">

View File

@ -44,7 +44,7 @@ export default function LoginPage() {
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">
<header className="flex items-center justify-between px-4 md:px-10 py-4 md: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">

View File

@ -41,6 +41,8 @@ export default function StudentJoinClient({ sessionCode, quizTitle, schoolName,
if (!res.ok) {
if (data.code === 'SESSION_INACTIVE') {
setError('Ce quiz est terminé. Vous ne pouvez plus rejoindre cette session.')
} else if (data.code === 'SESSION_FULL') {
setError(data.error ?? 'Cette session est complète.')
} else {
setError(data.error ?? 'Erreur lors de la connexion')
}
@ -57,7 +59,7 @@ export default function StudentJoinClient({ sessionCode, quizTitle, schoolName,
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">
<header className="flex items-center justify-between px-4 md:px-10 py-4 md: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">
@ -81,7 +83,7 @@ export default function StudentJoinClient({ sessionCode, quizTitle, schoolName,
</div>
</div>
<div className="p-8">
<div className="p-5 md: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">

View File

@ -119,9 +119,9 @@ export default function ExamClient({ sessionCode, participationId, studentName,
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">
<header className="border-b border-border bg-background-secondary px-4 md:px-8 py-3 md:py-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center flex-shrink-0">
<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>
@ -129,21 +129,21 @@ export default function ExamClient({ sessionCode, participationId, studentName,
<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>
<div className="flex items-center gap-3 md:gap-6">
<div className="flex items-center gap-1.5 bg-background-card border border-border rounded-lg px-2.5 py-1.5">
<span className="text-xs text-text-muted hidden sm:inline">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">
<div className="w-7 h-7 md:w-8 md:h-8 bg-primary/20 rounded-full flex items-center justify-center text-primary text-xs font-bold flex-shrink-0">
{studentName.split(' ').map((n) => n[0]).join('')}
</div>
<span className="text-sm text-text-secondary">{studentName}</span>
<span className="text-xs md:text-sm text-text-secondary hidden sm:inline">{studentName}</span>
</div>
</div>
</header>
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto px-3 md:px-4 py-4 md:py-8">
{/* Progress */}
<div className="mb-8">
<div className="flex items-center justify-between mb-3">
@ -166,9 +166,9 @@ export default function ExamClient({ sessionCode, participationId, studentName,
</div>
{/* Question layout */}
<div className="flex gap-6">
{/* Image placeholder */}
<div className="w-72 flex-shrink-0">
<div className="flex flex-col md:flex-row gap-4 md:gap-6">
{/* Image placeholder — masquée sur mobile */}
<div className="hidden md:block 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">
@ -183,7 +183,7 @@ export default function ExamClient({ sessionCode, participationId, studentName,
</div>
{/* Question & Answers */}
<div className="flex-1">
<div className="flex-1 min-w-0">
<div className="card p-6 mb-4">
<h2 className="text-lg font-semibold text-text-primary mb-1">
Question {currentIndex + 1}
@ -229,12 +229,12 @@ export default function ExamClient({ sessionCode, participationId, studentName,
)}
{/* Navigation */}
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-2">
<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',
'flex items-center gap-1.5 text-sm px-3 md: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'

View File

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import ExamClient from './ExamClient'
@ -17,8 +17,7 @@ export default async function ExamPage({
redirect(`/quiz/${code}`)
}
const supabase = await createClient()
const db = supabase as any
const db = createAdminClient() as any
const { data: session } = await db
.from('sessions')

View File

@ -52,9 +52,9 @@ export default function ResultsClient({ results, sessionCode }: Props) {
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">
<header className="border-b border-border bg-background-secondary px-4 md:px-8 py-3 md: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">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center flex-shrink-0">
<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>
@ -62,16 +62,16 @@ export default function ResultsClient({ results, sessionCode }: Props) {
<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">
<div className="w-7 h-7 md:w-8 md:h-8 bg-primary/20 rounded-full flex items-center justify-center text-primary text-xs font-bold flex-shrink-0">
{student.first_name[0]}{student.last_name[0]}
</div>
<span className="text-sm text-text-secondary">{student.first_name} {student.last_name}</span>
<span className="text-xs md: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">
<div className="max-w-3xl mx-auto px-3 md:px-4 py-6 md: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="card p-5 md:p-8 mb-6 md: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">
@ -93,7 +93,7 @@ export default function ResultsClient({ results, sessionCode }: Props) {
{getScoreMessage(summary.score)}
</p>
<div className="flex items-center justify-center gap-8 text-sm">
<div className="flex flex-wrap items-center justify-center gap-4 md:gap-8 text-sm">
<div className="flex items-center gap-2 text-green-400">
<CheckCircle size={16} />
<span><strong>{summary.correct}</strong> correctes</span>

View File

@ -1,5 +1,6 @@
'use client'
import { useState } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
@ -10,11 +11,15 @@ import {
BarChart3,
Settings,
LogOut,
Shield,
Menu,
X,
} from 'lucide-react'
import { cn } from '@/lib/utils'
interface SidebarProps {
username: string
email: string
role: string
}
@ -26,9 +31,10 @@ const navLinks = [
{ href: '/dashboard/settings', label: 'Paramètres', icon: Settings },
]
export default function Sidebar({ username, role }: SidebarProps) {
export default function Sidebar({ username, email, role }: SidebarProps) {
const pathname = usePathname()
const router = useRouter()
const [mobileOpen, setMobileOpen] = useState(false)
const handleLogout = async () => {
const supabase = createClient()
@ -37,13 +43,15 @@ export default function Sidebar({ username, role }: SidebarProps) {
router.refresh()
}
return (
<aside className="w-64 bg-background-secondary border-r border-border flex flex-col h-screen sticky top-0">
const closeMobile = () => setMobileOpen(false)
const sidebarContent = (
<>
{/* Brand */}
<div className="p-6 border-b border-border">
<div className="p-5 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">
<div className="w-10 h-10 bg-primary rounded-xl flex items-center justify-center shadow-lg shadow-primary/30 flex-shrink-0">
<svg width="20" height="20" 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>
@ -62,6 +70,7 @@ export default function Sidebar({ username, role }: SidebarProps) {
<Link
key={href}
href={href}
onClick={closeMobile}
className={cn('sidebar-link', isActive && 'sidebar-link-active')}
>
<Icon size={18} />
@ -71,6 +80,21 @@ export default function Sidebar({ username, role }: SidebarProps) {
})}
</nav>
{/* Section admin */}
{role === 'admin' && (
<div className="px-4 pb-2 border-t border-border pt-3 mt-1">
<p className="text-xs font-medium text-text-muted uppercase tracking-wider px-3 mb-1">Administration</p>
<Link
href="/dashboard/admin/users"
onClick={closeMobile}
className={cn('sidebar-link', pathname.startsWith('/dashboard/admin') && 'sidebar-link-active')}
>
<Shield size={18} />
<span>Utilisateurs</span>
</Link>
</div>
)}
{/* User profile + logout */}
<div className="border-t border-border p-4">
<div className="flex items-center gap-3 mb-3">
@ -79,7 +103,7 @@ export default function Sidebar({ username, role }: SidebarProps) {
</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>
<p className="text-xs text-text-muted truncate">{email}</p>
</div>
</div>
<button
@ -90,6 +114,49 @@ export default function Sidebar({ username, role }: SidebarProps) {
<span>Déconnexion</span>
</button>
</div>
</aside>
</>
)
return (
<>
{/* Bouton hamburger — mobile uniquement */}
<button
onClick={() => setMobileOpen(true)}
className="lg:hidden fixed top-4 left-4 z-40 p-2 bg-background-secondary border border-border rounded-lg shadow-md"
aria-label="Ouvrir le menu"
>
<Menu size={20} className="text-text-primary" />
</button>
{/* Overlay mobile */}
{mobileOpen && (
<div
className="lg:hidden fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
onClick={closeMobile}
/>
)}
{/* Sidebar desktop (sticky) */}
<aside className="hidden lg:flex w-64 flex-shrink-0 bg-background-secondary border-r border-border flex-col h-screen sticky top-0">
{sidebarContent}
</aside>
{/* Sidebar mobile (drawer) */}
<aside
className={cn(
'lg:hidden fixed top-0 left-0 z-50 w-72 bg-background-secondary border-r border-border flex flex-col h-screen transition-transform duration-300',
mobileOpen ? 'translate-x-0' : '-translate-x-full'
)}
>
{/* Bouton fermer */}
<button
onClick={closeMobile}
className="absolute top-4 right-4 p-1.5 hover:bg-background-elevated rounded-lg text-text-muted hover:text-text-primary transition-colors"
>
<X size={18} />
</button>
{sidebarContent}
</aside>
</>
)
}

2
next-env.d.ts vendored
View File

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