SolyQuiz/app/dashboard/sessions/[id]/live/LiveSessionClient.tsx

385 lines
16 KiB
TypeScript

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