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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user