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>
730 lines
30 KiB
TypeScript
730 lines
30 KiB
TypeScript
'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>
|
|
)
|
|
}
|