feat(notes): vues structurées tableau/kanban, flashcards et MCP robuste
Some checks failed
CI / Lint, Test & Build (push) Failing after 57s
CI / Deploy production (on server) (push) Has been skipped

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>
This commit is contained in:
Antigravity
2026-05-24 23:03:16 +00:00
parent ecd7e57c2e
commit 0784c94242
63 changed files with 10133 additions and 619 deletions

View File

@@ -1,6 +1,7 @@
'use client'
import { useState, useCallback } from 'react'
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'
@@ -37,6 +38,20 @@ export function FlashcardGenerateDialog({
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
@@ -75,7 +90,9 @@ export function FlashcardGenerateDialog({
})
const data = await res.json()
if (!res.ok) {
toast.error(data.error || t('flashcards.saveFailed'))
toast.error(
(data.errorKey ? t(data.errorKey) : null) || data.error || t('flashcards.saveFailed'),
)
return
}
toast.success(t('flashcards.savedCount', { count: data.savedCount }))
@@ -98,18 +115,18 @@ export function FlashcardGenerateDialog({
})
}
if (!open) return null
if (!open || !mounted) return null
return (
return createPortal(
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
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-[90vh] overflow-hidden flex flex-col"
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 items-center justify-between px-5 py-4 border-b border-border/60">
<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>
@@ -122,7 +139,7 @@ export function FlashcardGenerateDialog({
</button>
</div>
<div className="flex-1 overflow-y-auto p-5 space-y-5">
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain p-5 space-y-5 custom-scrollbar">
{!cards ? (
<>
<div>
@@ -185,7 +202,7 @@ export function FlashcardGenerateDialog({
)}
</div>
<div className="px-5 py-4 border-t border-border/60 flex gap-2 justify-end">
<div className="shrink-0 px-5 py-4 border-t border-border/60 flex gap-2 justify-end">
<button
type="button"
onClick={onClose}
@@ -216,6 +233,7 @@ export function FlashcardGenerateDialog({
)}
</div>
</div>
</div>
</div>,
document.body,
)
}

View File

@@ -0,0 +1,213 @@
'use client'
import { useState } from 'react'
import { cn } from '@/lib/utils'
interface WeekData {
week: string
rate: number
total: number
}
interface RetentionCurveProps {
data: WeekData[]
className?: string
}
export function RetentionCurve({ data, className }: RetentionCurveProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
// Filtrer les semaines avec données
const weeks = data.filter((w) => w.total > 0)
if (weeks.length === 0) {
return (
<div className="flex items-center justify-center h-28 border border-dashed border-border/40 rounded-xl bg-muted/10">
<p className="text-xs text-concrete/50 italic">Aucune donnée disponible</p>
</div>
)
}
// Dimensions SVG
const width = 500
const height = 120
const padding = { top: 15, right: 30, left: 30, bottom: 25 }
const chartWidth = width - padding.left - padding.right
const chartHeight = height - padding.top - padding.bottom
// Calculer les coordonnées des points
const points = weeks.map((w, i) => {
// Si 1 seul point, on le centre
const x = weeks.length === 1
? padding.left + chartWidth / 2
: padding.left + (i * chartWidth) / (weeks.length - 1)
// Taux entre 0 et 100
const val = Math.max(0, Math.min(100, w.rate))
const y = padding.top + chartHeight * (1 - val / 100)
return { x, y, rate: w.rate, week: w.week, total: w.total }
})
// Créer le path SVG pour la ligne
let linePath = ''
let areaPath = ''
if (points.length === 1) {
// Une seule semaine : ligne pointillée horizontale + point central
const yVal = points[0].y
linePath = `M ${padding.left} ${yVal} L ${width - padding.right} ${yVal}`
} else {
// Plusieurs semaines : tracer la courbe ligne
linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
// Area fermée pour le dégradé sous la courbe
areaPath = `${linePath} L ${points[points.length - 1].x} ${height - padding.bottom} L ${points[0].x} ${height - padding.bottom} Z`
}
const activePoint = hoveredIndex !== null ? points[hoveredIndex] : null
return (
<div className={cn('relative w-full select-none', className)}>
{/* SVG Container */}
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full h-auto overflow-visible"
>
<defs>
{/* Dégradé sous la courbe */}
<linearGradient id="retentionGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--color-brand-accent, #6366f1)" stopOpacity="0.22" />
<stop offset="100%" stopColor="var(--color-brand-accent, #6366f1)" stopOpacity="0.0" />
</linearGradient>
{/* Lueur d'accent */}
<filter id="accentGlow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
{/* Lignes de guide d'arrière-plan (Grid) */}
<g stroke="currentColor" strokeOpacity={0.05} strokeWidth={1}>
<line x1={padding.left} y1={padding.top} x2={width - padding.right} y2={padding.top} />
<line x1={padding.left} y1={padding.top + chartHeight / 2} x2={width - padding.right} y2={padding.top + chartHeight / 2} />
<line x1={padding.left} y1={height - padding.bottom} x2={width - padding.right} y2={height - padding.bottom} />
</g>
{/* Tracé Dégradé Rempli (uniquement si plusieurs points) */}
{points.length > 1 && areaPath && (
<path
d={areaPath}
fill="url(#retentionGrad)"
className="transition-all duration-300"
/>
)}
{/* Ligne principale de la courbe */}
<path
d={linePath}
fill="none"
stroke="var(--color-brand-accent, #6366f1)"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={points.length === 1 ? '4 4' : undefined}
className="transition-all duration-300"
/>
{/* Guide vertical au survol */}
{activePoint && (
<line
x1={activePoint.x}
y1={padding.top}
x2={activePoint.x}
y2={height - padding.bottom}
stroke="var(--color-brand-accent, #6366f1)"
strokeOpacity={0.25}
strokeWidth={1.5}
strokeDasharray="2 2"
/>
)}
{/* Points et zones interactives */}
{points.map((p, idx) => {
const isHovered = hoveredIndex === idx
return (
<g key={idx} className="cursor-pointer">
{/* Cercle extérieur (ombre / lueur d'accent si survol) */}
<circle
cx={p.x}
cy={p.y}
r={isHovered ? 8 : 4}
fill="var(--color-brand-accent, #6366f1)"
fillOpacity={isHovered ? 0.3 : 0.15}
className="transition-all duration-200"
/>
{/* Cercle intérieur (le point lui-même) */}
<circle
cx={p.x}
cy={p.y}
r={isHovered ? 4.5 : 3.5}
fill="var(--color-brand-accent, #6366f1)"
stroke={isHovered ? '#fff' : 'none'}
strokeWidth={1}
className="transition-all duration-200"
filter={isHovered ? 'url(#accentGlow)' : undefined}
/>
{/* Zone invisible large pour faciliter le survol souris/toucher */}
<rect
x={p.x - 20}
y={padding.top}
width={40}
height={chartHeight}
fill="transparent"
onMouseEnter={() => setHoveredIndex(idx)}
onMouseLeave={() => setHoveredIndex(null)}
/>
</g>
);
})}
{/* Labels des semaines sous le graphe (X-Axis) */}
{points.map((p, idx) => {
const label = p.week.slice(5) // MM-DD
const isHovered = hoveredIndex === idx
// Pour éviter la surcharge visuelle, on n'affiche pas tous les labels si trop nombreux, sauf si survolé
const shouldShowLabel = points.length <= 8 || idx === 0 || idx === points.length - 1 || isHovered
if (!shouldShowLabel) return null
return (
<text
key={idx}
x={p.x}
y={height - 8}
textAnchor="middle"
className={cn(
"text-[9px] font-mono fill-concrete/60 select-none transition-colors",
isHovered && "fill-brand-accent font-bold"
)}
>
{label}
</text>
)
})}
</svg>
{/* Floating Info Tooltip */}
<div className="absolute right-0 top-0 h-4 flex items-center">
{activePoint ? (
<div className="text-[10px] text-foreground bg-card border border-border px-2 py-0.5 rounded-lg shadow-sm font-mono flex items-center gap-2 animate-fadeIn">
<span className="font-bold text-brand-accent">{activePoint.rate}% de succès</span>
<span className="text-concrete/60">({activePoint.total} révs)</span>
<span className="text-concrete/40">· sem. {activePoint.week.slice(5)}</span>
</div>
) : (
<span className="text-[9px] text-concrete/40 italic">Survolez un point pour les détails</span>
)}
</div>
</div>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
@@ -15,7 +15,7 @@ interface RevisionHeatmapProps {
}
function intensityClass(count: number, max: number): string {
if (count <= 0) return 'bg-black/[0.04] dark:bg-white/[0.06]'
if (count <= 0) return 'bg-black/[0.06] dark:bg-white/[0.08]'
const ratio = count / Math.max(max, 1)
if (ratio >= 0.75) return 'bg-brand-accent'
if (ratio >= 0.5) return 'bg-brand-accent/70'
@@ -23,47 +23,159 @@ function intensityClass(count: number, max: number): string {
return 'bg-brand-accent/20'
}
export function RevisionHeatmap({ data, className }: RevisionHeatmapProps) {
const { t } = useLanguage()
function resolveDateLocale(langCode: string): string {
if (langCode === 'fa') return 'fa-IR-u-ca-persian-nu-arabext'
return langCode
}
const { cells, maxCount } = useMemo(() => {
export function RevisionHeatmap({ data, className }: RevisionHeatmapProps) {
const { t, language } = useLanguage()
const [hovered, setHovered] = useState<{ label: string; count: number; date: string } | null>(null)
const [selected, setSelected] = useState<{ label: string; count: number; date: string } | null>(null)
const dateLocale = resolveDateLocale(language ?? 'en')
const { cells, maxCount, totalReviews, monthLabels } = useMemo(() => {
const map = new Map(data.map((d) => [d.date, d.count]))
const today = new Date()
const now = new Date()
// todayUTC est le début de la journée courante à minuit UTC
const todayUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
const cells: { date: string; count: number; label: string }[] = []
const monthLabels: { index: number; label: string }[] = []
let lastMonth = -1
for (let i = 89; i >= 0; i--) {
const d = new Date(today)
d.setDate(d.getDate() - i)
const d = new Date(todayUTC)
d.setUTCDate(d.getUTCDate() - i)
const key = d.toISOString().slice(0, 10)
const count = map.get(key) || 0
const month = d.getUTCMonth()
if (month !== lastMonth) {
monthLabels.push({
index: 89 - i,
label: d.toLocaleDateString(dateLocale, { month: 'short', timeZone: 'UTC' }),
})
lastMonth = month
}
cells.push({
date: key,
count: map.get(key) || 0,
label: d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }),
count,
label: d.toLocaleDateString(dateLocale, { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }),
})
}
const maxCount = Math.max(1, ...cells.map((c) => c.count))
return { cells, maxCount }
}, [data])
const totalReviews = cells.reduce((s, c) => s + c.count, 0)
return { cells, maxCount, totalReviews, monthLabels }
}, [data, dateLocale])
const pct = (index: number) => `${(index / 90) * 100}%`
const activeInfo = hovered || selected
return (
<div className={cn('space-y-3', className)}>
<div className={cn('space-y-2', className)}>
{/* En-tête */}
<div className="flex items-center justify-between">
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
{t('flashcards.heatmapTitle')}
</p>
<span className="text-[10px] text-concrete/60">{t('flashcards.heatmapLast90')}</span>
<span className="text-[10px] text-concrete/60">
{totalReviews > 0 ? `${totalReviews} révisions · 90 jours` : t('flashcards.heatmapLast90')}
</span>
</div>
<div className="grid grid-cols-[repeat(15,minmax(0,1fr))] gap-1 sm:grid-cols-[repeat(18,minmax(0,1fr))]">
{cells.map((cell) => (
<div
key={cell.date}
title={`${cell.label}: ${cell.count}`}
className={cn(
'aspect-square rounded-[3px] transition-colors',
intensityClass(cell.count, maxCount),
)}
/>
{/* Labels de mois au-dessus de la grille */}
<div className="relative h-4">
{monthLabels.map((m) => (
<span
key={m.label + m.index}
className="absolute text-[9px] text-concrete/60 font-medium translate-y-0.5"
style={{ left: pct(m.index) }}
>
{m.label}
</span>
))}
</div>
{/* Grille pleine largeur */}
<div className="grid grid-cols-[repeat(15,minmax(0,1fr))] gap-1 sm:grid-cols-[repeat(18,minmax(0,1fr))]">
{cells.map((cell) => {
const isHovered = hovered?.date === cell.date
const isSelected = selected?.date === cell.date
const reviewText = cell.count > 0
? `${cell.count} révision${cell.count > 1 ? 's' : ''}`
: 'Aucune révision'
return (
<button
key={cell.date}
type="button"
title={`${reviewText} - ${cell.label}`}
className={cn(
'aspect-square rounded-[3px] transition-all cursor-pointer focus:outline-none focus:ring-2 focus:ring-brand-accent focus:ring-offset-1 focus:ring-offset-background',
intensityClass(cell.count, maxCount),
(isHovered || isSelected) && 'ring-2 ring-brand-accent ring-offset-1 ring-offset-background scale-105 z-10',
)}
onMouseEnter={() => setHovered({ label: cell.label, count: cell.count, date: cell.date })}
onMouseLeave={() => setHovered(null)}
onClick={() => {
if (selected?.date === cell.date) {
setSelected(null)
} else {
setSelected({ label: cell.label, count: cell.count, date: cell.date })
}
}}
/>
)
})}
</div>
{/* Info au survol / clic */}
<div className="h-6 flex items-center justify-between text-[11px] border-b border-border/20 pb-1">
{activeInfo ? (
<p className="flex items-center gap-1.5 animate-fadeIn">
<span className="font-semibold text-foreground">
{activeInfo.count > 0
? `${activeInfo.count} révision${activeInfo.count > 1 ? 's' : ''}`
: 'Aucune révision'}
</span>
<span className="text-concrete">· {activeInfo.label}</span>
{selected?.date === activeInfo.date && !hovered && (
<span className="text-[9px] bg-brand-accent/10 text-brand-accent px-1.5 py-0.2 rounded-full font-medium">
sélectionné
</span>
)}
</p>
) : (
<p className="text-[10px] text-concrete/40 italic">
Survolez ou cliquez sur un carré pour voir le détail
</p>
)}
{selected && (
<button
type="button"
onClick={() => setSelected(null)}
className="text-[10px] text-brand-accent hover:text-brand-accent/80 hover:underline transition-colors"
>
Effacer la sélection
</button>
)}
</div>
{/* Légende */}
<div className="flex items-center gap-2 pt-0.5">
<span className="text-[9px] text-concrete/50">Moins</span>
<div className="flex gap-0.5">
{['bg-black/[0.06] dark:bg-white/[0.08]', 'bg-brand-accent/20', 'bg-brand-accent/40', 'bg-brand-accent/70', 'bg-brand-accent'].map((cls, i) => (
<div key={i} className={cn('w-3 h-3 rounded-[3px]', cls)} />
))}
</div>
<span className="text-[9px] text-concrete/50">Plus</span>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff