"use client"; import { useState, useEffect, useRef, 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; 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([]); const [allNodes, setAllNodes] = useState([]); const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); const [deletingNode, setDeletingNode] = useState(null); const [priorityDimensions, setPriorityDimensions] = useState([]); const [lockedDimensions, setLockedDimensions] = useState([]); const [expandedDimensions, setExpandedDimensions] = useState>(() => { 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>({}); 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(null); const [dragHoverDimension, setDragHoverDimension] = useState(null); const draggedNodeRef = useRef<{ id: number; dimensions?: 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) => { if (event.dataTransfer.types.includes('application/node-info') || event.dataTransfer.types.includes('text/plain')) { event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; } }; const handleDimensionDragEnter = (event: DragEvent, dimension: string) => { if (event.dataTransfer.types.includes('application/node-info') || event.dataTransfer.types.includes('text/plain')) { setDragHoverDimension(dimension); } }; const handleDimensionDragLeave = (event: DragEvent, dimension: string) => { if (dragHoverDimension === dimension) { setDragHoverDimension(null); } }; const handleNodeDropOnDimension = async (event: DragEvent, dimension: string) => { event.preventDefault(); event.stopPropagation(); // Try to get data from ref first (works in Electron/Tauri webviews) let payload: { id: number; dimensions?: string[] } | null = draggedNodeRef.current; // Fallback to dataTransfer for browser compatibility if (!payload) { const raw = event.dataTransfer.getData('application/node-info') || event.dataTransfer.getData('text/plain'); if (raw) { try { payload = JSON.parse(raw); } catch (e) { console.error('Failed to parse drag data:', e); } } } // Clear the ref draggedNodeRef.current = null; if (!payload?.id) { console.warn('No valid node data in drop event'); return; } try { 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, node: Node) => { event.dataTransfer.effectAllowed = 'copy'; const nodeData = { id: node.id, dimensions: node.dimensions || [] }; // Store in ref for webview compatibility (dataTransfer.getData can fail in Electron/Tauri) draggedNodeRef.current = nodeData; // Also set in dataTransfer for browser compatibility event.dataTransfer.setData('application/node-info', JSON.stringify(nodeData)); event.dataTransfer.setData('text/plain', JSON.stringify(nodeData)); 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 handleNodeDragEnd = () => { // Clear ref if drag ends without a drop draggedNodeRef.current = null; }; const renderNodeItem = (node: Node) => ( ); 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 (
{/* Search Modal */} setShowSearchModal(false)} onNodeSelect={handleNodeSelectFromSearch} existingFilters={selectedFilters} /> {/* Nodes List */}
{loading ? (
Loading nodes...
) : (
{/* + ADD NODE Button & folder expand */}
{dropFeedback && (
{dropFeedback}
)} {/* SEARCH Button */} {/* DIMENSIONS Section Header */} {lockedDimensions.length > 0 && ( )} {/* Locked Dimension Sections */} {!dimensionsSectionCollapsed && lockedDimensions.map((lockedDim) => { const dimNodes = getNodesForDimension(lockedDim.dimension); const isExpanded = expandedDimensions.has(lockedDim.dimension); return (
{/* Dimension Header */} {/* Dimension Nodes (when expanded) */} {isExpanded && (
{dimNodes.length === 0 ? (
No nodes in this dimension
) : ( dimNodes.map((node) => renderNodeItem(node)) )}
)}
); })} {/* ALL NODES Section Header */} {/* All Nodes List */} {!allNodesSectionCollapsed && ( nodes.length === 0 ? (
{selectedFilters.length > 0 ? 'No nodes match filters' : 'No nodes yet - click "Add Node" above to get started'}
) : ( nodes.map((node) => renderNodeItem(node)) ) )}
)}
); }