ajout gestion utilisateurs, refacto création quiz, gestion des utilisateurs externes
This commit is contained in:
parent
28aa3b0e10
commit
a376e5d5de
66
app/api/admin/users/[id]/route.ts
Normal file
66
app/api/admin/users/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
65
app/api/admin/users/create/route.ts
Normal file
65
app/api/admin/users/create/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
54
app/api/admin/users/route.ts
Normal file
54
app/api/admin/users/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
73
app/api/answers/[id]/route.ts
Normal file
73
app/api/answers/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
55
app/api/questions/[id]/route.ts
Normal file
55
app/api/questions/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
85
app/api/quizzes/[id]/route.ts
Normal file
85
app/api/quizzes/[id]/route.ts
Normal file
@ -0,0 +1,85 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
60
app/api/sessions/[id]/route.ts
Normal file
60
app/api/sessions/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@ -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 })
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
324
app/dashboard/admin/users/UsersClient.tsx
Normal file
324
app/dashboard/admin/users/UsersClient.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
256
app/dashboard/admin/users/create/page.tsx
Normal file
256
app/dashboard/admin/users/create/page.tsx
Normal 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'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'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>
|
||||
)
|
||||
}
|
||||
47
app/dashboard/admin/users/page.tsx
Normal file
47
app/dashboard/admin/users/page.tsx
Normal 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} />
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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'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'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">
|
||||
|
||||
@ -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>
|
||||
|
||||
376
app/dashboard/quizzes/[id]/edit/QuizEditorClient.tsx
Normal file
376
app/dashboard/quizzes/[id]/edit/QuizEditorClient.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
app/dashboard/quizzes/[id]/edit/page.tsx
Normal file
39
app/dashboard/quizzes/[id]/edit/page.tsx
Normal 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} />
|
||||
}
|
||||
@ -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 ? (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
2
next-env.d.ts
vendored
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user