460 lines
17 KiB
TypeScript
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'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'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>
|
|
)
|
|
}
|