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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user