feat: générateur d'exercices + planning de révision IA
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m28s
CI / Deploy production (on server) (push) Has been skipped

- Générateur d'exercices : bouton dans menu note → IA crée 5 exercices
  - Niveaux variés (facile/moyen/difficile) avec emojis 🟢🟡🔴
  - Corrigés détaillés dans des toggles (cliquer pour révéler)
  - Callout warning pour le niveau
  - Notes créées dans le même carnet
- Planning de révision : bouton dans barre carnet → IA crée planning
  - Choix date d'examen
  - Répétition espacée (première lecture → revoir → révision globale)
  - Rappels automatiques ajoutés aux notes (9h le jour J)
  - Vue chronologique avec activités et notes par jour
- Services : exercise-generator.service.ts + study-planner.service.ts
- Endpoints : /api/ai/generate-exercises + /api/ai/study-plan
- i18n FR/EN complet
This commit is contained in:
Antigravity
2026-06-14 19:57:21 +00:00
parent 940c3daf62
commit 104af3149f
11 changed files with 791 additions and 243 deletions

View File

@@ -20,7 +20,7 @@ import {
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
import { Button } from '@/components/ui/button'
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3 } from 'lucide-react'
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays } from 'lucide-react'
import { emitNoteChange } from '@/lib/note-change-sync'
import { useReminderCheck } from '@/hooks/use-reminder-check'
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
@@ -30,6 +30,7 @@ import { useLanguage } from '@/lib/i18n'
import { useEditorUI } from '@/context/editor-ui-context'
import { NoteHistoryModal } from '@/components/note-history-modal'
import { CreateNotebookDialog } from '@/components/create-notebook-dialog'
import { StudyPlannerDialog } from '@/components/wizard/study-planner-dialog'
import { toast } from 'sonner'
import { AnimatePresence, motion } from 'motion/react'
@@ -134,6 +135,7 @@ export function HomeClient({
const [layoutMode, setLayoutMode] = useState<NotesLayoutMode>(initialLayoutMode)
const [addPropertyOpen, setAddPropertyOpen] = useState(false)
const [isEnablingStructured, setIsEnablingStructured] = useState(false)
const [showStudyPlanner, setShowStudyPlanner] = useState(false)
const notebookFilter = searchParams.get('notebook')
const schemaHook = useNotebookSchema(notebookFilter)
@@ -1036,6 +1038,19 @@ export function HomeClient({
<span>{t('notebook.summary')}</span>
</button>
)}
{searchParams.get('notebook') && (
<button
onClick={() => setShowStudyPlanner(true)}
disabled={!initialSettings.aiAssistantEnabled}
className={cn(
"flex items-center gap-2 text-[13px] font-medium transition-opacity",
initialSettings.aiAssistantEnabled ? "text-brand-accent hover:opacity-70" : "text-muted-foreground opacity-50 cursor-not-allowed"
)}
>
<CalendarDays size={16} />
<span>{t('wizard.studyPlanner') || 'Planning'}</span>
</button>
)}
<button
onClick={() => setSortOrder(s => s === 'newest' ? 'oldest' : s === 'oldest' ? 'alpha' : s === 'alpha' ? 'manual' : 'newest')}
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
@@ -1269,6 +1284,14 @@ export function HomeClient({
}}
/>
)}
{showStudyPlanner && currentNotebook && (
<StudyPlannerDialog
notebookId={currentNotebook.id}
notebookName={currentNotebook.name}
onClose={() => setShowStudyPlanner(false)}
/>
)}
</div>
)
}

View File

