SolyQuiz/app/dashboard/sessions/[id]/live/LiveSessionClient.tsx
corenthin-lebreton 28aa3b0e10 initial project
2026-02-26 20:10:14 +01:00

295 lines
11 KiB
TypeScript

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