Files
Momento/memento-note/components/personas-panel.tsx
Antigravity e2672cd2c2
Some checks failed
CI / Lint, Test & Build (push) Failing after 1m19s
CI / Deploy production (on server) (push) Has been skipped
feat(notes): liens internes, onglet Réseau, living blocks et consentement IA
Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 14:27:29 +00:00

278 lines
10 KiB
TypeScript

'use client'
import { useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import {
Wrench, TrendingUp, Users, ThumbsDown, Sun,
Loader2, ChevronDown, ChevronUp, X, Copy, CheckCircle, Sparkles,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { MarkdownContent } from '@/components/markdown-content'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
// ── Personas definition ──────────────────────────────────────────────────────
const PERSONAS = [
{
id: 'engineer',
label: 'Ingénieur',
sublabel: 'Faisabilité · Risques tech',
icon: Wrench,
color: 'text-blue-600',
bg: 'bg-blue-50 dark:bg-blue-950/30',
border: 'border-blue-200 dark:border-blue-800/50',
hoverBorder: 'hover:border-blue-400/60',
activeBg: 'bg-blue-600',
},
{
id: 'financial',
label: 'Financier',
sublabel: 'ROI · Coûts · Viabilité',
icon: TrendingUp,
color: 'text-emerald-600',
bg: 'bg-emerald-50 dark:bg-emerald-950/30',
border: 'border-emerald-200 dark:border-emerald-800/50',
hoverBorder: 'hover:border-emerald-400/60',
activeBg: 'bg-emerald-600',
},
{
id: 'customer',
label: 'Client',
sublabel: 'UX · Valeur · Besoins',
icon: Users,
color: 'text-violet-600',
bg: 'bg-violet-50 dark:bg-violet-950/30',
border: 'border-violet-200 dark:border-violet-800/50',
hoverBorder: 'hover:border-violet-400/60',
activeBg: 'bg-violet-600',
},
{
id: 'skeptic',
label: 'Sceptique',
sublabel: 'Points faibles · Angles morts',
icon: ThumbsDown,
color: 'text-rose-600',
bg: 'bg-rose-50 dark:bg-rose-950/30',
border: 'border-rose-200 dark:border-rose-800/50',
hoverBorder: 'hover:border-rose-400/60',
activeBg: 'bg-rose-600',
},
{
id: 'optimist',
label: 'Optimiste',
sublabel: 'Opportunités · Potentiel',
icon: Sun,
color: 'text-amber-600',
bg: 'bg-amber-50 dark:bg-amber-950/30',
border: 'border-amber-200 dark:border-amber-800/50',
hoverBorder: 'hover:border-amber-400/60',
activeBg: 'bg-amber-600',
},
] as const
type PersonaId = (typeof PERSONAS)[number]['id']
interface PersonaResult {
personaId: PersonaId
personaLabel: string
analysis: string
}
interface PersonasPanelProps {
noteTitle?: string
noteContent?: string
}
// ── Component ─────────────────────────────────────────────────────────────────
export function PersonasPanel({ noteTitle, noteContent }: PersonasPanelProps) {
const { requestAiConsent } = useAiConsent()
const [loadingId, setLoadingId] = useState<PersonaId | null>(null)
const [results, setResults] = useState<Map<PersonaId, PersonaResult>>(new Map())
const [expanded, setExpanded] = useState<PersonaId | null>(null)
const [copied, setCopied] = useState<PersonaId | null>(null)
const handlePersona = async (personaId: PersonaId) => {
if (loadingId) return
// Toggle off if already showing
if (expanded === personaId && results.has(personaId)) {
setExpanded(null)
return
}
// If we already have the result, just expand it
if (results.has(personaId)) {
setExpanded(personaId)
return
}
if (!noteContent || noteContent.replace(/<[^>]+>/g, '').trim().split(/\s+/).length < 5) {
return
}
setLoadingId(personaId)
try {
const consented = await requestAiConsent()
if (!consented) return
const res = await fetch('/api/ai/personas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: noteContent, title: noteTitle, personaId }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Erreur')
setResults(prev => new Map(prev).set(personaId, data))
setExpanded(personaId)
} catch {
// silent error — user can retry
} finally {
setLoadingId(null)
}
}
const handleCopy = (personaId: PersonaId) => {
const result = results.get(personaId)
if (!result) return
navigator.clipboard.writeText(result.analysis).catch(() => {})
setCopied(personaId)
setTimeout(() => setCopied(null), 2000)
}
const handleClear = (personaId: PersonaId) => {
setResults(prev => {
const next = new Map(prev)
next.delete(personaId)
return next
})
if (expanded === personaId) setExpanded(null)
}
return (
<div className="space-y-3">
{/* Header */}
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-border/40" />
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-concrete whitespace-nowrap flex items-center gap-1.5">
<Sparkles size={10} className="text-brand-accent" />
Prismes IA
</h4>
<div className="h-px flex-1 bg-border/40" />
</div>
<p className="text-[10px] text-concrete/60 leading-relaxed text-center px-2">
Réinterprétez vos notes à travers différents prismes d'analyse.
</p>
{/* Persona buttons */}
<div className="space-y-2">
{PERSONAS.map((persona) => {
const Icon = persona.icon
const isLoading = loadingId === persona.id
const hasResult = results.has(persona.id)
const isExpanded = expanded === persona.id
const result = results.get(persona.id)
return (
<div key={persona.id} className="rounded-xl overflow-hidden border border-border/60">
{/* Persona row */}
<button
type="button"
onClick={() => handlePersona(persona.id)}
disabled={!!loadingId && !isLoading}
className={cn(
'w-full flex items-center gap-3 p-3.5 transition-all text-left group',
hasResult ? cn(persona.bg, persona.border, 'border-b') : 'bg-white/50 dark:bg-white/5 hover:bg-white dark:hover:bg-white/10',
isLoading && 'animate-pulse',
!!loadingId && !isLoading && 'opacity-40 cursor-not-allowed',
)}
>
<div className={cn(
'p-2 rounded-lg shrink-0 transition-colors',
hasResult ? cn(persona.bg, 'border', persona.border) : 'bg-slate-50 dark:bg-white/10',
!hasResult && `group-hover:${persona.activeBg} group-hover:text-white`,
persona.color,
)}>
{isLoading
? <Loader2 size={14} className="animate-spin" />
: <Icon size={14} />
}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={cn('text-[11px] font-bold text-ink', hasResult && persona.color)}>
{persona.label}
</span>
{hasResult && (
<span className={cn('text-[8px] font-bold uppercase tracking-widest px-1.5 py-0.5 rounded-full', persona.bg, persona.color)}>
prêt
</span>
)}
</div>
<p className="text-[9px] text-concrete/60 uppercase tracking-tight">{persona.sublabel}</p>
</div>
{hasResult
? isExpanded ? <ChevronUp size={14} className="text-concrete shrink-0" /> : <ChevronDown size={14} className="text-concrete shrink-0" />
: null
}
</button>
{/* Expanded result */}
<AnimatePresence>
{isExpanded && result && (
<motion.div
key="result"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className={cn('p-4 space-y-3', persona.bg)}>
<div className="text-[11px] leading-relaxed text-ink/80">
<MarkdownContent content={result.analysis} />
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<button
type="button"
onClick={() => handleCopy(persona.id)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-white/70 dark:bg-black/20 border border-border/40 text-[9px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-all"
>
{copied === persona.id
? <><CheckCircle size={10} className={persona.color} /> Copié</>
: <><Copy size={10} /> Copier</>
}
</button>
<button
type="button"
onClick={() => handleClear(persona.id)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-white/70 dark:bg-black/20 border border-border/40 text-[9px] font-bold uppercase tracking-widest text-concrete hover:text-rose-500 transition-all"
>
<X size={10} /> Effacer
</button>
<button
type="button"
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-border/40 bg-white/70 dark:bg-black/20 text-[9px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-all"
title="Regénérer"
onClick={(e) => {
e.stopPropagation()
handleClear(persona.id)
setTimeout(() => handlePersona(persona.id), 50)
}}
>
Regénérer
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
})}
</div>
</div>
)
}