SolyQuiz/app/dashboard/sessions/create/CreateSessionClient.tsx

460 lines
17 KiB
TypeScript

'use client'
import { useState, useRef, useEffect, useMemo } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import {
BookOpen,
Users,
Building,
GraduationCap,
Copy,
Check,
Link as LinkIcon,
Search,
ChevronDown,
Hash,
X,
} from 'lucide-react'
import { cn } from '@/lib/utils'
interface Quiz {
id: string
title: string
questions?: { id: string }[]
subchapter?: {
name: string
category?: { name: string }
} | null
}
interface Props {
quizzes: Quiz[]
}
interface CreatedSession {
id: string
short_code: string
url: string
}
// ——— Custom quiz dropdown ———
interface GroupedCategory {
categoryName: string
subchapters: {
subchapterName: string
quizzes: Quiz[]
}[]
}
function QuizDropdown({
quizzes,
value,
onChange,
}: {
quizzes: Quiz[]
value: string
onChange: (id: string) => void
}) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const ref = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
const selected = quizzes.find((q) => q.id === value)
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
useEffect(() => {
if (open) setTimeout(() => searchRef.current?.focus(), 50)
}, [open])
const grouped = useMemo<GroupedCategory[]>(() => {
const q = search.toLowerCase()
const filtered = quizzes.filter(
(quiz) =>
!q ||
quiz.title.toLowerCase().includes(q) ||
quiz.subchapter?.name.toLowerCase().includes(q) ||
quiz.subchapter?.category?.name.toLowerCase().includes(q)
)
const map = new Map<string, Map<string, Quiz[]>>()
for (const quiz of filtered) {
const catName = quiz.subchapter?.category?.name ?? 'Sans catégorie'
const subName = quiz.subchapter?.name ?? 'Sans chapitre'
if (!map.has(catName)) map.set(catName, new Map())
const subMap = map.get(catName)!
if (!subMap.has(subName)) subMap.set(subName, [])
subMap.get(subName)!.push(quiz)
}
return Array.from(map.entries()).map(([categoryName, subMap]) => ({
categoryName,
subchapters: Array.from(subMap.entries()).map(([subchapterName, quizzes]) => ({
subchapterName,
quizzes,
})),
}))
}, [quizzes, search])
const totalFiltered = grouped.reduce(
(acc, g) => acc + g.subchapters.reduce((a, s) => a + s.quizzes.length, 0),
0
)
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={cn(
'input-field w-full flex items-center justify-between text-left transition-all',
open && 'border-primary/60 ring-1 ring-primary/30'
)}
>
<span className={selected ? 'text-text-primary' : 'text-text-muted'}>
{selected ? (
<span className="flex items-center gap-2">
<BookOpen size={14} className="text-primary flex-shrink-0" />
<span className="truncate">{selected.title}</span>
{selected.subchapter && (
<span className="text-text-muted text-xs flex-shrink-0">
{selected.subchapter.category?.name}
</span>
)}
</span>
) : (
'Choisir un modèle de quiz...'
)}
</span>
<ChevronDown
size={16}
className={cn('text-text-muted flex-shrink-0 transition-transform duration-200', open && 'rotate-180')}
/>
</button>
{open && (
<div className="absolute left-0 right-0 top-[calc(100%+6px)] bg-background-card border border-border rounded-xl shadow-2xl shadow-black/50 z-40 overflow-hidden">
{/* Search */}
<div className="p-2 border-b border-border">
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
<input
ref={searchRef}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Rechercher un quiz..."
className="w-full bg-background-elevated border border-border rounded-lg pl-8 pr-8 py-2 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:border-primary/50"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary"
>
<X size={13} />
</button>
)}
</div>
</div>
{/* Options */}
<div className="max-h-64 overflow-y-auto">
{totalFiltered === 0 ? (
<div className="py-8 text-center">
<p className="text-sm text-text-muted">Aucun quiz trouvé</p>
</div>
) : (
grouped.map((group) => (
<div key={group.categoryName}>
{/* Category header */}
<div className="px-3 pt-3 pb-1">
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{group.categoryName}
</p>
</div>
{group.subchapters.map((sub) => (
<div key={sub.subchapterName}>
{/* Subchapter label */}
<div className="px-3 pb-1 pt-0.5">
<p className="text-xs text-text-muted/70 pl-2 border-l border-border">
{sub.subchapterName}
</p>
</div>
{/* Quizzes */}
{sub.quizzes.map((quiz) => (
<button
key={quiz.id}
type="button"
onClick={() => { onChange(quiz.id); setOpen(false); setSearch('') }}
className={cn(
'w-full flex items-center justify-between px-4 py-2.5 hover:bg-background-elevated/80 transition-colors text-left group',
value === quiz.id && 'bg-primary/10'
)}
>
<div className="flex items-center gap-2.5 min-w-0">
{value === quiz.id ? (
<Check size={14} className="text-primary flex-shrink-0" />
) : (
<BookOpen size={14} className="text-text-muted group-hover:text-text-secondary flex-shrink-0 transition-colors" />
)}
<span className={cn(
'text-sm truncate',
value === quiz.id ? 'text-primary font-medium' : 'text-text-primary'
)}>
{quiz.title}
</span>
</div>
{quiz.questions && quiz.questions.length > 0 && (
<span className="flex items-center gap-1 text-xs text-text-muted flex-shrink-0 ml-2">
<Hash size={10} />
{quiz.questions.length} question{quiz.questions.length > 1 ? 's' : ''}
</span>
)}
</button>
))}
</div>
))}
</div>
))
)}
</div>
{quizzes.length > 0 && (
<div className="px-3 py-2 border-t border-border bg-background-secondary/40">
<p className="text-xs text-text-muted">
{totalFiltered} quiz{totalFiltered > 1 ? 's' : ''} disponible{totalFiltered > 1 ? 's' : ''}
</p>
</div>
)}
</div>
)}
</div>
)
}
// ——— Main component ———
export default function CreateSessionClient({ quizzes }: Props) {
const router = useRouter()
const searchParams = useSearchParams()
const preselectedQuizId = searchParams.get('quiz_id') ?? ''
const [quizId, setQuizId] = useState(preselectedQuizId)
const [schoolName, setSchoolName] = useState('')
const [className, setClassName] = useState('')
const [totalParticipants, setTotalParticipants] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [createdSession, setCreatedSession] = useState<CreatedSession | null>(null)
const [copied, setCopied] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!quizId) {
setError('Veuillez sélectionner un quiz')
return
}
setLoading(true)
setError(null)
try {
const res = await fetch('/api/sessions/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
quiz_id: quizId,
school_name: schoolName || null,
class_name: className || null,
total_participants: totalParticipants ? parseInt(totalParticipants) : 0,
}),
})
const data = await res.json()
if (!res.ok) setError(data.error ?? 'Erreur lors de la création de la session')
else setCreatedSession(data.session)
} catch {
setError('Erreur réseau')
} finally {
setLoading(false)
}
}
const handleCopy = async () => {
if (!createdSession) return
const url = `${window.location.origin}/quiz/${createdSession.short_code}`
await navigator.clipboard.writeText(url)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
if (createdSession) {
const sessionUrl = `${typeof window !== 'undefined' ? window.location.origin : ''}/quiz/${createdSession.short_code}`
return (
<div className="p-8">
<div className="max-w-2xl mx-auto">
<div className="card p-8 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-2xl font-bold text-text-primary mb-2">Session créée !</h2>
<p className="text-text-secondary mb-8">
Partagez ce lien avec vos étudiants pour qu&apos;ils rejoignent la session.
</p>
<div className="bg-background-elevated border border-border rounded-xl p-4 mb-6">
<p className="text-xs text-text-muted uppercase tracking-wider mb-2">Code de session</p>
<p className="text-4xl font-mono font-bold text-primary tracking-widest mb-3">
{createdSession.short_code}
</p>
<div className="flex items-center gap-2 bg-background border border-border rounded-lg px-3 py-2">
<LinkIcon size={14} className="text-text-muted flex-shrink-0" />
<span className="text-sm text-text-secondary truncate flex-1">{sessionUrl}</span>
<button
onClick={handleCopy}
className={cn(
'flex items-center gap-1 text-xs px-2 py-1 rounded transition-colors flex-shrink-0',
copied ? 'bg-green-500/20 text-green-400' : 'bg-primary/10 text-primary hover:bg-primary/20'
)}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
{copied ? 'Copié !' : 'Copier'}
</button>
</div>
</div>
<div className="flex gap-3 justify-center">
<Link href={`/dashboard/sessions/${createdSession.id}/live`} className="btn-primary">
<Users size={16} />
Suivre en direct
</Link>
<button
onClick={() => { setCreatedSession(null); setQuizId(''); setSchoolName(''); setClassName(''); setTotalParticipants('') }}
className="btn-secondary"
>
Nouvelle session
</button>
</div>
</div>
</div>
</div>
)
}
return (
<div className="p-4 md:p-8">
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl md:text-3xl font-bold text-text-primary mb-2">Configurer une session</h1>
<p className="text-text-secondary mb-6 md:mb-8 text-sm md:text-base">
Remplissez les informations ci-dessous pour générer un lien d&apos;accès unique pour vos participants.
</p>
<form onSubmit={handleSubmit}>
<div className="card overflow-hidden">
{/* Section 1: Quiz Selection */}
<div className="p-6 border-b border-border">
<div className="flex items-center gap-2 mb-4">
<BookOpen size={18} className="text-primary" />
<h2 className="font-semibold text-text-primary">Sélection du Quiz</h2>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1.5">Quiz à lancer</label>
<QuizDropdown quizzes={quizzes} value={quizId} onChange={setQuizId} />
<p className="text-xs text-text-muted mt-1.5">
Sélectionnez le contenu pédagogique pour cette session.
</p>
</div>
</div>
{/* Section 2: Context */}
<div className="p-6">
<div className="flex items-center gap-2 mb-4">
<Users size={18} className="text-primary" />
<h2 className="font-semibold text-text-primary">Contexte & Participants</h2>
</div>
<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">
<Building size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type="text"
value={schoolName}
onChange={(e) => setSchoolName(e.target.value)}
placeholder="Ex: Tech Institute"
className="input-field pl-9"
/>
</div>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1.5">Nom de la classe / Groupe</label>
<div className="relative">
<GraduationCap size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type="text"
value={className}
onChange={(e) => setClassName(e.target.value)}
placeholder="Ex: Promo 2024 - Dev Web"
className="input-field pl-9"
/>
</div>
</div>
</div>
<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" />
<input
type="number"
value={totalParticipants}
onChange={(e) => setTotalParticipants(e.target.value)}
placeholder="Ex: 25"
min="1"
className="input-field pl-9"
/>
</div>
</div>
</div>
{/* Footer actions */}
<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>
<div className="flex items-center gap-3">
{error && <p className="text-sm text-red-400">{error}</p>}
<button
type="submit"
disabled={loading}
className={cn('btn-primary', loading && 'opacity-70 cursor-not-allowed')}
>
{loading ? (
<>
<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>
Génération...
</>
) : (
<>
<LinkIcon size={16} />
Générer le lien
</>
)}
</button>
</div>
</div>
</div>
</form>
</div>
</div>
)
}