feat(dashboard): tableau de bord Second Brain configurable avec chargement progressif
Briefing granulaire, pistes rapides puis enrichissement async, layout persisté v5, suggestions agents, intégration Gmail et navigation sidebar alignée sur /home. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
729
memento-note/components/intelligence-hub.tsx
Normal file
729
memento-note/components/intelligence-hub.tsx
Normal file
@@ -0,0 +1,729 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import {
|
||||
Sparkles, Zap, Lightbulb, Bot, ChevronLeft, ChevronRight,
|
||||
ExternalLink, GitCompare, X, RefreshCw, Loader2, Link2, Brain, ArrowRight,
|
||||
} from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
|
||||
import { MEMORY_ECHO_LEGACY_EN_FALLBACKS } from '@/lib/ai/memory-echo-i18n'
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────
|
||||
|
||||
export interface IntelBriefingInsight {
|
||||
id: string
|
||||
insight: string
|
||||
score: number
|
||||
date: string
|
||||
viewed: boolean
|
||||
note1: { id: string; title: string | null }
|
||||
note2: { id: string; title: string | null }
|
||||
note1Excerpt?: string
|
||||
note2Excerpt?: string
|
||||
}
|
||||
|
||||
export interface IntelBridgeNote {
|
||||
noteId: string
|
||||
bridgeScore: number
|
||||
clusterNames?: string[]
|
||||
note?: { id: string; title: string | null; content?: string }
|
||||
}
|
||||
|
||||
export interface IntelBridgeSuggestion {
|
||||
clusterAId: number
|
||||
clusterBId: number
|
||||
clusterAName: string
|
||||
clusterBName: string
|
||||
suggestedTitle: string
|
||||
suggestedContent: string
|
||||
justification: string
|
||||
}
|
||||
|
||||
export interface IntelAgentAction {
|
||||
id: string
|
||||
agentName: string
|
||||
result: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type IntelFilter = 'all' | 'connections' | 'bridges' | 'ideas' | 'agents'
|
||||
|
||||
type IntelItem =
|
||||
| { kind: 'insight'; id: string; priority: number; insight: IntelBriefingInsight }
|
||||
| { kind: 'bridge'; id: string; priority: number; bridge: IntelBridgeNote }
|
||||
| { kind: 'suggestion'; id: string; priority: number; suggestion: IntelBridgeSuggestion }
|
||||
| { kind: 'agent'; id: string; priority: number; agent: IntelAgentAction }
|
||||
|
||||
const CLUSTER_COLORS = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF']
|
||||
|
||||
const FILTER_KINDS: Record<IntelFilter, IntelItem['kind'][] | null> = {
|
||||
all: null,
|
||||
connections: ['insight'],
|
||||
bridges: ['bridge'],
|
||||
ideas: ['suggestion'],
|
||||
agents: ['agent'],
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function formatRelativeTime(
|
||||
dateStr: string,
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
const mins = Math.floor(diff / 60000)
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
const days = Math.floor(diff / 86400000)
|
||||
if (mins < 1) return t('time.justNow')
|
||||
if (mins < 60) return t('time.minutesAgo', { count: mins })
|
||||
if (hours < 24) return t('time.hoursAgo', { count: hours })
|
||||
return t('time.daysAgo', { count: days })
|
||||
}
|
||||
|
||||
// ─── Visual: lien entre deux notes ─────────────────────
|
||||
|
||||
function ConnectionDiagram({
|
||||
note1Title,
|
||||
note2Title,
|
||||
score,
|
||||
color = '#6366F1',
|
||||
}: {
|
||||
note1Title: string
|
||||
note2Title: string
|
||||
score: number
|
||||
color?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-3">
|
||||
<div
|
||||
className="flex-1 min-w-0 p-2.5 rounded-xl border border-border/30 bg-white/80 dark:bg-zinc-900/60 text-start"
|
||||
style={{ borderColor: `${color}30` }}
|
||||
>
|
||||
<p className="text-[10px] font-semibold text-ink dark:text-dark-ink truncate leading-tight">
|
||||
{note1Title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 flex flex-col items-center gap-0.5 px-1">
|
||||
<div className="w-8 h-px" style={{ background: `linear-gradient(90deg, transparent, ${color}, transparent)` }} />
|
||||
<span
|
||||
className="text-[9px] font-mono font-bold px-2 py-0.5 rounded-full"
|
||||
style={{ color, backgroundColor: `${color}14`, border: `1px solid ${color}25` }}
|
||||
>
|
||||
{Math.round(score * 100)}%
|
||||
</span>
|
||||
<div className="w-8 h-px" style={{ background: `linear-gradient(90deg, transparent, ${color}, transparent)` }} />
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 min-w-0 p-2.5 rounded-xl border border-border/30 bg-white/80 dark:bg-zinc-900/60 text-start"
|
||||
style={{ borderColor: `${color}30` }}
|
||||
>
|
||||
<p className="text-[10px] font-semibold text-ink dark:text-dark-ink truncate leading-tight">
|
||||
{note2Title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Props ─────────────────────────────────────────────
|
||||
|
||||
export interface IntelligenceHubProps {
|
||||
loading: boolean
|
||||
aiActive: boolean
|
||||
hasAiConsent: boolean
|
||||
providerReady: boolean
|
||||
memoryEchoEnabled: boolean
|
||||
insights: IntelBriefingInsight[]
|
||||
bridgeNotes: IntelBridgeNote[]
|
||||
bridgeSuggestions: IntelBridgeSuggestion[]
|
||||
agentActions: IntelAgentAction[]
|
||||
onNoteSelect: (noteId: string) => void
|
||||
onRefreshEcho: () => void
|
||||
onEnableAi: () => void
|
||||
echoRefreshing: boolean
|
||||
onDismissInsight: (insight: IntelBriefingInsight) => void
|
||||
onDismissBridgeSuggestion: (s: IntelBridgeSuggestion) => void
|
||||
onCreateBridgeSuggestion: (s: IntelBridgeSuggestion) => void
|
||||
onOpenInsightNote: (insight: IntelBriefingInsight, noteId: string) => void
|
||||
dismissingInsightId: string | null
|
||||
actingBridgeSuggestionKey: string | null
|
||||
prefersReducedMotion: boolean
|
||||
}
|
||||
|
||||
// ─── Component ─────────────────────────────────────────
|
||||
|
||||
export function IntelligenceHub({
|
||||
loading,
|
||||
aiActive,
|
||||
hasAiConsent,
|
||||
providerReady,
|
||||
memoryEchoEnabled,
|
||||
insights,
|
||||
bridgeNotes,
|
||||
bridgeSuggestions,
|
||||
agentActions,
|
||||
onNoteSelect,
|
||||
onRefreshEcho,
|
||||
onEnableAi,
|
||||
echoRefreshing,
|
||||
onDismissInsight,
|
||||
onDismissBridgeSuggestion,
|
||||
onCreateBridgeSuggestion,
|
||||
onOpenInsightNote,
|
||||
dismissingInsightId,
|
||||
actingBridgeSuggestionKey,
|
||||
prefersReducedMotion,
|
||||
}: IntelligenceHubProps) {
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
const [filter, setFilter] = useState<IntelFilter>('all')
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
|
||||
const localizeInsightText = useCallback((insight: IntelBriefingInsight) => {
|
||||
const text = insight.insight.trim()
|
||||
if (MEMORY_ECHO_LEGACY_EN_FALLBACKS.has(text)) {
|
||||
if (insight.note1Excerpt && insight.note2Excerpt) {
|
||||
return t('homeDashboard.connectionBetween', {
|
||||
note1: insight.note1.title || insight.note1Excerpt.slice(0, 50),
|
||||
note2: insight.note2.title || insight.note2Excerpt.slice(0, 50),
|
||||
})
|
||||
}
|
||||
return t('memoryEcho.defaultInsight')
|
||||
}
|
||||
return text
|
||||
}, [t])
|
||||
|
||||
const allItems = useMemo(() => {
|
||||
const insightNoteIds = new Set<string>()
|
||||
for (const i of insights) {
|
||||
insightNoteIds.add(i.note1.id)
|
||||
insightNoteIds.add(i.note2.id)
|
||||
}
|
||||
|
||||
const items: IntelItem[] = []
|
||||
|
||||
for (const insight of insights) {
|
||||
items.push({
|
||||
kind: 'insight',
|
||||
id: `insight-${insight.id}`,
|
||||
priority: (insight.viewed ? 40 : 100) + insight.score * 10,
|
||||
insight,
|
||||
})
|
||||
}
|
||||
|
||||
for (const bridge of bridgeNotes) {
|
||||
if (insightNoteIds.has(bridge.noteId)) continue
|
||||
items.push({
|
||||
kind: 'bridge',
|
||||
id: `bridge-${bridge.noteId}`,
|
||||
priority: 60 + bridge.bridgeScore * 10,
|
||||
bridge,
|
||||
})
|
||||
}
|
||||
|
||||
for (const suggestion of bridgeSuggestions) {
|
||||
items.push({
|
||||
kind: 'suggestion',
|
||||
id: `suggestion-${suggestion.clusterAId}-${suggestion.clusterBId}`,
|
||||
priority: 80,
|
||||
suggestion,
|
||||
})
|
||||
}
|
||||
|
||||
for (const agent of agentActions) {
|
||||
items.push({
|
||||
kind: 'agent',
|
||||
id: `agent-${agent.id}`,
|
||||
priority: 30,
|
||||
agent,
|
||||
})
|
||||
}
|
||||
|
||||
return items.sort((a, b) => b.priority - a.priority).slice(0, 10)
|
||||
}, [insights, bridgeNotes, bridgeSuggestions, agentActions])
|
||||
|
||||
const counts = useMemo(() => ({
|
||||
all: allItems.length,
|
||||
connections: allItems.filter(i => i.kind === 'insight').length,
|
||||
bridges: allItems.filter(i => i.kind === 'bridge').length,
|
||||
ideas: allItems.filter(i => i.kind === 'suggestion').length,
|
||||
agents: allItems.filter(i => i.kind === 'agent').length,
|
||||
}), [allItems])
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const kinds = FILTER_KINDS[filter]
|
||||
if (!kinds) return allItems
|
||||
return allItems.filter(i => kinds.includes(i.kind))
|
||||
}, [allItems, filter])
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(0)
|
||||
}, [filter])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeIndex >= filteredItems.length && filteredItems.length > 0) {
|
||||
setActiveIndex(filteredItems.length - 1)
|
||||
}
|
||||
}, [activeIndex, filteredItems.length])
|
||||
|
||||
const activeItem = filteredItems[activeIndex] ?? null
|
||||
const newCount = insights.filter(i => !i.viewed).length
|
||||
|
||||
const goPrev = () => setActiveIndex(i => Math.max(0, i - 1))
|
||||
const goNext = () => setActiveIndex(i => Math.min(filteredItems.length - 1, i + 1))
|
||||
|
||||
const filters: { key: IntelFilter; label: string }[] = [
|
||||
{ key: 'all', label: t('homeDashboard.intelFilterAll') },
|
||||
{ key: 'connections', label: t('homeDashboard.intelFilterConnections') },
|
||||
{ key: 'bridges', label: t('homeDashboard.intelFilterBridges') },
|
||||
{ key: 'ideas', label: t('homeDashboard.intelFilterIdeas') },
|
||||
{ key: 'agents', label: t('homeDashboard.intelFilterAgents') },
|
||||
]
|
||||
|
||||
const slideVariants = prefersReducedMotion
|
||||
? { initial: {}, animate: {}, exit: {} }
|
||||
: {
|
||||
initial: { opacity: 0, x: 24, scale: 0.98 },
|
||||
animate: { opacity: 1, x: 0, scale: 1 },
|
||||
exit: { opacity: 0, x: -24, scale: 0.98 },
|
||||
}
|
||||
|
||||
const renderSpotlight = (item: IntelItem) => {
|
||||
switch (item.kind) {
|
||||
case 'insight': {
|
||||
const { insight } = item
|
||||
const text = localizeInsightText(insight)
|
||||
const excerpt = insight.note1Excerpt || insight.note2Excerpt
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Sparkles size={12} className="text-indigo-500" />
|
||||
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">
|
||||
{t('homeDashboard.semanticConnection')}
|
||||
</span>
|
||||
{!insight.viewed && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-ochre animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<ConnectionDiagram
|
||||
note1Title={insight.note1.title || t('homeDashboard.untitled')}
|
||||
note2Title={insight.note2.title || t('homeDashboard.untitled')}
|
||||
score={insight.score}
|
||||
/>
|
||||
<p className="text-[11px] text-ink/75 dark:text-dark-ink/75 font-serif italic leading-relaxed line-clamp-3 px-1">
|
||||
« {text} »
|
||||
</p>
|
||||
{excerpt && (
|
||||
<p className="text-[9px] text-concrete/80 line-clamp-2 mt-2 px-1 border-s-2 border-indigo-500/20 ps-2">
|
||||
{excerpt}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 mt-auto pt-3 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenInsightNote(insight, insight.note1.id)}
|
||||
className="inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
<ExternalLink size={9} />
|
||||
{t('homeDashboard.intelOpenNote')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenInsightNote(insight, insight.note2.id)}
|
||||
className="inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg border border-border/40 hover:border-indigo-400/40 transition-colors"
|
||||
>
|
||||
<GitCompare size={9} />
|
||||
{t('homeDashboard.intelCompare')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={dismissingInsightId === insight.id}
|
||||
onClick={() => onDismissInsight(insight)}
|
||||
className="p-1.5 rounded-lg border border-border/30 text-concrete hover:text-rose-500 ms-auto disabled:opacity-40"
|
||||
title={t('homeDashboard.dismissConnection')}
|
||||
>
|
||||
{dismissingInsightId === insight.id
|
||||
? <Loader2 size={10} className="animate-spin" />
|
||||
: <X size={10} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case 'bridge': {
|
||||
const { bridge } = item
|
||||
const title = bridge.note?.title || t('homeDashboard.untitled')
|
||||
const excerpt = bridge.note?.content ? stripHtml(bridge.note.content).slice(0, 160) : ''
|
||||
const names = bridge.clusterNames ?? []
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap size={12} className="text-ochre" />
|
||||
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-ochre">
|
||||
{t('homeDashboard.bridgeNote')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[9px] font-mono font-bold text-ochre bg-ochre/10 px-2 py-0.5 rounded-full">
|
||||
{Math.round(bridge.bridgeScore * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNoteSelect(bridge.noteId)}
|
||||
className="text-start group flex-1"
|
||||
>
|
||||
<p className="text-sm font-semibold text-ink dark:text-dark-ink group-hover:text-ochre transition-colors mb-2 leading-snug">
|
||||
{title}
|
||||
</p>
|
||||
{excerpt && (
|
||||
<p className="text-[10px] text-concrete leading-relaxed line-clamp-3 mb-3">{excerpt}</p>
|
||||
)}
|
||||
{names.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{names.map((name, i) => (
|
||||
<span
|
||||
key={`${bridge.noteId}-${name}`}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-full border border-border/25 bg-white/70 dark:bg-zinc-900/50"
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: CLUSTER_COLORS[i % CLUSTER_COLORS.length] }}
|
||||
/>
|
||||
<span className="text-[8px] font-mono uppercase text-concrete">{name}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNoteSelect(bridge.noteId)}
|
||||
className="mt-3 inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg bg-ochre/90 text-white hover:bg-ochre transition-colors w-fit"
|
||||
>
|
||||
<ExternalLink size={9} />
|
||||
{t('homeDashboard.intelOpenNote')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case 'suggestion': {
|
||||
const { suggestion } = item
|
||||
const key = `${suggestion.clusterAId}-${suggestion.clusterBId}`
|
||||
const busy = actingBridgeSuggestionKey === key
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Lightbulb size={12} className="text-violet-500" />
|
||||
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-violet-600 dark:text-violet-400 truncate">
|
||||
{t('homeDashboard.intelMissingLink')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="px-2 py-1 rounded-lg bg-violet-500/10 text-[9px] font-mono font-bold text-violet-700 dark:text-violet-300 truncate max-w-[45%]">
|
||||
{suggestion.clusterAName}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-violet-400/40 via-ochre/60 to-violet-400/40" />
|
||||
<span className="px-2 py-1 rounded-lg bg-ochre/10 text-[9px] font-mono font-bold text-ochre truncate max-w-[45%]">
|
||||
{suggestion.clusterBName}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-ink dark:text-dark-ink mb-1.5">{suggestion.suggestedTitle}</p>
|
||||
<p className="text-[10px] text-concrete leading-relaxed line-clamp-2 flex-1">{suggestion.suggestedContent}</p>
|
||||
<div className="flex items-center gap-1.5 mt-3 pt-3 border-t border-border/15">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onCreateBridgeSuggestion(suggestion)}
|
||||
className="inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg bg-violet-600 text-white hover:bg-violet-700 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{busy ? <Loader2 size={9} className="animate-spin" /> : <Link2 size={9} />}
|
||||
{t('homeDashboard.createBridgeNote')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onDismissBridgeSuggestion(suggestion)}
|
||||
className="text-[8.5px] font-mono uppercase px-2 py-1.5 rounded-lg border border-border/30 text-concrete hover:text-rose-500 disabled:opacity-40"
|
||||
>
|
||||
{t('homeDashboard.dismiss')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case 'agent': {
|
||||
const { agent } = item
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Bot size={12} className="text-ochre" />
|
||||
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-ochre">
|
||||
{agent.agentName}
|
||||
</span>
|
||||
<span className="text-[8px] font-mono text-concrete/60 ms-auto">
|
||||
{formatRelativeTime(agent.createdAt, t)}
|
||||
</span>
|
||||
</div>
|
||||
{agent.result ? (
|
||||
<p className="text-[11px] text-concrete leading-relaxed line-clamp-4 flex-1">
|
||||
{agent.result}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[11px] text-concrete/60 italic flex-1">{t('homeDashboard.intelAgentNoResult')}</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/agents')}
|
||||
className="mt-3 inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg border border-ochre/30 text-ochre hover:bg-ochre/10 transition-colors w-fit"
|
||||
>
|
||||
{t('homeDashboard.intelViewAgent')}
|
||||
<ArrowRight size={9} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const spotlightAccent = (item: IntelItem | null) => {
|
||||
if (!item) return 'from-stone-100/50 to-transparent border-border/30'
|
||||
switch (item.kind) {
|
||||
case 'insight': return 'from-indigo-500/[0.06] via-transparent to-transparent border-indigo-400/25'
|
||||
case 'bridge': return 'from-ochre/[0.06] via-transparent to-transparent border-ochre/25'
|
||||
case 'suggestion': return 'from-violet-500/[0.06] via-transparent to-transparent border-violet-400/25'
|
||||
case 'agent': return 'from-ochre/[0.04] via-transparent to-transparent border-border/30'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="p-5 rounded-2xl bg-white dark:bg-zinc-900 border border-border/30 flex flex-col">
|
||||
{/* Header */}
|
||||
<DashboardWidgetTitleRow
|
||||
widgetId="intelligence"
|
||||
icon={(
|
||||
<div className="w-5 h-5 rounded bg-brand-accent/10 flex items-center justify-center text-brand-accent shrink-0">
|
||||
<Sparkles size={12} />
|
||||
</div>
|
||||
)}
|
||||
title={t('homeDashboard.aiFound')}
|
||||
actions={(
|
||||
<>
|
||||
{newCount > 0 && (
|
||||
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-2 py-0.5 rounded uppercase">
|
||||
{newCount} {t('homeDashboard.new')}
|
||||
</span>
|
||||
)}
|
||||
{aiActive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefreshEcho}
|
||||
disabled={echoRefreshing}
|
||||
title={t('homeDashboard.analyzeNotes')}
|
||||
className="p-1.5 rounded-lg border border-border/30 hover:border-brand-accent/40 hover:bg-brand-accent/5 transition-all disabled:opacity-40"
|
||||
>
|
||||
{echoRefreshing
|
||||
? <Loader2 size={11} className="animate-spin text-brand-accent" />
|
||||
: <RefreshCw size={11} className="text-concrete hover:text-brand-accent" />}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<div className="h-[220px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
|
||||
) : !aiActive ? (
|
||||
<div className="p-5 rounded-2xl border border-dashed border-border/40 bg-stone-50/50 dark:bg-zinc-950/30 text-center space-y-3 min-h-[180px] flex flex-col justify-center">
|
||||
<p className="text-xs text-concrete leading-relaxed">
|
||||
{!hasAiConsent
|
||||
? t('homeDashboard.aiConsentRequired')
|
||||
: !providerReady
|
||||
? t('homeDashboard.aiProviderUnavailable')
|
||||
: t('homeDashboard.memoryEchoDisabled')}
|
||||
</p>
|
||||
{!hasAiConsent && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEnableAi}
|
||||
className="text-[9px] font-mono uppercase font-bold px-3 py-1.5 rounded-lg bg-ink text-white dark:bg-white dark:text-black hover:opacity-90 transition-opacity mx-auto"
|
||||
>
|
||||
{t('homeDashboard.enableAi')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : allItems.length === 0 ? (
|
||||
<div className="p-5 rounded-2xl border border-dashed border-border/40 bg-stone-50/50 dark:bg-zinc-950/30 text-center space-y-3 min-h-[180px] flex flex-col justify-center">
|
||||
<Brain size={22} className="mx-auto text-concrete/40" strokeWidth={1.25} />
|
||||
<p className="text-xs text-concrete leading-relaxed">{t('homeDashboard.noConnections')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefreshEcho}
|
||||
disabled={echoRefreshing}
|
||||
className="inline-flex items-center gap-1.5 text-[9px] font-mono uppercase font-bold px-3 py-1.5 rounded-lg border border-ochre/30 text-ochre hover:bg-ochre/10 transition-all disabled:opacity-40 mx-auto"
|
||||
>
|
||||
{echoRefreshing ? <Loader2 size={11} className="animate-spin" /> : <Sparkles size={11} />}
|
||||
{t('homeDashboard.analyzeNotes')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Filtres */}
|
||||
<div className="flex gap-1 overflow-x-auto custom-scrollbar pb-1 -mx-0.5 px-0.5 mb-3">
|
||||
{filters.map(f => {
|
||||
const count = counts[f.key]
|
||||
if (f.key !== 'all' && count === 0) return null
|
||||
const active = filter === f.key
|
||||
return (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
onClick={() => setFilter(f.key)}
|
||||
className={`shrink-0 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[8.5px] font-mono font-bold uppercase tracking-wider transition-all ${
|
||||
active
|
||||
? 'bg-ink text-white dark:bg-white dark:text-black shadow-sm'
|
||||
: 'bg-stone-100 dark:bg-zinc-800 text-concrete hover:text-ink dark:hover:text-dark-ink'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
{count > 0 && (
|
||||
<span className={`text-[7px] px-1 py-px rounded-full ${active ? 'bg-white/20' : 'bg-black/5 dark:bg-white/10'}`}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredItems.length === 0 ? (
|
||||
<p className="text-xs text-concrete italic text-center py-8">{t('homeDashboard.intelFilterEmpty')}</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Spotlight carousel */}
|
||||
<div className="relative">
|
||||
{filteredItems.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goPrev}
|
||||
disabled={activeIndex === 0}
|
||||
className="absolute start-0 top-1/2 -translate-y-1/2 -translate-x-1 z-10 p-1 rounded-full border border-border/40 bg-white/90 dark:bg-zinc-900/90 shadow-sm disabled:opacity-25 hover:border-ochre/40 transition-all"
|
||||
aria-label={t('homeDashboard.intelPrev')}
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
disabled={activeIndex >= filteredItems.length - 1}
|
||||
className="absolute end-0 top-1/2 -translate-y-1/2 translate-x-1 z-10 p-1 rounded-full border border-border/40 bg-white/90 dark:bg-zinc-900/90 shadow-sm disabled:opacity-25 hover:border-ochre/40 transition-all"
|
||||
aria-label={t('homeDashboard.intelNext')}
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`min-h-[200px] mx-5 p-4 rounded-2xl border bg-gradient-to-br ${spotlightAccent(activeItem)} transition-colors`}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{activeItem && (
|
||||
<motion.div
|
||||
key={activeItem.id}
|
||||
initial={slideVariants.initial}
|
||||
animate={slideVariants.animate}
|
||||
exit={slideVariants.exit}
|
||||
transition={{ duration: prefersReducedMotion ? 0 : 0.22, ease: 'easeOut' }}
|
||||
className="min-h-[180px] flex flex-col"
|
||||
>
|
||||
{renderSpotlight(activeItem)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation dots + position */}
|
||||
{filteredItems.length > 1 && (
|
||||
<div className="flex items-center justify-center gap-3 mt-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{filteredItems.map((item, idx) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setActiveIndex(idx)}
|
||||
className={`rounded-full transition-all ${
|
||||
idx === activeIndex
|
||||
? 'w-5 h-1.5 bg-ochre'
|
||||
: 'w-1.5 h-1.5 bg-concrete/25 hover:bg-concrete/50'
|
||||
}`}
|
||||
aria-label={t('homeDashboard.intelGoTo', { index: idx + 1 })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[8px] font-mono text-concrete/60 uppercase">
|
||||
{t('homeDashboard.intelPosition', { current: activeIndex + 1, total: filteredItems.length })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File d'attente compacte */}
|
||||
{filteredItems.length > 1 && (
|
||||
<div className="flex gap-1.5 mt-3 overflow-x-auto custom-scrollbar pt-1">
|
||||
{filteredItems.map((item, idx) => {
|
||||
const isActive = idx === activeIndex
|
||||
let label = ''
|
||||
let Icon = Sparkles
|
||||
let accent = 'text-indigo-500'
|
||||
if (item.kind === 'insight') {
|
||||
label = item.insight.note1.title?.slice(0, 28) || t('homeDashboard.untitled')
|
||||
Icon = Sparkles
|
||||
accent = 'text-indigo-500'
|
||||
} else if (item.kind === 'bridge') {
|
||||
label = item.bridge.note?.title?.slice(0, 28) || t('homeDashboard.untitled')
|
||||
Icon = Zap
|
||||
accent = 'text-ochre'
|
||||
} else if (item.kind === 'suggestion') {
|
||||
label = item.suggestion.suggestedTitle.slice(0, 28)
|
||||
Icon = Lightbulb
|
||||
accent = 'text-violet-500'
|
||||
} else {
|
||||
label = item.agent.agentName
|
||||
Icon = Bot
|
||||
accent = 'text-ochre'
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setActiveIndex(idx)}
|
||||
className={`shrink-0 flex items-center gap-1.5 px-2 py-1.5 rounded-lg border text-start max-w-[130px] transition-all ${
|
||||
isActive
|
||||
? 'border-ochre/40 bg-ochre/5 shadow-sm'
|
||||
: 'border-border/25 bg-stone-50/50 dark:bg-zinc-950/30 hover:border-border/50'
|
||||
}`}
|
||||
>
|
||||
<Icon size={9} className={`shrink-0 ${accent}`} />
|
||||
<span className="text-[8px] font-medium text-ink dark:text-dark-ink truncate">{label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user