Politur: Verschmelzung von Liste, Graph und Details zu einem einheitlichen, schwebenden Glassmorphism-Dashboard (Graph im Hintergrund, Panels schweben elegant davor)

This commit is contained in:
Hitonabi
2026-07-09 12:27:46 +02:00
parent 895eb9cac7
commit 2ed7100f90
9 changed files with 1165 additions and 1214 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title> <title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-Ch3qlJmN.js"></script> <script type="module" crossorigin src="/assets/index-P6XXlxiQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-1USExdfg.css"> <link rel="stylesheet" crossorigin href="/assets/index-CunuYLZu.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+123 -200
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react" import { useEffect, useMemo, useRef, useState } from "react"
import ForceGraph2D from "react-force-graph-2d" import ForceGraph2D from "react-force-graph-2d"
import { forceCollide, forceX, forceY } from "d3-force" import { forceCollide, forceX, forceY } from "d3-force"
import { Trash2, Sparkles, Share2, RefreshCw } from "lucide-react" import { RefreshCw } from "lucide-react"
import { type MemoryGraph } from "@/lib/api" import { type MemoryGraph } from "@/lib/api"
const CAT_COLOR: Record<string, string> = { const CAT_COLOR: Record<string, string> = {
@@ -10,7 +10,6 @@ const CAT_COLOR: Record<string, string> = {
const CAT_LABEL: Record<string, string> = { const CAT_LABEL: Record<string, string> = {
identity: "Identität", knowledge: "Wissen", rules: "Regeln", events: "Ereignisse", identity: "Identität", knowledge: "Wissen", rules: "Regeln", events: "Ereignisse",
} }
const AUTO = new Set(["auto", "agent", "hermes"])
const EDGE_HI = "rgba(99, 102, 241, 0.9)" const EDGE_HI = "rgba(99, 102, 241, 0.9)"
const DIM_EDGE = "rgba(255, 255, 255, 0.02)" const DIM_EDGE = "rgba(255, 255, 255, 0.02)"
@@ -22,8 +21,11 @@ function hexToRgba(hex: string, alpha: number): string {
return `rgba(${r}, ${g}, ${b}, ${alpha})` return `rgba(${r}, ${g}, ${b}, ${alpha})`
} }
export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id: string) => void }) { export function GraphView({ data, selectedNodeId, onNodeSelect }: {
const [selected, setSelected] = useState<string | null>(null) data: MemoryGraph
selectedNodeId: string | null
onNodeSelect: (id: string | null) => void
}) {
const [hovered, setHovered] = useState<string | null>(null) const [hovered, setHovered] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const fgRef = useRef<any>(null) const fgRef = useRef<any>(null)
@@ -114,7 +116,7 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
fg.d3ReheatSimulation() fg.d3ReheatSimulation()
}, [graphData]) }, [graphData])
const activeNode = hovered || selected const activeNode = hovered || selectedNodeId
// Layout zurücksetzen/neu anschubsen // Layout zurücksetzen/neu anschubsen
const relayout = () => { const relayout = () => {
@@ -122,19 +124,6 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
fgRef.current.zoomToFit(400, 40) fgRef.current.zoomToFit(400, 40)
} }
const sel = selected ? data.nodes.find((n) => n.id === selected) ?? null : null
// Nachbarn über das statische, sichere Props-Array ermitteln
const neighbors = useMemo(() => {
if (!selected) return []
const ids = new Set<string>()
data.edges.forEach((e) => {
if (e.source === selected) ids.add(e.target)
if (e.target === selected) ids.add(e.source)
})
return data.nodes.filter((n) => ids.has(n.id))
}, [selected, data])
// Nachbarmenge für extrem schnellen Lookup im Render-Loop // Nachbarmenge für extrem schnellen Lookup im Render-Loop
const connectedNodes = useMemo(() => { const connectedNodes = useMemo(() => {
if (!activeNode) return new Set<string>() if (!activeNode) return new Set<string>()
@@ -146,207 +135,141 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
return set return set
}, [activeNode, data]) }, [activeNode, data])
if (!data.nodes.length) {
return (
<div className="text-xs text-muted-foreground border border-dashed border-border/60 rounded-2xl p-12 text-center">
Noch keine Fakten der Graph füllt sich, sobald Hermes lernt oder du Einträge anlegst.
</div>
)
}
return ( return (
<div className="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-3"> <div className="absolute inset-0" ref={containerRef}
<div className="relative h-[calc(100vh-13rem)] min-h-[560px] rounded-2xl border border-border/60 overflow-hidden" style={{ background: "radial-gradient(circle at center, rgba(59, 130, 246, 0.08) 0%, #030712 100%)" }}>
ref={containerRef}
style={{ background: "radial-gradient(circle at center, rgba(59, 130, 246, 0.08) 0%, #030712 100%)" }}>
<ForceGraph2D <ForceGraph2D
ref={fgRef} ref={fgRef}
width={dimensions.width} width={dimensions.width}
height={dimensions.height} height={dimensions.height}
graphData={graphData} graphData={graphData}
backgroundColor="rgba(0,0,0,0)" backgroundColor="rgba(0,0,0,0)"
nodeRelSize={4} nodeRelSize={4}
cooldownTicks={120} cooldownTicks={120}
// Hover- und Klick-Logik // Hover- und Klick-Logik
onNodeClick={(node: any) => setSelected(node.id)} onNodeClick={(node: any) => onNodeSelect(node.id)}
onBackgroundClick={() => setSelected(null)} onBackgroundClick={() => onNodeSelect(null)}
onNodeHover={(node: any) => { onNodeHover={(node: any) => {
setHovered(node ? node.id : null) setHovered(node ? node.id : null)
if (containerRef.current) { if (containerRef.current) {
containerRef.current.style.cursor = node ? "pointer" : "default" containerRef.current.style.cursor = node ? "pointer" : "default"
} }
}} }}
// Kanten-Animationen & Partikelfluss // Kanten-Animationen & Partikelfluss
linkColor={(link: any) => { linkColor={(link: any) => {
const sId = typeof link.source === "object" ? link.source.id : link.source const sId = typeof link.source === "object" ? link.source.id : link.source
const tId = typeof link.target === "object" ? link.target.id : link.target const tId = typeof link.target === "object" ? link.target.id : link.target
// Highlight-Kanten leuchten in Indigo auf if (activeNode === sId || activeNode === tId) return EDGE_HI
if (activeNode === sId || activeNode === tId) return EDGE_HI if (activeNode) return DIM_EDGE
// Wenn ein anderer Knoten aktiv ist, blenden wir diese Kante fast komplett aus const sourceNode = graphData.nodes.find(n => n.id === sId)
if (activeNode) return DIM_EDGE const catColor = sourceNode ? CAT_COLOR[sourceNode.category] : "#64748b"
return hexToRgba(catColor, 0.25)
}}
linkWidth={(link: any) => {
const sId = typeof link.source === "object" ? link.source.id : link.source
const tId = typeof link.target === "object" ? link.target.id : link.target
return (activeNode === sId || activeNode === tId) ? 2.5 : 1.4
}}
// Standard-Zustand: Kante leuchtet in der Farbe des Quell-Clusters (deutlich sichtbar mit alpha=0.25) // Daten-Partikel fließen auf den aktiven Verbindungen
const sourceNode = graphData.nodes.find(n => n.id === sId) linkDirectionalParticles={(link: any) => {
const catColor = sourceNode ? CAT_COLOR[sourceNode.category] : "#64748b" const sId = typeof link.source === "object" ? link.source.id : link.source
return hexToRgba(catColor, 0.25) const tId = typeof link.target === "object" ? link.target.id : link.target
}} return (activeNode === sId || activeNode === tId) ? 4 : 0
linkWidth={(link: any) => { }}
const sId = typeof link.source === "object" ? link.source.id : link.source linkDirectionalParticleWidth={2.2}
const tId = typeof link.target === "object" ? link.target.id : link.target linkDirectionalParticleSpeed={0.006}
return (activeNode === sId || activeNode === tId) ? 2.5 : 1.4
}}
// Daten-Partikel fließen auf den aktiven Verbindungen // Custom Canvas Rendering für glühende Knoten und 100% lesbare Text-Pillen
linkDirectionalParticles={(link: any) => { nodeCanvasObject={(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
const sId = typeof link.source === "object" ? link.source.id : link.source const x = node.x ?? 0
const tId = typeof link.target === "object" ? link.target.id : link.target const y = node.y ?? 0
return (activeNode === sId || activeNode === tId) ? 4 : 0 const size = 5.5 + Math.min(degree[node.id] || 0, 12) * 0.9
}} const color = CAT_COLOR[node.category] || "#64748b"
linkDirectionalParticleWidth={2.2} const isHovered = node.id === hovered
linkDirectionalParticleSpeed={0.006} const isSelected = node.id === selectedNodeId
// Custom Canvas Rendering für glühende Knoten und 100% lesbare Text-Pillen const isDimmed = activeNode && node.id !== activeNode && !connectedNodes.has(node.id)
nodeCanvasObject={(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => { const finalColor = isDimmed ? "#1e293b" : color
const x = node.x ?? 0
const y = node.y ?? 0
const size = 5.5 + Math.min(degree[node.id] || 0, 12) * 0.9
const color = CAT_COLOR[node.category] || "#64748b"
const isHovered = node.id === hovered
const isSelected = node.id === selected
// Abgedunkelter Zustand, wenn ein anderer Knoten aktiv ist und dieser kein Nachbar ist if (!isDimmed) {
const isDimmed = activeNode && node.id !== activeNode && !connectedNodes.has(node.id) ctx.shadowColor = color
const finalColor = isDimmed ? "#1e293b" : color ctx.shadowBlur = isHovered || isSelected ? 12 : 6
}
// 1. Zeichne weichen Glow-Schatten ctx.beginPath()
if (!isDimmed) { ctx.arc(x, y, size, 0, 2 * Math.PI, false)
ctx.shadowColor = color ctx.fillStyle = finalColor
ctx.shadowBlur = isHovered || isSelected ? 12 : 6 ctx.fill()
} ctx.shadowBlur = 0
// 2. Zeichne den Knoten-Kreis if (isHovered || isSelected) {
ctx.beginPath() ctx.beginPath()
ctx.arc(x, y, size, 0, 2 * Math.PI, false) ctx.arc(x, y, size + 2, 0, 2 * Math.PI, false)
ctx.fillStyle = finalColor ctx.strokeStyle = isSelected ? "#ffffff" : "rgba(255, 255, 255, 0.7)"
ctx.fill() ctx.lineWidth = 1.5
ctx.shadowBlur = 0 // Schatten zurücksetzen
// 3. Zeichne weißen Außenring bei Interaktivität
if (isHovered || isSelected) {
ctx.beginPath()
ctx.arc(x, y, size + 2, 0, 2 * Math.PI, false)
ctx.strokeStyle = isSelected ? "#ffffff" : "rgba(255, 255, 255, 0.7)"
ctx.lineWidth = 1.5
ctx.stroke()
}
// 4. Typografie (Immer scharf & mitskalierend gezeichnet)
const label = node.content.length > 30 ? node.content.slice(0, 29) + "…" : node.content
const baseFontSize = isHovered || isSelected ? 10 : 9
const fontSize = baseFontSize / Math.max(0.6, globalScale) // Verhindert Riesen-Schriften beim Rauszoomen
ctx.font = `${isHovered || isSelected ? "bold" : "500"} ${fontSize}px "Space Grotesk", sans-serif`
const textWidth = ctx.measureText(label).width
const bpad = 4 / Math.max(0.6, globalScale)
// Render Label-Hintergrund für absolute Lesbarkeit
ctx.fillStyle = isDimmed ? "rgba(10, 15, 25, 0.45)" : "rgba(8, 12, 20, 0.85)"
ctx.beginPath()
// Zeichne abgerundete Pille unter dem Knoten
const rx = x - textWidth / 2 - bpad
const ry = y + size + (5 / Math.max(0.6, globalScale))
const rw = textWidth + bpad * 2
const rh = fontSize + bpad * 1.5
const rad = 4 / Math.max(0.6, globalScale)
ctx.beginPath()
ctx.moveTo(rx + rad, ry)
ctx.lineTo(rx + rw - rad, ry)
ctx.quadraticCurveTo(rx + rw, ry, rx + rw, ry + rad)
ctx.lineTo(rx + rw, ry + rh - rad)
ctx.quadraticCurveTo(rx + rw, ry + rh, rx + rw - rad, ry + rh)
ctx.lineTo(rx + rad, ry + rh)
ctx.quadraticCurveTo(rx, ry + rh, rx, ry + rh - rad)
ctx.lineTo(rx, ry + rad)
ctx.quadraticCurveTo(rx, ry, rx + rad, ry)
ctx.closePath()
ctx.fill()
// Feine Kontur für die Text-Pille
ctx.strokeStyle = isHovered || isSelected ? "rgba(255, 255, 255, 0.15)" : "rgba(255, 255, 255, 0.05)"
ctx.lineWidth = 0.5 / Math.max(0.6, globalScale)
ctx.stroke() ctx.stroke()
}
// Text zeichnen const label = node.content.length > 30 ? node.content.slice(0, 29) + "…" : node.content
ctx.textAlign = "center" const baseFontSize = isHovered || isSelected ? 10 : 9
ctx.textBaseline = "top" const fontSize = baseFontSize / Math.max(0.6, globalScale)
ctx.fillStyle = isDimmed
? "#475569"
: (isHovered || isSelected ? "#ffffff" : "#cbd5e1")
ctx.fillText(label, x, y + size + (6 / Math.max(0.6, globalScale))) ctx.font = `${isHovered || isSelected ? "bold" : "500"} ${fontSize}px "Space Grotesk", sans-serif`
}} const textWidth = ctx.measureText(label).width
/> const bpad = 4 / Math.max(0.6, globalScale)
<button onClick={relayout} title="Knoten neu anordnen" ctx.fillStyle = isDimmed ? "rgba(10, 15, 25, 0.45)" : "rgba(8, 12, 20, 0.85)"
className="absolute right-3 top-3 z-10 flex items-center gap-1.5 rounded-lg border border-border/60 bg-background/50 px-2.5 py-1.5 text-[11px] text-muted-foreground hover:text-foreground backdrop-blur-sm transition-colors cursor-pointer"> ctx.beginPath()
<RefreshCw className="h-3.5 w-3.5" /> Zentrieren
</button>
{/* Legende */} const rx = x - textWidth / 2 - bpad
<div className="absolute left-3 bottom-3 flex flex-wrap gap-2 rounded-lg bg-background/40 px-2.5 py-1.5 backdrop-blur-sm"> const ry = y + size + (5 / Math.max(0.6, globalScale))
{Object.entries(CAT_LABEL).map(([k, l]) => ( const rw = textWidth + bpad * 2
<span key={k} className="flex items-center gap-1 text-[10px] text-muted-foreground"> const rh = fontSize + bpad * 1.5
<span className="h-2 w-2 rounded-full" style={{ background: CAT_COLOR[k] }} />{l} const rad = 4 / Math.max(0.6, globalScale)
</span>
))}
</div>
</div>
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-4 h-[calc(100vh-13rem)] min-h-[560px] overflow-y-auto scrollbar-thin"> ctx.beginPath()
{sel ? ( ctx.moveTo(rx + rad, ry)
<> ctx.lineTo(rx + rw - rad, ry)
<div className="flex items-center gap-2 mb-3"> ctx.quadraticCurveTo(rx + rw, ry, rx + rw, ry + rad)
<span className="w-3 h-3 rounded-full" style={{ background: CAT_COLOR[sel.category], boxShadow: `0 0 0 2px ${AUTO.has(sel.source) ? "#34d399" : "#475569"}` }} /> ctx.lineTo(rx + rw, ry + rh - rad)
<span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: CAT_COLOR[sel.category] }}> ctx.quadraticCurveTo(rx + rw, ry + rh, rx + rw - rad, ry + rh)
{CAT_LABEL[sel.category] || sel.category} ctx.lineTo(rx + rad, ry + rh)
</span> ctx.quadraticCurveTo(rx, ry + rh, rx, ry + rh - rad)
<span className={`ml-auto text-[10px] font-mono flex items-center gap-1 ${AUTO.has(sel.source) ? "text-emerald-400" : "text-muted-foreground/70"}`}> ctx.lineTo(rx, ry + rad)
{AUTO.has(sel.source) && <Sparkles className="h-2.5 w-2.5" />}{sel.source} ctx.quadraticCurveTo(rx, ry, rx + rad, ry)
</span> ctx.closePath()
</div> ctx.fill()
<div className="text-sm text-foreground leading-relaxed mb-4 break-words">{sel.content}</div>
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 mb-2 flex items-center gap-1"> ctx.strokeStyle = isHovered || isSelected ? "rgba(255, 255, 255, 0.15)" : "rgba(255, 255, 255, 0.05)"
<Share2 className="h-3 w-3" /> verwandte Fakten ctx.lineWidth = 0.5 / Math.max(0.6, globalScale)
</div> ctx.stroke()
<div className="flex flex-col gap-1.5 mb-4">
{neighbors.length ? neighbors.map((n) => ( ctx.textAlign = "center"
<button key={n.id} onClick={() => setSelected(n.id)} ctx.textBaseline = "top"
className="flex items-center gap-2 text-[11px] text-muted-foreground hover:text-foreground text-left transition-colors cursor-pointer"> ctx.fillStyle = isDimmed ? "#475569" : (isHovered || isSelected ? "#ffffff" : "#cbd5e1")
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ background: CAT_COLOR[n.category] }} />
<span className="truncate">{n.content}</span> ctx.fillText(label, x, y + size + (6 / Math.max(0.6, globalScale)))
</button> }}
)) : <span className="text-[11px] text-muted-foreground/60"></span>} />
</div>
<button onClick={() => { onDelete(sel.id); setSelected(null) }} <button onClick={relayout} title="Zentrieren"
className="flex items-center gap-1.5 text-[11px] text-red-400 border border-red-500/20 rounded-lg px-2.5 py-1.5 hover:bg-red-500/5 transition-all cursor-pointer"> className="absolute right-3 top-3 z-10 flex items-center gap-1.5 rounded-lg border border-border/60 bg-background/50 px-2.5 py-1.5 text-[11px] text-muted-foreground hover:text-foreground backdrop-blur-sm transition-colors cursor-pointer">
<Trash2 className="h-3.5 w-3.5" /> vergessen <RefreshCw className="h-3.5 w-3.5" /> Zentrieren
</button> </button>
</>
) : ( {/* Legende */}
<div className="h-full flex flex-col items-center justify-center text-center text-muted-foreground/70 gap-2 pt-24"> <div className="absolute left-3 bottom-3 flex flex-wrap gap-2 rounded-lg bg-background/40 px-2.5 py-1.5 backdrop-blur-sm">
<Share2 className="h-6 w-6" /> {Object.entries(CAT_LABEL).map(([k, l]) => (
<div className="text-xs max-w-[180px] leading-relaxed"> <span key={k} className="flex items-center gap-1 text-[10px] text-muted-foreground">
Auf einen Knoten klicken, um den Fakt und seine semantischen Nachbarn zu sehen. Hover hebt das Netz hervor. <span className="h-2 w-2 rounded-full" style={{ background: CAT_COLOR[k] }} />{l}
</div> </span>
</div> ))}
)}
</div> </div>
</div> </div>
) )
+222 -194
View File
@@ -31,15 +31,13 @@ const BORDER_CLASSES: Record<string, string> = {
} }
// Onboarding-Prompt: der Nutzer startet damit ein Gespräch mit Hermes (Terminal/Telegram). // Onboarding-Prompt: der Nutzer startet damit ein Gespräch mit Hermes (Terminal/Telegram).
// Hermes interviewt ihn Schritt für Schritt — der Auto-Lern-Hook füllt dabei das Gedächtnis.
// Bewusst GENERISCH (nicht auf eine Person zugeschnitten).
const ONBOARDING_PROMPT = `Hi Hermes! Lass uns ein kurzes Onboarding machen, damit du dich künftig an mich erinnerst. Stell mir nacheinander ein paar kurze Fragen zu: const ONBOARDING_PROMPT = `Hi Hermes! Lass uns ein kurzes Onboarding machen, damit du dich künftig an mich erinnerst. Stell mir nacheinander ein paar kurze Fragen zu:
1) wer ich bin und woran ich gerade arbeite, 1) wer ich bin und woran ich gerade arbeite,
2) wie ich angesprochen werden möchte, 2) wie ich angesprochen werden möchte,
3) meine bevorzugten Tools, Sprachen und Arbeitsweise, 3) meine bevorzugten Tools, Sprachen und Arbeitsweise,
4) wichtige Regeln/Konventionen, die du beachten sollst, 4) wichtige Regeln/Konventionen, die du beachten sollst,
5) meine Infrastruktur (Server, Dienste ohne Geheimnisse). 5) meine Infrastruktur (Server, Dienste ohne Geheimnisse).
Frag immer nur eine Sache auf einmal und fass am Ende zusammen, was du dir gemerkt hast.` Frag immer nur eine sache auf einmal und fass am Ende zusammen, was du dir gemerkt hast.`
export function MemoryView() { export function MemoryView() {
const [filter, setFilter] = useState("") const [filter, setFilter] = useState("")
@@ -50,6 +48,7 @@ export function MemoryView() {
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [view, setView] = useState<"liste" | "graph">("liste") const [view, setView] = useState<"liste" | "graph">("liste")
const [showAdd, setShowAdd] = useState(false) const [showAdd, setShowAdd] = useState(false)
const [selected, setSelected] = useState<string | null>(null)
const qc = useQueryClient() const qc = useQueryClient()
const { showAlert, showConfirm, dialogElement } = useDialog() const { showAlert, showConfirm, dialogElement } = useDialog()
@@ -134,203 +133,222 @@ export function MemoryView() {
}) })
} catch (e: any) { } catch (e: any) {
showAlert("Fehler", `Fehler bei der Deduplizierung: ${e.message}`) showAlert("Fehler", `Fehler bei der Deduplizierung: ${e.message}`)
} finally { setDeduping(false) } } finally {
setDeduping(false)
}
} }
const StatChip = ({ value, label, accent }: { value: number; label: string; accent?: "auto" }) => ( const sel = useMemo(() => items.find((m) => m.id === selected), [items, selected])
<span className={cn("flex items-center gap-1.5 text-[11px] rounded-lg px-2.5 py-1 border", const neighbors = useMemo(() => {
accent === "auto" ? "text-emerald-400 bg-emerald-500/[0.07] border-emerald-500/20" : "text-muted-foreground bg-card/40 border-border/50")}> if (!sel || !graph) return []
{accent === "auto" && <Sparkles className="h-3 w-3" />} const connectedIds = new Set([
<b className={cn("font-semibold", accent === "auto" ? "" : "text-foreground")}>{value}</b> {label} ...graph.edges.filter((e) => e.source === sel.id).map((e) => e.target),
</span> ...graph.edges.filter((e) => e.target === sel.id).map((e) => e.source)
) ])
return items.filter((m) => connectedIds.has(m.id))
}, [sel, graph, items])
const CAT_COLOR_MAP: Record<string, string> = {
identity: "#00f5ff", knowledge: "#3b82f6", rules: "#d946ef", events: "#fbbf24",
}
const CAT_LABEL_MAP: Record<string, string> = {
identity: "Identität", knowledge: "Wissen", rules: "Regeln", events: "Ereignisse",
}
const AUTO = new Set(["auto", "agent", "hermes"])
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Zweispaltiges Haupt-Layout ab Y=0 */} {empty ? (
<div className="flex flex-col lg:flex-row gap-6 items-start"> <div className="rounded-2xl border border-dashed border-border/60 bg-card/20 p-8 sm:p-10 flex flex-col items-center text-center gap-5">
<div className="h-14 w-14 rounded-2xl bg-primary/10 flex items-center justify-center">
{/* Linke Spalte (Titel, Statistiken, Filter, Liste) */} <MessagesSquare className="h-7 w-7 text-primary" />
<div className="w-full lg:max-w-md xl:max-w-xl lg:shrink-0 space-y-5"> </div>
{/* Titel & Beschreibung */} <div className="space-y-1.5 max-w-lg">
<div> <h3 className="text-base font-semibold text-foreground">Lass Hermes dich kennenlernen</h3>
<h1 className="text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent"> <p className="text-xs text-muted-foreground leading-relaxed">
Gedächtnis-Pool Statt Fakten einzutippen: führ ein kurzes Onboarding-Gespräch mit Hermes. Was ihr besprecht,
</h1> merkt sich das Gedächtnis automatisch du musst nichts manuell pflegen.
<p className="text-sm text-muted-foreground">
Geteilte Konstitution Hermes, IDEs &amp; Gateway lesen und lernen hier per MCP.
</p> </p>
</div> </div>
{/* Statistiken (Chips) */} {/* Vorgeschlagener Prompt */}
{!empty && ( <div className="w-full max-w-lg text-left">
<div className="flex flex-wrap items-center gap-2"> <div className="flex items-center justify-between mb-1.5">
<StatChip value={stats.total} label="Fakten" /> <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60">Sende das an Hermes</span>
<StatChip value={stats.auto} label="auto gelernt" accent="auto" /> <button onClick={copyPrompt} className="flex items-center gap-1 text-[10px] font-semibold text-muted-foreground hover:text-foreground transition-colors">
<StatChip value={stats.manual} label="manuell" /> {copied ? <><Check className="h-3 w-3 text-emerald-400" /> Kopiert</> : <><Copy className="h-3 w-3" /> Kopieren</>}
<StatChip value={stats.cats} label="Kategorien" />
</div>
)}
{/* Mobile-Steuerung (nur sichtbar unter lg) */}
{!empty && (
<div className="flex flex-wrap items-center gap-2 lg:hidden">
<div className="relative flex-1 min-w-[140px]">
<input
value={q}
onChange={(e) => setQ(e.target.value)}
aria-label="Gedächtnis durchsuchen"
type="search"
placeholder="Suchen…"
className="w-full h-9 pl-8 pr-3 rounded-lg border border-border/60 bg-card/45 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
/>
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
</div>
<button onClick={() => setShowAdd((s) => !s)}
className="h-9 px-3 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all flex items-center gap-1.5 cursor-pointer shadow-md shadow-primary/10">
<Plus className="h-4 w-4" />
</button>
<div className="flex items-center gap-1 p-1 bg-card/30 border border-border/40 rounded-lg">
{([["liste", List], ["graph", Share2]] as const).map(([v, Icon]) => (
<button key={v} onClick={() => setView(v)}
className={cn("flex h-7 px-2.5 items-center gap-1.5 text-[11px] font-semibold rounded-md transition-all cursor-pointer",
view === v ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}>
<Icon className="h-3.5 w-3.5" />
</button>
))}
</div>
<button onClick={cleanup} disabled={deduping}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/60 bg-card hover:border-primary/50 transition-all cursor-pointer shadow-md disabled:opacity-50">
<Sparkles className="h-4 w-4 text-primary" />
</button> </button>
</div> </div>
)} <pre className="w-full rounded-xl border border-border/60 bg-background/40 p-3.5 text-[11px] leading-relaxed text-foreground/90 whitespace-pre-wrap font-mono">{ONBOARDING_PROMPT}</pre>
</div>
{/* Eintrag anlegen (einklappbar) */} <div className="flex flex-wrap items-center justify-center gap-2">
{showAdd && ( <button onClick={goTerminal}
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-4 space-y-3"> className="h-9 px-4 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all flex items-center gap-1.5 cursor-pointer">
<div className="flex items-center justify-between"> <TerminalIcon className="h-4 w-4" /> Im Terminal starten
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Neuen Eintrag anlegen</div> </button>
<button onClick={() => setShowAdd(false)} aria-label="Schließen" className="text-muted-foreground hover:text-foreground"><X className="h-4 w-4" aria-hidden="true" /></button> <button onClick={copyPrompt}
</div> className="h-9 px-4 rounded-lg border border-border/60 bg-card text-xs font-semibold hover:border-primary/50 transition-all flex items-center gap-1.5 cursor-pointer">
<textarea value={content} onChange={(e) => setContent(e.target.value)} rows={2} {copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />} Prompt kopieren
aria-label="Eintragstext" </button>
placeholder="Eine Regel, Vorliebe oder einen stabilen Fakt über dich oder das Projekt…" </div>
className="w-full resize-none rounded-xl border border-border/60 bg-background/30 p-3 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground leading-relaxed" /> <p className="text-[11px] text-muted-foreground/70 flex items-center gap-1.5">
<div className="flex flex-wrap items-center justify-between gap-3"> <Send className="h-3 w-3" /> Funktioniert genauso über Telegram {" "}
<select value={category} onChange={(e) => setCategory(e.target.value)} <button onClick={() => setShowAdd(true)} className="underline hover:text-foreground">oder lieber manuell anlegen</button>.
aria-label="Kategorie" </p>
className="h-8 rounded-lg border border-border/60 bg-background/50 px-2 text-xs outline-none font-semibold text-foreground cursor-pointer"> </div>
{CATEGORIES.map((c) => <option key={c} value={c} className="bg-popover text-foreground">{CAT_CONFIG[c]?.label || c}</option>)} ) : (
</select> /* Der voll integrierte, schwebende Wissens-Hub */
<button onClick={add} className="h-9 px-4 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all flex items-center gap-1.5 cursor-pointer"> <div className="relative w-full h-[calc(100vh-8.5rem)] min-h-[620px] rounded-2xl border border-border/60 bg-[#030712] overflow-hidden flex flex-col lg:block">
<Plus className="h-4 w-4" /> Speichern
</button>
</div>
</div>
)}
{error && <div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">Fehler: {error}</div>} {/* 1. GraphView nimmt auf Desktop 100% des Hintergrunds ein */}
<div className="absolute inset-0 z-0 hidden lg:block">
<GraphErrorBoundary>
<Suspense fallback={<div className="absolute inset-0 flex items-center justify-center text-xs text-muted-foreground">Graph wird geladen</div>}>
<GraphView data={graphData} selectedNodeId={selected} onNodeSelect={setSelected} />
</Suspense>
</GraphErrorBoundary>
</div>
{/* Empty-State oder Listen-Inhalte */} {/* 2. Schwebendes linkes Panel (Wissens-Liste) */}
{empty ? ( <div className={cn(
<div className="rounded-2xl border border-dashed border-border/60 bg-card/20 p-8 sm:p-10 flex flex-col items-center text-center gap-5"> "z-10 flex flex-col transition-all duration-300",
<div className="h-14 w-14 rounded-2xl bg-primary/10 flex items-center justify-center"> "lg:absolute lg:left-4 lg:top-4 lg:bottom-4 lg:w-96 lg:rounded-xl lg:border lg:border-border/40 lg:bg-background/65 lg:backdrop-blur-md lg:p-4 lg:shadow-2xl lg:shadow-black/50 lg:h-[calc(100%-2rem)]",
<MessagesSquare className="h-7 w-7 text-primary" /> "w-full h-full p-4 flex-1 lg:flex-none",
</div> view === "graph" && "hidden lg:flex"
<div className="space-y-1.5 max-w-lg"> )}>
<h3 className="text-base font-semibold text-foreground">Lass Hermes dich kennenlernen</h3> {/* Header & Steuerung direkt im Panel */}
<p className="text-xs text-muted-foreground leading-relaxed"> <div className="space-y-3 mb-3 shrink-0">
Statt Fakten einzutippen: führ ein kurzes Onboarding-Gespräch mit Hermes. Was ihr besprecht, <div>
merkt sich das Gedächtnis automatisch du musst nichts manuell pflegen. <h1 className="text-xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent">
Gedächtnis-Pool
</h1>
<p className="text-[11px] text-muted-foreground leading-relaxed mt-0.5">
IDEs &amp; Gateway lesen und lernen hier per MCP.
</p> </p>
</div> </div>
{/* Vorgeschlagener Prompt */} {/* Statistiken (Chips) */}
<div className="w-full max-w-lg text-left"> <div className="flex flex-wrap items-center gap-1.5">
<div className="flex items-center justify-between mb-1.5"> <span className="text-[9px] font-semibold bg-primary/10 text-primary border border-primary/20 px-2 py-0.5 rounded-md">{stats.total} Fakten</span>
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60">Sende das an Hermes</span> <span className="text-[9px] font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-2 py-0.5 rounded-md">{stats.auto} auto</span>
<button onClick={copyPrompt} className="flex items-center gap-1 text-[10px] font-semibold text-muted-foreground hover:text-foreground transition-colors"> <span className="text-[9px] font-semibold bg-card/60 text-muted-foreground border border-border/40 px-2 py-0.5 rounded-md">{stats.cats} Kat.</span>
{copied ? <><Check className="h-3 w-3 text-emerald-400" /> Kopiert</> : <><Copy className="h-3 w-3" /> Kopieren</>}
</button>
</div>
<pre className="w-full rounded-xl border border-border/60 bg-background/40 p-3.5 text-[11px] leading-relaxed text-foreground/90 whitespace-pre-wrap font-mono">{ONBOARDING_PROMPT}</pre>
</div> </div>
<div className="flex flex-wrap items-center justify-center gap-2"> {/* Steuerungsleiste: Suchen, Eintrag, Deduplizieren */}
<button onClick={goTerminal} <div className="flex items-center gap-1.5 w-full">
className="h-9 px-4 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all flex items-center gap-1.5 cursor-pointer"> <div className="relative flex-1">
<TerminalIcon className="h-4 w-4" /> Im Terminal starten <input
value={q}
onChange={(e) => setQ(e.target.value)}
aria-label="Gedächtnis durchsuchen"
type="search"
placeholder="Semantisch suchen…"
className="w-full h-8 pl-8 pr-3 rounded-lg border border-border/50 bg-background/40 text-[11px] outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
/>
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
</div>
<button onClick={() => setShowAdd((s) => !s)} title="Eintrag hinzufügen"
className="h-8 w-8 shrink-0 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all flex items-center justify-center cursor-pointer shadow-md shadow-primary/10">
<Plus className="h-4 w-4" />
</button> </button>
<button onClick={copyPrompt} <button onClick={cleanup} disabled={deduping} title="Deduplizieren"
className="h-9 px-4 rounded-lg border border-border/60 bg-card text-xs font-semibold hover:border-primary/50 transition-all flex items-center gap-1.5 cursor-pointer"> className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-card/45 hover:border-primary/50 transition-all cursor-pointer shadow-md disabled:opacity-50">
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />} Prompt kopieren <Sparkles className="h-3.5 w-3.5 text-primary" />
</button> </button>
{/* Mobile View Switcher */}
<div className="flex items-center gap-1 p-0.5 bg-card/30 border border-border/40 rounded-lg lg:hidden">
{([["liste", List], ["graph", Share2]] as const).map(([v, Icon]) => (
<button key={v} onClick={() => setView(v)}
className={cn("flex h-7 w-7 items-center justify-center rounded-md transition-all cursor-pointer",
view === v ? "bg-primary text-primary-foreground" : "text-muted-foreground")}>
<Icon className="h-3.5 w-3.5" />
</button>
))}
</div>
</div> </div>
<p className="text-[11px] text-muted-foreground/70 flex items-center gap-1.5">
<Send className="h-3 w-3" /> Funktioniert genauso über Telegram {" "}
<button onClick={() => setShowAdd(true)} className="underline hover:text-foreground">oder lieber manuell anlegen</button>.
</p>
</div>
) : (
<div className={cn("space-y-5", view === "graph" && "hidden lg:block")}>
{/* Kategorie-Filter */} {/* Kategorie-Filter */}
<div className="flex flex-wrap items-center gap-1.5 p-1 bg-card/30 border border-border/40 rounded-xl w-fit"> <div className="flex flex-wrap items-center gap-1 p-0.5 bg-card/30 border border-border/40 rounded-lg w-full overflow-x-auto shrink-0 scrollbar-none">
<button onClick={() => setFilter("")} <button onClick={() => setFilter("")}
className={cn("h-7 px-3 rounded-lg text-[10px] font-semibold uppercase tracking-wider transition-all cursor-pointer", className={cn("h-6 px-2 rounded-md text-[9px] font-semibold uppercase tracking-wider transition-all cursor-pointer shrink-0",
!filter ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}>Alle</button> !filter ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}>Alle</button>
{CATEGORIES.map((c) => { {CATEGORIES.map((c) => {
const conf = CAT_CONFIG[c] || DEFAULT_CAT const conf = CAT_CONFIG[c] || DEFAULT_CAT
const Icon = conf.icon
return ( return (
<button key={c} onClick={() => setFilter(c)} <button key={c} onClick={() => setFilter(c)}
className={cn("h-7 px-2.5 rounded-lg text-[10px] font-semibold uppercase tracking-wider transition-all cursor-pointer flex items-center gap-1", className={cn("h-6 px-2 rounded-md text-[9px] font-semibold uppercase tracking-wider transition-all cursor-pointer flex items-center gap-1 shrink-0",
filter === c ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}> filter === c ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground")}>
<Icon className="h-3 w-3" /> {conf.label} {conf.label}
</button> </button>
) )
})} })}
</div> </div>
</div>
{/* Listenelemente (Scrollbar im Glaspanel) */}
<div className="flex-1 overflow-y-auto pr-1 scrollbar-thin space-y-4">
{showAdd && (
<div className="rounded-xl border border-border/50 bg-background/50 p-3 space-y-2 shrink-0">
<div className="flex items-center justify-between">
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Neuer Eintrag</div>
<button onClick={() => setShowAdd(false)} className="text-muted-foreground hover:text-foreground"><X className="h-3.5 w-3.5" /></button>
</div>
<textarea value={content} onChange={(e) => setContent(e.target.value)} rows={2}
placeholder="Vorliebe, Regel oder Fakt…"
className="w-full resize-none rounded-lg border border-border/50 bg-background/30 p-2 text-[11px] outline-none focus:ring-1 focus:ring-primary/50 text-foreground leading-relaxed" />
<div className="flex items-center justify-between gap-2">
<select value={category} onChange={(e) => setCategory(e.target.value)}
className="h-7 rounded-md border border-border/50 bg-background/50 px-1 text-[10px] outline-none text-foreground cursor-pointer">
{CATEGORIES.map((c) => <option key={c} value={c} className="bg-popover text-foreground">{CAT_CONFIG[c]?.label || c}</option>)}
</select>
<button onClick={add} className="h-7 px-2.5 rounded-md bg-primary text-primary-foreground text-[10px] font-semibold hover:opacity-90 transition-all cursor-pointer">
Speichern
</button>
</div>
</div>
)}
{error && <div className="rounded-lg border border-red-500/20 bg-red-500/5 p-3 text-[10px] text-red-400">Fehler: {error}</div>}
{items.length === 0 ? ( {items.length === 0 ? (
<div className="text-xs text-muted-foreground border border-dashed border-border/60 rounded-2xl p-12 text-center bg-card/10"> <div className="text-[11px] text-muted-foreground border border-dashed border-border/60 rounded-xl p-8 text-center bg-card/10">
Keine Einträge für die aktuellen Filterkriterien gefunden. Keine Einträge gefunden.
</div> </div>
) : ( ) : (
<div className="space-y-5 max-h-[calc(100vh-14rem)] overflow-y-auto pr-1 scrollbar-thin"> <div className="space-y-4">
{[...CATEGORIES, "__other"].map((c) => { {[...CATEGORIES, "__other"].map((c) => {
const list = c === "__other" ? otherItems : (grouped[c] || []) const list = c === "__other" ? otherItems : (grouped[c] || [])
if (!list.length) return null if (!list.length) return null
const conf = CAT_CONFIG[c] || DEFAULT_CAT const conf = CAT_CONFIG[c] || DEFAULT_CAT
const Icon = conf.icon const Icon = conf.icon
return ( return (
<div key={c} className="space-y-2"> <div key={c} className="space-y-1.5">
{/* Sektions-Kopf je Kategorie */} <div className="flex items-center gap-1.5 px-0.5">
<div className="flex items-center gap-2 mb-2 px-1"> <Icon className={cn("h-3 w-3", conf.text)} />
<Icon className={cn("h-3.5 w-3.5", conf.text)} /> <span className={cn("text-[9px] font-bold uppercase tracking-wider", conf.text)}>{conf.label}</span>
<span className={cn("text-[11px] font-bold uppercase tracking-wider", conf.text)}>{conf.label}</span> <span className="text-[9px] font-mono text-muted-foreground/60 bg-card/50 rounded px-1">{list.length}</span>
<span className="text-[10px] font-mono text-muted-foreground/60 bg-card/50 rounded px-1.5 py-0.5">{list.length}</span>
<div className="ml-1 h-px flex-1 bg-border/30" />
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
{list.map((m) => { {list.map((m) => {
const isAuto = AUTO_SOURCES.has(m.source) const isAuto = AUTO_SOURCES.has(m.source)
const isSelected = selected === m.id
return ( return (
<div key={m.id} className={cn("flex items-start justify-between gap-4 p-3.5 rounded-xl border border-l-4 bg-card/45 backdrop-blur-md border-border/60 hover:border-primary/20 transition-all group", <div key={m.id} onClick={() => setSelected(isSelected ? null : m.id)}
BORDER_CLASSES[m.category] || "border-l-muted")}> className={cn(
<span className="text-xs text-foreground leading-relaxed break-words whitespace-pre-wrap flex-1 min-w-0">{m.content}</span> "flex items-start justify-between gap-3 p-2.5 rounded-lg border border-l-4 transition-all group cursor-pointer text-left",
<div className="flex items-center gap-2 shrink-0"> isSelected ? "bg-primary/10 border-primary shadow-md" : "bg-card/45 border-border/40 hover:bg-card/75",
{typeof m.score === "number" && ( BORDER_CLASSES[m.category] || "border-l-muted"
<span className="text-[9px] font-mono text-primary bg-primary/10 px-1.5 py-0.5 rounded" title="Relevanz der semantischen Suche">{Math.round(m.score * 100)}%</span> )}>
)} <span className="text-[11px] text-foreground leading-relaxed break-words whitespace-pre-wrap flex-1 min-w-0">{m.content}</span>
<span className={cn("flex items-center gap-1 text-[9px] font-mono px-1.5 py-0.5 rounded", <div className="flex items-center gap-1.5 shrink-0 self-center">
isAuto ? "text-emerald-400 bg-emerald-500/10" : "text-muted-foreground/60 bg-background/20")} {isAuto && <span title="Auto gelernt"><Sparkles className="h-2.5 w-2.5 text-emerald-400" /></span>}
title={isAuto ? "Automatisch gelernt" : "Manuell angelegt"}> <button onClick={(e) => { e.stopPropagation(); del(m.id); if (selected === m.id) setSelected(null) }}
{isAuto && <Sparkles className="h-2.5 w-2.5" />}{m.source} className="h-5 w-5 rounded-md flex items-center justify-center border border-border/40 text-muted-foreground hover:text-red-400 hover:bg-red-500/5 transition-all opacity-0 group-hover:opacity-100">
</span> <Trash2 className="h-3.5 w-3.5" />
<button onClick={() => del(m.id)} aria-label="Eintrag löschen" title="Eintrag löschen"
className="h-7 w-7 rounded-lg flex items-center justify-center border border-border/40 text-muted-foreground hover:text-red-400 hover:bg-red-500/5 transition-all opacity-0 group-hover:opacity-100">
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
</button> </button>
</div> </div>
</div> </div>
@@ -343,54 +361,64 @@ export function MemoryView() {
</div> </div>
)} )}
</div> </div>
)} </div>
</div>
{/* Rechte Spalte (Steuerungszeile & Graph-Bereich) */} {/* 3. Mobiler Fallback für den Graph (unter lg) */}
{!empty && ( <div className={cn("w-full h-[500px] relative lg:hidden", view === "liste" && "hidden")}>
<div className="flex-1 w-full space-y-5"> <GraphErrorBoundary>
{/* Desktop-Steuerungszeile (nur sichtbar ab lg) */} <Suspense fallback={<div className="h-full rounded-2xl border border-border/60 bg-card/30 flex items-center justify-center text-xs text-muted-foreground">Graph wird geladen</div>}>
<div className="hidden lg:flex items-center justify-between gap-3 h-12"> <GraphView data={graphData} selectedNodeId={selected} onNodeSelect={setSelected} />
<span className="text-[11px] text-muted-foreground/70 flex items-center gap-1.5"> </Suspense>
<Sparkles className="h-3 w-3 text-emerald-400" /> lernt automatisch aus Hermes-Gesprächen </GraphErrorBoundary>
</span> </div>
<div className="flex items-center gap-2"> {/* 4. Schwebendes rechtes Panel (Details zum ausgewählten Knoten) */}
<div className="relative"> {sel && (
<input <div className={cn(
value={q} "z-10 flex flex-col transition-all duration-300",
onChange={(e) => setQ(e.target.value)} "lg:absolute lg:right-4 lg:top-4 lg:bottom-4 lg:w-80 lg:rounded-xl lg:border lg:border-border/40 lg:bg-background/65 lg:backdrop-blur-md lg:p-4 lg:shadow-2xl lg:shadow-black/50 lg:h-[calc(100%-2rem)]",
aria-label="Gedächtnis durchsuchen" "w-full p-4 border-t border-border/60 bg-card/45 shrink-0 flex flex-col"
type="search" )}>
placeholder="Semantisch suchen…" <div className="flex items-center gap-2 mb-3 shrink-0">
className="w-56 h-9 pl-8 pr-3 rounded-lg border border-border/60 bg-card/45 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground" <span className="w-2.5 h-2.5 rounded-full" style={{ background: CAT_COLOR_MAP[sel.category] }} />
/> <span className="text-[9px] font-bold uppercase tracking-wider" style={{ color: CAT_COLOR_MAP[sel.category] }}>
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" /> {CAT_LABEL_MAP[sel.category] || sel.category}
</span>
<span className={`ml-auto text-[9px] font-mono flex items-center gap-1 ${AUTO.has(sel.source) ? "text-emerald-400" : "text-muted-foreground/70"}`}>
{AUTO.has(sel.source) && <Sparkles className="h-2.5 w-2.5" />}
{sel.source}
</span>
<button onClick={() => setSelected(null)} className="text-muted-foreground hover:text-foreground ml-1.5 cursor-pointer"><X className="h-4 w-4" /></button>
</div>
<div className="flex-1 overflow-y-auto pr-1 scrollbar-thin text-xs text-foreground leading-relaxed mb-4 break-words whitespace-pre-wrap text-left">
{sel.content}
</div>
<div className="mt-auto shrink-0 space-y-3 pt-3 border-t border-border/30">
<div className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground/70 flex items-center gap-1">
<Share2 className="h-2.5 w-2.5" /> verwandte Fakten
</div> </div>
<button onClick={() => setShowAdd((s) => !s)} <div className="flex flex-col gap-1 max-h-40 overflow-y-auto scrollbar-none text-left">
className="h-9 px-3 rounded-lg bg-primary text-primary-foreground text-xs font-semibold hover:opacity-90 transition-all flex items-center gap-1.5 cursor-pointer shadow-md shadow-primary/10"> {neighbors.length ? neighbors.map((n) => (
<Plus className="h-4 w-4" /> Eintrag <button key={n.id} onClick={() => setSelected(n.id)}
</button> className="flex items-center gap-2 text-[10px] text-muted-foreground hover:text-foreground text-left transition-colors cursor-pointer py-0.5">
<button onClick={cleanup} disabled={deduping} <span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ background: CAT_COLOR_MAP[n.category] }} />
className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/60 bg-card hover:border-primary/50 transition-all cursor-pointer shadow-md disabled:opacity-50" <span className="truncate">{n.content}</span>
title="Deduplizieren"> </button>
<Sparkles className="h-4 w-4 text-primary" /> )) : <span className="text-[10px] text-muted-foreground/60"></span>}
</div>
<button onClick={() => { del(sel.id); setSelected(null) }}
className="flex w-full items-center justify-center gap-1.5 text-[10px] font-bold text-red-400 border border-red-500/20 rounded-lg py-2 hover:bg-red-500/5 transition-all cursor-pointer">
<Trash2 className="h-3.5 w-3.5" /> Eintrag vergessen
</button> </button>
</div> </div>
</div> </div>
)}
{/* Graph-Sicht */} </div>
<div className={cn("w-full", view === "liste" && "hidden lg:block")}> )}
<GraphErrorBoundary>
<Suspense fallback={<div className="h-[480px] rounded-2xl border border-border/60 bg-card/30 flex items-center justify-center text-xs text-muted-foreground">Graph wird geladen</div>}>
<GraphView data={graphData} onDelete={del} />
</Suspense>
</GraphErrorBoundary>
</div>
</div>
)}
</div>
{dialogElement} {dialogElement}
</div> </div>
) )