Initial commit: RA-H Open Source Edition

Local-first knowledge management system with BYO API keys.

Features:
- 3-panel UI (Nodes | Focus | Helpers)
- SQLite + sqlite-vec for vector search
- Agent system (Easy/Hard mode orchestrators)
- Content extraction (YouTube, PDF, web)
- Integrate workflow for connection discovery
- Dimension system with auto-assignment

Tech stack:
- Next.js 15 + TypeScript + Tailwind CSS
- Anthropic (Claude) + OpenAI (GPT) via Vercel AI SDK

Setup:
  npm install && npm rebuild better-sqlite3
  scripts/dev/bootstrap-local.sh
  npm run dev

MIT License
This commit is contained in:
“BeeRad”
2025-12-15 16:14:28 +11:00
commit 733d1c3407
226 changed files with 46231 additions and 0 deletions
+835
View File
@@ -0,0 +1,835 @@
"use client";
import { useState, useEffect, type DragEvent } from 'react';
import { ChevronRight, ChevronDown, Folder, FolderOpen, Layers, List, Maximize2, Minimize2 } from 'lucide-react';
import { Node } from '@/types/database';
import AddNodeButton from './AddNodeButton';
import Chip from '../common/Chip';
import { getNodeIcon } from '@/utils/nodeIcons';
import SearchModal from './SearchModal';
interface NodesPanelProps {
selectedNodes: Set<number>;
onNodeSelect: (nodeId: number, multiSelect: boolean) => void;
onNodeCreated?: (node: Node) => void;
onNodeDeleted?: (nodeId: number) => void;
refreshTrigger?: number;
onToggleFolderView?: (isOpen?: boolean) => void;
folderViewOpen?: boolean;
}
interface LockedDimension {
dimension: string;
count: number;
isPriority: boolean;
}
export default function NodesPanel({ selectedNodes, onNodeSelect, onNodeCreated, onNodeDeleted, refreshTrigger, onToggleFolderView, folderViewOpen = false }: NodesPanelProps) {
const [nodes, setNodes] = useState<Node[]>([]);
const [allNodes, setAllNodes] = useState<Node[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [deletingNode, setDeletingNode] = useState<number | null>(null);
const [priorityDimensions, setPriorityDimensions] = useState<string[]>([]);
const [lockedDimensions, setLockedDimensions] = useState<LockedDimension[]>([]);
const [expandedDimensions, setExpandedDimensions] = useState<Set<string>>(() => {
if (typeof window === 'undefined') return new Set();
const saved = window.localStorage.getItem('expandedDimensions');
return saved ? new Set(JSON.parse(saved)) : new Set();
});
const [dimensionNodes, setDimensionNodes] = useState<Record<string, Node[]>>({});
const [selectedFilters, setSelectedFilters] = useState<{type: 'dimension' | 'title', value: string}[]>([]);
const [dimensionsSectionCollapsed, setDimensionsSectionCollapsed] = useState(true); // Default: collapsed
const [allNodesSectionCollapsed, setAllNodesSectionCollapsed] = useState(false);
const [showSearchModal, setShowSearchModal] = useState(false);
const [dropFeedback, setDropFeedback] = useState<string | null>(null);
const [dragHoverDimension, setDragHoverDimension] = useState<string | null>(null);
useEffect(() => {
fetchNodes();
fetchLockedDimensions();
}, []);
// Save expanded dimensions to localStorage
useEffect(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem('expandedDimensions', JSON.stringify([...expandedDimensions]));
}, [expandedDimensions]);
// Refresh nodes and locked dimensions when SSE events trigger updates
useEffect(() => {
if (refreshTrigger !== undefined && refreshTrigger > 0) {
console.log('🔄 Refreshing nodes and locked dimensions due to SSE event');
fetchNodes();
fetchLockedDimensions();
}
}, [refreshTrigger]);
// Global Cmd+K / Ctrl+K keyboard shortcut for search
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
setShowSearchModal(true);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
// Re-apply filters when allNodes changes
useEffect(() => {
if (selectedFilters.length > 0) {
applyFiltersWithSelection(selectedFilters);
} else {
setNodes(allNodes);
}
}, [allNodes, priorityDimensions]);
const fetchNodes = async () => {
try {
const response = await fetch('/api/nodes?limit=100');
const result = await response.json();
const fetchedNodes = result.data || [];
setAllNodes(fetchedNodes);
setNodes(fetchedNodes);
} catch (error) {
console.error('Error fetching nodes:', error);
} finally {
setLoading(false);
}
};
const fetchLockedDimensions = async () => {
try {
const response = await fetch('/api/dimensions/popular');
const result = await response.json();
if (result.success) {
const locked = result.data.filter((d: any) => d.isPriority);
setLockedDimensions(locked);
const priority = locked.map((d: any) => d.dimension);
setPriorityDimensions(priority);
}
} catch (error) {
console.error('Error fetching locked dimensions:', error);
}
};
const toggleDimension = async (dimension: string) => {
const isCurrentlyExpanded = expandedDimensions.has(dimension);
setExpandedDimensions(prev => {
const next = new Set(prev);
if (next.has(dimension)) {
next.delete(dimension);
} else {
next.add(dimension);
}
return next;
});
// Fetch nodes for this dimension when expanding (if not already fetched)
if (!isCurrentlyExpanded && !dimensionNodes[dimension]) {
await fetchNodesForDimension(dimension);
}
};
const fetchNodesForDimension = async (dimension: string) => {
try {
// Sort by edge count (most connected first), then updated_at
const response = await fetch(`/api/nodes?dimensions=${encodeURIComponent(dimension)}&limit=200&sortBy=edges`);
const result = await response.json();
if (result.success) {
setDimensionNodes(prev => ({
...prev,
[dimension]: result.data || []
}));
}
} catch (error) {
console.error(`Error fetching nodes for dimension ${dimension}:`, error);
}
};
const handleDimensionDragOver = (event: DragEvent<HTMLElement>) => {
if (event.dataTransfer.types.includes('application/node-info')) {
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
}
};
const handleDimensionDragEnter = (event: DragEvent<HTMLElement>, dimension: string) => {
if (event.dataTransfer.types.includes('application/node-info')) {
setDragHoverDimension(dimension);
}
};
const handleDimensionDragLeave = (event: DragEvent<HTMLElement>, dimension: string) => {
if (dragHoverDimension === dimension) {
setDragHoverDimension(null);
}
};
const handleNodeDropOnDimension = async (event: DragEvent<HTMLElement>, dimension: string) => {
if (!event.dataTransfer.types.includes('application/node-info')) {
return;
}
event.preventDefault();
const raw = event.dataTransfer.getData('application/node-info');
if (!raw) return;
try {
const payload = JSON.parse(raw) as { id: number; dimensions?: string[] };
if (!payload?.id) return;
const currentDimensions = payload.dimensions || [];
if (currentDimensions.some((dim) => dim.toLowerCase() === dimension.toLowerCase())) {
setDropFeedback(`Node already in ${dimension}`);
setTimeout(() => setDropFeedback(null), 2500);
return;
}
const updatedDimensions = Array.from(new Set([...currentDimensions, dimension]));
const response = await fetch(`/api/nodes/${payload.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dimensions: updatedDimensions })
});
if (!response.ok) {
throw new Error('Failed to update node dimensions');
}
setDropFeedback(`Added to ${dimension}`);
setTimeout(() => setDropFeedback(null), 2500);
if (expandedDimensions.has(dimension)) {
fetchNodesForDimension(dimension);
}
fetchLockedDimensions();
setDragHoverDimension(null);
} catch (error) {
console.error('Error handling node drop:', error);
setDropFeedback('Failed to add dimension');
setTimeout(() => setDropFeedback(null), 2500);
setDragHoverDimension(null);
}
};
const getNodesForDimension = (dimension: string): Node[] => {
// Use fetched dimension-specific nodes if available, otherwise filter from loaded nodes
return dimensionNodes[dimension] || nodes.filter(node =>
node.dimensions?.some(d => d.toLowerCase() === dimension.toLowerCase())
);
};
const applyFilters = () => {
if (selectedFilters.length === 0) {
setNodes(allNodes);
return;
}
const dimensionFilters = selectedFilters.filter(f => f.type === 'dimension').map(f => f.value);
const titleFilters = selectedFilters.filter(f => f.type === 'title').map(f => f.value);
const filtered = allNodes.filter(node => {
// Must match ALL selected dimension filters
const dimensionMatch = dimensionFilters.length === 0 ||
dimensionFilters.every(dim =>
node.dimensions?.some(nodeDim => nodeDim.toLowerCase() === dim.toLowerCase())
);
// Must match ANY selected title filter
const titleMatch = titleFilters.length === 0 ||
titleFilters.some(title =>
node.title?.toLowerCase().includes(title.toLowerCase())
);
return dimensionMatch && titleMatch;
});
// Sort by priority dimensions first
const sorted = filtered.sort((a, b) => {
const aHasPriority = a.dimensions?.some(dim => priorityDimensions.includes(dim)) || false;
const bHasPriority = b.dimensions?.some(dim => priorityDimensions.includes(dim)) || false;
if (aHasPriority && !bHasPriority) return -1;
if (!aHasPriority && bHasPriority) return 1;
return 0;
});
setNodes(sorted);
};
const addFilter = (type: 'dimension' | 'title', value: string) => {
const newFilter = { type, value };
const newFilters = [...selectedFilters, newFilter];
setSelectedFilters(newFilters);
// Apply filters immediately
setTimeout(() => {
if (newFilters.length === 0) {
setNodes(allNodes);
} else {
applyFiltersWithSelection(newFilters);
}
}, 0);
};
const removeFilter = (index: number) => {
const newFilters = selectedFilters.filter((_, i) => i !== index);
setSelectedFilters(newFilters);
// Apply filters immediately
setTimeout(() => {
if (newFilters.length === 0) {
setNodes(allNodes);
} else {
applyFiltersWithSelection(newFilters);
}
}, 0);
};
const applyFiltersWithSelection = async (filters: {type: 'dimension' | 'title', value: string}[]) => {
const dimensionFilters = filters.filter(f => f.type === 'dimension').map(f => f.value);
const titleFilters = filters.filter(f => f.type === 'title').map(f => f.value);
try {
// Build API query params
const params = new URLSearchParams();
if (dimensionFilters.length > 0) {
params.append('dimensions', dimensionFilters.join(','));
}
if (titleFilters.length > 0) {
// For title filters, we'll search each one and combine results
const titleResults: Node[] = [];
for (const title of titleFilters) {
const response = await fetch(`/api/nodes?search=${encodeURIComponent(title)}&limit=50`);
const result = await response.json();
if (result.success) {
titleResults.push(...result.data);
}
}
// If we have dimension filters too, intersect the results
if (dimensionFilters.length > 0) {
const dimensionResponse = await fetch(`/api/nodes?${params.toString()}&limit=200`);
const dimensionResult = await dimensionResponse.json();
if (dimensionResult.success) {
// Find intersection: nodes that match dimensions AND titles
const dimensionNodeIds = new Set(dimensionResult.data.map((n: Node) => n.id));
const intersectedNodes = titleResults.filter(node => dimensionNodeIds.has(node.id));
setNodes(intersectedNodes);
}
} else {
// Only title filters
setNodes(titleResults);
}
} else if (dimensionFilters.length > 0) {
// Only dimension filters
const response = await fetch(`/api/nodes?${params.toString()}&limit=200`);
const result = await response.json();
if (result.success) {
setNodes(result.data);
}
}
} catch (error) {
console.error('Error applying filters:', error);
// Fallback to local filtering
const filtered = allNodes.filter(node => {
const dimensionMatch = dimensionFilters.length === 0 ||
dimensionFilters.every(dim =>
node.dimensions?.some(nodeDim => nodeDim.toLowerCase() === dim.toLowerCase())
);
const titleMatch = titleFilters.length === 0 ||
titleFilters.some(title =>
node.title?.toLowerCase().includes(title.toLowerCase())
);
return dimensionMatch && titleMatch;
});
setNodes(filtered);
}
};
const clearSearch = () => {
setSelectedFilters([]);
setNodes(allNodes);
};
const collapseAllDimensions = () => {
setExpandedDimensions(new Set());
};
const handleNodeDragStart = (event: DragEvent<HTMLElement>, node: Node) => {
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData('application/node-info', JSON.stringify({
id: node.id,
dimensions: node.dimensions || []
}));
const preview = document.createElement('div');
preview.textContent = node.title || `Node #${node.id}`;
preview.style.position = 'fixed';
preview.style.top = '-1000px';
preview.style.left = '-1000px';
preview.style.padding = '4px 8px';
preview.style.background = '#0f0f0f';
preview.style.color = '#f8fafc';
preview.style.fontSize = '11px';
preview.style.fontWeight = '600';
preview.style.borderRadius = '6px';
preview.style.border = '1px solid #1f1f1f';
document.body.appendChild(preview);
event.dataTransfer.setDragImage(preview, 6, 6);
setTimeout(() => {
if (preview.parentNode) {
preview.parentNode.removeChild(preview);
}
}, 0);
};
const renderNodeItem = (node: Node) => (
<button
key={node.id}
draggable
onDragStart={(event) => handleNodeDragStart(event, node)}
onClick={(e) => {
const multiSelect = e.metaKey || e.ctrlKey;
onNodeSelect(node.id, multiSelect);
}}
style={{
width: '100%',
padding: '12px 16px', /* Increased from 10px to 12px */
margin: '2px 0', /* Added vertical margin for better spacing */
textAlign: 'left',
display: 'flex',
alignItems: 'center',
gap: '10px',
fontSize: '14px',
fontFamily: 'inherit',
background: selectedNodes.has(node.id) ? '#1a1a1a' : 'transparent',
color: selectedNodes.has(node.id) ? '#fff' : '#d1d5db',
border: 'none',
cursor: 'pointer',
transition: 'background 0.1s',
whiteSpace: 'nowrap',
overflow: 'hidden'
}}
onMouseEnter={(e) => {
if (!selectedNodes.has(node.id)) {
e.currentTarget.style.background = '#0a0a0a';
}
}}
onMouseLeave={(e) => {
if (!selectedNodes.has(node.id)) {
e.currentTarget.style.background = 'transparent';
}
}}
>
{/* Title */}
<span style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
flex: 1
}}>
{node.title || 'Untitled'}
</span>
{/* Source Icon - Only show if node has a link */}
{node.link && (
<span style={{
display: 'flex',
alignItems: 'center',
flexShrink: 0,
marginLeft: '8px'
}}>
{getNodeIcon(node)}
</span>
)}
</button>
);
const handleAddNode = async () => {
setCreating(true);
try {
const response = await fetch('/api/nodes', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: 'New Node',
content: '',
link: null,
dimensions: []
}),
});
const result = await response.json();
if (result.success) {
// Add new node to both lists immediately
setAllNodes(prevNodes => [result.data, ...prevNodes]);
setNodes(prevNodes => [result.data, ...prevNodes]);
// Notify parent component about new node creation
if (onNodeCreated) {
onNodeCreated(result.data);
}
} else {
console.error('Failed to create node:', result.message);
}
} catch (error) {
console.error('Error creating node:', error);
} finally {
setCreating(false);
}
};
// handleDeleteNode removed - deletion only available in focus panel
const handleNodeSelectFromSearch = (nodeId: number) => {
// Open node in focus panel without filtering the nodes panel
onNodeSelect(nodeId, false);
};
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: '#000' }}>
{/* Search Modal */}
<SearchModal
isOpen={showSearchModal}
onClose={() => setShowSearchModal(false)}
onNodeSelect={handleNodeSelectFromSearch}
existingFilters={selectedFilters}
/>
{/* Nodes List */}
<div style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
padding: '8px 0'
}}>
{loading ? (
<div style={{
padding: '16px',
color: '#666',
fontSize: '14px'
}}>
Loading nodes...
</div>
) : (
<div>
{/* + ADD NODE Button & folder expand */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '0 8px' }}>
<button
onClick={handleAddNode}
disabled={creating}
style={{
flex: 1,
padding: '12px 12px',
fontSize: '11px',
fontWeight: 600,
color: '#22c55e',
textTransform: 'uppercase',
letterSpacing: '0.1em',
borderBottom: '1px solid #1a1a1a',
background: '#0a0a0a',
border: 'none',
cursor: creating ? 'not-allowed' : 'pointer',
textAlign: 'left',
display: 'flex',
alignItems: 'center',
gap: '8px',
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
if (!creating) {
e.currentTarget.style.background = '#151515';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#0a0a0a';
}}
>
<span style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '20px',
height: '20px',
borderRadius: '50%',
background: '#22c55e',
color: '#0a0a0a',
fontSize: '16px',
lineHeight: 1,
fontWeight: 300,
flexShrink: 0
}}>+</span>
{creating ? 'Adding...' : 'Add Node'}
</button>
<button
onClick={() => onToggleFolderView?.()}
style={{
width: '48px',
height: '48px',
borderRadius: '10px',
border: '1px solid #1f1f1f',
background: folderViewOpen ? '#16301f' : '#080808',
color: folderViewOpen ? '#22c55e' : '#cbd5f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'background 0.2s ease, color 0.2s ease'
}}
title={folderViewOpen ? 'Close dimension folder view' : 'Open dimension folder view'}
onMouseEnter={(e) => {
e.currentTarget.style.background = folderViewOpen ? '#1c3b27' : '#101010';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = folderViewOpen ? '#16301f' : '#080808';
}}
>
{folderViewOpen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
</button>
</div>
{dropFeedback && (
<div style={{
margin: '6px 8px 0',
padding: '6px 10px',
borderRadius: '6px',
background: '#0d1a12',
color: '#7de8a5',
fontSize: '11px',
textTransform: 'uppercase',
letterSpacing: '0.05em'
}}>
{dropFeedback}
</div>
)}
{/* SEARCH Button */}
<button
onClick={() => setShowSearchModal(true)}
style={{
width: '100%',
padding: '12px 16px',
margin: '4px 8px', /* Added side margins */
fontSize: '11px',
fontWeight: 600,
color: '#555',
textTransform: 'uppercase',
letterSpacing: '0.1em',
borderRadius: '4px', /* Added border radius */
background: '#0f0f0f', /* Slightly different background */
border: 'none',
cursor: 'pointer',
textAlign: 'left',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#151515';
e.currentTarget.style.color = '#777';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#0a0a0a';
e.currentTarget.style.color = '#555';
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" />
</svg>
Search
</div>
<span style={{ fontSize: '9px', color: '#444', fontWeight: 400 }}>K</span>
</button>
{/* DIMENSIONS Section Header */}
{lockedDimensions.length > 0 && (
<button
onClick={() => setDimensionsSectionCollapsed(!dimensionsSectionCollapsed)}
style={{
width: '100%',
padding: '14px 18px',
fontSize: '11px',
fontWeight: 700,
color: dimensionsSectionCollapsed ? '#94a3b8' : '#f8fafc',
textTransform: 'uppercase',
letterSpacing: '0.18em',
borderBottom: '1px solid #151515',
borderLeft: '3px solid transparent',
background: dimensionsSectionCollapsed ? '#050505' : '#0e1811',
borderTop: '1px solid #000',
borderRight: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
transition: 'all 0.2s'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<Layers size={14} color={dimensionsSectionCollapsed ? '#94a3b8' : '#f8fafc'} />
<span>Dimensions ({lockedDimensions.length})</span>
</div>
{dimensionsSectionCollapsed ? (
<ChevronRight size={14} strokeWidth={2.5} color="#94a3b8" />
) : (
<ChevronDown size={14} strokeWidth={2.5} color="#f8fafc" />
)}
</button>
)}
{/* Locked Dimension Sections */}
{!dimensionsSectionCollapsed && lockedDimensions.map((lockedDim) => {
const dimNodes = getNodesForDimension(lockedDim.dimension);
const isExpanded = expandedDimensions.has(lockedDim.dimension);
return (
<div key={lockedDim.dimension} style={{ marginBottom: '4px' }}>
{/* Dimension Header */}
<button
onClick={() => toggleDimension(lockedDim.dimension)}
onDragOver={(e) => handleDimensionDragOver(e)}
onDragEnter={(e) => handleDimensionDragEnter(e, lockedDim.dimension)}
onDragLeave={(e) => handleDimensionDragLeave(e, lockedDim.dimension)}
onDrop={(e) => handleNodeDropOnDimension(e, lockedDim.dimension)}
style={{
width: '100%',
padding: '10px 18px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: dragHoverDimension === lockedDim.dimension ? '#152214' : (isExpanded ? '#0d0d0d' : '#050505'),
border: dragHoverDimension === lockedDim.dimension ? '1px solid #1f3d28' : 'none',
borderBottom: dragHoverDimension === lockedDim.dimension ? '1px solid #1f3d28' : '1px solid #121212',
borderLeft: '2px solid transparent',
color: '#cbd5f5',
fontSize: '12px',
fontWeight: 600,
cursor: 'pointer',
textAlign: 'left',
transition: 'all 0.2s'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', flex: 1 }}>
{isExpanded ? (
<FolderOpen size={16} color="#7de8a5" />
) : (
<Folder size={16} color="#64748b" />
)}
<span style={{
textTransform: 'uppercase',
letterSpacing: '0.08em'
}}>
{lockedDim.dimension}
</span>
</div>
<span
style={{
minWidth: '32px',
textAlign: 'right',
fontSize: '11px',
color: '#94a3b8',
fontWeight: 500,
background: '#0f1a12',
borderRadius: '999px',
padding: '2px 8px'
}}
>
{lockedDim.count}
</span>
</button>
{/* Dimension Nodes (when expanded) */}
{isExpanded && (
<div>
{dimNodes.length === 0 ? (
<div style={{
padding: '12px 16px',
color: '#555',
fontSize: '12px',
fontStyle: 'italic'
}}>
No nodes in this dimension
</div>
) : (
dimNodes.map((node) => renderNodeItem(node))
)}
</div>
)}
</div>
);
})}
{/* ALL NODES Section Header */}
<button
onClick={() => setAllNodesSectionCollapsed(!allNodesSectionCollapsed)}
style={{
width: '100%',
padding: '14px 18px',
fontSize: '11px',
fontWeight: 700,
color: allNodesSectionCollapsed ? '#94a3b8' : '#f8fafc',
textTransform: 'uppercase',
letterSpacing: '0.18em',
borderBottom: '1px solid #151515',
borderLeft: '3px solid transparent',
background: allNodesSectionCollapsed ? '#050505' : '#0e1811',
borderTop: '1px solid #000',
borderRight: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
transition: 'all 0.2s'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<List size={14} color={allNodesSectionCollapsed ? '#94a3b8' : '#f8fafc'} />
<span>Nodes</span>
</div>
{allNodesSectionCollapsed ? (
<ChevronRight size={14} strokeWidth={2.5} color="#94a3b8" />
) : (
<ChevronDown size={14} strokeWidth={2.5} color="#f8fafc" />
)}
</button>
{/* All Nodes List */}
{!allNodesSectionCollapsed && (
nodes.length === 0 ? (
<div style={{
padding: '16px',
color: '#666',
fontSize: '14px',
textAlign: 'center'
}}>
{selectedFilters.length > 0 ? 'No nodes match filters' : 'No nodes yet - click "Add Node" above to get started'}
</div>
) : (
nodes.map((node) => renderNodeItem(node))
)
)}
</div>
)}
</div>
</div>
);
}