Ajoute la base organisable par carnet (schéma, champs partagés, valeurs par note) avec activation guidée, tableau éditable, kanban et suppression de colonnes. Corrige le multiselect en vue tableau et enrichit sidebar, grille et i18n FR/EN. Inclut aussi les améliorations flashcards SM-2, l'audit consentement IA et la robustesse du serveur MCP (config, validation, rate-limit, métriques). Co-authored-by: Cursor <cursoragent@cursor.com>
240 lines
8.3 KiB
TypeScript
240 lines
8.3 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useCallback, useEffect } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { GraduationCap, Loader2, Sparkles, X } from 'lucide-react'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
|
import { toast } from 'sonner'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
export type FlashcardStyle = 'qa' | 'cloze' | 'concept'
|
|
|
|
export interface PreviewCard {
|
|
front: string
|
|
back: string
|
|
type: FlashcardStyle
|
|
}
|
|
|
|
interface FlashcardGenerateDialogProps {
|
|
open: boolean
|
|
onClose: () => void
|
|
noteId: string
|
|
noteTitle: string
|
|
onSaved?: (deckId: string) => void
|
|
}
|
|
|
|
export function FlashcardGenerateDialog({
|
|
open,
|
|
onClose,
|
|
noteId,
|
|
noteTitle,
|
|
onSaved,
|
|
}: FlashcardGenerateDialogProps) {
|
|
const { t } = useLanguage()
|
|
const { requestAiConsent } = useAiConsent()
|
|
const [count, setCount] = useState(10)
|
|
const [style, setStyle] = useState<FlashcardStyle>('qa')
|
|
const [loading, setLoading] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
const [cards, setCards] = useState<PreviewCard[] | null>(null)
|
|
const [mounted, setMounted] = useState(false)
|
|
|
|
useEffect(() => {
|
|
setMounted(true)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose()
|
|
}
|
|
document.addEventListener('keydown', onKey)
|
|
return () => document.removeEventListener('keydown', onKey)
|
|
}, [open, onClose])
|
|
|
|
const handleGenerate = useCallback(async () => {
|
|
if (!(await requestAiConsent())) return
|
|
setLoading(true)
|
|
try {
|
|
const res = await fetch('/api/flashcards/generate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ noteId, count, style }),
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) {
|
|
if (data.errorKey) {
|
|
toast.error(t(data.errorKey) || data.error)
|
|
} else {
|
|
toast.error(data.error || t('flashcards.generateFailed'))
|
|
}
|
|
return
|
|
}
|
|
setCards(data.cards || [])
|
|
} catch {
|
|
toast.error(t('flashcards.generateFailed'))
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [count, noteId, requestAiConsent, style, t])
|
|
|
|
const handleSave = useCallback(async () => {
|
|
if (!cards?.length) return
|
|
setSaving(true)
|
|
try {
|
|
const res = await fetch('/api/flashcards/save', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ noteId, cards }),
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) {
|
|
toast.error(
|
|
(data.errorKey ? t(data.errorKey) : null) || data.error || t('flashcards.saveFailed'),
|
|
)
|
|
return
|
|
}
|
|
toast.success(t('flashcards.savedCount', { count: data.savedCount }))
|
|
onSaved?.(data.deckId)
|
|
onClose()
|
|
setCards(null)
|
|
} catch {
|
|
toast.error(t('flashcards.saveFailed'))
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}, [cards, noteId, onClose, onSaved, t])
|
|
|
|
const updateCard = (index: number, field: 'front' | 'back', value: string) => {
|
|
setCards((prev) => {
|
|
if (!prev) return prev
|
|
const next = [...prev]
|
|
next[index] = { ...next[index], [field]: value }
|
|
return next
|
|
})
|
|
}
|
|
|
|
if (!open || !mounted) return null
|
|
|
|
return createPortal(
|
|
<div
|
|
className="fixed inset-0 z-[100] flex items-start sm:items-center justify-center overflow-y-auto bg-black/40 backdrop-blur-sm p-4 sm:p-6"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className="bg-card border border-border rounded-2xl shadow-xl w-full max-w-lg max-h-[min(90dvh,calc(100dvh-2rem))] my-auto overflow-hidden flex flex-col shrink-0"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex shrink-0 items-center justify-between px-5 py-4 border-b border-border/60">
|
|
<div className="flex items-center gap-2">
|
|
<GraduationCap size={18} className="text-brand-accent" />
|
|
<div>
|
|
<h2 className="text-sm font-semibold">{t('flashcards.generateTitle')}</h2>
|
|
<p className="text-[11px] text-muted-foreground truncate max-w-[280px]">{noteTitle}</p>
|
|
</div>
|
|
</div>
|
|
<button type="button" onClick={onClose} className="p-1.5 rounded-lg hover:bg-black/5">
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain p-5 space-y-5 custom-scrollbar">
|
|
{!cards ? (
|
|
<>
|
|
<div>
|
|
<label className="text-[11px] font-bold uppercase tracking-wider text-concrete block mb-2">
|
|
{t('flashcards.cardCount')} ({count})
|
|
</label>
|
|
<input
|
|
type="range"
|
|
min={5}
|
|
max={20}
|
|
value={count}
|
|
onChange={(e) => setCount(Number(e.target.value))}
|
|
className="w-full accent-brand-accent"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-[11px] font-bold uppercase tracking-wider text-concrete block mb-2">
|
|
{t('flashcards.styleLabel')}
|
|
</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{(['qa', 'cloze', 'concept'] as const).map((s) => (
|
|
<button
|
|
key={s}
|
|
type="button"
|
|
onClick={() => setStyle(s)}
|
|
className={cn(
|
|
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
|
|
style === s
|
|
? 'bg-brand-accent/10 border-brand-accent/40 text-brand-accent'
|
|
: 'border-border text-muted-foreground hover:border-brand-accent/30',
|
|
)}
|
|
>
|
|
{t(`flashcards.style.${s}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<p className="text-xs text-muted-foreground">{t('flashcards.previewHint')}</p>
|
|
{cards.map((card, i) => (
|
|
<div key={i} className="p-3 rounded-xl border border-border/60 space-y-2 bg-paper/30">
|
|
<input
|
|
value={card.front}
|
|
onChange={(e) => updateCard(i, 'front', e.target.value)}
|
|
className="w-full text-sm font-medium bg-transparent border-b border-border/40 pb-1 outline-none"
|
|
placeholder={t('flashcards.frontPlaceholder')}
|
|
/>
|
|
<textarea
|
|
value={card.back}
|
|
onChange={(e) => updateCard(i, 'back', e.target.value)}
|
|
rows={2}
|
|
className="w-full text-xs text-muted-foreground bg-transparent outline-none resize-none"
|
|
placeholder={t('flashcards.backPlaceholder')}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="shrink-0 px-5 py-4 border-t border-border/60 flex gap-2 justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-4 py-2 text-xs font-medium rounded-lg border border-border hover:bg-black/[0.03]"
|
|
>
|
|
{t('general.cancel')}
|
|
</button>
|
|
{!cards ? (
|
|
<button
|
|
type="button"
|
|
onClick={handleGenerate}
|
|
disabled={loading}
|
|
className="px-4 py-2 text-xs font-bold rounded-lg bg-brand-accent text-white flex items-center gap-1.5 disabled:opacity-60"
|
|
>
|
|
{loading ? <Loader2 size={14} className="animate-spin" /> : <Sparkles size={14} />}
|
|
{t('flashcards.generateAction')}
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={handleSave}
|
|
disabled={saving}
|
|
className="px-4 py-2 text-xs font-bold rounded-lg bg-brand-accent text-white flex items-center gap-1.5 disabled:opacity-60"
|
|
>
|
|
{saving && <Loader2 size={14} className="animate-spin" />}
|
|
{t('flashcards.confirmSave')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)
|
|
}
|