- Ne plus auto-scroller vers Memory Echo : pastille bas de page + reset scroll au titre - Badges notes/carnets générés par l’IA dans la sidebar - Wizard : langue du contenu = langue de la requête ; refresh carnet après création - Voix : erreurs not-allowed/no-speech moins bruyantes
346 lines
15 KiB
TypeScript
346 lines
15 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useRef } 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'
|
|
import { markAiCreatedNotebook } from '@/lib/ai-created-highlight'
|
|
|
|
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 | Promise<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 [opening, setOpening] = useState(false)
|
|
const completedRef = useRef(false)
|
|
|
|
const finishAndOpen = async (notebookId: string, notebookName: string) => {
|
|
if (completedRef.current) return
|
|
completedRef.current = true
|
|
setOpening(true)
|
|
try {
|
|
markAiCreatedNotebook(notebookId)
|
|
const toastMsg = (t('wizard.createdToast') || 'Carnet « {name} » prêt — notes IA disponibles')
|
|
.replace('{name}', notebookName)
|
|
toast.success(toastMsg)
|
|
await onComplete(notebookId)
|
|
} catch (e: any) {
|
|
completedRef.current = false
|
|
setOpening(false)
|
|
toast.error(e?.message || t('general.error') || 'Erreur')
|
|
}
|
|
}
|
|
|
|
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,
|
|
}),
|
|
})
|
|
|
|
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
|
|
}
|
|
|
|
const notebookId = data.notebookId as string
|
|
const notebookName = (data.notebookName || topic) as string
|
|
setSuccess({
|
|
notebookId,
|
|
notebookName,
|
|
noteTitles: data.noteTitles || [],
|
|
})
|
|
setLoading(false)
|
|
// Ouvre tout de suite : refresh sidebar + navigation (plus besoin de F5)
|
|
void finishAndOpen(notebookId, notebookName)
|
|
} 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
|
|
type="button"
|
|
disabled={opening}
|
|
onClick={() => void finishAndOpen(success.notebookId, success.notebookName)}
|
|
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 disabled:opacity-60"
|
|
>
|
|
{opening ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
|
{opening
|
|
? (t('wizard.openingNotebook') || 'Ouverture…')
|
|
: (t('wizard.openNotebook') || 'Ouvrir le carnet')}
|
|
</button>
|
|
<p className="text-[11px] text-muted-foreground text-center max-w-sm">
|
|
{t('wizard.autoOpenHint') || 'Ouverture automatique du carnet et mise à jour de la barre latérale…'}
|
|
</p>
|
|
</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>
|
|
)
|
|
}
|