Files
Momento/memento-note/components/note-network-tab.tsx
Antigravity 36336e6b0d
Some checks failed
CI / Lint, Test & Build (push) Failing after 32s
CI / Deploy production (on server) (push) Has been skipped
feat(flashcards): révision SM-2, génération IA et page /revision
Livre US-FLASHCARDS avec decks, session de révision, stats et migration Prisma. Finalise le Web Clipper (i18n 15 langues) et corrige les erreurs ESLint bloquant la CI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 19:22:20 +00:00

757 lines
28 KiB
TypeScript

'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChevronUp, HelpCircle, Loader2, Network, Sparkles, Link2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
import { useNotebooks } from '@/context/notebooks-context'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { NOTE_CHANGE_EVENT } from '@/lib/note-change-sync'
import { openNoteInNewTab } from '@/lib/navigation/open-note'
import {
SEMANTIC_SIMILARITY_FLOOR,
semanticOrbitRadius,
semanticProximityPercent,
semanticProximityRatio,
} from '@/lib/ai/semantic-proximity'
interface NetworkNote {
id: string
title: string | null
notebookId: string | null
}
interface NetworkLink {
id: string
note: NetworkNote
contextSnippet: string | null
}
interface UnlinkedMention {
title: string
snippet: string
}
interface SemanticConnection {
noteId: string
title: string | null
notebookId: string | null
similarity: number
excerpt: string
}
interface EmbedHost {
note: NetworkNote
blockIds: string[]
}
type OrbitRelationship = 'backlink' | 'outbound' | 'mention' | 'semantic' | 'embed'
interface OrbitNode {
key: string
id?: string
title: string
color: string
notebookName: string
relationship: OrbitRelationship
snippet?: string | null
similarity?: number
}
interface NoteNetworkTabProps {
noteId: string
noteTitle: string
}
const MAX_GRAPH_NODES = 10
const MAX_LIST_ITEMS = 5
const DEFAULT_COLOR = '#71717A'
const SEMANTIC_COLOR = '#7C3AED'
const CX = 160
const CY = 110
function orbitRadius(relationship: OrbitRelationship): number {
switch (relationship) {
case 'outbound': return 52
case 'backlink': return 68
case 'embed': return 78
case 'semantic': return 88
default: return 94
}
}
function cleanSnippet(text: string, max = 140): string {
const plain = text
.replace(/<[^>]+>/g, ' ')
.replace(/\|+/g, ' ')
.replace(/\*{1,2}([^*]+)\*{1,2}/g, '$1')
.replace(/\s+/g, ' ')
.trim()
if (!plain) return ''
if (plain.length <= max) return plain
return `${plain.slice(0, max).trim()}`
}
function SectionHelp({ label, help }: { label: string; help: string }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="p-0.5 rounded-md text-muted-foreground/70 hover:text-foreground hover:bg-muted transition-colors"
aria-label={label}
>
<HelpCircle className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[260px] text-left text-xs leading-relaxed">
<p className="font-medium mb-1">{label}</p>
<p className="text-background/85">{help}</p>
</TooltipContent>
</Tooltip>
)
}
function InteractiveOrbitGraph({
nodes,
extraCount,
centerTitle,
t,
relationshipLabel,
similarityFloor,
}: {
nodes: OrbitNode[]
extraCount: number
centerTitle: string
t: (key: string, params?: Record<string, string | number>) => string
relationshipLabel: (rel: OrbitRelationship) => string
similarityFloor: number
}) {
const svgRef = useRef<SVGSVGElement>(null)
const [offsets, setOffsets] = useState<Record<string, { dx: number; dy: number }>>({})
const [activeKey, setActiveKey] = useState<string | null>(null)
const [hoveredKey, setHoveredKey] = useState<string | null>(null)
const dragRef = useRef<{
key: string
pointerId: number
startX: number
startY: number
baseDx: number
baseDy: number
moved: boolean
} | null>(null)
const basePositions = useMemo(() => {
return nodes.map((node, i) => {
const angle = i * (nodes.length > 0 ? (2 * Math.PI) / nodes.length : 0) - Math.PI / 2
const r = node.relationship === 'semantic' && node.similarity != null
? semanticOrbitRadius(node.similarity, similarityFloor)
: orbitRadius(node.relationship)
return {
key: node.key,
x: CX + r * Math.cos(angle),
y: CY + r * 0.88 * Math.sin(angle),
}
})
}, [nodes, similarityFloor])
const nodePosition = useCallback((key: string, baseX: number, baseY: number) => {
const o = offsets[key] || { dx: 0, dy: 0 }
return { x: baseX + o.dx, y: baseY + o.dy }
}, [offsets])
const clientToSvg = useCallback((clientX: number, clientY: number) => {
const svg = svgRef.current
if (!svg) return { x: 0, y: 0 }
const pt = svg.createSVGPoint()
pt.x = clientX
pt.y = clientY
const ctm = svg.getScreenCTM()
if (!ctm) return { x: 0, y: 0 }
return pt.matrixTransform(ctm.inverse())
}, [])
const endDrag = useCallback((pointerId: number) => {
const d = dragRef.current
if (!d || d.pointerId !== pointerId) return
dragRef.current = null
setActiveKey(null)
}, [])
const startDrag = useCallback((
nodeKey: string,
pointerId: number,
clientX: number,
clientY: number,
baseDx: number,
baseDy: number,
) => {
const pt = clientToSvg(clientX, clientY)
dragRef.current = {
key: nodeKey,
pointerId,
startX: pt.x,
startY: pt.y,
baseDx,
baseDy,
moved: false,
}
setActiveKey(nodeKey)
}, [clientToSvg])
useEffect(() => {
const onMove = (e: PointerEvent) => {
const d = dragRef.current
if (!d || e.pointerId !== d.pointerId) return
e.preventDefault()
const pt = clientToSvg(e.clientX, e.clientY)
const dx = d.baseDx + (pt.x - d.startX)
const dy = d.baseDy + (pt.y - d.startY)
if (!d.moved && Math.hypot(pt.x - d.startX, pt.y - d.startY) > 4) {
d.moved = true
}
setOffsets(prev => ({ ...prev, [d.key]: { dx, dy } }))
}
const onUp = (e: PointerEvent) => {
const d = dragRef.current
if (!d || e.pointerId !== d.pointerId) return
const moved = d.moved
const node = nodes.find(n => n.key === d.key)
endDrag(e.pointerId)
if (!moved && node?.id) {
openNoteInNewTab(node.id)
}
}
window.addEventListener('pointermove', onMove, { passive: false })
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
return () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
}
}, [clientToSvg, endDrag, nodes])
const hoveredNode = nodes.find(n => n.key === hoveredKey) || null
return (
<div className="rounded-xl border border-border/50 bg-muted/30 overflow-hidden">
<div className="p-2 pb-0">
<svg
ref={svgRef}
width="100%"
height="220"
viewBox="0 0 320 220"
className="select-none block"
role="img"
aria-label={t('documentInfo.network.graphTitle')}
style={{ touchAction: 'none' }}
>
<circle cx={CX} cy={CY} r="96" fill="none" stroke="currentColor" strokeWidth="1" strokeDasharray="3,6" className="text-border/80" />
{basePositions.map((base, i) => {
const node = nodes[i]
const { x, y } = nodePosition(node.key, base.x, base.y)
const isSemantic = node.relationship === 'semantic'
const isMention = node.relationship === 'mention'
const stroke = isSemantic ? SEMANTIC_COLOR : isMention ? '#94A3B8' : '#A47148'
const proximity = isSemantic && node.similarity != null
? semanticProximityRatio(node.similarity, similarityFloor)
: null
return (
<line
key={`line-${node.key}`}
x1={CX}
y1={CY}
x2={x}
y2={y}
stroke={stroke}
strokeWidth={isSemantic ? 1.2 + (proximity ?? 0) * 2.2 : 1.5}
strokeDasharray={isMention || isSemantic ? '5,4' : undefined}
className={isSemantic ? undefined : 'opacity-55'}
opacity={isSemantic ? 0.35 + (proximity ?? 0) * 0.5 : 0.55}
style={{ pointerEvents: 'none' }}
/>
)
})}
<circle cx={CX} cy={CY} r="14" fill="#A47148" stroke="var(--background)" strokeWidth="3" style={{ pointerEvents: 'none' }} />
<circle cx={CX} cy={CY} r="4" fill="white" style={{ pointerEvents: 'none' }} />
{basePositions.map((base, i) => {
const node = nodes[i]
const { x, y } = nodePosition(node.key, base.x, base.y)
const isActive = activeKey === node.key || hoveredKey === node.key
const canOpen = !!node.id
return (
<g key={node.key}>
<circle
cx={x}
cy={y}
r={18}
fill="transparent"
className={cn(canOpen && 'cursor-pointer')}
onPointerEnter={() => setHoveredKey(node.key)}
onPointerLeave={() => {
if (activeKey !== node.key) setHoveredKey(null)
}}
onPointerDown={(e) => {
if (!canOpen) return
e.preventDefault()
e.stopPropagation()
const o = offsets[node.key] || { dx: 0, dy: 0 }
startDrag(node.key, e.pointerId, e.clientX, e.clientY, o.dx, o.dy)
e.currentTarget.setPointerCapture(e.pointerId)
}}
/>
<circle
cx={x}
cy={y}
r={isActive ? 10 : 7}
fill={node.color}
stroke={isActive ? 'var(--foreground)' : 'var(--background)'}
strokeWidth={2}
style={{ pointerEvents: 'none' }}
/>
<text
x={x}
y={y + 15}
textAnchor="middle"
fontSize="7"
fill="currentColor"
className="text-muted-foreground"
style={{ pointerEvents: 'none' }}
>
{node.title.length > 13 ? `${node.title.slice(0, 12)}` : node.title}
</text>
</g>
)
})}
</svg>
{extraCount > 0 && (
<p className="text-[9px] text-right text-muted-foreground px-1 -mt-1">
{t('documentInfo.network.moreNodes', { count: extraCount })}
</p>
)}
</div>
<div className="mx-2 mb-2 p-2.5 rounded-lg border border-border/40 bg-background min-h-[52px]">
{hoveredNode ? (
<div>
<div className="flex justify-between gap-2 text-[9px] text-muted-foreground mb-0.5">
<span className="truncate">{hoveredNode.notebookName}</span>
<span className="font-semibold shrink-0 text-foreground">{relationshipLabel(hoveredNode.relationship)}</span>
</div>
<p className="font-medium text-xs leading-snug">{hoveredNode.title}</p>
{hoveredNode.similarity != null && (
<p className="text-[10px] text-violet-600 dark:text-violet-400 mt-0.5">
{t('documentInfo.network.affinityLine', {
percentage: semanticProximityPercent(hoveredNode.similarity, similarityFloor),
})}
</p>
)}
{hoveredNode.snippet && (
<p className="text-[10px] text-muted-foreground line-clamp-2 mt-1">{cleanSnippet(hoveredNode.snippet)}</p>
)}
{hoveredNode.id && (
<p className="text-[9px] text-muted-foreground mt-1">{t('documentInfo.network.clickToOpen')}</p>
)}
</div>
) : (
<p className="text-[10px] text-muted-foreground text-center leading-relaxed">
{t('documentInfo.network.dragHint')}
</p>
)}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 px-3 pb-2.5 text-[9px] text-muted-foreground border-t border-border/30 pt-2">
<span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-[#A47148]" />{t('documentInfo.network.legendCenter')}</span>
<span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-violet-600" />{t('documentInfo.network.legendSemantic')}</span>
<span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-[#A47148]/60" />{t('documentInfo.network.legendWiki')}</span>
</div>
</div>
)
}
export function NoteNetworkTab({ noteId, noteTitle }: NoteNetworkTabProps) {
const { t } = useLanguage()
const { notebooks } = useNotebooks()
const [loading, setLoading] = useState(true)
const [backlinks, setBacklinks] = useState<NetworkLink[]>([])
const [outbound, setOutbound] = useState<NetworkLink[]>([])
const [unlinkedMentions, setUnlinkedMentions] = useState<UnlinkedMention[]>([])
const [semanticConnections, setSemanticConnections] = useState<SemanticConnection[]>([])
const [embedHosts, setEmbedHosts] = useState<EmbedHost[]>([])
const [consentRequired, setConsentRequired] = useState(false)
const [similarityFloor, setSimilarityFloor] = useState(SEMANTIC_SIMILARITY_FLOOR)
const [helpOpen, setHelpOpen] = useState(false)
const [showAllSemantic, setShowAllSemantic] = useState(false)
const [showWiki, setShowWiki] = useState(true)
const [refreshKey, setRefreshKey] = useState(0)
const loadNetwork = useCallback(() => {
if (!noteId) return
setLoading(true)
fetch(`/api/notes/${noteId}/network`)
.then(r => r.json())
.then(data => {
setBacklinks(data.backlinks || [])
setOutbound(data.outbound || [])
setUnlinkedMentions(data.unlinkedMentions || [])
setSemanticConnections(data.semanticConnections || [])
setEmbedHosts(data.embedHosts || [])
setConsentRequired(!!data.consentRequired)
setSimilarityFloor(typeof data.similarityFloor === 'number' ? data.similarityFloor : SEMANTIC_SIMILARITY_FLOOR)
})
.catch(() => {
setBacklinks([])
setOutbound([])
setUnlinkedMentions([])
setSemanticConnections([])
setEmbedHosts([])
})
.finally(() => setLoading(false))
}, [noteId])
useEffect(() => {
loadNetwork()
}, [loadNetwork, refreshKey])
useEffect(() => {
const onNoteChange = (event: Event) => {
const detail = (event as CustomEvent).detail
if (detail?.type === 'updated' && detail.note?.id === noteId) {
setRefreshKey(k => k + 1)
}
}
window.addEventListener(NOTE_CHANGE_EVENT, onNoteChange)
return () => window.removeEventListener(NOTE_CHANGE_EVENT, onNoteChange)
}, [noteId])
const colorForNotebook = (notebookId: string | null) =>
notebooks.find(n => n.id === notebookId)?.color || DEFAULT_COLOR
const notebookNameFor = (notebookId: string | null) =>
notebooks.find(n => n.id === notebookId)?.name || t('documentInfo.network.unknownNotebook')
const sortedSemantic = useMemo(
() => [...semanticConnections].sort((a, b) => b.similarity - a.similarity),
[semanticConnections]
)
const graphNodes = useMemo(() => {
const nodes: OrbitNode[] = []
const seen = new Set<string>()
const push = (node: OrbitNode) => {
if (node.id) {
if (seen.has(node.id)) return
seen.add(node.id)
}
nodes.push(node)
}
outbound.forEach(link => {
if (!link.note) return
push({
key: `out-${link.id}`,
id: link.note.id,
title: link.note.title || t('documentInfo.network.untitled'),
color: colorForNotebook(link.note.notebookId),
notebookName: notebookNameFor(link.note.notebookId),
relationship: 'outbound',
snippet: link.contextSnippet,
})
})
backlinks.forEach(link => {
if (!link.note) return
push({
key: `in-${link.id}`,
id: link.note.id,
title: link.note.title || t('documentInfo.network.untitled'),
color: colorForNotebook(link.note.notebookId),
notebookName: notebookNameFor(link.note.notebookId),
relationship: 'backlink',
snippet: link.contextSnippet,
})
})
sortedSemantic.forEach(conn => {
push({
key: `sem-${conn.noteId}`,
id: conn.noteId,
title: conn.title || t('documentInfo.network.untitled'),
color: SEMANTIC_COLOR,
notebookName: notebookNameFor(conn.notebookId),
relationship: 'semantic',
snippet: conn.excerpt,
similarity: conn.similarity,
})
})
embedHosts.forEach((host, i) => {
push({
key: `embed-${i}-${host.note.id}`,
id: host.note.id,
title: host.note.title || t('documentInfo.network.untitled'),
color: colorForNotebook(host.note.notebookId),
notebookName: notebookNameFor(host.note.notebookId),
relationship: 'embed',
snippet: t('documentInfo.network.embedSnippetOne'),
})
})
return nodes
}, [sortedSemantic, outbound, backlinks, embedHosts, notebooks, t])
const orbitNodes = graphNodes.slice(0, MAX_GRAPH_NODES)
const extraCount = Math.max(0, graphNodes.length - MAX_GRAPH_NODES)
const relationshipLabel = (rel: OrbitRelationship) => {
switch (rel) {
case 'backlink': return t('documentInfo.network.inboundShort')
case 'outbound': return t('documentInfo.network.outboundShort')
case 'semantic': return t('documentInfo.network.semanticShort')
case 'embed': return t('documentInfo.network.embedShort')
default: return t('documentInfo.network.mentionShort')
}
}
const hasWiki = backlinks.length > 0 || outbound.length > 0 || unlinkedMentions.length > 0
const visibleSemantic = showAllSemantic ? sortedSemantic : sortedSemantic.slice(0, MAX_LIST_ITEMS)
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<p className="text-xs">{t('documentInfo.loading')}</p>
</div>
)
}
const isEmpty =
!hasWiki && sortedSemantic.length === 0 && embedHosts.length === 0
return (
<div className="p-4 space-y-4">
<div className="space-y-2">
<div className="flex items-start justify-between gap-2">
<div>
<h4 className="text-sm font-semibold text-foreground">{t('documentInfo.network.graphTitle')}</h4>
<p className="text-[11px] text-muted-foreground leading-relaxed mt-1">{t('documentInfo.network.intro')}</p>
</div>
<button
type="button"
onClick={() => setHelpOpen(v => !v)}
className="shrink-0 p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-expanded={helpOpen}
aria-label={t('documentInfo.network.helpToggle')}
>
<HelpCircle className="h-4 w-4" />
</button>
</div>
{helpOpen && (
<div className="rounded-xl border border-border/60 bg-muted/30 p-3 space-y-2 text-[11px] text-muted-foreground leading-relaxed">
<p className="font-medium text-foreground text-xs">{t('documentInfo.network.helpTitle')}</p>
<p>{t('documentInfo.network.helpGraph')}</p>
<p>{t('documentInfo.network.helpSemantic')}</p>
<p>{t('documentInfo.network.helpWiki')}</p>
<p>{t('documentInfo.network.helpEmbed')}</p>
</div>
)}
</div>
{isEmpty ? (
<div className="text-center py-8 px-4 border border-dashed border-border/60 rounded-xl text-muted-foreground space-y-2">
<Network className="h-7 w-7 mx-auto opacity-30" />
<p className="text-xs leading-relaxed">{t('documentInfo.network.empty')}</p>
{consentRequired && (
<p className="text-[11px] opacity-80">{t('documentInfo.network.consentHint')}</p>
)}
</div>
) : (
<>
{orbitNodes.length > 0 && (
<InteractiveOrbitGraph
nodes={orbitNodes}
extraCount={extraCount}
centerTitle={noteTitle || t('documentInfo.network.untitled')}
t={t}
relationshipLabel={relationshipLabel}
similarityFloor={similarityFloor}
/>
)}
{sortedSemantic.length > 0 && (
<section className="space-y-2">
<div className="flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-violet-600" />
<h5 className="text-xs font-semibold">{t('documentInfo.network.semanticListTitle')}</h5>
<SectionHelp
label={t('documentInfo.network.semanticListTitle')}
help={t('documentInfo.network.semanticListHelp')}
/>
<span className="text-[10px] text-muted-foreground ml-auto">{sortedSemantic.length}</span>
</div>
<div className="space-y-1.5">
{visibleSemantic.map(conn => (
<button
key={conn.noteId}
type="button"
onClick={() => openNoteInNewTab(conn.noteId)}
className="w-full text-left p-3 rounded-xl border border-violet-500/15 bg-violet-500/[0.04] hover:border-violet-500/35 transition-all"
>
<div className="flex items-start justify-between gap-2">
<span className="text-xs font-medium leading-snug line-clamp-2">{conn.title || t('documentInfo.network.untitled')}</span>
<span className="text-[10px] font-semibold text-violet-700 dark:text-violet-300 shrink-0">
{semanticProximityPercent(conn.similarity, similarityFloor)} %
</span>
</div>
{conn.excerpt && (
<p className="text-[10px] text-muted-foreground mt-1 line-clamp-2 leading-relaxed">{cleanSnippet(conn.excerpt)}</p>
)}
</button>
))}
</div>
{sortedSemantic.length > MAX_LIST_ITEMS && (
<button
type="button"
onClick={() => setShowAllSemantic(v => !v)}
className="text-[11px] text-violet-700 dark:text-violet-300 font-medium hover:underline"
>
{showAllSemantic
? t('documentInfo.network.showLess')
: t('documentInfo.network.showMoreSemantic', { count: sortedSemantic.length - MAX_LIST_ITEMS })}
</button>
)}
</section>
)}
{embedHosts.length > 0 && (
<section className="space-y-2">
<div className="flex items-center gap-1.5">
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
<h5 className="text-xs font-semibold">{t('documentInfo.network.embedListTitle')}</h5>
<SectionHelp label={t('documentInfo.network.embedListTitle')} help={t('documentInfo.network.helpEmbed')} />
</div>
{embedHosts.map(host => (
<button
key={host.note.id}
type="button"
onClick={() => openNoteInNewTab(host.note.id)}
className="w-full text-left p-3 rounded-xl border border-border/50 hover:bg-muted/40 transition-all"
>
<p className="text-xs font-medium line-clamp-2">{host.note.title || t('documentInfo.network.untitled')}</p>
<p className="text-[10px] text-muted-foreground mt-0.5">{t('documentInfo.network.embedSnippetOne')}</p>
</button>
))}
</section>
)}
{hasWiki && (
<section className="space-y-2">
<button
type="button"
onClick={() => setShowWiki(v => !v)}
className="flex items-center gap-2 w-full text-left group"
>
<h5 className="text-xs font-semibold text-muted-foreground group-hover:text-foreground transition-colors">
{t('documentInfo.network.wikiSectionTitle')}
</h5>
{showWiki ? <ChevronUp className="h-3.5 w-3.5 text-muted-foreground" /> : <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />}
</button>
{showWiki && (
<div className="space-y-3 pl-0.5">
{backlinks.length > 0 && (
<WikiList
title={t('documentInfo.network.inboundList', { count: backlinks.length })}
help={t('documentInfo.network.inboundHelp')}
links={backlinks}
onOpen={openNoteInNewTab}
untitled={t('documentInfo.network.untitled')}
/>
)}
{outbound.length > 0 && (
<WikiList
title={t('documentInfo.network.outboundList', { count: outbound.length })}
help={t('documentInfo.network.outboundHelp')}
links={outbound}
onOpen={openNoteInNewTab}
untitled={t('documentInfo.network.untitled')}
/>
)}
{unlinkedMentions.length > 0 && (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<p className="text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
{t('documentInfo.network.unlinkedList', { count: unlinkedMentions.length })}
</p>
<SectionHelp label={t('documentInfo.network.unlinkedListTitle')} help={t('documentInfo.network.unlinkedHelp')} />
</div>
{unlinkedMentions.map((m, i) => (
<div key={i} className="p-2.5 rounded-lg border border-border/40 bg-muted/20 text-[10px] text-muted-foreground leading-relaxed">
<span className="font-medium text-foreground">[[{m.title}]]</span>
{m.snippet && cleanSnippet(m.snippet) && (
<span className="block mt-1 italic">{cleanSnippet(m.snippet, 100)}</span>
)}
</div>
))}
</div>
)}
{backlinks.length === 0 && outbound.length === 0 && unlinkedMentions.length === 0 && (
<p className="text-[11px] text-muted-foreground italic">{t('documentInfo.network.noWikiYet')}</p>
)}
</div>
)}
</section>
)}
</>
)}
</div>
)
}
function WikiList({
title,
help,
links,
onOpen,
untitled,
}: {
title: string
help: string
links: NetworkLink[]
onOpen: (id: string) => void
untitled: string
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<p className="text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">{title}</p>
<SectionHelp label={title} help={help} />
</div>
{links.map(link => (
<button
key={link.id}
type="button"
onClick={() => onOpen(link.note.id)}
className="w-full text-left p-2.5 rounded-lg border border-border/40 hover:bg-muted/30 transition-all"
>
<p className="text-xs font-medium truncate">{link.note.title || untitled}</p>
{link.contextSnippet && (
<p className="text-[10px] text-muted-foreground mt-0.5 line-clamp-2">{cleanSnippet(link.contextSnippet)}</p>
)}
</button>
))}
</div>
)
}