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 } from 'react'
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Sparkles, X, ShieldAlert, Check } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { motion, AnimatePresence } from 'motion/react'
@@ -15,12 +16,27 @@ interface AiConsentModalProps {
export function AiConsentModal({ open, onClose, onConfirm }: AiConsentModalProps) {
const { t } = useLanguage()
const [remember, setRemember] = useState(true)
const [mounted, setMounted] = useState(false)
return (
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])
if (!mounted) return null
return createPortal(
<AnimatePresence>
{open && (
<div className="fixed inset-0 z-[150] flex items-center justify-center p-4">
{/* Glassmorphism Backdrop */}
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 sm:p-6">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -29,51 +45,51 @@ export function AiConsentModal({ open, onClose, onConfirm }: AiConsentModalProps
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
/>
{/* Modal Container */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ type: 'spring', duration: 0.4 }}
className={cn(
"relative w-full max-w-lg overflow-hidden border border-[var(--border)]",
"bg-[var(--memento-paper)] text-[var(--ink)] shadow-2xl rounded-lg p-6",
"flex flex-col gap-5"
'relative w-full max-w-lg overflow-hidden rounded-2xl border border-border',
'bg-memento-paper dark:bg-background text-foreground shadow-2xl p-6',
'flex flex-col gap-5',
)}
onClick={(e) => e.stopPropagation()}
>
{/* Close Button */}
<button
type="button"
onClick={onClose}
className="absolute top-4 right-4 p-1 rounded-md text-[var(--ink)]/60 hover:text-[var(--ink)] hover:bg-[var(--concrete)] transition-colors"
className="absolute top-4 right-4 p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
<X className="w-5 h-5" />
</button>
{/* Header */}
<div className="flex items-start gap-4">
<div className="p-3 bg-[var(--accent-tint)] text-[var(--accent-color)] rounded-lg">
<BrainIcon className="w-6 h-6 animate-pulse" />
<div className="p-3 bg-brand-accent/10 text-brand-accent rounded-xl">
<BrainIcon className="w-6 h-6" />
</div>
<div className="flex flex-col gap-1 pr-6">
<div className="flex flex-col gap-1 pr-8">
<h3 className="text-lg font-semibold tracking-tight">
{t('consent.ai.modalTitle') || 'Consentement requis pour le traitement par IA'}
</h3>
<span className="text-xs uppercase tracking-wider text-[var(--ink)]/50 font-medium">
<span className="text-xs uppercase tracking-wider text-muted-foreground font-medium">
{t('consent.ai.complianceBadge') || 'Conformité RGPD'}
</span>
</div>
</div>
{/* Content */}
<div className="text-sm leading-relaxed text-[var(--ink)]/80 flex flex-col gap-3">
<p>
<div className="text-sm leading-relaxed text-muted-foreground flex flex-col gap-3">
<p className="text-foreground/90">
{t('consent.ai.modalDescription') ||
"Pour analyser vos notes, PDFs ou sessions de remue-méninges, Memento transmet de manière sécurisée ces données à des API d'IA tierces (OpenAI, Gemini, DeepSeek). Nous appliquons une politique de rétention de données nulle. En acceptant, vous autorisez ce traitement."}
</p>
<div className="p-3 bg-[var(--concrete)] border border-[var(--border)] rounded-md text-xs flex gap-3 items-start">
<ShieldAlert className="w-4 h-4 text-[var(--accent-color)] shrink-0 mt-0.5" />
<div className="p-3 bg-black/[0.03] dark:bg-white/[0.04] border border-border rounded-xl text-xs flex gap-3 items-start">
<ShieldAlert className="w-4 h-4 text-brand-accent shrink-0 mt-0.5" />
<div className="flex flex-col gap-0.5">
<span className="font-semibold">{t('consent.ai.zeroRetentionTitle') || 'Zéro Rétention de Données'}</span>
<span className="font-semibold text-foreground">
{t('consent.ai.zeroRetentionTitle') || 'Zéro Rétention de Données'}
</span>
<span>
{t('consent.ai.zeroRetentionDesc') ||
'Toutes les requêtes sortantes incluent des indicateurs de non-apprentissage pour protéger votre propriété intellectuelle.'}
@@ -82,7 +98,6 @@ export function AiConsentModal({ open, onClose, onConfirm }: AiConsentModalProps
</div>
</div>
{/* Checkbox */}
<label className="flex items-center gap-3 cursor-pointer select-none py-1">
<div className="relative">
<input
@@ -93,35 +108,30 @@ export function AiConsentModal({ open, onClose, onConfirm }: AiConsentModalProps
/>
<div
className={cn(
"w-5 h-5 rounded border border-[var(--border)] transition-colors flex items-center justify-center",
remember ? "bg-[var(--accent-color)] border-[var(--accent-color)]" : "bg-transparent"
'w-5 h-5 rounded border border-border transition-colors flex items-center justify-center',
remember ? 'bg-brand-accent border-brand-accent' : 'bg-transparent',
)}
>
{remember && <Check className="w-3.5 h-3.5 text-white stroke-[3px]" />}
</div>
</div>
<span className="text-xs font-medium text-[var(--ink)]/80">
<span className="text-xs font-medium text-foreground/80">
{t('consent.ai.rememberMe') || 'Se souvenir de mon choix (ne plus demander)'}
</span>
</label>
{/* Actions */}
<div className="flex items-center justify-end gap-3 mt-2">
<div className="flex items-center justify-end gap-3 mt-1">
<button
type="button"
onClick={onClose}
className={cn(
"px-4 py-2 text-xs font-semibold rounded-md border border-[var(--border)]",
"hover:bg-[var(--concrete)] transition-colors bg-transparent text-[var(--ink)]"
)}
className="px-4 py-2 text-xs font-semibold rounded-lg border border-border hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-colors bg-transparent text-foreground"
>
{t('consent.ai.rejectButton') || 'Refuser'}
</button>
<button
type="button"
onClick={() => onConfirm(remember)}
className={cn(
"px-4 py-2 text-xs font-semibold rounded-md text-white shadow-sm transition-opacity hover:opacity-90",
"bg-[var(--accent-color)] flex items-center gap-2"
)}
className="px-4 py-2 text-xs font-semibold rounded-lg text-white shadow-sm transition-opacity hover:opacity-90 bg-brand-accent flex items-center gap-2"
>
<Sparkles className="w-3.5 h-3.5" />
{t('consent.ai.acceptButton') || 'Autoriser et continuer'}
@@ -130,7 +140,8 @@ export function AiConsentModal({ open, onClose, onConfirm }: AiConsentModalProps
</motion.div>
</div>
)}
</AnimatePresence>
</AnimatePresence>,
document.body,
)
}

View File

@@ -69,6 +69,17 @@ export function AiConsentProvider({ children, initialPersistentConsent = false }
const handleConfirm = async (remember: boolean) => {
setModalOpen(false)
const grantConsentLocally = async () => {
if (remember) {
setLocalStorageAiConsent(true)
setPersistentConsent(true)
await updateAISettings({ aiProcessingConsent: true })
} else {
await updateSession({ aiSessionConsent: true })
setSessionConsent(true)
}
}
try {
const res = await fetch('/api/user/ai-consent', {
method: 'POST',
@@ -76,32 +87,48 @@ export function AiConsentProvider({ children, initialPersistentConsent = false }
body: JSON.stringify({ consent: true, remember }),
})
if (!res.ok) {
toast.error(t('consent.ai.auditFailed') || 'Impossible denregistrer votre consentement. Réessayez.')
pendingResolveRef.current?.(false)
pendingResolveRef.current = null
return
}
if (res.ok) {
const data = await res.json().catch(() => ({}))
if (data?.auditLogged === false) {
console.warn('[AiConsentProvider] Consent saved without audit trail')
}
if (remember) {
setLocalStorageAiConsent(true)
setPersistentConsent(true)
try {
await updateAISettings({ aiProcessingConsent: true })
} catch (e) {
console.error('[AiConsentProvider] Failed to sync consent to DB:', e)
if (remember) {
setLocalStorageAiConsent(true)
setPersistentConsent(true)
try {
await updateAISettings({ aiProcessingConsent: true })
} catch (e) {
console.error('[AiConsentProvider] Failed to sync consent to DB:', e)
}
} else {
await updateSession({ aiSessionConsent: true })
setSessionConsent(true)
}
} else {
await updateSession({ aiSessionConsent: true })
setSessionConsent(true)
try {
await grantConsentLocally()
} catch (fallbackError) {
console.error('[AiConsentProvider] Consent fallback failed:', fallbackError)
toast.error(t('consent.ai.auditFailed') || 'Impossible denregistrer votre consentement. Réessayez.')
pendingResolveRef.current?.(false)
pendingResolveRef.current = null
return
}
}
pendingResolveRef.current?.(true)
pendingResolveRef.current = null
} catch (e) {
console.error('[AiConsentProvider] Failed to log consent audit:', e)
toast.error(t('consent.ai.auditFailed') || 'Impossible denregistrer votre consentement. Réessayez.')
pendingResolveRef.current?.(false)
try {
await grantConsentLocally()
pendingResolveRef.current?.(true)
} catch (fallbackError) {
console.error('[AiConsentProvider] Consent fallback failed:', fallbackError)
toast.error(t('consent.ai.auditFailed') || 'Impossible denregistrer votre consentement. Réessayez.')
pendingResolveRef.current?.(false)
}
pendingResolveRef.current = null
}
}