"use client"; import { useEffect, useRef, useState, type DragEvent } from 'react'; import { Trash2, Loader, Database, RefreshCw, Pencil, X, Save, Plus, Link2, Tag, Share2, AlignLeft, ChevronDown, ChevronRight, Check, Folder } from 'lucide-react'; import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer'; import { Node, NodeConnection } from '@/types/database'; import { getNodeIcon } from '@/utils/nodeIcons'; import ConfirmDialog from '../common/ConfirmDialog'; import { SourceReader } from './source'; import SourceEditor from './source/SourceEditor'; import { openExternalUrl, shouldOpenExternally } from '@/utils/openExternalUrl'; import { normalizeNodeLink } from '@/utils/nodeLink'; import NodeSearchModal from './edges/NodeSearchModal'; import { getNodeProcessedState, parseNodeMetadata } from '@/services/nodes/metadata'; import type { ContextSummary } from '@/types/database'; interface FocusPanelProps { openTabs: number[]; activeTab: number | null; onTabSelect: (nodeId: number) => void; onNodeClick?: (nodeId: number) => void; onTabClose: (nodeId: number) => void; refreshTrigger?: number; onTextSelect?: (nodeId: number, nodeTitle: string, text: string) => void; highlightedPassage?: { nodeId: number; selectedText: string } | null; } type HoverSection = 'description' | 'source' | null; export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeClick, onTabClose, refreshTrigger, onTextSelect, highlightedPassage, }: FocusPanelProps) { const [nodesData, setNodesData] = useState>({}); const [edgesData, setEdgesData] = useState>({}); const [loadingNodes, setLoadingNodes] = useState>(new Set()); const [loadingEdges, setLoadingEdges] = useState>(new Set()); const [embeddingNode, setEmbeddingNode] = useState(null); const [deletingNode, setDeletingNode] = useState(null); const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState(null); const [deletingEdge, setDeletingEdge] = useState(null); const [edgeEditingId, setEdgeEditingId] = useState(null); const [edgeEditingValue, setEdgeEditingValue] = useState(''); const [edgeSavingId, setEdgeSavingId] = useState(null); const [edgesExpanded, setEdgesExpanded] = useState>({}); const [edgeSearchOpen, setEdgeSearchOpen] = useState(false); const [hoveredConnectionId, setHoveredConnectionId] = useState(null); const [propsCollapsed, setPropsCollapsed] = useState(false); const [titleEditMode, setTitleEditMode] = useState(false); const [titleEditValue, setTitleEditValue] = useState(''); const [titleSaving, setTitleSaving] = useState(false); const [linkEditMode, setLinkEditMode] = useState(false); const [linkEditValue, setLinkEditValue] = useState(''); const [linkSaving, setLinkSaving] = useState(false); const [descEditMode, setDescEditMode] = useState(false); const [descEditValue, setDescEditValue] = useState(''); const [descSaving, setDescSaving] = useState(false); const [regeneratingDescription, setRegeneratingDescription] = useState(null); const [sourceEditMode, setSourceEditMode] = useState(false); const [sourceEditValue, setSourceEditValue] = useState(''); const [sourceSaving, setSourceSaving] = useState(false); const [availableContexts, setAvailableContexts] = useState([]); const [contextSaving, setContextSaving] = useState(false); const [contextMenuOpen, setContextMenuOpen] = useState(false); const [hoveredSection, setHoveredSection] = useState(null); const titleInputRef = useRef(null); const linkInputRef = useRef(null); const descTextareaRef = useRef(null); const sourceTextareaRef = useRef(null); const skipTitleBlurRef = useRef(false); const skipLinkBlurRef = useRef(false); const contextMenuRef = useRef(null); const currentNode = activeTab !== null ? nodesData[activeTab] : undefined; const normalizedCurrentLink = normalizeNodeLink(currentNode?.link || null); const currentProcessedState = getNodeProcessedState(currentNode?.metadata); const currentNodeMetadata = parseNodeMetadata(currentNode?.metadata); const currentEdges = activeTab !== null ? edgesData[activeTab] || [] : []; const currentEdgesExpanded = activeTab !== null ? Boolean(edgesExpanded[activeTab]) : false; const visibleEdges = currentEdgesExpanded ? currentEdges : currentEdges.slice(0, 3); useEffect(() => { if (titleEditMode) titleInputRef.current?.focus(); }, [titleEditMode]); useEffect(() => { if (linkEditMode) linkInputRef.current?.focus(); }, [linkEditMode]); useEffect(() => { if (descEditMode) descTextareaRef.current?.focus(); }, [descEditMode]); useEffect(() => { if (sourceEditMode) sourceTextareaRef.current?.focus(); }, [sourceEditMode]); useEffect(() => { if (!contextMenuOpen) return; const handlePointerDown = (event: MouseEvent) => { if (!contextMenuRef.current?.contains(event.target as globalThis.Node)) { setContextMenuOpen(false); } }; document.addEventListener('mousedown', handlePointerDown); return () => document.removeEventListener('mousedown', handlePointerDown); }, [contextMenuOpen]); useEffect(() => { void (async () => { try { const response = await fetch('/api/contexts'); const data = await response.json(); if (response.ok && data.success) { setAvailableContexts(data.data || []); } } catch (error) { console.error('Failed to fetch contexts:', error); } })(); }, []); useEffect(() => { openTabs.forEach((tabId) => { if (!nodesData[tabId] && !loadingNodes.has(tabId)) { void fetchNodeData(tabId); } }); }, [openTabs, nodesData, loadingNodes]); useEffect(() => { openTabs.forEach((tabId) => { if (nodesData[tabId] && !edgesData[tabId] && !loadingEdges.has(tabId)) { void fetchEdgesData(tabId); } }); }, [openTabs, nodesData, edgesData, loadingEdges]); useEffect(() => { if (refreshTrigger !== undefined && refreshTrigger > 0 && activeTab) { void fetchNodeData(activeTab); void fetchEdgesData(activeTab); } }, [refreshTrigger, activeTab]); useEffect(() => { setTitleEditMode(false); setTitleEditValue(''); setLinkEditMode(false); setLinkEditValue(''); setDescEditMode(false); setDescEditValue(''); setSourceEditMode(false); setSourceEditValue(''); setContextMenuOpen(false); setEdgeSearchOpen(false); setEdgeEditingId(null); setEdgeEditingValue(''); setHoveredSection(null); }, [activeTab]); const fetchNodeData = async (id: number) => { setLoadingNodes(prev => new Set(prev).add(id)); try { const response = await fetch(`/api/nodes/${id}`); if (response.status === 404) { setNodesData(prev => { const next = { ...prev }; delete next[id]; return next; }); setEdgesData(prev => { const next = { ...prev }; delete next[id]; return next; }); onTabClose(id); return; } const data = await response.json(); if (response.ok && data.node) { setNodesData(prev => ({ ...prev, [id]: data.node })); } } catch (error) { console.warn(`Failed to fetch node ${id}:`, error); } finally { setLoadingNodes(prev => { const next = new Set(prev); next.delete(id); return next; }); } }; const fetchEdgesData = async (nodeId: number) => { setLoadingEdges(prev => new Set(prev).add(nodeId)); try { const response = await fetch(`/api/nodes/${nodeId}/edges`); const data = await response.json(); if (response.ok && data.success && data.data) { setEdgesData(prev => ({ ...prev, [nodeId]: data.data })); } } catch (error) { console.error(`Error fetching edges for node ${nodeId}:`, error); } finally { setLoadingEdges(prev => { const next = new Set(prev); next.delete(nodeId); return next; }); } }; const updateNode = async (nodeId: number, updates: Record) => { const response = await fetch(`/api/nodes/${nodeId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates), }); if (!response.ok) { throw new Error('Failed to update node'); } const result = await response.json(); if (result.node) { setNodesData(prev => ({ ...prev, [nodeId]: result.node })); return result.node as Node; } throw new Error('Missing updated node in response'); }; const toggleProcessedState = async () => { if (activeTab === null || !currentNode) return; const nextState = currentProcessedState === 'processed' ? 'not_processed' : 'processed'; try { await updateNode(activeTab, { metadata: { state: nextState, }, }); } catch (error) { console.error('Error updating processed state:', error); window.alert('Failed to update processed state. Please try again.'); } }; const renderMetadataSection = () => { const metadataEntries = Object.entries(currentNodeMetadata).filter(([, value]) => value !== undefined); const rawJson = metadataEntries.length > 0 ? JSON.stringify(currentNodeMetadata, null, 2) : ''; return (
Metadata
{metadataEntries.length === 0 ? (
No metadata stored for this node.
) : (
raw
{rawJson}
)}
); }; const startTitleEdit = () => { if (!currentNode) return; setTitleEditValue(currentNode.title || ''); setTitleEditMode(true); }; const saveTitle = async () => { if (!activeTab) return; const nextTitle = titleEditValue.trim(); if (!nextTitle) { window.alert('Title cannot be empty'); return; } setTitleSaving(true); try { await updateNode(activeTab, { title: nextTitle }); setTitleEditMode(false); setTitleEditValue(''); } catch (error) { console.error('Error saving title:', error); window.alert('Failed to save title. Please try again.'); } finally { setTitleSaving(false); } }; const startLinkEdit = () => { setLinkEditValue(currentNode?.link || ''); setLinkEditMode(true); }; const saveLink = async () => { if (!activeTab) return; setLinkSaving(true); try { await updateNode(activeTab, { link: linkEditValue.trim() || null }); setLinkEditMode(false); setLinkEditValue(''); } catch (error) { console.error('Error saving link:', error); window.alert('Failed to save URL. Please try again.'); } finally { setLinkSaving(false); } }; const startDescEdit = () => { setDescEditValue(currentNode?.description || ''); setDescEditMode(true); }; const saveDesc = async () => { if (!activeTab) return; setDescSaving(true); try { await updateNode(activeTab, { description: descEditValue }); setDescEditMode(false); setDescEditValue(''); } catch (error) { console.error('Error saving description:', error); window.alert('Failed to save description. Please try again.'); } finally { setDescSaving(false); } }; const regenerateDescription = async (nodeId: number) => { setRegeneratingDescription(nodeId); try { const response = await fetch(`/api/nodes/${nodeId}/regenerate-description`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); if (!response.ok) { throw new Error('Failed to regenerate description'); } const result = await response.json(); if (result.node) { setNodesData(prev => ({ ...prev, [nodeId]: result.node })); } } catch (error) { console.error('Error regenerating description:', error); window.alert('Failed to regenerate description. Please try again.'); } finally { setRegeneratingDescription(null); } }; const startSourceEdit = () => { setSourceEditValue(currentNode?.source || ''); setSourceEditMode(true); }; const saveSource = async () => { if (!activeTab) return; setSourceSaving(true); try { await updateNode(activeTab, { source: sourceEditValue }); setSourceEditMode(false); setSourceEditValue(''); } catch (error) { console.error('Error saving source:', error); window.alert('Failed to save source. Please try again.'); } finally { setSourceSaving(false); } }; const saveContext = async (value: string) => { if (!activeTab) return; setContextSaving(true); try { await updateNode(activeTab, { context_id: value ? Number(value) : null }); setContextMenuOpen(false); } catch (error) { console.error('Error saving context:', error); window.alert('Failed to save context. Please try again.'); } finally { setContextSaving(false); } }; const embedContent = async (nodeId: number) => { setEmbeddingNode(nodeId); try { const response = await fetch('/api/ingestion', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ nodeId }), }); const result = await response.json(); if (!response.ok) { throw new Error(result.error || 'Failed to embed content'); } await fetchNodeData(nodeId); } catch (error) { console.error('Error embedding content:', error); window.alert(`Failed to embed content: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { setEmbeddingNode(null); } }; const createEdgeWithExplanation = async (targetNodeId: number, explanation: string) => { if (activeTab === null) return; try { const response = await fetch('/api/edges', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from_node_id: activeTab, to_node_id: targetNodeId, source: 'user', explanation: explanation || '', }), }); if (!response.ok) { throw new Error('Failed to create edge'); } await fetchEdgesData(activeTab); } catch (error) { console.error('Error creating edge:', error); window.alert('Failed to create connection. Please try again.'); throw error; } }; const startEditEdgeExplanation = (edgeId: number, currentExplanation?: string) => { setEdgeEditingId(edgeId); setEdgeEditingValue(currentExplanation || ''); }; const cancelEditEdgeExplanation = () => { setEdgeEditingId(null); setEdgeEditingValue(''); }; const saveEdgeExplanation = async ( edgeId: number, currentContext: Record | null | undefined ) => { if (activeTab === null) return; setEdgeSavingId(edgeId); try { const response = await fetch(`/api/edges/${edgeId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ context: { ...(currentContext ?? {}), explanation: edgeEditingValue, }, }), }); if (!response.ok) { throw new Error('Failed to update edge'); } await fetchEdgesData(activeTab); cancelEditEdgeExplanation(); } catch (error) { console.error('Failed updating edge explanation:', error); window.alert('Failed to update connection explanation.'); } finally { setEdgeSavingId(null); } }; const deleteEdge = async (edgeId: number) => { if (activeTab === null) return; setDeletingEdge(edgeId); try { const response = await fetch(`/api/edges/${edgeId}`, { method: 'DELETE', }); if (!response.ok) { throw new Error('Failed to delete edge'); } await fetchEdgesData(activeTab); } catch (error) { console.error('Error deleting edge:', error); window.alert('Failed to delete connection. Please try again.'); } finally { setDeletingEdge(null); } }; const executeDeleteNode = async (nodeId: number) => { setDeletingNode(nodeId); try { const response = await fetch(`/api/nodes/${nodeId}`, { method: 'DELETE', }); if (!response.ok) { throw new Error('Failed to delete node'); } onTabClose(nodeId); setNodesData(prev => { const next = { ...prev }; delete next[nodeId]; return next; }); } catch (error) { console.error('Error deleting node:', error); window.alert('Failed to delete node. Please try again.'); } finally { setDeletingNode(null); } }; const renderStatusIndicator = () => { if (!currentNode || activeTab === null) return null; const chunkStatus = currentNode.chunk_status ?? null; if (embeddingNode === activeTab || chunkStatus === 'chunking') { return ; } if (chunkStatus === 'error') { return ( ); } if (chunkStatus === 'not_chunked') { return ( Pending embed ); } return null; }; // ── Shared inline style constants (styled-jsx doesn't apply inside helper fns) ── const S = { section: { display: 'flex' as const, flexDirection: 'column' as const, gap: '10px', paddingTop: '18px', marginTop: '4px', }, sectionHeader: { display: 'flex' as const, alignItems: 'center' as const, gap: '10px', marginBottom: '2px', }, sectionLabel: { fontSize: '9.5px', letterSpacing: '0.1em', textTransform: 'uppercase' as const, color: 'var(--rah-text-muted)', fontWeight: 600, flexShrink: 0, }, sectionRule: { flex: 1, height: '1px', background: 'var(--rah-border)', }, iconBtn: { display: 'inline-flex' as const, alignItems: 'center' as const, justifyContent: 'center' as const, width: '22px', height: '22px', background: 'transparent', border: '1px solid transparent', borderRadius: '5px', color: 'var(--rah-text-muted)', cursor: 'pointer', padding: 0, }, editorBlock: { display: 'flex' as const, flexDirection: 'column' as const, gap: '10px', }, textarea: { display: 'block' as const, width: '100%', boxSizing: 'border-box' as const, background: 'var(--rah-bg-surface)', border: '1px solid var(--rah-border-strong)', borderRadius: '8px', color: 'var(--rah-text-base)', fontFamily: 'inherit', outline: 'none', padding: '12px', }, editorActions: { display: 'flex' as const, justifyContent: 'flex-end' as const, gap: '8px', }, primaryBtn: { display: 'inline-flex' as const, alignItems: 'center' as const, gap: '5px', padding: '6px 12px', border: 'none', background: 'var(--rah-accent-green)', color: 'var(--rah-text-inverse)', fontSize: '11px', fontWeight: 600, borderRadius: '6px', cursor: 'pointer', fontFamily: 'inherit', }, secondaryBtn: { display: 'inline-flex' as const, alignItems: 'center' as const, gap: '5px', padding: '6px 12px', border: '1px solid var(--rah-border-strong)', background: 'transparent', color: 'var(--rah-text-soft)', fontSize: '11px', borderRadius: '6px', cursor: 'pointer', fontFamily: 'inherit', }, }; const renderDescriptionSection = () => (
setHoveredSection('description')} onMouseLeave={() => setHoveredSection(prev => prev === 'description' ? null : prev)} > {/* Section header with extending rule */}
Description
{!descEditMode && ( <> {activeTab !== null && ( )} )}
{descEditMode ? (