SolyQuiz/app/dashboard/quizzes/QuizzesClient.tsx
corenthin-lebreton 28aa3b0e10 initial project
2026-02-26 20:10:14 +01:00

720 lines
32 KiB
TypeScript

'use client'
import { useState, useRef } from 'react'
import { useRouter } from 'next/navigation'
import {
Search,
ChevronDown,
ChevronUp,
Upload,
Plus as PlusIcon,
BookOpen,
Users,
Grid,
Edit2,
Play,
Trash2,
Check,
X,
Loader2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
interface Quiz {
id: string
title: string
updated_at: string
}
interface Subchapter {
id: string
name: string
quizzes: Quiz[]
}
interface Category {
id: string
name: string
description: string | null
subchapters: Subchapter[]
}
interface Props {
initialCategories: Category[]
stats: {
totalQuizzes: number
totalCategories: number
activeStudents: number
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: '2-digit',
month: 'short',
year: 'numeric',
})
}
export default function QuizzesClient({ initialCategories, stats: initialStats }: Props) {
const router = useRouter()
const [categories, setCategories] = useState<Category[]>(initialCategories)
const [stats, setStats] = useState(initialStats)
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(initialCategories.slice(0, 1).map((c) => c.id))
)
const [search, setSearch] = useState('')
const [uploading, setUploading] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
// --- Category CRUD state ---
const [showNewCategoryModal, setShowNewCategoryModal] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [newCategoryDesc, setNewCategoryDesc] = useState('')
const [creatingCategory, setCreatingCategory] = useState(false)
const [createCategoryError, setCreateCategoryError] = useState<string | null>(null)
const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null)
const [editingCategoryName, setEditingCategoryName] = useState('')
const [savingCategoryId, setSavingCategoryId] = useState<string | null>(null)
const [confirmDeleteCategoryId, setConfirmDeleteCategoryId] = useState<string | null>(null)
const [deletingCategoryId, setDeletingCategoryId] = useState<string | null>(null)
// --- Subchapter CRUD state ---
const [editingSubchapterId, setEditingSubchapterId] = useState<string | null>(null)
const [editingSubchapterName, setEditingSubchapterName] = useState('')
const [savingSubchapterId, setSavingSubchapterId] = useState<string | null>(null)
const [confirmDeleteSubchapterId, setConfirmDeleteSubchapterId] = useState<string | null>(null)
const [deletingSubchapterId, setDeletingSubchapterId] = useState<string | null>(null)
const [addingSubchapterForCategoryId, setAddingSubchapterForCategoryId] = useState<string | null>(null)
const [newSubchapterName, setNewSubchapterName] = useState('')
const [savingNewSubchapter, setSavingNewSubchapter] = useState(false)
const toggleCategory = (id: string) => {
setExpandedCategories((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const filteredCategories = categories.filter((cat) => {
if (!search) return true
const q = search.toLowerCase()
return (
cat.name.toLowerCase().includes(q) ||
cat.subchapters.some((s) => s.name.toLowerCase().includes(q))
)
})
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
setUploadError(null)
const formData = new FormData()
formData.append('file', file)
try {
const res = await fetch('/api/upload-quiz', { method: 'POST', body: formData })
const data = await res.json()
if (!res.ok) setUploadError(data.error ?? "Erreur lors de l'import")
else router.refresh()
} catch {
setUploadError('Erreur réseau')
} finally {
setUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
// --- Category handlers ---
const handleCreateCategory = async () => {
if (!newCategoryName.trim()) return
setCreatingCategory(true)
setCreateCategoryError(null)
try {
const res = await fetch('/api/categories/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newCategoryName.trim(), description: newCategoryDesc.trim() || null }),
})
const data = await res.json()
if (!res.ok) {
setCreateCategoryError(data.error ?? 'Erreur')
} else {
const newCat: Category = { ...data.category, subchapters: [] }
setCategories((prev) => [...prev, newCat])
setStats((s) => ({ ...s, totalCategories: s.totalCategories + 1 }))
setShowNewCategoryModal(false)
setNewCategoryName('')
setNewCategoryDesc('')
setExpandedCategories((prev) => new Set([...prev, newCat.id]))
}
} catch {
setCreateCategoryError('Erreur réseau')
} finally {
setCreatingCategory(false)
}
}
const startEditCategory = (cat: Category) => {
setEditingCategoryId(cat.id)
setEditingCategoryName(cat.name)
setConfirmDeleteCategoryId(null)
}
const handleSaveCategory = async (id: string) => {
if (!editingCategoryName.trim()) return
setSavingCategoryId(id)
try {
const res = await fetch(`/api/categories/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: editingCategoryName.trim() }),
})
if (res.ok) {
setCategories((prev) =>
prev.map((c) => (c.id === id ? { ...c, name: editingCategoryName.trim() } : c))
)
setEditingCategoryId(null)
}
} finally {
setSavingCategoryId(null)
}
}
const handleDeleteCategory = async (id: string) => {
setDeletingCategoryId(id)
try {
const res = await fetch(`/api/categories/${id}`, { method: 'DELETE' })
if (res.ok) {
const cat = categories.find((c) => c.id === id)
const removedSubchapters = cat?.subchapters.length ?? 0
const removedQuizzes = cat?.subchapters.reduce((acc, s) => acc + s.quizzes.length, 0) ?? 0
setCategories((prev) => prev.filter((c) => c.id !== id))
setStats((s) => ({
...s,
totalCategories: Math.max(0, s.totalCategories - 1),
totalQuizzes: Math.max(0, s.totalQuizzes - removedQuizzes),
}))
setConfirmDeleteCategoryId(null)
}
} finally {
setDeletingCategoryId(null)
}
}
// --- Subchapter handlers ---
const startEditSubchapter = (sub: Subchapter) => {
setEditingSubchapterId(sub.id)
setEditingSubchapterName(sub.name)
setConfirmDeleteSubchapterId(null)
}
const handleSaveSubchapter = async (subId: string) => {
if (!editingSubchapterName.trim()) return
setSavingSubchapterId(subId)
try {
const res = await fetch(`/api/subchapters/${subId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: editingSubchapterName.trim() }),
})
if (res.ok) {
setCategories((prev) =>
prev.map((c) => ({
...c,
subchapters: c.subchapters.map((s) =>
s.id === subId ? { ...s, name: editingSubchapterName.trim() } : s
),
}))
)
setEditingSubchapterId(null)
}
} finally {
setSavingSubchapterId(null)
}
}
const handleDeleteSubchapter = async (subId: string) => {
setDeletingSubchapterId(subId)
try {
const res = await fetch(`/api/subchapters/${subId}`, { method: 'DELETE' })
if (res.ok) {
let removedQuizzes = 0
setCategories((prev) =>
prev.map((c) => {
const sub = c.subchapters.find((s) => s.id === subId)
if (sub) removedQuizzes = sub.quizzes.length
return { ...c, subchapters: c.subchapters.filter((s) => s.id !== subId) }
})
)
setStats((s) => ({ ...s, totalQuizzes: Math.max(0, s.totalQuizzes - removedQuizzes) }))
setConfirmDeleteSubchapterId(null)
}
} finally {
setDeletingSubchapterId(null)
}
}
const handleCreateSubchapter = async (categoryId: string) => {
if (!newSubchapterName.trim()) return
setSavingNewSubchapter(true)
try {
const res = await fetch('/api/subchapters/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category_id: categoryId, name: newSubchapterName.trim() }),
})
const data = await res.json()
if (res.ok) {
const newSub: Subchapter = { ...data.subchapter, quizzes: [] }
setCategories((prev) =>
prev.map((c) =>
c.id === categoryId ? { ...c, subchapters: [...c.subchapters, newSub] } : c
)
)
setAddingSubchapterForCategoryId(null)
setNewSubchapterName('')
}
} finally {
setSavingNewSubchapter(false)
}
}
const handlePlaySubchapter = (subchapter: Subchapter) => {
const firstQuiz = subchapter.quizzes[0]
if (firstQuiz) {
router.push(`/dashboard/sessions/create?quiz_id=${firstQuiz.id}`)
} else {
router.push('/dashboard/sessions/create')
}
}
const categoryIcons = ['🔴', '🔵', '🟢', '🟡', '🟣', '🟠']
return (
<div className="p-8">
{/* Header */}
<div className="flex items-start justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-text-primary mb-2">Gestion des Quiz</h1>
<p className="text-text-secondary max-w-lg">
Organisez votre curriculum, gérez les catégories et mettez à jour les contenus pédagogiques.
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setShowNewCategoryModal(true)}
className="btn-secondary flex items-center gap-2"
>
<PlusIcon size={16} />
Nouvelle Catégorie
</button>
<label className={cn('btn-primary cursor-pointer', uploading && 'opacity-70 cursor-not-allowed')}>
<Upload size={16} />
{uploading ? 'Import...' : 'Importer un Quiz (JSON)'}
<input
ref={fileInputRef}
type="file"
accept=".json"
className="hidden"
onChange={handleFileUpload}
disabled={uploading}
/>
</label>
</div>
</div>
{uploadError && (
<div className="mb-6 bg-red-500/10 border border-red-500/30 text-red-400 px-4 py-3 rounded-lg text-sm">
{uploadError}
</div>
)}
{/* Stats */}
<div className="grid grid-cols-3 gap-4 mb-8">
{[
{ icon: BookOpen, label: 'Total Quiz', value: stats.totalQuizzes, color: 'text-green-400' },
{ icon: Grid, label: 'Catégories', value: stats.totalCategories, color: 'text-blue-400' },
{ icon: Users, label: 'Étudiants Actifs', value: stats.activeStudents, color: 'text-purple-400' },
].map(({ icon: Icon, label, value, color }) => (
<div key={label} className="card p-5 flex items-center gap-4">
<div className={cn('w-12 h-12 rounded-xl bg-background-elevated flex items-center justify-center', color)}>
<Icon size={22} />
</div>
<div>
<p className="text-text-secondary text-sm">{label}</p>
<p className="text-2xl font-bold text-text-primary">{value}</p>
</div>
</div>
))}
</div>
{/* Categories Section */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-text-primary">Catégories Actives</h2>
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Rechercher un chapitre..."
className="input-field pl-9 py-2 text-sm w-64"
/>
</div>
</div>
<div className="space-y-3">
{filteredCategories.length === 0 ? (
<div className="card p-12 text-center">
<BookOpen size={32} className="mx-auto text-text-muted mb-3" />
<p className="text-text-secondary">Aucune catégorie trouvée</p>
</div>
) : (
filteredCategories.map((category, index) => {
const isExpanded = expandedCategories.has(category.id)
const totalQuizCount = category.subchapters.reduce((acc, s) => acc + s.quizzes.length, 0)
const isEditingCat = editingCategoryId === category.id
const isConfirmingDelete = confirmDeleteCategoryId === category.id
const isDeletingCat = deletingCategoryId === category.id
return (
<div key={category.id} className="card overflow-hidden">
{/* Category header */}
<div className="flex items-center justify-between p-4">
<button
onClick={() => !isEditingCat && toggleCategory(category.id)}
className="flex items-center gap-3 flex-1 text-left"
>
<span className="text-xl">{categoryIcons[index % categoryIcons.length]}</span>
{isEditingCat ? (
<input
value={editingCategoryName}
onChange={(e) => setEditingCategoryName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveCategory(category.id)
if (e.key === 'Escape') setEditingCategoryId(null)
}}
onClick={(e) => e.stopPropagation()}
className="input-field text-sm py-1.5 w-56"
autoFocus
/>
) : (
<div>
<p className="font-semibold text-text-primary">{category.name}</p>
<p className="text-xs text-text-muted">
{category.subchapters.length} Chapitre{category.subchapters.length > 1 ? 's' : ''} · {totalQuizCount} Quiz
</p>
</div>
)}
</button>
<div className="flex items-center gap-2">
{isEditingCat ? (
<>
<button
onClick={() => handleSaveCategory(category.id)}
disabled={savingCategoryId === category.id}
className="p-1.5 bg-primary/10 hover:bg-primary/20 rounded-md text-primary transition-colors"
>
{savingCategoryId === category.id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Check size={14} />
)}
</button>
<button
onClick={() => setEditingCategoryId(null)}
className="p-1.5 hover:bg-background-elevated rounded-md text-text-muted hover:text-text-primary transition-colors"
>
<X size={14} />
</button>
</>
) : isConfirmingDelete ? (
<div className="flex items-center gap-2 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-1.5">
<span className="text-xs text-red-400">Supprimer ?</span>
<button
onClick={() => handleDeleteCategory(category.id)}
disabled={isDeletingCat}
className="text-xs text-red-400 hover:text-red-300 font-medium transition-colors"
>
{isDeletingCat ? <Loader2 size={12} className="animate-spin" /> : 'Oui'}
</button>
<button
onClick={() => setConfirmDeleteCategoryId(null)}
className="text-xs text-text-muted hover:text-text-primary transition-colors"
>
Non
</button>
</div>
) : (
<>
<span className="text-xs px-2.5 py-1 rounded-full border bg-green-500/10 text-green-400 border-green-500/20 font-medium">
Publié
</span>
<button
onClick={(e) => { e.stopPropagation(); startEditCategory(category) }}
className="p-1.5 hover:bg-background-elevated rounded-md text-text-muted hover:text-text-primary transition-colors"
title="Renommer"
>
<Edit2 size={14} />
</button>
<button
onClick={(e) => { e.stopPropagation(); setConfirmDeleteCategoryId(category.id); setEditingCategoryId(null) }}
className="p-1.5 hover:bg-red-500/10 rounded-md text-text-muted hover:text-red-400 transition-colors"
title="Supprimer"
>
<Trash2 size={14} />
</button>
<button onClick={() => toggleCategory(category.id)}>
{isExpanded ? (
<ChevronUp size={18} className="text-text-muted" />
) : (
<ChevronDown size={18} className="text-text-muted" />
)}
</button>
</>
)}
</div>
</div>
{/* Subchapters table */}
{isExpanded && (
<div className="border-t border-border">
<table className="w-full">
<thead>
<tr className="border-b border-border">
<th className="text-left text-xs font-medium text-text-muted px-6 py-3 uppercase tracking-wider">Chapitre</th>
<th className="text-left text-xs font-medium text-text-muted px-6 py-3 uppercase tracking-wider">Quiz</th>
<th className="text-left text-xs font-medium text-text-muted px-6 py-3 uppercase tracking-wider">Dernière Modif.</th>
<th className="text-right text-xs font-medium text-text-muted px-6 py-3 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody>
{category.subchapters.map((subchapter) => {
const isEditingSub = editingSubchapterId === subchapter.id
const isConfirmingDeleteSub = confirmDeleteSubchapterId === subchapter.id
const isDeletingSub = deletingSubchapterId === subchapter.id
return (
<tr key={subchapter.id} className="border-b border-border/50 hover:bg-background-elevated/30 transition-colors">
<td className="px-6 py-4 text-sm font-medium text-text-primary">
{isEditingSub ? (
<input
value={editingSubchapterName}
onChange={(e) => setEditingSubchapterName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveSubchapter(subchapter.id)
if (e.key === 'Escape') setEditingSubchapterId(null)
}}
className="input-field text-sm py-1 w-48"
autoFocus
/>
) : (
subchapter.name
)}
</td>
<td className="px-6 py-4 text-sm text-text-secondary">
{subchapter.quizzes.length} Quiz
</td>
<td className="px-6 py-4 text-sm text-text-secondary">
{subchapter.quizzes[0] ? formatDate(subchapter.quizzes[0].updated_at) : '—'}
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-1.5">
{isEditingSub ? (
<>
<button
onClick={() => handleSaveSubchapter(subchapter.id)}
disabled={savingSubchapterId === subchapter.id}
className="p-1.5 bg-primary/10 hover:bg-primary/20 rounded-md text-primary transition-colors"
>
{savingSubchapterId === subchapter.id ? (
<Loader2 size={13} className="animate-spin" />
) : (
<Check size={13} />
)}
</button>
<button
onClick={() => setEditingSubchapterId(null)}
className="p-1.5 hover:bg-background-elevated rounded-md text-text-muted hover:text-text-primary transition-colors"
>
<X size={13} />
</button>
</>
) : isConfirmingDeleteSub ? (
<div className="flex items-center gap-2 bg-red-500/10 border border-red-500/20 rounded-lg px-2.5 py-1">
<span className="text-xs text-red-400">Supprimer ?</span>
<button
onClick={() => handleDeleteSubchapter(subchapter.id)}
disabled={isDeletingSub}
className="text-xs text-red-400 hover:text-red-300 font-medium"
>
{isDeletingSub ? <Loader2 size={11} className="animate-spin" /> : 'Oui'}
</button>
<button
onClick={() => setConfirmDeleteSubchapterId(null)}
className="text-xs text-text-muted hover:text-text-primary"
>
Non
</button>
</div>
) : (
<>
<button
onClick={() => startEditSubchapter(subchapter)}
className="p-1.5 hover:bg-background-elevated rounded-md text-text-muted hover:text-text-primary transition-colors"
title="Renommer"
>
<Edit2 size={14} />
</button>
<button
onClick={() => handlePlaySubchapter(subchapter)}
className="p-1.5 hover:bg-primary/10 rounded-md text-text-muted hover:text-primary transition-colors"
title="Lancer une session"
disabled={subchapter.quizzes.length === 0}
>
<Play size={14} />
</button>
<button
onClick={() => { setConfirmDeleteSubchapterId(subchapter.id); setEditingSubchapterId(null) }}
className="p-1.5 hover:bg-red-500/10 rounded-md text-text-muted hover:text-red-400 transition-colors"
title="Supprimer"
>
<Trash2 size={14} />
</button>
</>
)}
</div>
</td>
</tr>
)
})}
{/* Add subchapter row */}
{addingSubchapterForCategoryId === category.id && (
<tr className="border-b border-border/50 bg-background-elevated/20">
<td className="px-6 py-3" colSpan={4}>
<div className="flex items-center gap-2">
<input
value={newSubchapterName}
onChange={(e) => setNewSubchapterName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateSubchapter(category.id)
if (e.key === 'Escape') { setAddingSubchapterForCategoryId(null); setNewSubchapterName('') }
}}
placeholder="Nom du chapitre..."
className="input-field text-sm py-1.5 flex-1 max-w-xs"
autoFocus
/>
<button
onClick={() => handleCreateSubchapter(category.id)}
disabled={savingNewSubchapter || !newSubchapterName.trim()}
className="p-1.5 bg-primary/10 hover:bg-primary/20 rounded-md text-primary transition-colors disabled:opacity-40"
>
{savingNewSubchapter ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
</button>
<button
onClick={() => { setAddingSubchapterForCategoryId(null); setNewSubchapterName('') }}
className="p-1.5 hover:bg-background-elevated rounded-md text-text-muted transition-colors"
>
<X size={14} />
</button>
</div>
</td>
</tr>
)}
</tbody>
</table>
<div className="border-t border-border/50 p-3 flex justify-center">
<button
onClick={() => {
setAddingSubchapterForCategoryId(category.id)
setNewSubchapterName('')
setExpandedCategories((prev) => new Set([...prev, category.id]))
}}
className="text-sm text-primary hover:text-primary-light transition-colors flex items-center gap-1"
>
<PlusIcon size={14} />
Ajouter un chapitre
</button>
</div>
</div>
)}
</div>
)
})
)}
</div>
</div>
{/* New Category Modal */}
{showNewCategoryModal && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-background-card border border-border rounded-2xl w-full max-w-md shadow-2xl shadow-black/50">
<div className="flex items-center justify-between p-6 border-b border-border">
<h2 className="text-lg font-bold text-text-primary">Nouvelle Catégorie</h2>
<button
onClick={() => { setShowNewCategoryModal(false); setCreateCategoryError(null); setNewCategoryName(''); setNewCategoryDesc('') }}
className="p-1.5 hover:bg-background-elevated rounded-lg transition-colors"
>
<X size={18} className="text-text-muted" />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm text-text-secondary mb-1.5">Nom de la catégorie *</label>
<input
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreateCategory()}
placeholder="Ex: Développement Web"
className="input-field"
autoFocus
/>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1.5">Description (optionnelle)</label>
<textarea
value={newCategoryDesc}
onChange={(e) => setNewCategoryDesc(e.target.value)}
placeholder="Décrivez cette catégorie..."
className="input-field resize-none h-20"
/>
</div>
{createCategoryError && (
<p className="text-sm text-red-400">{createCategoryError}</p>
)}
</div>
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-border bg-background-secondary/30 rounded-b-2xl">
<button
onClick={() => { setShowNewCategoryModal(false); setCreateCategoryError(null); setNewCategoryName(''); setNewCategoryDesc('') }}
className="btn-secondary"
>
Annuler
</button>
<button
onClick={handleCreateCategory}
disabled={creatingCategory || !newCategoryName.trim()}
className={cn('btn-primary', (creatingCategory || !newCategoryName.trim()) && 'opacity-60 cursor-not-allowed')}
>
{creatingCategory ? (
<><Loader2 size={16} className="animate-spin" /> Création...</>
) : (
<><PlusIcon size={16} /> Créer la catégorie</>
)}
</button>
</div>
</div>
</div>
)}
</div>
)
}