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:
“BeeRad”
2025-12-15 16:14:28 +11:00
commit 733d1c3407
226 changed files with 46231 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,376 @@
"use client";
import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
interface DimensionSearchModalProps {
isOpen: boolean;
onClose: () => void;
onDimensionSelect: (dimension: string) => void;
existingDimensions: string[];
}
interface DimensionSuggestion {
dimension: string;
count: number;
isPriority: boolean;
}
export default function DimensionSearchModal({
isOpen,
onClose,
onDimensionSelect,
existingDimensions
}: DimensionSearchModalProps) {
const [searchQuery, setSearchQuery] = useState('');
const [suggestions, setSuggestions] = useState<DimensionSuggestion[]>([]);
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]);
// Fetch dimension suggestions
useEffect(() => {
const fetchSuggestions = async () => {
try {
const response = await fetch('/api/dimensions/popular?limit=50');
const result = await response.json();
if (result.success) {
const allDimensions: DimensionSuggestion[] = result.data;
// Filter based on search query and exclude existing dimensions
const filtered = allDimensions.filter(dim => {
const matchesQuery = !searchQuery.trim() ||
dim.dimension.toLowerCase().includes(searchQuery.toLowerCase());
const notExisting = !existingDimensions.includes(dim.dimension);
return matchesQuery && notExisting;
});
// Sort: priority first, then by count
const sorted = filtered.sort((a, b) => {
if (a.isPriority && !b.isPriority) return -1;
if (!a.isPriority && b.isPriority) return 1;
return b.count - a.count;
});
setSuggestions(sorted.slice(0, 20));
setSelectedIndex(0);
}
} catch (error) {
console.error('Error fetching dimension suggestions:', error);
setSuggestions([]);
}
};
if (isOpen) {
const timeoutId = setTimeout(fetchSuggestions, 100);
return () => clearTimeout(timeoutId);
}
}, [searchQuery, existingDimensions, isOpen]);
// 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') {
e.preventDefault();
if (suggestions[selectedIndex]) {
// Select existing dimension
handleSelectDimension(suggestions[selectedIndex].dimension);
} else if (searchQuery.trim()) {
// Create new dimension
handleSelectDimension(searchQuery.trim());
}
}
};
const handleSelectDimension = (dimension: string) => {
onDimensionSelect(dimension);
setSearchQuery('');
setSuggestions([]);
onClose();
};
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
if (!isOpen) return null;
const canCreateNew = searchQuery.trim() &&
!suggestions.some(s => s.dimension.toLowerCase() === searchQuery.toLowerCase());
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 dimensions"
>
<div
ref={modalRef}
style={{
background: '#050505',
border: '1px solid #1f1f1f',
borderRadius: '12px',
width: '90%',
maxWidth: '500px',
boxShadow: '0 25px 65px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.05)',
animation: 'slideIn 150ms ease-out'
}}
>
{/* Search Input */}
<div style={{
padding: '16px',
borderBottom: (suggestions.length > 0 || canCreateNew) ? '1px solid #1f1f1f' : 'none'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
background: '#0a0a0a',
padding: '12px',
borderRadius: '6px',
border: '1px solid #1f1f1f'
}}>
{/* 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 or create dimension..."
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) => (
<button
key={suggestion.dimension}
onClick={() => handleSelectDimension(suggestion.dimension)}
onMouseEnter={() => setSelectedIndex(index)}
style={{
width: '100%',
padding: '12px 16px',
textAlign: 'left',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontSize: '13px',
background: index === selectedIndex ? '#252525' : 'transparent',
border: 'none',
borderBottom: index < suggestions.length - 1 ? '1px solid #1f1f1f' : 'none',
color: '#e5e5e5',
cursor: 'pointer',
transition: 'background 0.1s',
fontFamily: 'inherit'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{
color: suggestion.isPriority ? '#22c55e' : '#e5e5e5'
}}>
{suggestion.dimension}
</span>
</div>
<span style={{ color: '#666', fontSize: '11px' }}>
{suggestion.count}
</span>
</button>
))}
</div>
)}
{/* Create New Option */}
{canCreateNew && (
<button
onClick={() => handleSelectDimension(searchQuery.trim())}
style={{
width: '100%',
padding: '12px 16px',
textAlign: 'left',
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '13px',
background: selectedIndex === suggestions.length ? '#252525' : 'transparent',
border: 'none',
borderTop: suggestions.length > 0 ? '1px solid #1f1f1f' : 'none',
color: '#22c55e',
cursor: 'pointer',
transition: 'background 0.1s',
fontFamily: 'inherit'
}}
onMouseEnter={() => setSelectedIndex(suggestions.length)}
>
<span style={{ fontSize: '16px', fontWeight: 300 }}>+</span>
Create "{searchQuery.trim()}"
</button>
)}
{/* Keyboard Hint */}
<div style={{
padding: '12px 16px',
borderTop: '1px solid #1f1f1f',
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;
}
@@ -0,0 +1,397 @@
"use client";
import { useState, useEffect, useRef } from 'react';
import DimensionSearchModal from './DimensionSearchModal';
interface DimensionTagsProps {
dimensions: string[];
priorityDimensions?: string[];
onUpdate: (dimensions: string[]) => Promise<void>;
onPriorityToggle?: (dimension: string) => Promise<void>;
disabled?: boolean;
}
interface DimensionSuggestion {
dimension: string;
count: number;
}
export default function DimensionTags({
dimensions,
priorityDimensions = [],
onUpdate,
onPriorityToggle,
disabled = false
}: DimensionTagsProps) {
const [isAdding, setIsAdding] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [suggestions, setSuggestions] = useState<DimensionSuggestion[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Sort dimensions with priority ones first
const sortedDimensions = [...dimensions].sort((a, b) => {
const aPriority = priorityDimensions.includes(a);
const bPriority = priorityDimensions.includes(b);
if (aPriority && !bPriority) return -1;
if (!aPriority && bPriority) return 1;
return 0;
});
useEffect(() => {
if (isAdding && inputRef.current) {
inputRef.current.focus();
}
}, [isAdding]);
useEffect(() => {
if (searchQuery.length > 0) {
fetchSuggestions(searchQuery);
} else if (isAdding) {
// Load popular dimensions when field is empty
fetchPopularDimensions();
} else {
setSuggestions([]);
}
}, [searchQuery, isAdding]);
const fetchSuggestions = async (query: string) => {
setIsLoading(true);
try {
const response = await fetch(`/api/dimensions/search?q=${encodeURIComponent(query)}`);
const data = await response.json();
if (data.success) {
setSuggestions(data.data);
}
} catch (error) {
console.error('Error fetching dimension suggestions:', error);
setSuggestions([]);
} finally {
setIsLoading(false);
}
};
const fetchPopularDimensions = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/dimensions/popular');
const data = await response.json();
if (data.success) {
setSuggestions(data.data);
}
} catch (error) {
console.error('Error fetching popular dimensions:', error);
setSuggestions([]);
} finally {
setIsLoading(false);
}
};
const addDimension = async (dimension: string) => {
if (!dimension.trim() || dimensions.includes(dimension.trim())) {
return;
}
const newDimensions = [...dimensions, dimension.trim()];
await onUpdate(newDimensions);
setSearchQuery('');
setSuggestions([]);
setIsAdding(false);
};
const removeDimension = async (index: number) => {
const dimension = sortedDimensions[index];
const newDimensions = dimensions.filter(d => d !== dimension);
await onUpdate(newDimensions);
};
const handleDragStart = (index: number) => {
setDraggedIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === index) return;
const draggedDimension = sortedDimensions[draggedIndex];
const targetDimension = sortedDimensions[index];
// Find original positions in unsorted array
const draggedOrigIndex = dimensions.indexOf(draggedDimension);
const targetOrigIndex = dimensions.indexOf(targetDimension);
const newDimensions = [...dimensions];
newDimensions.splice(draggedOrigIndex, 1);
newDimensions.splice(targetOrigIndex, 0, draggedDimension);
onUpdate(newDimensions);
setDraggedIndex(index);
};
const handleDragEnd = () => {
setDraggedIndex(null);
};
const moveDimension = async (fromIndex: number, direction: 'up' | 'down') => {
const toIndex = direction === 'up' ? fromIndex - 1 : fromIndex + 1;
if (toIndex < 0 || toIndex >= sortedDimensions.length) return;
const fromDimension = sortedDimensions[fromIndex];
const toDimension = sortedDimensions[toIndex];
// Find original positions in unsorted array
const fromOrigIndex = dimensions.indexOf(fromDimension);
const toOrigIndex = dimensions.indexOf(toDimension);
const newDimensions = [...dimensions];
[newDimensions[fromOrigIndex], newDimensions[toOrigIndex]] = [newDimensions[toOrigIndex], newDimensions[fromOrigIndex]];
await onUpdate(newDimensions);
};
const togglePriority = async (dimension: string) => {
if (onPriorityToggle) {
await onPriorityToggle(dimension);
}
};
// Check if dimensions overflow 2 lines (approximate)
const shouldShowExpandButton = sortedDimensions.length > 6; // Rough estimate for 2 lines
const displayedDimensions = (!isExpanded && shouldShowExpandButton)
? sortedDimensions.slice(0, 6)
: sortedDimensions;
const hiddenCount = sortedDimensions.length - 6;
return (
<div>
<div
ref={containerRef}
onClick={() => {
if (shouldShowExpandButton && !isExpanded) {
setIsExpanded(true);
}
}}
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
marginBottom: '8px',
cursor: (shouldShowExpandButton && !isExpanded) ? 'pointer' : 'default',
position: 'relative',
minHeight: dimensions.length === 0 ? '24px' : 'auto'
}}
>
{/* Show placeholder when no dimensions */}
{dimensions.length === 0 && !disabled && (
<span style={{
fontSize: '11px',
color: '#555',
fontStyle: 'italic',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}>
No dimensions
</span>
)}
{displayedDimensions.map((dimension, index) => {
const isPriority = priorityDimensions.includes(dimension);
return (
<div
key={`${dimension}-${index}`}
draggable={!disabled}
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
onClick={(e) => {
e.stopPropagation();
if (!disabled && onPriorityToggle) {
togglePriority(dimension);
}
}}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '3px',
fontSize: '10px',
color: isPriority ? '#22c55e' : '#d1d5db', /* Changed from gold to green */
background: isPriority ? '#0f2417' : '#1a1a1a', /* Green-tinted background */
border: isPriority ? '1px solid #166534' : '1px solid #333', /* Green border */
borderRadius: '8px',
padding: '2px 6px',
cursor: disabled ? 'default' : (onPriorityToggle ? 'pointer' : 'grab'),
opacity: draggedIndex === index ? 0.5 : 1,
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
if (!disabled) {
e.currentTarget.style.borderColor = isPriority ? '#22c55e' : '#555'; /* Green hover */
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = isPriority ? '#166534' : '#333'; /* Green default */
}}
title={isPriority ? 'Priority dimension (click to unpin)' : 'Click to pin as priority dimension'}
>
<span>{dimension}</span>
{/* Reorder buttons removed - no longer needed */}
{/* Remove button */}
{!disabled && (
<button
onClick={(e) => {
e.stopPropagation();
removeDimension(index);
}}
style={{
padding: '0 2px',
fontSize: '14px',
color: '#666',
background: 'transparent',
border: 'none',
cursor: 'pointer',
marginLeft: '2px'
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#ff6b6b'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
>
×
</button>
)}
</div>
);
})}
{/* Show "+X more" indicator */}
{shouldShowExpandButton && !isExpanded && hiddenCount > 0 && (
<div
onClick={(e) => {
e.stopPropagation();
setIsExpanded(true);
}}
style={{
display: 'inline-flex',
alignItems: 'center',
fontSize: '11px',
color: '#666',
background: 'transparent',
border: '1px dashed #333',
borderRadius: '12px',
padding: '2px 8px',
cursor: 'pointer',
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#999';
e.currentTarget.style.borderColor = '#444';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = '#666';
e.currentTarget.style.borderColor = '#333';
}}
>
+{hiddenCount} more
</div>
)}
{/* Collapse button when expanded */}
{isExpanded && shouldShowExpandButton && (
<div
onClick={(e) => {
e.stopPropagation();
setIsExpanded(false);
}}
style={{
display: 'inline-flex',
alignItems: 'center',
fontSize: '11px',
color: '#666',
background: 'transparent',
border: '1px dashed #333',
borderRadius: '12px',
padding: '2px 8px',
cursor: 'pointer',
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#999';
e.currentTarget.style.borderColor = '#444';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = '#666';
e.currentTarget.style.borderColor = '#333';
}}
>
show less
</div>
)}
{/* Add dimension button - Opens modal */}
{!disabled && (
<button
onClick={(e) => {
e.stopPropagation();
setIsAdding(true);
}}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
fontSize: '11px',
fontWeight: 600,
color: '#22c55e',
textTransform: 'uppercase',
letterSpacing: '0.1em',
borderBottom: '1px solid #1a1a1a',
background: '#0a0a0a',
border: 'none',
cursor: 'pointer',
padding: '8px 12px',
borderRadius: '8px',
transition: 'all 0.2s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#151515';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#0a0a0a';
}}
title="Add dimension"
>
<span style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '16px',
height: '16px',
borderRadius: '50%',
background: '#22c55e',
color: '#0a0a0a',
fontSize: '12px',
lineHeight: 1,
fontWeight: 300,
flexShrink: 0
}}>+</span>
Add
</button>
)}
</div>
{/* Dimension Search Modal */}
<DimensionSearchModal
isOpen={isAdding}
onClose={() => setIsAdding(false)}
onDimensionSelect={(dim) => {
addDimension(dim);
}}
existingDimensions={dimensions}
/>
</div>
);
}
@@ -0,0 +1,350 @@
"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;
}