@@ -19,7 +19,7 @@ import { Badge } from '@/components/ui/badge'
import {
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal,
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer, PenTool, Loader2 as Loader2Icon
} from 'lucide-react'
import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog'
import { NoteShareDialog } from './note-share-dialog'
@@ -235,6 +235,29 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
input.click()
}
const [generatingExercises, setGeneratingExercises] = useState(false)
const handleGenerateExercises = async () => {
if (generatingExercises) return
setGeneratingExercises(true)
try {
const res = await fetch('/api/ai/generate-exercises', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ noteId: note.id, count: 5, language }),
})
const data = await res.json()
if (!res.ok) {
toast.error(data.errorKey === 'ai.featureLocked' ? (t('ai.featureLocked') || 'Plan requis') : (data.error || 'Erreur'))
} else {
toast.success(t('richTextEditor.exercisesGenerated') || `${data.exercises?.length || 0} exercices créés !`)
}
} catch (e: any) {
toast.error(e.message || 'Erreur')
} finally {
setGeneratingExercises(false)
}
}
const handleConvertToRichtext = async () => {
if (isConverting || !state.content.trim()) return
setIsConverting(true)
@@ -456,6 +479,17 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
<Printer className="h-4 w-4 me-2" />
{t('richTextEditor.exportPdf') || 'Exporter en PDF'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleGenerateExercises} disabled={generatingExercises}>
{generatingExercises
? <Loader2Icon className="h-4 w-4 me-2 animate-spin" />
: <PenTool className="h-4 w-4 me-2 text-brand-accent" />
}
{generatingExercises
? (t('richTextEditor.exercisesLoading') || 'Génération...')
: (t('richTextEditor.generateExercises') || 'Générer des exercices')
}
</DropdownMenuItem>
<DropdownMenuItem onClick={openMarkdownImport}>
<FileUp className="h-4 w-4 me-2" />
{t('richTextEditor.importMarkdown')}

View File

@@ -0,0 +1,146 @@
'use client'
import { useState } from 'react'
import { Calendar, Loader2, X, Sparkles, CheckCircle2, BookOpen } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
interface StudyDay {
date: string
noteIds: string[]
noteTitles: string[]
activity: string
}
export function StudyPlannerDialog({
notebookId,
notebookName,
onClose,
}: {
notebookId: string
notebookName: string
onClose: () => void
}) {
const { t } = useLanguage()
const [examDate, setExamDate] = useState('')
const [loading, setLoading] = useState(false)
const [plan, setPlan] = useState<{ days: StudyDay[]; totalDays: number } | null>(null)
const handleGenerate = async () => {
if (!examDate) return
setLoading(true)
try {
const res = await fetch('/api/ai/study-plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notebookId, examDate }),
})
const data = await res.json()
if (!res.ok) {
toast.error(data.errorKey === 'ai.featureLocked' ? (t('ai.featureLocked') || 'Plan requis') : (data.error || 'Erreur'))
} else {
setPlan(data)
toast.success(t('wizard.studyPlanSuccess') || 'Planning créé ! Des rappels ont été ajoutés à vos notes.')
}
} catch (e: any) {
toast.error(e.message || 'Erreur')
} finally {
setLoading(false)
}
}
const today = new Date().toISOString().slice(0, 10)
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-lg rounded-2xl border border-border bg-card shadow-2xl overflow-hidden" onClick={(e) => e.stopPropagation()}>
<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">
<Calendar className="h-5 w-5 text-brand-accent" />
<h2 className="text-base font-semibold">{t('wizard.studyPlanner') || 'Planning de révision'}</h2>
</div>
<button onClick={onClose} className="p-1 rounded-lg hover:bg-muted text-muted-foreground">
<X className="h-5 w-5" />
</button>
</div>
<div className="p-6 min-h-[300px]">
{!plan && !loading && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t('wizard.studyPlannerDesc') || `L'IA va créer un planning de révision pour le carnet "${notebookName}" basé sur la répétition espacée.`}
</p>
<div>
<label className="text-[10px] uppercase tracking-widest font-bold text-muted-foreground mb-2 block">
{t('wizard.examDate') || 'Date de l\'examen'}
</label>
<input
type="date"
value={examDate}
min={today}
onChange={(e) => setExamDate(e.target.value)}
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>
<button
onClick={handleGenerate}
disabled={!examDate}
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-sm rounded-lg bg-brand-accent text-white hover:bg-brand-accent/90 disabled:opacity-40 transition-colors font-medium"
>
<Sparkles className="h-4 w-4" />
{t('wizard.generatePlan') || 'Générer le planning'}
</button>
</div>
)}
{loading && (
<div className="flex flex-col items-center justify-center py-12 gap-3">
<Loader2 className="h-8 w-8 animate-spin text-brand-accent" />
<p className="text-sm text-muted-foreground">{t('wizard.studyPlanLoading') || 'Création du planning...'}</p>
</div>
)}
{plan && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium text-green-600 dark:text-green-400 mb-2">
<CheckCircle2 className="h-5 w-5" />
{plan.totalDays} {t('wizard.daysPlanned') || 'jours planifiés'}
</div>
<div className="max-h-72 overflow-y-auto space-y-2 pr-1">
{plan.days.map((day, i) => (
<div key={i} className="flex items-start gap-3 p-3 rounded-lg border border-border/50 hover:bg-muted/20 transition-colors">
<div className="flex-shrink-0 w-12 text-center">
<div className="text-[10px] uppercase font-bold text-muted-foreground">
{new Date(day.date).toLocaleDateString(undefined, { weekday: 'short' })}
</div>
<div className="text-lg font-bold">
{new Date(day.date).getDate()}
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-foreground mb-1">{day.activity}</p>
{day.noteTitles.length > 0 && (
<div className="flex flex-wrap gap-1">
{day.noteTitles.map((title, j) => (
<span key={j} className="inline-flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded-full bg-brand-accent/10 text-brand-accent">
<BookOpen size={9} />
{title.length > 30 ? title.slice(0, 30) + '...' : title}
</span>
))}
</div>
)}
</div>
</div>
))}
</div>
<p className="text-[11px] text-muted-foreground italic text-center">
{t('wizard.studyPlanReminders') || 'Des rappels ont été ajoutés automatiquement à vos notes.'}
</p>
</div>
)}
</div>
</div>
</div>
)
}