- Wizard IA: création de carnet complet (étudiant/prof/ingénieur)
- 3 profils, choix niveau, nombre de notes (3-12)
- Étapes numérotées, messages de progression, écran de succès
- Notes riches avec callouts, toggles, équations, colonnes
- Structured View auto-créé avec propriétés Statut/Difficulté
- Embeddings en arrière-plan
- Export PDF: clone le DOM réel, rend KaTeX, préserve couleurs callouts
- Fix: boutons toggle/callout en hover-only
- Fix: parsing JSON robuste (backslashes LaTeX)
- Fix: flushSync warning (queueMicrotask)
- Fix: drag handle clamp viewport
- Bouton wizard dans la sidebar (✨ à côté du +)
- i18n FR/EN complet
318 lines
14 KiB
TypeScript
318 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { GraduationCap, BookOpen, Wrench, Sparkles, Loader2, X, ArrowRight, ArrowLeft, Check, FileText } from 'lucide-react'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { cn } from '@/lib/utils'
|
|
import { toast } from 'sonner'
|
|
|
|
type WizardProfile = 'student' | 'teacher' | 'engineer'
|
|
|
|
interface ProfileOption {
|
|
id: WizardProfile
|
|
icon: typeof GraduationCap
|
|
titleKey: string
|
|
descKey: string
|
|
placeholderKey: string
|
|
}
|
|
|
|
const PROFILES: ProfileOption[] = [
|
|
{
|
|
id: 'student',
|
|
icon: GraduationCap,
|
|
titleKey: 'wizard.profileStudent',
|
|
descKey: 'wizard.profileStudentDesc',
|
|
placeholderKey: 'wizard.topicStudentPlaceholder',
|
|
},
|
|
{
|
|
id: 'teacher',
|
|
icon: BookOpen,
|
|
titleKey: 'wizard.profileTeacher',
|
|
descKey: 'wizard.profileTeacherDesc',
|
|
placeholderKey: 'wizard.topicTeacherPlaceholder',
|
|
},
|
|
{
|
|
id: 'engineer',
|
|
icon: Wrench,
|
|
titleKey: 'wizard.profileEngineer',
|
|
descKey: 'wizard.profileEngineerDesc',
|
|
placeholderKey: 'wizard.topicEngineerPlaceholder',
|
|
},
|
|
]
|
|
|
|
const LEVELS = ['wizard.levelBeginner', 'wizard.levelIntermediate', 'wizard.levelAdvanced', 'wizard.levelExpert']
|
|
|
|
export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void; onComplete: (notebookId: string) => void }) {
|
|
const { t, language } = useLanguage()
|
|
const [step, setStep] = useState<0 | 1 | 2>(0)
|
|
const [profile, setProfile] = useState<WizardProfile | null>(null)
|
|
const [topic, setTopic] = useState('')
|
|
const [level, setLevel] = useState(1)
|
|
const [count, setCount] = useState(6)
|
|
const [loading, setLoading] = useState(false)
|
|
const [progressMsg, setProgressMsg] = useState('')
|
|
const [success, setSuccess] = useState<{ notebookId: string; notebookName: string; noteTitles: string[] } | null>(null)
|
|
|
|
const handleSubmit = async () => {
|
|
if (!profile || !topic.trim()) return
|
|
setLoading(true)
|
|
setProgressMsg(t('wizard.progressGenerating') || 'Génération du contenu par l\'IA...')
|
|
try {
|
|
setProgressMsg(t('wizard.progressCalling') || 'Appel de l\'IA...')
|
|
const res = await fetch('/api/ai/notebook-wizard', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
profile,
|
|
topic: topic.trim(),
|
|
level: t(LEVELS[level]),
|
|
count,
|
|
language,
|
|
notebookName: topic.trim(),
|
|
}),
|
|
})
|
|
|
|
setProgressMsg(t('wizard.progressParsing') || 'Analyse de la réponse...')
|
|
const data = await res.json()
|
|
|
|
if (!res.ok) {
|
|
if (data.errorKey === 'ai.featureLocked') {
|
|
toast.error(t('ai.featureLocked') || 'Cette fonctionnalité nécessite un plan supérieur')
|
|
} else if (data.errorKey === 'ai.quotaExceeded') {
|
|
toast.error(t('ai.quotaExceeded') || 'Quota IA dépassé')
|
|
} else {
|
|
toast.error(data.error || 'Erreur')
|
|
}
|
|
setLoading(false)
|
|
return
|
|
}
|
|
|
|
setProgressMsg(t('wizard.progressCreating') || 'Création du carnet et des notes...')
|
|
|
|
setSuccess({
|
|
notebookId: data.notebookId,
|
|
notebookName: data.notebookName || topic,
|
|
noteTitles: data.noteTitles || [],
|
|
})
|
|
setLoading(false)
|
|
} catch (e: any) {
|
|
toast.error(e.message || 'Erreur')
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const selectedProfile = PROFILES.find(p => p.id === profile)
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" dir="auto" onClick={onClose}>
|
|
<div
|
|
className="w-full max-w-2xl rounded-2xl border border-border bg-card shadow-2xl overflow-hidden"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50 bg-brand-accent/5">
|
|
<div className="flex items-center gap-2">
|
|
<Sparkles className="h-5 w-5 text-brand-accent" />
|
|
<h2 className="text-base font-semibold">{t('wizard.title')}</h2>
|
|
</div>
|
|
<button onClick={onClose} className="p-1 rounded-lg hover:bg-muted text-muted-foreground">
|
|
<X className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Progress bar */}
|
|
<div className="flex items-center px-6 pt-3 gap-2">
|
|
{[0, 1, 2].map(s => (
|
|
<div key={s} className="flex items-center gap-2 flex-1">
|
|
<div className={cn(
|
|
'w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold transition-colors flex-shrink-0',
|
|
s <= step ? 'bg-brand-accent text-white' : 'bg-border text-muted-foreground'
|
|
)}>
|
|
{s + 1}
|
|
</div>
|
|
{s < 2 && <div className={cn('h-0.5 flex-1 rounded-full transition-colors', s < step ? 'bg-brand-accent' : 'bg-border')} />}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="p-6 min-h-[360px]">
|
|
{success ? (
|
|
<div className="flex flex-col items-center py-6 gap-4">
|
|
<div className="w-14 h-14 rounded-full bg-green-100 dark:bg-green-950/40 flex items-center justify-center">
|
|
<Check className="h-7 w-7 text-green-600 dark:text-green-400" />
|
|
</div>
|
|
<div className="text-center">
|
|
<h3 className="text-base font-semibold mb-1">
|
|
{t('wizard.created') || 'Carnet créé :'} <span className="text-brand-accent">{success.notebookName}</span>
|
|
</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
{success.noteTitles.length} {t('wizard.notesCreated') || 'notes créées'}
|
|
</p>
|
|
</div>
|
|
<div className="w-full max-h-48 overflow-y-auto rounded-xl border border-border bg-muted/20 p-3 space-y-1.5">
|
|
{success.noteTitles.map((title, i) => (
|
|
<div key={i} className="flex items-center gap-2 text-sm py-1">
|
|
<FileText size={14} className="text-brand-accent flex-shrink-0" />
|
|
<span className="truncate">{title}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<button
|
|
onClick={() => onComplete(success.notebookId)}
|
|
className="flex items-center gap-2 px-5 py-2.5 text-sm rounded-lg bg-brand-accent text-white hover:bg-brand-accent/90 transition-colors font-medium"
|
|
>
|
|
<Sparkles className="h-4 w-4" />
|
|
{t('wizard.openNotebook') || 'Ouvrir le carnet'}
|
|
</button>
|
|
</div>
|
|
) : loading ? (
|
|
<div className="flex flex-col items-center justify-center py-16 gap-4">
|
|
<Loader2 className="h-10 w-10 animate-spin text-brand-accent" />
|
|
<p className="text-sm text-muted-foreground text-center max-w-sm">
|
|
{progressMsg}
|
|
</p>
|
|
<div className="flex flex-wrap gap-2 justify-center mt-2">
|
|
{['Toggle', 'Callout', 'Math', 'Colonnes', 'Sommaire', 'Link Preview'].map(tag => (
|
|
<span key={tag} className="text-[10px] px-2 py-0.5 rounded-full bg-brand-accent/10 text-brand-accent font-medium">
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : step === 0 ? (
|
|
/* Step 1: Choose profile */
|
|
<div className="space-y-3">
|
|
<p className="text-sm text-muted-foreground mb-4">{t('wizard.chooseProfile')}</p>
|
|
{PROFILES.map(p => (
|
|
<button
|
|
key={p.id}
|
|
onClick={() => { setProfile(p.id); setStep(1) }}
|
|
className="w-full flex items-start gap-3 p-4 rounded-xl border border-border hover:border-brand-accent/40 hover:bg-brand-accent/[0.03] transition-all text-left group"
|
|
>
|
|
<div className="w-10 h-10 rounded-lg bg-brand-accent/10 flex items-center justify-center flex-shrink-0 group-hover:bg-brand-accent/20 transition-colors">
|
|
<p.icon className="h-5 w-5 text-brand-accent" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="text-sm font-semibold mb-0.5">{t(p.titleKey)}</h3>
|
|
<p className="text-xs text-muted-foreground leading-relaxed">{t(p.descKey)}</p>
|
|
</div>
|
|
<ArrowRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-brand-accent transition-colors mt-1" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : step === 1 ? (
|
|
/* Step 2: Topic + options */
|
|
<div className="space-y-5">
|
|
<div>
|
|
<label className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground mb-2 block">
|
|
{t('wizard.topic')}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={topic}
|
|
onChange={(e) => setTopic(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter' && topic.trim()) { setStep(2) } }}
|
|
placeholder={selectedProfile ? t(selectedProfile.placeholderKey) : ''}
|
|
autoFocus
|
|
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-brand-accent/30"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground mb-2 block">
|
|
{t('wizard.level')}
|
|
</label>
|
|
<div className="flex gap-2">
|
|
{LEVELS.map((lvlKey, i) => (
|
|
<button
|
|
key={lvlKey}
|
|
onClick={() => setLevel(i)}
|
|
className={cn(
|
|
'flex-1 py-2 rounded-lg text-xs font-medium border transition-all',
|
|
level === i
|
|
? 'border-brand-accent bg-brand-accent/10 text-brand-accent'
|
|
: 'border-border text-muted-foreground hover:border-foreground/20'
|
|
)}
|
|
>
|
|
{t(lvlKey)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground mb-2 block">
|
|
{t('wizard.noteCount')}: {count}
|
|
</label>
|
|
<input
|
|
type="range"
|
|
min={3}
|
|
max={12}
|
|
value={count}
|
|
onChange={(e) => setCount(Number(e.target.value))}
|
|
className="w-full accent-brand-accent"
|
|
/>
|
|
<div className="flex justify-between text-[10px] text-muted-foreground mt-1">
|
|
<span>3</span>
|
|
<span>12</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-between pt-2">
|
|
<button onClick={() => setStep(0)} className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
|
<ArrowLeft className="h-4 w-4" />
|
|
{t('general.back') || 'Retour'}
|
|
</button>
|
|
<button
|
|
onClick={() => setStep(2)}
|
|
disabled={!topic.trim()}
|
|
className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg bg-brand-accent text-white hover:bg-brand-accent/90 disabled:opacity-40 transition-colors font-medium"
|
|
>
|
|
{t('general.continue') || 'Continuer'}
|
|
<ArrowRight className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
/* Step 3: Confirm */
|
|
<div className="space-y-5">
|
|
<div className="rounded-xl border border-border bg-muted/20 p-4 space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
{selectedProfile && <selectedProfile.icon className="h-4 w-4 text-brand-accent" />}
|
|
<span className="text-sm font-medium">{selectedProfile && t(selectedProfile.titleKey)}</span>
|
|
</div>
|
|
<div className="space-y-1.5 text-sm">
|
|
<div className="flex justify-between"><span className="text-muted-foreground">{t('wizard.topic')}</span><span className="font-medium">{topic}</span></div>
|
|
<div className="flex justify-between"><span className="text-muted-foreground">{t('wizard.level')}</span><span className="font-medium">{t(LEVELS[level])}</span></div>
|
|
<div className="flex justify-between"><span className="text-muted-foreground">{t('wizard.noteCount')}</span><span className="font-medium">{count} {t('wizard.notes')}</span></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-lg bg-brand-accent/5 border border-brand-accent/20 p-3">
|
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
{t('wizard.confirmHint') || 'L\'IA va créer un carnet avec des notes riches : encadrés, sections repliables, formules mathématiques, colonnes de comparaison, et plus.'}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex justify-between pt-2">
|
|
<button onClick={() => setStep(1)} className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
|
<ArrowLeft className="h-4 w-4" />
|
|
{t('general.back') || 'Retour'}
|
|
</button>
|
|
<button
|
|
onClick={handleSubmit}
|
|
className="flex items-center gap-2 px-5 py-2.5 text-sm rounded-lg bg-brand-accent text-white hover:bg-brand-accent/90 transition-colors font-medium"
|
|
>
|
|
<Sparkles className="h-4 w-4" />
|
|
{t('wizard.generate') || 'Générer le carnet'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|