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
351 lines
10 KiB
TypeScript
351 lines
10 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useRef } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
|
||
interface EdgeSearchModalProps {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
onEdgeCreate: (targetNodeId: number, targetNodeTitle: string) => void;
|
||
sourceNodeId: number;
|
||
}
|
||
|
||
interface NodeSuggestion {
|
||
id: number;
|
||
title: string;
|
||
dimensions?: string[];
|
||
}
|
||
|
||
export default function EdgeSearchModal({
|
||
isOpen,
|
||
onClose,
|
||
onEdgeCreate,
|
||
sourceNodeId
|
||
}: EdgeSearchModalProps) {
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [suggestions, setSuggestions] = useState<NodeSuggestion[]>([]);
|
||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
const modalRef = useRef<HTMLDivElement>(null);
|
||
const returnFocusRef = useRef<HTMLElement | null>(null);
|
||
|
||
// Store the element that triggered the modal for return focus
|
||
useEffect(() => {
|
||
if (isOpen && document.activeElement instanceof HTMLElement) {
|
||
returnFocusRef.current = document.activeElement;
|
||
}
|
||
}, [isOpen]);
|
||
|
||
// Focus trap and accessibility
|
||
useEffect(() => {
|
||
if (!isOpen) return;
|
||
|
||
// Autofocus input
|
||
inputRef.current?.focus();
|
||
|
||
// Lock body scroll
|
||
document.body.style.overflow = 'hidden';
|
||
|
||
// Handle Escape key
|
||
const handleEscape = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
onClose();
|
||
}
|
||
};
|
||
|
||
// Focus trap: keep focus within modal
|
||
const handleTab = (e: KeyboardEvent) => {
|
||
if (e.key !== 'Tab') return;
|
||
|
||
const focusableElements = modalRef.current?.querySelectorAll(
|
||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||
);
|
||
|
||
if (!focusableElements || focusableElements.length === 0) return;
|
||
|
||
const firstElement = focusableElements[0] as HTMLElement;
|
||
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
|
||
|
||
if (e.shiftKey) {
|
||
if (document.activeElement === firstElement) {
|
||
e.preventDefault();
|
||
lastElement.focus();
|
||
}
|
||
} else {
|
||
if (document.activeElement === lastElement) {
|
||
e.preventDefault();
|
||
firstElement.focus();
|
||
}
|
||
}
|
||
};
|
||
|
||
document.addEventListener('keydown', handleEscape);
|
||
document.addEventListener('keydown', handleTab);
|
||
|
||
return () => {
|
||
document.body.style.overflow = '';
|
||
document.removeEventListener('keydown', handleEscape);
|
||
document.removeEventListener('keydown', handleTab);
|
||
|
||
// Return focus to trigger element
|
||
if (returnFocusRef.current) {
|
||
returnFocusRef.current.focus();
|
||
}
|
||
};
|
||
}, [isOpen, onClose]);
|
||
|
||
// Generate suggestions based on search query
|
||
useEffect(() => {
|
||
if (!searchQuery.trim()) {
|
||
setSuggestions([]);
|
||
return;
|
||
}
|
||
|
||
const fetchSuggestions = async () => {
|
||
try {
|
||
const response = await fetch(`/api/nodes/search?q=${encodeURIComponent(searchQuery)}&limit=10`);
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
const nodeSuggestions: NodeSuggestion[] = result.data
|
||
.filter((node: any) => node.id !== sourceNodeId) // Exclude source node
|
||
.map((node: any) => ({
|
||
id: node.id,
|
||
title: node.title,
|
||
dimensions: node.dimensions || []
|
||
}));
|
||
|
||
setSuggestions(nodeSuggestions);
|
||
setSelectedIndex(0);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching suggestions:', error);
|
||
setSuggestions([]);
|
||
}
|
||
};
|
||
|
||
const timeoutId = setTimeout(fetchSuggestions, 200);
|
||
return () => clearTimeout(timeoutId);
|
||
}, [searchQuery, sourceNodeId]);
|
||
|
||
// Handle keyboard navigation
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1));
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
||
} else if (e.key === 'Enter' && suggestions[selectedIndex]) {
|
||
e.preventDefault();
|
||
handleSelectSuggestion(suggestions[selectedIndex]);
|
||
}
|
||
};
|
||
|
||
const handleSelectSuggestion = (suggestion: NodeSuggestion) => {
|
||
onEdgeCreate(suggestion.id, suggestion.title);
|
||
setSearchQuery('');
|
||
setSuggestions([]);
|
||
onClose();
|
||
};
|
||
|
||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||
if (e.target === e.currentTarget) {
|
||
onClose();
|
||
}
|
||
};
|
||
|
||
if (!isOpen) return null;
|
||
|
||
const modalContent = (
|
||
<div
|
||
style={{
|
||
position: 'fixed',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
background: 'rgba(0, 0, 0, 0.8)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
zIndex: 9999,
|
||
animation: 'fadeIn 150ms ease-out'
|
||
}}
|
||
onClick={handleBackdropClick}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="Search nodes to connect"
|
||
>
|
||
<div
|
||
ref={modalRef}
|
||
style={{
|
||
background: '#1a1a1a',
|
||
border: '1px solid #333',
|
||
borderRadius: '8px',
|
||
width: '90%',
|
||
maxWidth: '600px',
|
||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.6)',
|
||
animation: 'slideIn 150ms ease-out'
|
||
}}
|
||
>
|
||
{/* Search Input */}
|
||
<div style={{
|
||
padding: '16px',
|
||
borderBottom: suggestions.length > 0 ? '1px solid #2a2a2a' : 'none'
|
||
}}>
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '12px',
|
||
background: '#0a0a0a',
|
||
padding: '12px',
|
||
borderRadius: '6px',
|
||
border: '1px solid #333'
|
||
}}>
|
||
{/* Search Icon */}
|
||
<svg width="16" height="16" viewBox="0 0 20 20" fill="#666">
|
||
<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>
|
||
|
||
{/* Input */}
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder="Search node to connect..."
|
||
style={{
|
||
flex: 1,
|
||
background: 'none',
|
||
border: 'none',
|
||
outline: 'none',
|
||
color: '#fff',
|
||
fontSize: '14px',
|
||
fontFamily: 'inherit'
|
||
}}
|
||
/>
|
||
|
||
{/* Close Button */}
|
||
<button
|
||
onClick={onClose}
|
||
style={{
|
||
background: 'none',
|
||
border: 'none',
|
||
color: '#666',
|
||
cursor: 'pointer',
|
||
fontSize: '20px',
|
||
padding: '0 4px',
|
||
lineHeight: 1,
|
||
transition: 'color 0.2s'
|
||
}}
|
||
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
|
||
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
||
aria-label="Close search"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Suggestions */}
|
||
{suggestions.length > 0 && (
|
||
<div style={{
|
||
maxHeight: '300px',
|
||
overflowY: 'auto'
|
||
}}>
|
||
{suggestions.map((suggestion, index) => {
|
||
const primaryDimension = suggestion.dimensions && suggestion.dimensions.length > 0
|
||
? suggestion.dimensions[0]
|
||
: '';
|
||
|
||
return (
|
||
<button
|
||
key={index}
|
||
onClick={() => handleSelectSuggestion(suggestion)}
|
||
onMouseEnter={() => setSelectedIndex(index)}
|
||
style={{
|
||
width: '100%',
|
||
padding: '12px 16px',
|
||
textAlign: 'left',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '10px',
|
||
fontSize: '13px',
|
||
background: index === selectedIndex ? '#252525' : 'transparent',
|
||
border: 'none',
|
||
borderBottom: index < suggestions.length - 1 ? '1px solid #2a2a2a' : 'none',
|
||
color: '#ccc',
|
||
cursor: 'pointer',
|
||
transition: 'background 0.1s',
|
||
fontFamily: 'inherit'
|
||
}}
|
||
>
|
||
{primaryDimension && (
|
||
<span style={{
|
||
color: '#666',
|
||
fontSize: '10px',
|
||
fontWeight: 600,
|
||
minWidth: '60px',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.05em'
|
||
}}>
|
||
{primaryDimension}
|
||
</span>
|
||
)}
|
||
<span style={{
|
||
flex: 1,
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
color: '#e5e5e5'
|
||
}}>
|
||
{suggestion.title}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Keyboard Hint */}
|
||
<div style={{
|
||
padding: '12px 16px',
|
||
borderTop: '1px solid #2a2a2a',
|
||
display: 'flex',
|
||
justifyContent: 'flex-end',
|
||
gap: '16px',
|
||
fontSize: '11px',
|
||
color: '#666'
|
||
}}>
|
||
<span>↑↓ Navigate</span>
|
||
<span>↵ Select</span>
|
||
<span>Esc Close</span>
|
||
</div>
|
||
</div>
|
||
|
||
<style jsx>{`
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
|
||
@keyframes slideIn {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(-20px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
`}</style>
|
||
</div>
|
||
);
|
||
|
||
return typeof window !== 'undefined' ? createPortal(modalContent, document.body) : null;
|
||
}
|