295 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|