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>
278 lines
10 KiB
TypeScript
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>
|
|
)
|
|
}
|