"use client"; import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; interface NodeSearchModalProps { isOpen: boolean; onClose: () => void; onEdgeCreate: (nodeId: number, explanation?: string) => Promise; excludeNodeId?: number | null; } interface NodeSuggestion { id: number; title: string; } export default function NodeSearchModal({ isOpen, onClose, onEdgeCreate, excludeNodeId, }: NodeSearchModalProps) { const [mounted, setMounted] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [suggestions, setSuggestions] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); const [selectedNode, setSelectedNode] = useState(null); const [explanation, setExplanation] = useState(''); const [submitting, setSubmitting] = useState(false); const inputRef = useRef(null); const textareaRef = useRef(null); useEffect(() => { setMounted(true); }, []); const resetState = () => { setSearchQuery(''); setSuggestions([]); setSelectedIndex(0); setSelectedNode(null); setExplanation(''); setSubmitting(false); }; const closeModal = () => { resetState(); onClose(); }; useEffect(() => { if (!isOpen) { resetState(); return; } const timer = setTimeout(() => inputRef.current?.focus(), 50); const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); if (selectedNode) { setSelectedNode(null); setExplanation(''); } else { closeModal(); } } }; document.addEventListener('keydown', handleEscape); return () => { clearTimeout(timer); document.removeEventListener('keydown', handleEscape); }; }, [isOpen, selectedNode]); useEffect(() => { if (!isOpen) return; if (!searchQuery.trim()) { setSuggestions([]); setSelectedIndex(0); return; } const timeoutId = setTimeout(async () => { try { const response = await fetch(`/api/nodes/search?q=${encodeURIComponent(searchQuery)}&limit=20`); const result = await response.json(); if (response.ok && result.success) { const nextSuggestions = (result.data as NodeSuggestion[]) .filter((node) => node.id !== excludeNodeId) .map((node) => ({ id: node.id, title: node.title, })); setSuggestions(nextSuggestions); setSelectedIndex(0); } else { setSuggestions([]); } } catch (error) { console.error('Error fetching node suggestions:', error); setSuggestions([]); } }, 150); return () => clearTimeout(timeoutId); }, [searchQuery, isOpen, excludeNodeId]); useEffect(() => { if (selectedNode) { setTimeout(() => textareaRef.current?.focus(), 50); } }, [selectedNode]); const handleCreate = async (node: NodeSuggestion, nextExplanation?: string) => { setSubmitting(true); try { await onEdgeCreate(node.id, nextExplanation?.trim() || undefined); closeModal(); } catch (err) { console.error('Failed to create edge:', err); } finally { setSubmitting(false); } }; const handleSearchKeyDown = async (event: React.KeyboardEvent) => { if (event.key === 'ArrowDown') { event.preventDefault(); setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1)); return; } if (event.key === 'ArrowUp') { event.preventDefault(); setSelectedIndex((prev) => Math.max(prev - 1, 0)); return; } if (event.key === 'Enter' && suggestions[selectedIndex]) { event.preventDefault(); setSelectedNode(suggestions[selectedIndex]); setExplanation(''); } }; if (!mounted || !isOpen) return null; const modal = (
{ if (e.target === e.currentTarget) closeModal(); }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(6px)', display: 'flex', justifyContent: 'center', paddingTop: '15vh', zIndex: 9999, }} >
e.stopPropagation()} style={{ width: '100%', maxWidth: selectedNode ? '480px' : '560px', maxHeight: '70vh', display: 'flex', flexDirection: 'column', gap: '8px', alignSelf: 'flex-start', }} > {!selectedNode && (
{ setSearchQuery(e.target.value); setSelectedNode(null); setExplanation(''); }} onKeyDown={(e) => { void handleSearchKeyDown(e); }} placeholder="Search nodes to connect..." style={{ flex: 1, background: 'none', border: 'none', outline: 'none', color: '#f0f0f0', fontSize: '15px', fontFamily: 'inherit', }} /> esc
)} {/* Results */} {!selectedNode && suggestions.length > 0 && (
{suggestions.map((suggestion, index) => ( ))}
)} {/* Selected node — explanation step */} {selectedNode && (
Connecting to
{selectedNode.title}