Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid. Co-authored-by: Cursor <cursoragent@cursor.com>
758 lines
28 KiB
TypeScript
758 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
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [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>
|
|
)
|
|
}
|