Files
Momento/memento-note/app/(main)/insights/page.tsx
Antigravity 3bdbec575a feat(ui): panneau repliable, carnets plus lisibles et accueil plus clair
Harmonise la liste des notes et l’éditeur (titres, dates, retour),
rend la saisie rapide et les raccourcis de l’accueil utilisables
sur téléphone, et conserve le panneau latéral repliable.
2026-09-05 20:53:48 +00:00

1358 lines
64 KiB
TypeScript

'use client'
import { useState, useEffect, useMemo, useCallback } from 'react'
import dynamic from 'next/dynamic'
import { useRouter, useSearchParams } from 'next/navigation'
import { useLanguage } from '@/lib/i18n'
import { motion, AnimatePresence, useReducedMotion } from 'motion/react'
import {
Sparkles,
RefreshCw,
Layers,
Trophy,
Zap,
Lightbulb,
Sliders,
CheckCircle2,
Clock,
AlertCircle,
ChevronRight,
ChevronDown,
Database,
Network,
List,
Search,
ArrowUpDown,
X,
} from 'lucide-react'
import { toast } from 'sonner'
import { useNotePeek, NotePeekPanel } from '@/components/note-peek'
import { createNote } from '@/app/actions/notes'
import { emitNoteChange } from '@/lib/note-change-sync'
const NetworkGraph = dynamic(
() => import('@/components/network-graph').then(m => ({ default: m.NetworkGraph })),
{
loading: () => (
<div className="w-full h-full flex items-center justify-center">
<RefreshCw className="animate-spin text-ochre/40" size={32} />
</div>
),
ssr: false,
}
)
interface Note {
id: string
title: string | null
content: string
clusterId?: number
}
interface Cluster {
id: string
clusterId: number
name?: string
noteIds: string[]
color?: string
}
interface BridgeNote {
noteId: string
bridgeScore: number
clustersConnected: number[]
clusterNames?: string[]
note?: {
id: string
title: string | null
content: string
}
}
interface BridgeSuggestion {
clusterAId: number
clusterBId: number
clusterAName: string
clusterBName: string
suggestedTitle: string
suggestedContent: string
justification: string
}
const COLOR_PALETTE = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF']
export default function InsightsPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { t, language: locale } = useLanguage()
const formatSyncTime = useCallback(
(date: Date) =>
date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }),
[locale]
)
const [notes, setNotes] = useState<Note[]>([])
const [clusters, setClusters] = useState<Cluster[]>([])
const [bridgeNotes, setBridgeNotes] = useState<BridgeNote[]>([])
const [suggestions, setSuggestions] = useState<BridgeSuggestion[]>([])
const [loading, setLoading] = useState(true)
const [isCalculating, setIsCalculating] = useState(false)
const [isReindexing, setIsReindexing] = useState(false)
const [embeddingStats, setEmbeddingStats] = useState<{ indexed: number; total: number } | null>(null)
const [isStale, setIsStale] = useState(false)
const [selectedClusterId, setSelectedClusterId] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<'graph' | 'dashboard'>('dashboard')
const [graphMode, setGraphMode] = useState<'visual' | 'list'>('list')
const [listFilter, setListFilter] = useState('')
const [listSort, setListSort] = useState<'size' | 'alpha' | 'bridges'>('size')
/** Un seul cluster déplié à la fois (null = tous repliés) */
const [expandedListClusterId, setExpandedListClusterId] = useState<string | null>(null)
const [dashboardFilter, setDashboardFilter] = useState('')
const [bridgesShowAll, setBridgesShowAll] = useState(false)
const [isolatedShowAll, setIsolatedShowAll] = useState(false)
const [suggestionsShowAll, setSuggestionsShowAll] = useState(false)
const [actingSuggestionKey, setActingSuggestionKey] = useState<string | null>(null)
const [dashboardSectionOpen, setDashboardSectionOpen] = useState({
recalc: false,
isolated: true,
bridges: true,
suggestions: false,
})
const [lastSyncTime, setLastSyncTime] = useState<string>('')
const peek = useNotePeek()
const prefersReducedMotion = useReducedMotion()
const DASHBOARD_PREVIEW = 8
const SUGGESTIONS_PREVIEW = 3
useEffect(() => {
loadInitialData()
}, [])
useEffect(() => {
const clusterParam = searchParams.get('cluster')
if (!clusterParam || clusters.length === 0) return
const match = clusters.find(
c => c.id === clusterParam || String(c.clusterId) === clusterParam,
)
if (match) setSelectedClusterId(match.id)
}, [searchParams, clusters])
// ─── Données calculées ───────────────────────────────────────────────────────
const selectedCluster = useMemo(
() => clusters.find(c => c.id === selectedClusterId) ?? null,
[clusters, selectedClusterId]
)
const selectedClusterNotes = useMemo(
() => (selectedCluster ? notes.filter(n => selectedCluster.noteIds.includes(n.id)) : []),
[notes, selectedCluster]
)
const isolatedClusters = useMemo(() => {
const networkedIds = new Set(
bridgeNotes.flatMap(b => b.clustersConnected.map(cid => String(cid)))
)
return clusters.filter(c => !networkedIds.has(c.id))
}, [clusters, bridgeNotes])
const listClusters = useMemo(() => {
const q = listFilter.trim().toLowerCase()
const rows = clusters.map(cluster => {
const clusterNotes = notes.filter(n => cluster.noteIds.includes(n.id))
const clusterBridges = bridgeNotes.filter(b =>
b.clustersConnected?.some(cid => String(cid) === cluster.id || cid === cluster.clusterId)
)
const name =
cluster.name ||
t('insightsView.clusterFallback', { index: cluster.clusterId })
const matchingNotes = q
? clusterNotes.filter(n =>
(n.title || '').toLowerCase().includes(q)
)
: clusterNotes
const nameMatches = !q || name.toLowerCase().includes(q)
return { cluster, name, clusterNotes, matchingNotes, clusterBridges, nameMatches }
})
const filtered = q
? rows.filter(r => r.nameMatches || r.matchingNotes.length > 0)
: rows
const sorted = [...filtered].sort((a, b) => {
if (listSort === 'alpha') return a.name.localeCompare(b.name, locale)
if (listSort === 'bridges') {
const d = b.clusterBridges.length - a.clusterBridges.length
return d !== 0 ? d : b.clusterNotes.length - a.clusterNotes.length
}
// size (default)
const d = b.clusterNotes.length - a.clusterNotes.length
return d !== 0 ? d : a.name.localeCompare(b.name, locale)
})
return sorted
}, [clusters, notes, bridgeNotes, listFilter, listSort, locale, t])
const bridgeList = useMemo(
() =>
bridgeNotes.map(b => ({
...b,
title: b.note?.title || t('insightsView.unknownNote'),
})),
[bridgeNotes, t]
)
const filteredIsolatedClusters = useMemo(() => {
const q = dashboardFilter.trim().toLowerCase()
if (!q) return isolatedClusters
return isolatedClusters.filter(c =>
(c.name || t('insightsView.clusterFallback', { index: c.clusterId }))
.toLowerCase()
.includes(q)
)
}, [isolatedClusters, dashboardFilter, t])
const filteredBridgeList = useMemo(() => {
const q = dashboardFilter.trim().toLowerCase()
const list = [...bridgeList].sort((a, b) => b.bridgeScore - a.bridgeScore)
if (!q) return list
return list.filter(b => {
const titleMatch = b.title.toLowerCase().includes(q)
const clusterMatch = b.clustersConnected.some(cid => {
const cluster = clusters.find(c => c.id === String(cid))
const name =
cluster?.name || t('insightsView.clusterFallback', { index: cid })
return name.toLowerCase().includes(q)
})
return titleMatch || clusterMatch
})
}, [bridgeList, dashboardFilter, clusters, t])
const visibleIsolatedClusters = isolatedShowAll || dashboardFilter.trim()
? filteredIsolatedClusters
: filteredIsolatedClusters.slice(0, DASHBOARD_PREVIEW)
const visibleBridgeList = bridgesShowAll || dashboardFilter.trim()
? filteredBridgeList
: filteredBridgeList.slice(0, DASHBOARD_PREVIEW)
/** Affiche au plus 2 thèmes (pair de brokerage) ; le reste derrière +N. */
const getBridgeThemeSlice = useCallback((clusterIds: number[]) => {
const ranked = [...clusterIds].sort((a, b) => {
const sizeA = clusters.find(c => c.id === String(a))?.noteIds.length ?? 0
const sizeB = clusters.find(c => c.id === String(b))?.noteIds.length ?? 0
return sizeB - sizeA
})
return {
primary: ranked.slice(0, 2),
extraCount: Math.max(0, ranked.length - 2),
}
}, [clusters])
// ─── Chargement initial ──────────────────────────────────────────────────────
const loadInitialData = async () => {
setLoading(true)
try {
const res = await fetch('/api/clusters')
if (res.ok) {
const data = await res.json()
if (data.clusters?.length > 0) {
const clustersWithColors = data.clusters.map((c: Cluster, i: number) => ({
...c,
id: c.clusterId.toString(),
color: COLOR_PALETTE[i % COLOR_PALETTE.length]
}))
setNotes(data.notes || [])
setClusters(clustersWithColors)
setIsStale(!!data.stale)
// Bridge notes incluses dans la réponse GET /clusters (enrichies)
if (data.bridgeNotes?.length > 0) {
setBridgeNotes(data.bridgeNotes)
} else {
const bridgeRes = await fetch('/api/bridge-notes?details=true')
if (bridgeRes.ok) {
const bridgeData = await bridgeRes.json()
setBridgeNotes(bridgeData.bridgeNotes || [])
}
}
const suggestionsRes = await fetch('/api/bridge-notes/suggestions')
if (suggestionsRes.ok) {
const suggestionsData = await suggestionsRes.json()
setSuggestions(suggestionsData.suggestions || [])
}
setLastSyncTime(formatSyncTime(new Date()))
if (typeof data.embeddingCount === 'number' && typeof data.totalNotes === 'number') {
setEmbeddingStats({ indexed: data.embeddingCount, total: data.totalNotes })
}
} else {
setIsStale(false)
if (typeof data.embeddingCount === 'number' && typeof data.totalNotes === 'number') {
setEmbeddingStats({ indexed: data.embeddingCount, total: data.totalNotes })
}
}
}
} catch (error) {
console.error('Error loading data:', error)
} finally {
setLoading(false)
}
}
const handleReindexEmbeddings = async () => {
setIsReindexing(true)
try {
const res = await fetch('/api/notes/reindex', { method: 'POST' })
if (!res.ok) throw new Error('reindex failed')
const data = await res.json()
setEmbeddingStats(prev => ({
indexed: data.count ?? prev?.indexed ?? 0,
total: data.total ?? prev?.total ?? notes.length,
}))
setLastSyncTime(formatSyncTime(new Date()))
setIsStale(true)
} catch (error) {
console.error('Error reindexing embeddings:', error)
} finally {
setIsReindexing(false)
}
}
// ─── Analyse (POST) ──────────────────────────────────────────────────────────
const performAnalysis = async () => {
setIsCalculating(true)
try {
const res = await fetch('/api/clusters', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force: true })
})
if (res.ok) {
const data = await res.json()
const clusterCount = data.clusters?.length || 0
if (clusterCount === 0) {
toast.info(t('insightsView.analysisNoClusters'))
} else {
toast.success(t('insightsView.analysisSuccess', { count: clusterCount }))
}
const clustersWithColors = (data.clusters || []).map((c: Cluster, i: number) => ({
...c,
id: c.clusterId.toString(),
color: COLOR_PALETTE[i % COLOR_PALETTE.length]
}))
setNotes(data.notes || [])
setClusters(clustersWithColors)
setBridgeNotes(data.bridgeNotes || [])
setIsStale(false)
const suggestionsRes = await fetch('/api/bridge-notes/suggestions')
if (suggestionsRes.ok) {
const suggestionsData = await suggestionsRes.json()
setSuggestions(suggestionsData.suggestions || [])
}
setLastSyncTime(formatSyncTime(new Date()))
if (data.notes?.length) {
setEmbeddingStats(prev => ({
indexed: prev?.indexed ?? data.notes.length,
total: data.notes.length,
}))
}
}
} catch (error) {
console.error('Error running analysis:', error)
toast.error(t('insightsView.analysisFailed'))
} finally {
setIsCalculating(false)
}
}
const handleNoteClick = (noteId: string) => {
peek.open(noteId)
}
const visibleSuggestions = suggestionsShowAll
? suggestions.slice(0, 12)
: suggestions.slice(0, SUGGESTIONS_PREVIEW)
const handleCreateSuggestion = useCallback(async (s: BridgeSuggestion) => {
const key = `${s.clusterAId}-${s.clusterBId}`
setActingSuggestionKey(key)
try {
const html = `<p>${s.suggestedContent.replace(/</g, '&lt;')}</p><p><em>${s.justification.replace(/</g, '&lt;')}</em></p>`
const note = await createNote({ title: s.suggestedTitle, content: html })
if (!note) throw new Error('create failed')
emitNoteChange({ type: 'created', note })
await fetch('/api/bridge-notes', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clusterAId: s.clusterAId, clusterBId: s.clusterBId }),
})
setSuggestions(prev =>
prev.filter(x => !(x.clusterAId === s.clusterAId && x.clusterBId === s.clusterBId))
)
toast.success(t('insightsView.suggestions.created'))
router.push(`/home?openNote=${note.id}`)
} catch {
toast.error(t('insightsView.suggestions.createError'))
} finally {
setActingSuggestionKey(null)
}
}, [router, t])
const handleDismissSuggestion = useCallback(async (s: BridgeSuggestion) => {
const key = `${s.clusterAId}-${s.clusterBId}`
setActingSuggestionKey(key)
try {
const res = await fetch('/api/bridge-notes', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clusterAId: s.clusterAId, clusterBId: s.clusterBId }),
})
if (!res.ok) throw new Error()
setSuggestions(prev =>
prev.filter(x => !(x.clusterAId === s.clusterAId && x.clusterBId === s.clusterBId))
)
} catch {
toast.error(t('insightsView.suggestions.dismissError'))
} finally {
setActingSuggestionKey(null)
}
}, [t])
const isRtl = locale === 'fa' || locale === 'ar'
const motionConfig = prefersReducedMotion
? { initial: false as const, animate: { opacity: 1, y: 0 }, transition: { duration: 0 } }
: {}
// ─── Rendu ───────────────────────────────────────────────────────────────────────────
return (
<div className="h-full flex flex-col bg-[#F9F8F6] dark:bg-[#0D0D0D] overflow-hidden">
{/* ── Header ── */}
<div className="p-6 sm:p-8 border-b border-border/20 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between sticky top-0 bg-[#F9F8F6]/80 dark:bg-[#0D0D0D]/80 backdrop-blur-md z-30 shrink-0">
<div className="flex items-center gap-4">
<div>
<div className="flex items-center gap-3 mb-1">
<div className="w-8 h-8 rounded-lg bg-ochre/10 flex items-center justify-center text-ochre">
<Sparkles size={18} />
</div>
<h1 className="text-xl sm:text-2xl font-serif font-medium text-ink dark:text-dark-ink">
{t('insightsView.title')}
</h1>
</div>
<p className="text-[10px] text-concrete tracking-[0.25em] uppercase font-bold">
{t('insightsView.subtitle')}
</p>
<div className="flex items-center gap-1.5 mt-1.5 text-[10px] text-concrete">
<span>{t('insightsView.semanticGraphLegend')}</span>
</div>
</div>
</div>
<div className="flex items-center justify-between sm:justify-end gap-3">
{/* Tab switcher mobile */}
<div className="flex lg:hidden p-1 bg-black/5 dark:bg-white/5 rounded-xl shrink-0">
<button
onClick={() => setViewMode('graph')}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg transition-all ${
viewMode === 'graph'
? 'bg-white dark:bg-black text-ink dark:text-dark-ink shadow-sm'
: 'text-concrete'
}`}
>
{t('insightsView.viewGraph')}
</button>
<button
onClick={() => setViewMode('dashboard')}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg transition-all ${
viewMode === 'dashboard'
? 'bg-white dark:bg-black text-ink dark:text-dark-ink shadow-sm'
: 'text-concrete'
}`}
>
{t('insightsView.viewDashboard')}
</button>
</div>
<button
onClick={performAnalysis}
disabled={isCalculating}
className="flex items-center gap-2 px-5 py-2.5 bg-ink text-paper dark:bg-white dark:text-black rounded-full text-xs font-bold uppercase tracking-widest hover:scale-105 active:scale-95 transition-all disabled:opacity-50 shadow-sm"
>
{isCalculating ? (
<RefreshCw size={13} className="animate-spin" />
) : (
<RefreshCw size={13} />
)}
{isCalculating ? t('insightsView.mapping') : t('insightsView.resync')}
</button>
</div>
</div>
{/* ── Chargement ── */}
{loading && (
<div className="flex-1 flex items-center justify-center">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center space-y-4"
>
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-ochre mx-auto" />
<p className="text-sm text-concrete">{t('insightsView.loading')}</p>
</motion.div>
</div>
)}
{/* ── État vide ── */}
{!loading && clusters.length === 0 && !isCalculating && (
<div className="flex-1 flex items-center justify-center">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center max-w-sm px-6"
>
<div className="w-20 h-20 rounded-2xl bg-ochre/10 flex items-center justify-center mx-auto mb-6">
<Sparkles size={32} className="text-ochre/60" />
</div>
<h3 className="text-xl font-serif font-medium text-ink dark:text-dark-ink mb-3">
{t('insightsView.emptyTitle')}
</h3>
<p className="text-sm text-concrete leading-relaxed mb-6">
{embeddingStats && embeddingStats.total < 10
? t('insightsView.emptyNeedMoreNotes', { count: 10 - embeddingStats.total })
: t('insightsView.emptyDescription')}
</p>
<button
onClick={performAnalysis}
disabled={isCalculating}
className="inline-flex items-center gap-2 px-6 py-3 bg-ink text-paper dark:bg-white dark:text-black rounded-full text-xs font-bold uppercase tracking-widest hover:scale-105 active:scale-95 transition-all disabled:opacity-50"
>
<RefreshCw size={14} />
{t('insightsView.analyzeNow')}
</button>
</motion.div>
</div>
)}
{/* ── Calcul en cours ── */}
{isCalculating && !loading && (
<div className="flex-1 flex items-center justify-center">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center space-y-6 max-w-xs"
>
<div className="w-16 h-16 rounded-2xl bg-ochre/10 flex items-center justify-center mx-auto">
<RefreshCw size={28} className="text-ochre animate-spin" />
</div>
<div className="space-y-2">
<p className="text-sm font-semibold text-ink dark:text-dark-ink">
{t('insightsView.mappingTitle')}
</p>
<p className="text-xs text-concrete leading-relaxed">
{t('insightsView.mappingHint')}
</p>
</div>
</motion.div>
</div>
)}
{/* ── Contenu principal ── */}
{!loading && clusters.length > 0 && !isCalculating && (
<div className="flex-1 flex overflow-hidden min-h-0">
{/* ── Graphe / Liste accessible (gauche) ── */}
<div
className={`flex-[1.4] p-6 relative min-h-0 flex flex-col ${
viewMode === 'graph' ? 'block lg:flex' : 'hidden lg:flex'
}`}
>
{/* Toggle visual/list accessible */}
<div className="flex items-center gap-1 mb-3 shrink-0">
<button
onClick={() => setGraphMode('visual')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg transition-all cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none ${
graphMode === 'visual'
? 'bg-ink text-paper dark:bg-white dark:text-black shadow-sm'
: 'text-concrete hover:bg-black/5 dark:hover:bg-white/5'
}`}
>
<Network size={12} /> {t('insightsView.viewGraph')}
</button>
<button
onClick={() => {
setGraphMode('list')
if (selectedClusterId) setExpandedListClusterId(selectedClusterId)
}}
className={`flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg transition-all cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none ${
graphMode === 'list'
? 'bg-ink text-paper dark:bg-white dark:text-black shadow-sm'
: 'text-concrete hover:bg-black/5 dark:hover:bg-white/5'
}`}
>
<List size={12} /> {t('insightsView.listView') || 'List'}
</button>
</div>
{/* Vue visuelle (D3) */}
{graphMode === 'visual' && (
<div
className="flex-1 min-h-0"
role="img"
aria-label={t('insightsView.graphAriaLabel', {
clusters: clusters.length,
notes: notes.length,
bridges: bridgeNotes.length,
}) || `Semantic network: ${clusters.length} clusters, ${notes.length} notes, ${bridgeNotes.length} bridges`}
>
<NetworkGraph
notes={notes}
clusters={clusters}
bridgeNotes={bridgeNotes}
onNoteSelect={handleNoteClick}
selectedClusterId={selectedClusterId}
onClusterSelect={setSelectedClusterId}
untitledLabel={t('insightsView.unknownNote')}
resetFocusLabel={t('insightsView.resetFocus')}
fitViewLabel={t('insightsView.fitGraphView')}
legendFilterPlaceholder={t('insightsView.legendFilterPlaceholder')}
legendShowMoreLabel={t('insightsView.legendShowMore')}
legendShowLessLabel={t('insightsView.legendShowLess')}
/>
</div>
)}
{/* Vue liste accessible (accordion + filtre + tri) */}
{graphMode === 'list' && (
<div
className="flex-1 min-h-0 flex flex-col gap-3"
role="region"
aria-label={t('insightsView.listAriaLabel') || 'Accessible cluster list'}
>
<div className="shrink-0 flex flex-col sm:flex-row gap-2">
<div className="relative flex-1 min-w-0">
<Search
size={14}
className="absolute start-3 top-1/2 -translate-y-1/2 text-concrete pointer-events-none"
aria-hidden
/>
<input
type="search"
value={listFilter}
onChange={e => setListFilter(e.target.value)}
placeholder={t('insightsView.listFilterPlaceholder')}
aria-label={t('insightsView.listFilterPlaceholder')}
className="w-full bg-white dark:bg-zinc-800/60 border border-border/40 rounded-xl ps-9 pe-3 py-2 text-xs outline-none focus:ring-2 focus:ring-ochre/30 focus:border-ochre/40 transition-all placeholder:text-concrete/70"
/>
</div>
<label className="shrink-0 flex items-center gap-1.5 px-3 py-2 rounded-xl bg-white dark:bg-zinc-800/60 border border-border/40 text-[10px] font-bold uppercase tracking-wider text-concrete">
<ArrowUpDown size={12} aria-hidden />
<span className="sr-only">{t('insightsView.listSortLabel')}</span>
<select
value={listSort}
onChange={e => setListSort(e.target.value as 'size' | 'alpha' | 'bridges')}
className="bg-transparent outline-none cursor-pointer text-ink dark:text-dark-ink font-bold uppercase tracking-wider"
aria-label={t('insightsView.listSortLabel')}
>
<option value="size">{t('insightsView.listSortSize')}</option>
<option value="bridges">{t('insightsView.listSortBridges')}</option>
<option value="alpha">{t('insightsView.listSortAlpha')}</option>
</select>
</label>
</div>
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar space-y-2">
{listClusters.length === 0 ? (
<p className="text-xs text-concrete text-center py-10 px-4">
{t('insightsView.listFilterEmpty')}
</p>
) : (
listClusters.map(({ cluster, name, clusterNotes, matchingNotes, clusterBridges, nameMatches }) => {
const isExpanded = expandedListClusterId === cluster.id
const displayNotes = !listFilter.trim() || nameMatches
? clusterNotes
: matchingNotes
return (
<div
key={cluster.id}
className="rounded-xl bg-white dark:bg-zinc-800 border border-border/30 overflow-hidden"
>
<button
type="button"
onClick={() => {
setExpandedListClusterId(prev =>
prev === cluster.id ? null : cluster.id
)
setSelectedClusterId(cluster.id)
}}
aria-expanded={isExpanded}
className="w-full flex items-center gap-2 p-3.5 text-start hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-colors cursor-pointer focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ochre/50 focus-visible:outline-none"
>
{isExpanded ? (
<ChevronDown size={14} className="text-concrete shrink-0" aria-hidden />
) : (
<ChevronRight size={14} className="text-concrete shrink-0" aria-hidden />
)}
<div
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: cluster.color }}
aria-hidden
/>
<h3 className="min-w-0 flex-1 text-[13px] font-semibold leading-snug text-ink dark:text-dark-ink">
{name}
</h3>
<span className="text-[9px] text-concrete shrink-0 tabular-nums">
{clusterNotes.length} {t('insightsView.graphNotesLabel')}
{clusterBridges.length > 0 &&
` · ${clusterBridges.length} ${t('insightsView.bridgeCount')}`}
{listFilter.trim() && matchingNotes.length > 0 && matchingNotes.length < clusterNotes.length && (
<span className="text-ochre">
{` · ${matchingNotes.length} ${t('insightsView.listFilterMatch')}`}
</span>
)}
</span>
</button>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
key="notes"
initial={prefersReducedMotion ? false : { height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={prefersReducedMotion ? undefined : { height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<ul className="px-3 pb-3 space-y-0.5 border-t border-border/20 pt-2">
{displayNotes.length === 0 ? (
<li className="px-2.5 py-2 text-[11px] text-concrete">
{t('insightsView.clusters.emptyCluster')}
</li>
) : (
displayNotes.map(note => (
<li key={note.id}>
<button
type="button"
onClick={() => handleNoteClick(note.id)}
className="w-full text-start px-2.5 py-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 text-xs text-ink dark:text-dark-ink flex items-center gap-2 cursor-pointer transition-colors focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
>
<ChevronRight size={11} className="text-concrete shrink-0" aria-hidden />
<span className="truncate">
{note.title || t('insightsView.unknownNote')}
</span>
</button>
</li>
))
)}
</ul>
</motion.div>
)}
</AnimatePresence>
</div>
)
})
)}
</div>
</div>
)}
</div>
{/* ── Dashboard (droite) ── */}
<div
className={`flex-1 border-l border-border/20 flex flex-col min-h-0 overflow-hidden bg-[#fcfbfa] dark:bg-zinc-900/10 backdrop-blur-sm ${
viewMode === 'dashboard' ? 'flex' : 'hidden lg:flex'
}`}
>
<div className="p-6 sm:p-8 flex-1 overflow-y-auto custom-scrollbar space-y-8">
{/* Filtre panneau droit (sticky) */}
<div className="sticky top-0 z-20 -mx-1 px-1 pb-2 bg-[#fcfbfa]/95 dark:bg-[#0D0D0D]/90 backdrop-blur-md">
<div className="relative">
<Search
size={14}
className="absolute start-3 top-1/2 -translate-y-1/2 text-concrete pointer-events-none"
aria-hidden
/>
<input
type="search"
value={dashboardFilter}
onChange={e => {
setDashboardFilter(e.target.value)
if (e.target.value.trim()) {
setBridgesShowAll(true)
setIsolatedShowAll(true)
setDashboardSectionOpen(s => ({ ...s, bridges: true, isolated: true }))
}
}}
placeholder={t('insightsView.dashboardFilterPlaceholder')}
aria-label={t('insightsView.dashboardFilterPlaceholder')}
className="w-full bg-white dark:bg-zinc-800/60 border border-border/40 rounded-xl ps-9 pe-3 py-2.5 text-xs outline-none focus:ring-2 focus:ring-ochre/30 focus:border-ochre/40 transition-all placeholder:text-concrete/70 shadow-sm"
/>
</div>
</div>
{/* Avertissement d'obsolescence (stale banner) */}
{isStale && !isCalculating && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="p-4 rounded-2xl bg-amber-500/10 border border-amber-500/20 text-amber-800 dark:text-amber-300 text-xs flex items-center justify-between gap-4 shadow-sm"
>
<div className="flex items-center gap-2.5">
<AlertCircle size={16} className="shrink-0 text-amber-500" />
<span>
{t('insightsView.staleResults')}
</span>
</div>
<button
onClick={performAnalysis}
className="px-3.5 py-2 bg-amber-500 text-white dark:text-zinc-950 font-bold uppercase tracking-wider text-[10px] rounded-lg hover:scale-105 active:scale-95 transition-all shrink-0 shadow-sm"
>
{t('insightsView.resync')}
</button>
</motion.div>
)}
{/* ① Panneau d'inspection cluster */}
<AnimatePresence>
{selectedCluster && (
<motion.div
initial={{ opacity: 0, y: -16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -16 }}
className="p-6 rounded-2xl bg-white dark:bg-zinc-800 border-2 border-ochre/30 shadow-md relative overflow-hidden"
>
<div
className="absolute top-0 left-0 w-1.5 h-full"
style={{ backgroundColor: selectedCluster.color }}
/>
<div className="flex items-center justify-between gap-4 mb-4 pl-3">
<div className="space-y-0.5">
<span className="text-[9px] font-bold uppercase tracking-widest text-ochre">
{t('insightsView.focusCluster.title')}
</span>
<h3 className="text-base font-serif font-semibold text-ink dark:text-dark-ink">
{selectedCluster.name ||
t('insightsView.clusterFallback', { index: selectedCluster.clusterId })}
</h3>
</div>
<button
onClick={() => setSelectedClusterId(null)}
className="p-1 px-3 bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 text-[10px] font-bold rounded-lg uppercase tracking-wider transition-colors shrink-0"
>
{t('insightsView.focusCluster.close')}
</button>
</div>
<div className="pl-3 space-y-3">
<p className="text-xs text-concrete">
{t('insightsView.focusCluster.description', {
count: selectedClusterNotes.length,
})}
</p>
<div className="space-y-1.5 max-h-[180px] overflow-y-auto custom-scrollbar pr-1">
{selectedClusterNotes.map(note => (
<button
key={note.id}
onClick={() => handleNoteClick(note.id)}
className="w-full text-left p-2.5 rounded-lg bg-black/[0.03] hover:bg-black/[0.07] dark:bg-white/[0.03] dark:hover:bg-white/[0.07] text-xs font-medium text-ink dark:text-dark-ink flex items-center justify-between gap-3 group transition-all"
>
<span className="truncate group-hover:translate-x-0.5 transition-transform">
{note.title || t('insightsView.unknownNote')}
</span>
<ChevronRight size={12} className="text-concrete shrink-0" />
</button>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* ② Stats */}
<div className="grid grid-cols-2 gap-4">
<div className="p-5 rounded-2xl bg-white dark:bg-zinc-800/40 border border-border/40 shadow-sm flex flex-col justify-between">
<div className="flex items-center gap-2 text-indigo-500 mb-2">
<Layers size={14} />
<span className="text-[10px] font-bold uppercase tracking-widest">
{t('insightsView.stats.clusters')}
</span>
</div>
<div>
<div className="text-2xl font-serif font-semibold text-ink dark:text-dark-ink">
{clusters.length}
</div>
<p className="text-[9px] text-concrete font-medium uppercase mt-1">
{t('insightsView.stats.themesSubtitle')}
</p>
</div>
</div>
<div className="p-5 rounded-2xl bg-white dark:bg-zinc-800/40 border border-border/40 shadow-sm flex flex-col justify-between">
<div className="flex items-center gap-2 text-ochre mb-2">
<Trophy size={14} />
<span className="text-[10px] font-bold uppercase tracking-widest">
{t('insightsView.stats.bridgeNotes')}
</span>
</div>
<div>
<div className="text-2xl font-serif font-semibold text-ink dark:text-dark-ink">
{bridgeNotes.length}
</div>
<p className="text-[9px] text-concrete font-medium uppercase mt-1">
{t('insightsView.stats.bridgesSubtitle')}
</p>
</div>
</div>
</div>
{/* ③ Système de Recalcul (replié par défaut) */}
<section className="rounded-2xl bg-white dark:bg-zinc-800 border border-border/40 shadow-sm overflow-hidden">
<button
type="button"
onClick={() => setDashboardSectionOpen(s => ({ ...s, recalc: !s.recalc }))}
aria-expanded={dashboardSectionOpen.recalc}
className="w-full flex items-center justify-between gap-4 p-5 text-start hover:bg-black/[0.02] dark:hover:bg-white/[0.03] transition-colors cursor-pointer"
>
<div className="flex items-center gap-2">
<Sliders size={15} className="text-ochre" />
<h4 className="text-[11px] font-black uppercase tracking-[0.2em] text-ink dark:text-dark-ink">
{t('insightsView.recalcSystem.title')}
</h4>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="flex items-center gap-1 text-[9.5px] font-bold text-emerald-500 uppercase">
<CheckCircle2 size={11} /> {t('insightsView.recalcSystem.statusSynced')}
</span>
{dashboardSectionOpen.recalc ? (
<ChevronDown size={14} className="text-concrete" aria-hidden />
) : (
<ChevronRight size={14} className="text-concrete" aria-hidden />
)}
</div>
</button>
<AnimatePresence initial={false}>
{dashboardSectionOpen.recalc && (
<motion.div
initial={prefersReducedMotion ? false : { height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={prefersReducedMotion ? undefined : { height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-5 pb-5 space-y-4 border-t border-border/10 pt-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<span className="text-[9px] text-concrete block">{t('insightsView.recalcSystem.scheduledCron')}</span>
<p className="text-xs text-ink dark:text-dark-ink font-semibold flex items-center gap-1.5">
<Clock size={12} className="opacity-50" /> 04:00
</p>
</div>
<div className="space-y-1">
<span className="text-[9px] text-concrete block">{t('insightsView.recalcSystem.lastSync')}</span>
<p className="text-xs text-ink dark:text-dark-ink font-bold font-mono">
{lastSyncTime || '—'}
</p>
</div>
</div>
<div className="pt-2 border-t border-border/10 space-y-3">
<div className="flex justify-between items-center text-[10px]">
<span className="text-concrete">
{embeddingStats
? t('insightsView.embeddingsHint', {
indexed: embeddingStats.indexed,
total: embeddingStats.total,
})
: '—'}
</span>
<span className="font-bold font-mono text-ink dark:text-dark-ink shrink-0 ms-3">
{embeddingStats
? `${embeddingStats.indexed} / ${embeddingStats.total}`
: '—'}
</span>
</div>
<button
type="button"
onClick={() => void handleReindexEmbeddings()}
disabled={isReindexing || isCalculating}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl border border-ochre/30 bg-ochre/5 hover:bg-ochre/10 text-[10px] font-bold uppercase tracking-widest text-ochre disabled:opacity-50 transition-colors"
>
{isReindexing ? (
<RefreshCw size={13} className="animate-spin" />
) : (
<Database size={13} />
)}
{isReindexing ? t('insightsView.mapping') : t('insightsView.resync')}
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</section>
{/* ④ Clusters Isolés */}
<section className="space-y-3">
<button
type="button"
onClick={() => setDashboardSectionOpen(s => ({ ...s, isolated: !s.isolated }))}
aria-expanded={dashboardSectionOpen.isolated}
className="w-full flex items-center justify-between gap-4 px-1 text-start cursor-pointer group"
>
<div className="flex items-center gap-2 min-w-0">
<AlertCircle size={15} className="text-rose-400 shrink-0" />
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink truncate">
{t('insightsView.isolatedClusters.title', { count: filteredIsolatedClusters.length })}
</h3>
</div>
{dashboardSectionOpen.isolated ? (
<ChevronDown size={14} className="text-concrete shrink-0" aria-hidden />
) : (
<ChevronRight size={14} className="text-concrete shrink-0" aria-hidden />
)}
</button>
{dashboardSectionOpen.isolated && (
<div className="space-y-2">
<p className="text-[10px] text-concrete italic px-1 leading-relaxed">
{t('insightsView.tipIsolatedAction')}
</p>
{visibleIsolatedClusters.map(c => (
<motion.div
key={c.id}
whileHover={prefersReducedMotion ? undefined : { y: -1 }}
onClick={() => setSelectedClusterId(c.id)}
className="p-3.5 rounded-xl bg-white dark:bg-zinc-800 border border-border/30 hover:border-black/10 dark:hover:border-white/10 flex items-center justify-between cursor-pointer transition-all focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
>
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: c.color }} />
<span className="text-xs font-medium text-ink dark:text-dark-ink truncate">
{c.name || t('insightsView.clusterFallback', { index: c.clusterId })}
</span>
</div>
<span className="text-[10px] text-rose-500 font-semibold uppercase tracking-wider bg-rose-500/5 px-2.5 py-0.5 rounded-full border border-rose-500/10 shrink-0">
{t('insightsView.isolatedClusters.badge')}
</span>
</motion.div>
))}
{!dashboardFilter.trim() && filteredIsolatedClusters.length > DASHBOARD_PREVIEW && (
<button
type="button"
onClick={() => setIsolatedShowAll(v => !v)}
className="w-full py-2 text-[10px] font-bold uppercase tracking-widest text-concrete hover:text-ink border border-dashed border-border/50 rounded-xl cursor-pointer"
>
{isolatedShowAll
? t('insightsView.legendShowLess')
: t('insightsView.legendShowMore', {
count: filteredIsolatedClusters.length - DASHBOARD_PREVIEW,
})}
</button>
)}
{filteredIsolatedClusters.length === 0 && (
<div className="p-4 bg-white dark:bg-zinc-800 rounded-xl text-xs text-concrete text-center italic border border-border/20">
{dashboardFilter.trim()
? t('insightsView.listFilterEmpty')
: t('insightsView.isolatedClusters.empty')}
</div>
)}
</div>
)}
</section>
{/* ⑤ Notes-Ponts Influentes */}
<section className="space-y-3">
<button
type="button"
onClick={() => setDashboardSectionOpen(s => ({ ...s, bridges: !s.bridges }))}
aria-expanded={dashboardSectionOpen.bridges}
className="w-full flex items-center justify-between gap-4 px-1 text-start cursor-pointer"
>
<div className="flex items-center gap-2 min-w-0">
<Zap size={16} className="text-ochre shrink-0" />
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink truncate">
{t('insightsView.bridgeNotes.title')}
<span className="ms-2 text-concrete font-mono normal-case tracking-normal">
({filteredBridgeList.length})
</span>
</h3>
</div>
{dashboardSectionOpen.bridges ? (
<ChevronDown size={14} className="text-concrete shrink-0" aria-hidden />
) : (
<ChevronRight size={14} className="text-concrete shrink-0" aria-hidden />
)}
</button>
{dashboardSectionOpen.bridges && (
<div className="space-y-3">
<p className="text-[10px] text-concrete italic px-1 leading-relaxed">
{t('insightsView.tipBridgeNotes')}
</p>
{visibleBridgeList.map(bridge => {
const { primary, extraCount } = getBridgeThemeSlice(bridge.clustersConnected)
const themeA = clusters.find(c => c.id === String(primary[0]))
const themeB = clusters.find(c => c.id === String(primary[1]))
const nameA =
themeA?.name ||
t('insightsView.clusterFallback', { index: primary[0] ?? 0 })
const nameB =
themeB?.name ||
(primary[1] !== undefined
? t('insightsView.clusterFallback', { index: primary[1] })
: null)
return (
<motion.div
key={bridge.noteId}
whileHover={prefersReducedMotion ? undefined : { x: 4 }}
onClick={() => handleNoteClick(bridge.noteId)}
className="p-4 rounded-xl bg-white dark:bg-zinc-800 border border-border/30 hover:border-ochre/40 hover:shadow-sm transition-all cursor-pointer group focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
tabIndex={0}
role="button"
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleNoteClick(bridge.noteId) } }}
>
<div className="flex items-start justify-between gap-3 mb-3">
<h4 className="text-xs font-semibold text-ink dark:text-dark-ink line-clamp-2 flex-1 group-hover:text-ochre transition-colors">
{bridge.title}
</h4>
<span
className="text-[9.5px] font-bold text-ochre bg-ochre/5 border border-ochre/10 px-2.5 py-0.5 rounded-full shrink-0"
title={t('insightsView.bridgeNotes.scoreHint')}
>
{t('insightsView.bridgeNotes.affinity', {
score: (bridge.bridgeScore * 100).toFixed(0),
})}
</span>
</div>
{nameB ? (
<div className="flex items-center gap-2 flex-wrap">
<button
type="button"
onClick={e => {
e.stopPropagation()
setSelectedClusterId(String(primary[0]))
}}
className="inline-flex items-center gap-1.5 max-w-[42%] px-2 py-1 rounded-lg bg-black/[0.03] dark:bg-white/[0.04] border border-border/30 hover:border-concrete/40 cursor-pointer"
>
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: themeA?.color || '#cbd5e1' }}
/>
<span className="text-[10px] font-medium text-ink dark:text-dark-ink truncate">
{nameA}
</span>
</button>
<span className="text-[10px] font-bold text-ochre shrink-0" aria-hidden>
</span>
<button
type="button"
onClick={e => {
e.stopPropagation()
setSelectedClusterId(String(primary[1]))
}}
className="inline-flex items-center gap-1.5 max-w-[42%] px-2 py-1 rounded-lg bg-black/[0.03] dark:bg-white/[0.04] border border-border/30 hover:border-concrete/40 cursor-pointer"
>
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: themeB?.color || '#cbd5e1' }}
/>
<span className="text-[10px] font-medium text-ink dark:text-dark-ink truncate">
{nameB}
</span>
</button>
{extraCount > 0 && (
<span className="text-[9px] text-concrete font-bold uppercase tracking-wider">
{t('insightsView.bridgeNotes.moreThemes', { count: extraCount })}
</span>
)}
</div>
) : (
<p className="text-[10px] text-concrete italic">
{t('insightsView.bridgeNotes.needsResync')}
</p>
)}
</motion.div>
)
})}
{!dashboardFilter.trim() && filteredBridgeList.length > DASHBOARD_PREVIEW && (
<button
type="button"
onClick={() => setBridgesShowAll(v => !v)}
className="w-full py-2.5 text-[10px] font-bold uppercase tracking-widest text-concrete hover:text-ink border border-dashed border-border/50 rounded-xl cursor-pointer"
>
{bridgesShowAll
? t('insightsView.legendShowLess')
: t('insightsView.legendShowMore', {
count: filteredBridgeList.length - DASHBOARD_PREVIEW,
})}
</button>
)}
{filteredBridgeList.length === 0 && !isCalculating && (
<div className="text-xs text-concrete italic text-center p-6 bg-white dark:bg-zinc-800 rounded-xl border border-border/20">
{dashboardFilter.trim()
? t('insightsView.listFilterEmpty')
: t('insightsView.bridgeNotes.empty')}
</div>
)}
</div>
)}
</section>
{/* ⑥ Opportunités de Connexion (link prediction — top near-miss pairs) */}
<section className="space-y-3">
<button
type="button"
onClick={() => setDashboardSectionOpen(s => ({ ...s, suggestions: !s.suggestions }))}
aria-expanded={dashboardSectionOpen.suggestions}
className="w-full flex items-center justify-between gap-4 px-1 text-start cursor-pointer"
>
<div className="flex items-center gap-2 min-w-0">
<Lightbulb size={16} className="text-indigo-500 shrink-0" />
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink truncate">
{t('insightsView.suggestions.title')}
{suggestions.length > 0 && (
<span className="ms-2 text-concrete font-mono normal-case tracking-normal">
({Math.min(suggestions.length, 12)})
</span>
)}
</h3>
</div>
{dashboardSectionOpen.suggestions ? (
<ChevronDown size={14} className="text-concrete shrink-0" aria-hidden />
) : (
<ChevronRight size={14} className="text-concrete shrink-0" aria-hidden />
)}
</button>
{dashboardSectionOpen.suggestions && (
<div className="space-y-3">
<p className="text-[10px] text-concrete italic px-1 leading-relaxed">
{t('insightsView.tipSuggestions')}
</p>
{visibleSuggestions.map(s => {
const key = `${s.clusterAId}-${s.clusterBId}`
const busy = actingSuggestionKey === key
return (
<div
key={key}
className="p-4 rounded-xl bg-white dark:bg-zinc-800 border border-indigo-500/15 hover:border-indigo-500/30 transition-all shadow-sm"
>
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span
className="w-2 h-2 rounded-full shrink-0 bg-indigo-500"
aria-hidden
/>
<span className="text-[10px] font-medium text-ink dark:text-dark-ink truncate">
{s.clusterAName}
</span>
<span className="text-[10px] font-bold text-ochre shrink-0" aria-hidden>
</span>
<span
className="w-2 h-2 rounded-full shrink-0 bg-ochre"
aria-hidden
/>
<span className="text-[10px] font-medium text-ink dark:text-dark-ink truncate">
{s.clusterBName}
</span>
</div>
<button
type="button"
disabled={busy}
onClick={() => void handleDismissSuggestion(s)}
className="p-1 rounded-md text-concrete hover:text-ink hover:bg-black/5 dark:hover:bg-white/5 disabled:opacity-50 cursor-pointer"
aria-label={t('insightsView.suggestions.dismiss')}
title={t('insightsView.suggestions.dismiss')}
>
<X size={14} />
</button>
</div>
<h4 className="text-sm font-semibold text-ink dark:text-dark-ink mb-1.5 line-clamp-2">
{s.suggestedTitle}
</h4>
<p className="text-[11px] text-muted-ink leading-relaxed line-clamp-2 mb-3">
{s.suggestedContent}
</p>
<button
type="button"
disabled={busy}
onClick={() => void handleCreateSuggestion(s)}
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-ink text-paper dark:bg-white dark:text-black text-[10px] font-bold uppercase tracking-widest hover:opacity-90 disabled:opacity-50 transition-opacity cursor-pointer"
>
{busy ? (
<RefreshCw size={12} className="animate-spin" />
) : (
<Lightbulb size={12} />
)}
{t('insightsView.suggestions.createNote')}
</button>
</div>
)
})}
{!suggestionsShowAll && suggestions.length > SUGGESTIONS_PREVIEW && (
<button
type="button"
onClick={() => setSuggestionsShowAll(true)}
className="w-full py-2.5 text-[10px] font-bold uppercase tracking-widest text-concrete hover:text-ink border border-dashed border-border/50 rounded-xl cursor-pointer"
>
{t('insightsView.legendShowMore', {
count: Math.min(suggestions.length, 12) - SUGGESTIONS_PREVIEW,
})}
</button>
)}
{suggestionsShowAll && suggestions.length > SUGGESTIONS_PREVIEW && (
<button
type="button"
onClick={() => setSuggestionsShowAll(false)}
className="w-full py-2.5 text-[10px] font-bold uppercase tracking-widest text-concrete hover:text-ink border border-dashed border-border/50 rounded-xl cursor-pointer"
>
{t('insightsView.legendShowLess')}
</button>
)}
{isCalculating && (
<div className="animate-pulse space-y-3">
{[1, 2].map(i => (
<div
key={i}
className="h-24 bg-indigo-500/5 rounded-xl border border-indigo-500/10"
/>
))}
</div>
)}
{!isCalculating && suggestions.length === 0 && (
<div className="text-xs text-concrete text-center italic p-6 border border-border/20 bg-white/40 dark:bg-zinc-800 rounded-xl">
{t('insightsView.suggestions.emptyDescription')}
</div>
)}
</div>
)}
</section>
</div>
</div>
</div>
)}
{/* ── Peek panel (factorisé) ── */}
<NotePeekPanel
note={peek.note}
blockId={peek.blockId}
loading={peek.loading}
mode="overlay"
onClose={peek.close}
onOpenFully={(n) => { router.push(`/home?openNote=${n.id}`); peek.close() }}
/>
</div>
)
}