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.
1358 lines
64 KiB
TypeScript
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, '<')}</p><p><em>${s.justification.replace(/</g, '<')}</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>
|
|
)
|
|
}
|