720 lines
32 KiB
TypeScript
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>
|
|
)
|
|
}
|