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="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-Ch3qlJmN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-1USExdfg.css">
<script type="module" crossorigin src="/assets/index-P6XXlxiQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CunuYLZu.css">
</head>
<body>
<div id="root"></div>
+130 -207
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react"
import ForceGraph2D from "react-force-graph-2d"
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"
const CAT_COLOR: Record<string, string> = {
@@ -10,7 +10,6 @@ const CAT_COLOR: Record<string, string> = {
const CAT_LABEL: Record<string, string> = {
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 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})`
}
export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id: string) => void }) {
const [selected, setSelected] = useState<string | null>(null)
export function GraphView({ data, selectedNodeId, onNodeSelect }: {
data: MemoryGraph
selectedNodeId: string | null
onNodeSelect: (id: string | null) => void
}) {
const [hovered, setHovered] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const fgRef = useRef<any>(null)
@@ -114,27 +116,14 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
fg.d3ReheatSimulation()
}, [graphData])
const activeNode = hovered || selected
const activeNode = hovered || selectedNodeId
// Layout zurücksetzen/neu anschubsen
const relayout = () => {
if (!fgRef.current) return
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
const connectedNodes = useMemo(() => {
if (!activeNode) return new Set<string>()
@@ -146,207 +135,141 @@ export function GraphView({ data, onDelete }: { data: MemoryGraph; onDelete: (id
return set
}, [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 (
<div className="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-3">
<div className="relative h-[calc(100vh-13rem)] min-h-[560px] rounded-2xl border border-border/60 overflow-hidden"
ref={containerRef}
style={{ background: "radial-gradient(circle at center, rgba(59, 130, 246, 0.08) 0%, #030712 100%)" }}>
<div className="absolute inset-0" ref={containerRef}
style={{ background: "radial-gradient(circle at center, rgba(59, 130, 246, 0.08) 0%, #030712 100%)" }}>
<ForceGraph2D
ref={fgRef}
width={dimensions.width}
height={dimensions.height}
graphData={graphData}
backgroundColor="rgba(0,0,0,0)"
nodeRelSize={4}
cooldownTicks={120}
<ForceGraph2D
ref={fgRef}
width={dimensions.width}
height={dimensions.height}
graphData={graphData}
backgroundColor="rgba(0,0,0,0)"
nodeRelSize={4}
cooldownTicks={120}
// Hover- und Klick-Logik
onNodeClick={(node: any) => onNodeSelect(node.id)}
onBackgroundClick={() => onNodeSelect(null)}
onNodeHover={(node: any) => {
setHovered(node ? node.id : null)
if (containerRef.current) {
containerRef.current.style.cursor = node ? "pointer" : "default"
}
}}
// Kanten-Animationen & Partikelfluss
linkColor={(link: any) => {
const sId = typeof link.source === "object" ? link.source.id : link.source
const tId = typeof link.target === "object" ? link.target.id : link.target
// Hover- und Klick-Logik
onNodeClick={(node: any) => setSelected(node.id)}
onBackgroundClick={() => setSelected(null)}
onNodeHover={(node: any) => {
setHovered(node ? node.id : null)
if (containerRef.current) {
containerRef.current.style.cursor = node ? "pointer" : "default"
}
}}
if (activeNode === sId || activeNode === tId) return EDGE_HI
if (activeNode) return DIM_EDGE
// Kanten-Animationen & Partikelfluss
linkColor={(link: any) => {
const sId = typeof link.source === "object" ? link.source.id : link.source
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
// Wenn ein anderer Knoten aktiv ist, blenden wir diese Kante fast komplett aus
if (activeNode) return DIM_EDGE
// Standard-Zustand: Kante leuchtet in der Farbe des Quell-Clusters (deutlich sichtbar mit alpha=0.25)
const sourceNode = graphData.nodes.find(n => n.id === sId)
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
}}
const sourceNode = graphData.nodes.find(n => n.id === sId)
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
}}
// Daten-Partikel fließen auf den aktiven Verbindungen
linkDirectionalParticles={(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) ? 4 : 0
}}
linkDirectionalParticleWidth={2.2}
linkDirectionalParticleSpeed={0.006}
// Custom Canvas Rendering für glühende Knoten und 100% lesbare Text-Pillen
nodeCanvasObject={(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
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 === selectedNodeId
// Daten-Partikel fließen auf den aktiven Verbindungen
linkDirectionalParticles={(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) ? 4 : 0
}}
linkDirectionalParticleWidth={2.2}
linkDirectionalParticleSpeed={0.006}
// Custom Canvas Rendering für glühende Knoten und 100% lesbare Text-Pillen
nodeCanvasObject={(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
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
const isDimmed = activeNode && node.id !== activeNode && !connectedNodes.has(node.id)
const finalColor = isDimmed ? "#1e293b" : color
const isDimmed = activeNode && node.id !== activeNode && !connectedNodes.has(node.id)
const finalColor = isDimmed ? "#1e293b" : color
// 1. Zeichne weichen Glow-Schatten
if (!isDimmed) {
ctx.shadowColor = color
ctx.shadowBlur = isHovered || isSelected ? 12 : 6
}
// 2. Zeichne den Knoten-Kreis
if (!isDimmed) {
ctx.shadowColor = color
ctx.shadowBlur = isHovered || isSelected ? 12 : 6
}
ctx.beginPath()
ctx.arc(x, y, size, 0, 2 * Math.PI, false)
ctx.fillStyle = finalColor
ctx.fill()
ctx.shadowBlur = 0
if (isHovered || isSelected) {
ctx.beginPath()
ctx.arc(x, y, size, 0, 2 * Math.PI, false)
ctx.fillStyle = finalColor
ctx.fill()
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.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()
}
// Text zeichnen
ctx.textAlign = "center"
ctx.textBaseline = "top"
ctx.fillStyle = isDimmed
? "#475569"
: (isHovered || isSelected ? "#ffffff" : "#cbd5e1")
ctx.fillText(label, x, y + size + (6 / Math.max(0.6, globalScale)))
}}
/>
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)
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)
ctx.fillStyle = isDimmed ? "rgba(10, 15, 25, 0.45)" : "rgba(8, 12, 20, 0.85)"
ctx.beginPath()
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()
<button onClick={relayout} title="Knoten neu anordnen"
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">
<RefreshCw className="h-3.5 w-3.5" /> Zentrieren
</button>
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()
{/* Legende */}
<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">
{Object.entries(CAT_LABEL).map(([k, l]) => (
<span key={k} className="flex items-center gap-1 text-[10px] text-muted-foreground">
<span className="h-2 w-2 rounded-full" style={{ background: CAT_COLOR[k] }} />{l}
</span>
))}
</div>
</div>
ctx.textAlign = "center"
ctx.textBaseline = "top"
ctx.fillStyle = isDimmed ? "#475569" : (isHovered || isSelected ? "#ffffff" : "#cbd5e1")
ctx.fillText(label, x, y + size + (6 / Math.max(0.6, globalScale)))
}}
/>
<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">
{sel ? (
<>
<div className="flex items-center gap-2 mb-3">
<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"}` }} />
<span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: CAT_COLOR[sel.category] }}>
{CAT_LABEL[sel.category] || sel.category}
</span>
<span className={`ml-auto text-[10px] 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>
</div>
<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">
<Share2 className="h-3 w-3" /> verwandte Fakten
</div>
<div className="flex flex-col gap-1.5 mb-4">
{neighbors.length ? neighbors.map((n) => (
<button key={n.id} onClick={() => setSelected(n.id)}
className="flex items-center gap-2 text-[11px] text-muted-foreground hover:text-foreground text-left transition-colors cursor-pointer">
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ background: CAT_COLOR[n.category] }} />
<span className="truncate">{n.content}</span>
</button>
)) : <span className="text-[11px] text-muted-foreground/60"></span>}
</div>
<button onClick={() => { onDelete(sel.id); setSelected(null) }}
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">
<Trash2 className="h-3.5 w-3.5" /> vergessen
</button>
</>
) : (
<div className="h-full flex flex-col items-center justify-center text-center text-muted-foreground/70 gap-2 pt-24">
<Share2 className="h-6 w-6" />
<div className="text-xs max-w-[180px] leading-relaxed">
Auf einen Knoten klicken, um den Fakt und seine semantischen Nachbarn zu sehen. Hover hebt das Netz hervor.
</div>
</div>
)}
<button onClick={relayout} title="Zentrieren"
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">
<RefreshCw className="h-3.5 w-3.5" /> Zentrieren
</button>
{/* Legende */}
<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">
{Object.entries(CAT_LABEL).map(([k, l]) => (
<span key={k} className="flex items-center gap-1 text-[10px] text-muted-foreground">
<span className="h-2 w-2 rounded-full" style={{ background: CAT_COLOR[k] }} />{l}
</span>
))}
</div>
</div>
)
+223 -195
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).
// 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:
1) wer ich bin und woran ich gerade arbeite,
2) wie ich angesprochen werden möchte,
3) meine bevorzugten Tools, Sprachen und Arbeitsweise,
4) wichtige Regeln/Konventionen, die du beachten sollst,
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() {
const [filter, setFilter] = useState("")
@@ -50,6 +48,7 @@ export function MemoryView() {
const [copied, setCopied] = useState(false)
const [view, setView] = useState<"liste" | "graph">("liste")
const [showAdd, setShowAdd] = useState(false)
const [selected, setSelected] = useState<string | null>(null)
const qc = useQueryClient()
const { showAlert, showConfirm, dialogElement } = useDialog()
@@ -134,203 +133,222 @@ export function MemoryView() {
})
} catch (e: any) {
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" }) => (
<span className={cn("flex items-center gap-1.5 text-[11px] rounded-lg px-2.5 py-1 border",
accent === "auto" ? "text-emerald-400 bg-emerald-500/[0.07] border-emerald-500/20" : "text-muted-foreground bg-card/40 border-border/50")}>
{accent === "auto" && <Sparkles className="h-3 w-3" />}
<b className={cn("font-semibold", accent === "auto" ? "" : "text-foreground")}>{value}</b> {label}
</span>
)
const sel = useMemo(() => items.find((m) => m.id === selected), [items, selected])
const neighbors = useMemo(() => {
if (!sel || !graph) return []
const connectedIds = new Set([
...graph.edges.filter((e) => e.source === sel.id).map((e) => e.target),
...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 (
<div className="space-y-6">
{/* Zweispaltiges Haupt-Layout ab Y=0 */}
<div className="flex flex-col lg:flex-row gap-6 items-start">
{/* Linke Spalte (Titel, Statistiken, Filter, Liste) */}
<div className="w-full lg:max-w-md xl:max-w-xl lg:shrink-0 space-y-5">
{/* Titel & Beschreibung */}
<div>
<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">
Gedächtnis-Pool
</h1>
<p className="text-sm text-muted-foreground">
Geteilte Konstitution Hermes, IDEs &amp; Gateway lesen und lernen hier per MCP.
{empty ? (
<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">
<MessagesSquare className="h-7 w-7 text-primary" />
</div>
<div className="space-y-1.5 max-w-lg">
<h3 className="text-base font-semibold text-foreground">Lass Hermes dich kennenlernen</h3>
<p className="text-xs text-muted-foreground leading-relaxed">
Statt Fakten einzutippen: führ ein kurzes Onboarding-Gespräch mit Hermes. Was ihr besprecht,
merkt sich das Gedächtnis automatisch du musst nichts manuell pflegen.
</p>
</div>
{/* Statistiken (Chips) */}
{!empty && (
<div className="flex flex-wrap items-center gap-2">
<StatChip value={stats.total} label="Fakten" />
<StatChip value={stats.auto} label="auto gelernt" accent="auto" />
<StatChip value={stats.manual} label="manuell" />
<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" />
{/* Vorgeschlagener Prompt */}
<div className="w-full max-w-lg text-left">
<div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60">Sende das an Hermes</span>
<button onClick={copyPrompt} className="flex items-center gap-1 text-[10px] font-semibold text-muted-foreground hover:text-foreground transition-colors">
{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>
{/* Eintrag anlegen (einklappbar) */}
{showAdd && (
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground">Neuen Eintrag anlegen</div>
<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>
</div>
<textarea value={content} onChange={(e) => setContent(e.target.value)} rows={2}
aria-label="Eintragstext"
placeholder="Eine Regel, Vorliebe oder einen stabilen Fakt über dich oder das Projekt…"
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" />
<div className="flex flex-wrap items-center justify-between gap-3">
<select value={category} onChange={(e) => setCategory(e.target.value)}
aria-label="Kategorie"
className="h-8 rounded-lg border border-border/60 bg-background/50 px-2 text-xs outline-none font-semibold 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-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">
<Plus className="h-4 w-4" /> Speichern
</button>
</div>
</div>
)}
<div className="flex flex-wrap items-center justify-center gap-2">
<button onClick={goTerminal}
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">
<TerminalIcon className="h-4 w-4" /> Im Terminal starten
</button>
<button onClick={copyPrompt}
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">
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />} Prompt kopieren
</button>
</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>
) : (
/* Der voll integrierte, schwebende Wissens-Hub */
<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">
{/* 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>
{error && <div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-xs text-red-400">Fehler: {error}</div>}
{/* Empty-State oder Listen-Inhalte */}
{empty ? (
<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">
<MessagesSquare className="h-7 w-7 text-primary" />
</div>
<div className="space-y-1.5 max-w-lg">
<h3 className="text-base font-semibold text-foreground">Lass Hermes dich kennenlernen</h3>
<p className="text-xs text-muted-foreground leading-relaxed">
Statt Fakten einzutippen: führ ein kurzes Onboarding-Gespräch mit Hermes. Was ihr besprecht,
merkt sich das Gedächtnis automatisch du musst nichts manuell pflegen.
{/* 2. Schwebendes linkes Panel (Wissens-Liste) */}
<div className={cn(
"z-10 flex flex-col transition-all duration-300",
"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)]",
"w-full h-full p-4 flex-1 lg:flex-none",
view === "graph" && "hidden lg:flex"
)}>
{/* Header & Steuerung direkt im Panel */}
<div className="space-y-3 mb-3 shrink-0">
<div>
<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>
</div>
{/* Vorgeschlagener Prompt */}
<div className="w-full max-w-lg text-left">
<div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60">Sende das an Hermes</span>
<button onClick={copyPrompt} className="flex items-center gap-1 text-[10px] font-semibold text-muted-foreground hover:text-foreground transition-colors">
{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>
{/* Statistiken (Chips) */}
<div className="flex flex-wrap items-center gap-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-[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>
<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>
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
<button onClick={goTerminal}
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">
<TerminalIcon className="h-4 w-4" /> Im Terminal starten
{/* Steuerungsleiste: Suchen, Eintrag, Deduplizieren */}
<div className="flex items-center gap-1.5 w-full">
<div className="relative flex-1">
<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 onClick={copyPrompt}
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">
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />} Prompt kopieren
<button onClick={cleanup} disabled={deduping} title="Deduplizieren"
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">
<Sparkles className="h-3.5 w-3.5 text-primary" />
</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>
<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 */}
<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("")}
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>
{CATEGORIES.map((c) => {
const conf = CAT_CONFIG[c] || DEFAULT_CAT
const Icon = conf.icon
return (
<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")}>
<Icon className="h-3 w-3" /> {conf.label}
{conf.label}
</button>
)
})}
</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 ? (
<div className="text-xs text-muted-foreground border border-dashed border-border/60 rounded-2xl p-12 text-center bg-card/10">
Keine Einträge für die aktuellen Filterkriterien gefunden.
<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 gefunden.
</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) => {
const list = c === "__other" ? otherItems : (grouped[c] || [])
if (!list.length) return null
const conf = CAT_CONFIG[c] || DEFAULT_CAT
const Icon = conf.icon
return (
<div key={c} className="space-y-2">
{/* Sektions-Kopf je Kategorie */}
<div className="flex items-center gap-2 mb-2 px-1">
<Icon className={cn("h-3.5 w-3.5", conf.text)} />
<span className={cn("text-[11px] font-bold uppercase tracking-wider", conf.text)}>{conf.label}</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 key={c} className="space-y-1.5">
<div className="flex items-center gap-1.5 px-0.5">
<Icon className={cn("h-3 w-3", conf.text)} />
<span className={cn("text-[9px] 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>
</div>
<div className="space-y-2">
<div className="space-y-1.5">
{list.map((m) => {
const isAuto = AUTO_SOURCES.has(m.source)
const isSelected = selected === m.id
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",
BORDER_CLASSES[m.category] || "border-l-muted")}>
<span className="text-xs text-foreground leading-relaxed break-words whitespace-pre-wrap flex-1 min-w-0">{m.content}</span>
<div className="flex items-center gap-2 shrink-0">
{typeof m.score === "number" && (
<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={cn("flex items-center gap-1 text-[9px] font-mono px-1.5 py-0.5 rounded",
isAuto ? "text-emerald-400 bg-emerald-500/10" : "text-muted-foreground/60 bg-background/20")}
title={isAuto ? "Automatisch gelernt" : "Manuell angelegt"}>
{isAuto && <Sparkles className="h-2.5 w-2.5" />}{m.source}
</span>
<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" />
<div key={m.id} onClick={() => setSelected(isSelected ? null : m.id)}
className={cn(
"flex items-start justify-between gap-3 p-2.5 rounded-lg border border-l-4 transition-all group cursor-pointer text-left",
isSelected ? "bg-primary/10 border-primary shadow-md" : "bg-card/45 border-border/40 hover:bg-card/75",
BORDER_CLASSES[m.category] || "border-l-muted"
)}>
<span className="text-[11px] text-foreground leading-relaxed break-words whitespace-pre-wrap flex-1 min-w-0">{m.content}</span>
<div className="flex items-center gap-1.5 shrink-0 self-center">
{isAuto && <span title="Auto gelernt"><Sparkles className="h-2.5 w-2.5 text-emerald-400" /></span>}
<button onClick={(e) => { e.stopPropagation(); del(m.id); if (selected === m.id) setSelected(null) }}
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">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
@@ -343,54 +361,64 @@ export function MemoryView() {
</div>
)}
</div>
)}
</div>
</div>
{/* Rechte Spalte (Steuerungszeile & Graph-Bereich) */}
{!empty && (
<div className="flex-1 w-full space-y-5">
{/* Desktop-Steuerungszeile (nur sichtbar ab lg) */}
<div className="hidden lg:flex items-center justify-between gap-3 h-12">
<span className="text-[11px] text-muted-foreground/70 flex items-center gap-1.5">
<Sparkles className="h-3 w-3 text-emerald-400" /> lernt automatisch aus Hermes-Gesprächen
</span>
{/* 3. Mobiler Fallback für den Graph (unter lg) */}
<div className={cn("w-full h-[500px] relative lg:hidden", view === "liste" && "hidden")}>
<GraphErrorBoundary>
<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>}>
<GraphView data={graphData} selectedNodeId={selected} onNodeSelect={setSelected} />
</Suspense>
</GraphErrorBoundary>
</div>
{/* 4. Schwebendes rechtes Panel (Details zum ausgewählten Knoten) */}
{sel && (
<div className={cn(
"z-10 flex flex-col transition-all duration-300",
"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)]",
"w-full p-4 border-t border-border/60 bg-card/45 shrink-0 flex flex-col"
)}>
<div className="flex items-center gap-2 mb-3 shrink-0">
<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] }}>
{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 items-center gap-2">
<div className="relative">
<input
value={q}
onChange={(e) => setQ(e.target.value)}
aria-label="Gedächtnis durchsuchen"
type="search"
placeholder="Semantisch suchen…"
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"
/>
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
<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>
<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" /> Eintrag
</button>
<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"
title="Deduplizieren">
<Sparkles className="h-4 w-4 text-primary" />
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto scrollbar-none text-left">
{neighbors.length ? neighbors.map((n) => (
<button key={n.id} onClick={() => setSelected(n.id)}
className="flex items-center gap-2 text-[10px] text-muted-foreground hover:text-foreground text-left transition-colors cursor-pointer py-0.5">
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ background: CAT_COLOR_MAP[n.category] }} />
<span className="truncate">{n.content}</span>
</button>
)) : <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>
</div>
</div>
)}
{/* Graph-Sicht */}
<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>
</div>
)}
{dialogElement}
</div>
)