- Redesigned welcome screen with centered Start button and CAPTURE quick add - Improved QuickAddInput with elegant mode tabs and circular submit button - Restored existing connections view in connections modal with edit/delete - Spotlight-style search modals across all search experiences - Three-panel color system and neutral design updates - Fixed textarea resize jump and node ID badge styling
2270 lines
86 KiB
TypeScript
2270 lines
86 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useRef } from 'react';
|
||
import { Eye, Trash2, Link, Loader, Database, Check } from 'lucide-react';
|
||
import { parseAndRenderContent } from '@/components/helpers/NodeLabelRenderer';
|
||
import { parseNodeMarkers } from '@/tools/infrastructure/nodeFormatter';
|
||
import { Node, NodeConnection } from '@/types/database';
|
||
import DimensionTags from './dimensions/DimensionTags';
|
||
import { getNodeIcon } from '@/utils/nodeIcons';
|
||
import ConfirmDialog from '../common/ConfirmDialog';
|
||
|
||
interface PopularDimension {
|
||
dimension: string;
|
||
count: number;
|
||
isPriority: boolean;
|
||
}
|
||
|
||
interface DimensionsResponse {
|
||
success: boolean;
|
||
data: PopularDimension[];
|
||
}
|
||
|
||
interface NodeSearchResult {
|
||
id: number;
|
||
title: string;
|
||
dimensions?: string[];
|
||
}
|
||
|
||
interface FocusPanelProps {
|
||
openTabs: number[];
|
||
activeTab: number | null;
|
||
onTabSelect: (nodeId: number) => void;
|
||
onNodeClick?: (nodeId: number) => void;
|
||
onTabClose: (nodeId: number) => void;
|
||
refreshTrigger?: number;
|
||
onReorderTabs?: (fromIndex: number, toIndex: number) => void;
|
||
}
|
||
|
||
export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeClick, onTabClose, refreshTrigger }: FocusPanelProps) {
|
||
const [nodesData, setNodesData] = useState<Record<number, Node>>({});
|
||
const [loadingNodes, setLoadingNodes] = useState<Set<number>>(new Set());
|
||
const [editingField, setEditingField] = useState<string | null>(null);
|
||
const [editingValue, setEditingValue] = useState<string>('');
|
||
const [editingNodeId, setEditingNodeId] = useState<number | null>(null);
|
||
const [savingField, setSavingField] = useState<string | null>(null);
|
||
const [embeddingNode, setEmbeddingNode] = useState<number | null>(null);
|
||
const [showReembedPrompt, setShowReembedPrompt] = useState<number | null>(null);
|
||
const [priorityDimensions, setPriorityDimensions] = useState<string[]>([]);
|
||
|
||
const activeNodeId = activeTab;
|
||
const currentNode = activeNodeId !== null ? nodesData[activeNodeId] : undefined;
|
||
|
||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(null);
|
||
|
||
// Auto-hide re-embed prompt after a few seconds
|
||
useEffect(() => {
|
||
if (showReembedPrompt !== null) {
|
||
const timeout = setTimeout(() => setShowReembedPrompt(null), 4000);
|
||
return () => clearTimeout(timeout);
|
||
}
|
||
}, [showReembedPrompt]);
|
||
|
||
// Edges state management (following same patterns as nodes)
|
||
const [edgesData, setEdgesData] = useState<{ [key: number]: NodeConnection[] }>({});
|
||
const [loadingEdges, setLoadingEdges] = useState<Set<number>>(new Set());
|
||
const [addingEdge, setAddingEdge] = useState<number | null>(null);
|
||
const [nodeSearchQuery, setNodeSearchQuery] = useState('');
|
||
const [nodeSearchSuggestions, setNodeSearchSuggestions] = useState<NodeSearchResult[]>([]);
|
||
const [selectedSearchIndex, setSelectedSearchIndex] = useState(0);
|
||
const [edgeExplanation, setEdgeExplanation] = useState('');
|
||
const [deletingEdge, setDeletingEdge] = useState<number | null>(null);
|
||
const [edgeEditingId, setEdgeEditingId] = useState<number | null>(null);
|
||
const [edgeEditingValue, setEdgeEditingValue] = useState<string>('');
|
||
const [edgeSavingId, setEdgeSavingId] = useState<number | null>(null);
|
||
const [deletingNode, setDeletingNode] = useState<number | null>(null);
|
||
const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState<number | null>(null);
|
||
|
||
// Chunk content toggle state - default to expanded (true)
|
||
const [chunkExpanded, setChunkExpanded] = useState<{ [key: number]: boolean }>({});
|
||
|
||
// Edges expand/collapse state
|
||
const [edgesExpanded, setEdgesExpanded] = useState<{ [key: number]: boolean }>({});
|
||
|
||
// Connections section collapsed state (default closed)
|
||
const [showConnectionsModal, setShowConnectionsModal] = useState(false);
|
||
|
||
// Title expanded state for click-to-expand full title
|
||
const [titleExpanded, setTitleExpanded] = useState<{ [key: number]: boolean }>({});
|
||
|
||
// Fetch priority dimensions on mount
|
||
useEffect(() => {
|
||
fetchPriorityDimensions();
|
||
}, []);
|
||
|
||
// Generate node search suggestions
|
||
useEffect(() => {
|
||
if (!nodeSearchQuery.trim() || !activeTab) {
|
||
setNodeSearchSuggestions([]);
|
||
return;
|
||
}
|
||
|
||
const fetchNodeSearchSuggestions = async () => {
|
||
try {
|
||
const response = await fetch(`/api/nodes/search?q=${encodeURIComponent(nodeSearchQuery)}&limit=10`);
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
const nodeSuggestions: NodeSearchResult[] = result.data
|
||
.filter((node: any) => node.id !== activeTab) // Exclude current node
|
||
.map((node: any) => ({
|
||
id: node.id,
|
||
title: node.title,
|
||
dimensions: node.dimensions || []
|
||
}));
|
||
|
||
setNodeSearchSuggestions(nodeSuggestions);
|
||
setSelectedSearchIndex(0);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching node search suggestions:', error);
|
||
setNodeSearchSuggestions([]);
|
||
}
|
||
};
|
||
|
||
const timeoutId = setTimeout(fetchNodeSearchSuggestions, 200);
|
||
return () => clearTimeout(timeoutId);
|
||
}, [nodeSearchQuery, activeTab]);
|
||
|
||
// Fetch node data when new tabs are opened
|
||
useEffect(() => {
|
||
openTabs.forEach((tabId) => {
|
||
if (!nodesData[tabId] && !loadingNodes.has(tabId)) {
|
||
fetchNodeData(tabId);
|
||
}
|
||
});
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [openTabs]);
|
||
|
||
useEffect(() => {
|
||
openTabs.forEach((tabId) => {
|
||
if (nodesData[tabId] && !edgesData[tabId] && !loadingEdges.has(tabId)) {
|
||
fetchEdgesData(tabId);
|
||
}
|
||
});
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [openTabs, nodesData, edgesData]);
|
||
|
||
|
||
// Refresh data when SSE events trigger updates
|
||
useEffect(() => {
|
||
if (refreshTrigger !== undefined && refreshTrigger > 0 && activeTab) {
|
||
fetchNodeData(activeTab);
|
||
fetchEdgesData(activeTab);
|
||
}
|
||
}, [refreshTrigger, activeTab]);
|
||
|
||
|
||
const fetchPriorityDimensions = async () => {
|
||
try {
|
||
const response = await fetch('/api/dimensions/popular');
|
||
const payload = (await response.json()) as DimensionsResponse;
|
||
if (payload?.success && Array.isArray(payload.data)) {
|
||
const priority = payload.data.filter((d) => d.isPriority).map((d) => d.dimension);
|
||
setPriorityDimensions(priority);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching priority dimensions:', error);
|
||
}
|
||
};
|
||
|
||
const fetchNodeData = async (id: number) => {
|
||
setLoadingNodes(prev => new Set(prev).add(id));
|
||
// First try to fetch as a node
|
||
try {
|
||
const nodeResponse = await fetch(`/api/nodes/${id}`);
|
||
if (nodeResponse.ok) {
|
||
const data = await nodeResponse.json();
|
||
if (data.node) {
|
||
setNodesData(prev => ({ ...prev, [id]: data.node }));
|
||
setLoadingNodes(prev => {
|
||
const newSet = new Set(prev);
|
||
newSet.delete(id);
|
||
return newSet;
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn(`Failed to fetch node ${id}:`, error);
|
||
}
|
||
setLoadingNodes(prev => {
|
||
const newSet = new Set(prev);
|
||
newSet.delete(id);
|
||
return newSet;
|
||
});
|
||
};
|
||
|
||
|
||
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 (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 newSet = new Set(prev);
|
||
newSet.delete(nodeId);
|
||
return newSet;
|
||
});
|
||
}
|
||
};
|
||
|
||
|
||
const truncateTitle = (title: string, maxLength: number = 20) => {
|
||
if (title.length <= maxLength) return title;
|
||
return title.substring(0, maxLength) + '...';
|
||
};
|
||
|
||
// Focus input when editing starts
|
||
useEffect(() => {
|
||
if (editingField && inputRef.current) {
|
||
inputRef.current.focus();
|
||
if (inputRef.current instanceof HTMLInputElement || inputRef.current instanceof HTMLTextAreaElement) {
|
||
inputRef.current.select();
|
||
}
|
||
}
|
||
}, [editingField]);
|
||
|
||
const startEdit = (field: string, currentValue: string) => {
|
||
if (savingField) return; // Don't start edit if currently saving
|
||
setEditingField(field);
|
||
setEditingValue(currentValue || '');
|
||
if (activeNodeId !== null) setEditingNodeId(activeNodeId);
|
||
};
|
||
|
||
const cancelEdit = () => {
|
||
setEditingField(null);
|
||
setEditingValue('');
|
||
setEditingNodeId(null);
|
||
};
|
||
|
||
const saveField = async () => {
|
||
const nodeId = editingNodeId ?? activeNodeId;
|
||
if (!nodeId || !editingField) return;
|
||
// Validate required fields
|
||
if (editingField === 'title' && !editingValue.trim()) {
|
||
alert('Title cannot be empty');
|
||
return;
|
||
}
|
||
|
||
setSavingField(editingField);
|
||
try {
|
||
const updateData: Record<string, string> = {};
|
||
|
||
if (editingField === 'content') {
|
||
updateData.content = editingValue;
|
||
} else {
|
||
updateData[editingField] = editingValue;
|
||
}
|
||
|
||
const response = await fetch(`/api/nodes/${nodeId}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(updateData),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to save');
|
||
}
|
||
|
||
const result = await response.json();
|
||
if (result.node) {
|
||
setNodesData(prev => ({ ...prev, [nodeId]: result.node }));
|
||
}
|
||
|
||
// Safety net: ensure edges exist for any tokens present in saved content
|
||
if (editingField === 'content' && typeof editingValue === 'string') {
|
||
try {
|
||
const tokens = parseNodeMarkers(editingValue);
|
||
const uniqueTargets = Array.from(new Set(tokens.map(t => t.id))).filter(id => id !== nodeId);
|
||
await Promise.allSettled(uniqueTargets.map(async (toId) => {
|
||
await fetch('/api/edges', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
from_node_id: nodeId,
|
||
to_node_id: toId,
|
||
source: 'user',
|
||
context: { explanation: 'Referenced via @ mention', created_via: 'at_mention' }
|
||
})
|
||
});
|
||
}));
|
||
// Refresh edges after ensuring
|
||
await fetchEdgesData(nodeId);
|
||
} catch (e) {
|
||
console.warn('Failed to ensure edges from tokens:', e);
|
||
}
|
||
}
|
||
|
||
setEditingField(null);
|
||
setEditingValue('');
|
||
setEditingNodeId(null);
|
||
} catch (error) {
|
||
console.error('Error saving field:', error);
|
||
alert('Failed to save changes. Please try again.');
|
||
} finally {
|
||
setSavingField(null);
|
||
}
|
||
};
|
||
|
||
// Explicit content saver to avoid stale state reads (used after @mention insert)
|
||
const saveContentExplicit = async (contentValue: string, nodeId: number) => {
|
||
if (!nodeId) return;
|
||
setSavingField('content');
|
||
try {
|
||
const response = await fetch(`/api/nodes/${nodeId}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ content: contentValue }),
|
||
});
|
||
if (!response.ok) throw new Error('Failed to save');
|
||
const result = await response.json();
|
||
if (result.node) {
|
||
setNodesData(prev => ({ ...prev, [nodeId]: result.node }));
|
||
}
|
||
// Safety net: ensure edges for tokens in this specific content
|
||
try {
|
||
const tokens = parseNodeMarkers(contentValue);
|
||
const uniqueTargets = Array.from(new Set(tokens.map(t => t.id))).filter(id => id !== nodeId);
|
||
await Promise.allSettled(uniqueTargets.map(async (toId) => {
|
||
await fetch('/api/edges', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
from_node_id: nodeId,
|
||
to_node_id: toId,
|
||
source: 'user',
|
||
context: { explanation: 'Referenced via @ mention', created_via: 'at_mention' }
|
||
})
|
||
});
|
||
}));
|
||
await fetchEdgesData(nodeId);
|
||
} catch (e) {
|
||
console.warn('Failed to ensure edges from tokens (explicit save):', e);
|
||
}
|
||
// Exit edit mode
|
||
setEditingField(null);
|
||
setEditingValue('');
|
||
} catch (e) {
|
||
console.error('Error saving content (explicit):', e);
|
||
alert('Failed to save changes. Please try again.');
|
||
} finally {
|
||
setSavingField(null);
|
||
}
|
||
};
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
saveField();
|
||
} else if (e.key === 'Escape') {
|
||
cancelEdit();
|
||
}
|
||
};
|
||
|
||
const handleBlur = () => {
|
||
// If mention is active, defer saving slightly to avoid race with selection
|
||
const attemptSave = () => {
|
||
if (editingField) {
|
||
saveField();
|
||
}
|
||
};
|
||
if (mentionActive) {
|
||
setTimeout(() => {
|
||
if (!mentionActive) attemptSave();
|
||
}, 220);
|
||
return;
|
||
}
|
||
// Small delay to allow for clicking inline buttons
|
||
setTimeout(attemptSave, 150);
|
||
};
|
||
|
||
// --- @mention state ---
|
||
const [mentionActive, setMentionActive] = useState(false);
|
||
const [mentionQuery, setMentionQuery] = useState('');
|
||
const [mentionResults, setMentionResults] = useState<NodeSearchResult[]>([]);
|
||
const [mentionIndex, setMentionIndex] = useState(0);
|
||
const mentionTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
const resetMention = () => {
|
||
setMentionActive(false);
|
||
setMentionQuery('');
|
||
setMentionResults([]);
|
||
setMentionIndex(0);
|
||
};
|
||
|
||
const findAtTrigger = (text: string, caret: number): { atIndex: number; query: string } | null => {
|
||
// Find last '@' before caret that is at start or preceded by whitespace
|
||
for (let i = caret - 1; i >= 0; i--) {
|
||
const ch = text[i];
|
||
if (ch === '@') {
|
||
const prev = i === 0 ? ' ' : text[i - 1];
|
||
if (/\s/.test(prev)) {
|
||
const span = text.slice(i + 1, caret);
|
||
// Stop if span contains disallowed characters (newline or punctuation other than - _ .)
|
||
if (/[^A-Za-z0-9 _\-.]/.test(span)) return null;
|
||
return { atIndex: i, query: span };
|
||
}
|
||
// If '@' is not preceded by whitespace, keep scanning left
|
||
}
|
||
// Stop scanning back on whitespace/newline to avoid spanning words
|
||
if (ch === '\n') break;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const runMentionSearch = async (query: string) => {
|
||
if (!activeTab) return;
|
||
if (query.trim().length < 2) {
|
||
setMentionResults([]);
|
||
return;
|
||
}
|
||
try {
|
||
const resp = await fetch(`/api/nodes/search?q=${encodeURIComponent(query)}&limit=10`);
|
||
const payload = (await resp.json()) as { success: boolean; data: NodeSearchResult[] };
|
||
if (payload?.success && Array.isArray(payload.data)) {
|
||
const filtered = payload.data
|
||
.filter((n) => n.id !== activeTab)
|
||
.map((n) => ({ ...n, dimensions: n.dimensions ?? [] }));
|
||
setMentionResults(filtered);
|
||
setMentionIndex(0);
|
||
}
|
||
} catch (error) {
|
||
console.warn('mention search failed:', error);
|
||
setMentionResults([]);
|
||
}
|
||
};
|
||
|
||
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||
const val = e.target.value;
|
||
setEditingValue(val);
|
||
|
||
// Detect @mention
|
||
const caret = e.target.selectionStart || val.length;
|
||
const trig = findAtTrigger(val, caret);
|
||
if (trig) {
|
||
setMentionActive(true);
|
||
setMentionQuery(trig.query);
|
||
if (mentionTimeout.current) clearTimeout(mentionTimeout.current);
|
||
mentionTimeout.current = setTimeout(() => runMentionSearch(trig.query), 280);
|
||
} else if (mentionActive) {
|
||
// Exit mention mode if trigger no longer valid
|
||
resetMention();
|
||
}
|
||
};
|
||
|
||
const replaceMentionWithToken = async (nodeId: number, title: string) => {
|
||
if (!inputRef.current || !(inputRef.current instanceof HTMLTextAreaElement)) return;
|
||
if (activeNodeId === null) return;
|
||
const ta = inputRef.current as HTMLTextAreaElement;
|
||
const sourceNodeId = activeNodeId!;
|
||
const text = editingValue;
|
||
const caret = ta.selectionStart || text.length;
|
||
let trig = findAtTrigger(text, caret);
|
||
if (!trig) {
|
||
// Fallback: try to locate the last occurrence of the current mention token
|
||
if (mentionQuery && mentionQuery.length > 0) {
|
||
const needle = '@' + mentionQuery;
|
||
const idx = text.lastIndexOf(needle);
|
||
if (idx !== -1) {
|
||
trig = { atIndex: idx, query: mentionQuery };
|
||
}
|
||
}
|
||
if (!trig) return;
|
||
}
|
||
// Build quote-safe token
|
||
const quoteTitleForToken = (t: string): string => {
|
||
if (t.includes('"')) {
|
||
// Wrap with single quotes; normalize inner straight single quotes
|
||
const norm = t.replace(/'/g, '’');
|
||
return `'${norm}'`;
|
||
}
|
||
// Wrap with double quotes; keep title as-is (no straight doubles inside)
|
||
return `"${t}"`;
|
||
};
|
||
const token = `[NODE:${nodeId}:${quoteTitleForToken(title)}]`;
|
||
const before = text.slice(0, trig.atIndex);
|
||
const after = text.slice(trig.atIndex + 1 + trig.query.length); // skip '@' and query
|
||
const newVal = before + token + after;
|
||
setEditingValue(newVal);
|
||
// Restore caret after token
|
||
const newCaret = (before + token).length;
|
||
requestAnimationFrame(() => {
|
||
ta.selectionStart = ta.selectionEnd = newCaret;
|
||
ta.focus();
|
||
});
|
||
|
||
// Create edge (idempotent server-side)
|
||
if (sourceNodeId) {
|
||
try {
|
||
await fetch('/api/edges', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
from_node_id: sourceNodeId,
|
||
to_node_id: nodeId,
|
||
source: 'user',
|
||
context: { explanation: 'Referenced via @ mention', created_via: 'at_mention' }
|
||
})
|
||
});
|
||
} catch (e) {
|
||
console.warn('edge create failed for mention:', e);
|
||
}
|
||
}
|
||
|
||
resetMention();
|
||
|
||
// Persist content immediately to avoid losing the token on refresh/navigation
|
||
try {
|
||
await saveContentExplicit(newVal, sourceNodeId);
|
||
} catch (e) {
|
||
console.warn('auto-save after mention failed:', e);
|
||
}
|
||
};
|
||
|
||
const embedContent = async (nodeId: number) => {
|
||
const node = nodesData[nodeId];
|
||
const hasContent = node?.content?.trim();
|
||
const hasChunk = node?.chunk?.trim();
|
||
// If chunk is empty but content exists, auto-populate chunk from content
|
||
if (!hasChunk && hasContent) {
|
||
try {
|
||
const response = await fetch(`/api/nodes/${nodeId}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ chunk: hasContent })
|
||
});
|
||
|
||
if (response.ok) {
|
||
const result = await response.json();
|
||
if (result.node) {
|
||
setNodesData(prev => ({ ...prev, [nodeId]: result.node }));
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to auto-populate chunk for embedding:', error);
|
||
return;
|
||
}
|
||
}
|
||
// If neither content nor chunk exist, require content
|
||
if (!hasContent && !hasChunk) {
|
||
startEdit('content', '');
|
||
return;
|
||
}
|
||
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');
|
||
}
|
||
|
||
// Show result details
|
||
const nodeStatus = result.node_embedding?.status;
|
||
const chunkStatus = result.chunk_embeddings?.status;
|
||
const chunksCreated = result.chunk_embeddings?.chunks_created || 0;
|
||
|
||
let message = 'Embedding complete:\n';
|
||
if (nodeStatus === 'completed') message += '✓ Node metadata embedded\n';
|
||
if (chunkStatus === 'completed') message += `✓ ${chunksCreated} chunks embedded\n`;
|
||
if (chunkStatus === 'skipped') message += '• No chunk content to embed\n';
|
||
|
||
console.log(message.trim());
|
||
|
||
// Refresh node data to get updated status
|
||
await fetchNodeData(nodeId);
|
||
|
||
} catch (error) {
|
||
console.error('Error embedding content:', error);
|
||
alert(`Failed to embed content: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||
} finally {
|
||
setEmbeddingNode(null);
|
||
}
|
||
};
|
||
|
||
|
||
// Edge management functions (following same patterns as node functions)
|
||
|
||
const handleEdgeNodeSelect = (targetNodeId: number, _targetNodeTitle?: string) => {
|
||
createEdgeWithExplanation(targetNodeId, '');
|
||
};
|
||
|
||
// Handle node search keyboard navigation
|
||
const handleNodeSearchKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
setSelectedSearchIndex(prev => Math.min(prev + 1, nodeSearchSuggestions.length - 1));
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
setSelectedSearchIndex(prev => Math.max(prev - 1, 0));
|
||
} else if (e.key === 'Enter' && nodeSearchSuggestions[selectedSearchIndex]) {
|
||
e.preventDefault();
|
||
handleSelectNodeSuggestion(nodeSearchSuggestions[selectedSearchIndex]);
|
||
}
|
||
};
|
||
|
||
const handleSelectNodeSuggestion = (suggestion: NodeSearchResult) => {
|
||
createEdgeWithExplanation(suggestion.id, '');
|
||
setNodeSearchQuery('');
|
||
setNodeSearchSuggestions([]);
|
||
};
|
||
|
||
const createEdgeWithExplanation = async (targetNodeId: number, explanation: string) => {
|
||
if (activeNodeId === null) return;
|
||
try {
|
||
const response = await fetch('/api/edges', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
from_node_id: activeNodeId,
|
||
to_node_id: targetNodeId,
|
||
source: 'user',
|
||
context: {
|
||
explanation: explanation,
|
||
created_via: 'focus_panel'
|
||
}
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to create edge');
|
||
}
|
||
|
||
// Refresh edges data
|
||
await fetchEdgesData(activeNodeId);
|
||
|
||
// Reset state
|
||
setAddingEdge(null);
|
||
setEdgeExplanation('');
|
||
|
||
} catch (error) {
|
||
console.error('Error creating edge:', error);
|
||
alert('Failed to create edge. Please try again.');
|
||
}
|
||
};
|
||
|
||
const startEditEdgeExplanation = (edgeId: number, currentExplanation: string | undefined) => {
|
||
if (edgeSavingId) return;
|
||
setEdgeEditingId(edgeId);
|
||
setEdgeEditingValue(currentExplanation || '');
|
||
};
|
||
|
||
const cancelEditEdgeExplanation = () => {
|
||
setEdgeEditingId(null);
|
||
setEdgeEditingValue('');
|
||
};
|
||
|
||
const saveEdgeExplanation = async (
|
||
edgeId: number,
|
||
currentContext: Record<string, unknown> | null | undefined
|
||
) => {
|
||
if (activeNodeId === null) return;
|
||
setEdgeSavingId(edgeId);
|
||
try {
|
||
const newContext: Record<string, unknown> = {
|
||
...(currentContext ?? {}),
|
||
explanation: edgeEditingValue
|
||
};
|
||
const response = await fetch(`/api/edges/${edgeId}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ context: newContext })
|
||
});
|
||
if (!response.ok) throw new Error('Failed to update edge');
|
||
await fetchEdgesData(activeNodeId);
|
||
setEdgeEditingId(null);
|
||
setEdgeEditingValue('');
|
||
} catch (e) {
|
||
console.error('Failed updating edge explanation:', e);
|
||
alert('Failed to update edge explanation');
|
||
} finally {
|
||
setEdgeSavingId(null);
|
||
}
|
||
};
|
||
|
||
const deleteEdge = async (edgeId: number) => {
|
||
if (activeNodeId === 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(activeNodeId);
|
||
|
||
} catch (error) {
|
||
console.error('Error deleting edge:', error);
|
||
alert('Failed to delete edge. Please try again.');
|
||
} finally {
|
||
setDeletingEdge(null);
|
||
}
|
||
};
|
||
|
||
const renderConnectionsBody = () => {
|
||
if (!activeTab) {
|
||
return <div style={{ color: '#777', fontSize: '12px' }}>Open a node to manage connections.</div>;
|
||
}
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||
{/* Search Section */}
|
||
<div style={{ marginBottom: '20px' }}>
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '16px',
|
||
background: '#141414',
|
||
padding: '16px 20px',
|
||
borderRadius: '12px',
|
||
border: '1px solid #262626',
|
||
boxShadow: '0 0 0 1px rgba(255, 255, 255, 0.02)'
|
||
}}>
|
||
<svg width="20" height="20" viewBox="0 0 20 20" fill="#525252" style={{ flexShrink: 0 }}>
|
||
<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
|
||
type="text"
|
||
value={nodeSearchQuery}
|
||
onChange={(e) => setNodeSearchQuery(e.target.value)}
|
||
onKeyDown={handleNodeSearchKeyDown}
|
||
placeholder="Connect to node..."
|
||
style={{
|
||
flex: 1,
|
||
background: 'none',
|
||
border: 'none',
|
||
outline: 'none',
|
||
color: '#fafafa',
|
||
fontSize: '16px',
|
||
fontFamily: 'inherit',
|
||
fontWeight: 400
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* Search Suggestions */}
|
||
{nodeSearchSuggestions.length > 0 && (
|
||
<div style={{
|
||
marginTop: '8px',
|
||
background: '#141414',
|
||
border: '1px solid #262626',
|
||
borderRadius: '12px',
|
||
maxHeight: '240px',
|
||
overflowY: 'auto',
|
||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.02)'
|
||
}}>
|
||
{nodeSearchSuggestions.map((suggestion, index) => (
|
||
<div
|
||
key={suggestion.id}
|
||
onClick={() => handleSelectNodeSuggestion(suggestion)}
|
||
style={{
|
||
padding: '14px 16px',
|
||
cursor: 'pointer',
|
||
borderBottom: index < nodeSearchSuggestions.length - 1 ? '1px solid #1f1f1f' : 'none',
|
||
background: index === selectedSearchIndex ? '#1a1a1a' : 'transparent',
|
||
transition: 'background 100ms ease',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '12px'
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (index !== selectedSearchIndex) {
|
||
e.currentTarget.style.background = '#1a1a1a';
|
||
}
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (index !== selectedSearchIndex) {
|
||
e.currentTarget.style.background = 'transparent';
|
||
}
|
||
}}
|
||
>
|
||
<span style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
fontSize: '10px',
|
||
fontWeight: 600,
|
||
color: '#0a0a0a',
|
||
background: '#22c55e',
|
||
padding: '4px 8px',
|
||
borderRadius: '6px',
|
||
minWidth: '28px',
|
||
textAlign: 'center',
|
||
flexShrink: 0,
|
||
fontFamily: "'SF Mono', 'Fira Code', monospace"
|
||
}}>
|
||
#{suggestion.id}
|
||
</span>
|
||
<span style={{
|
||
fontSize: '15px',
|
||
color: '#e5e5e5',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
flex: 1
|
||
}}>
|
||
{suggestion.title}
|
||
</span>
|
||
{index === selectedSearchIndex && (
|
||
<span style={{ color: '#525252', fontSize: '13px' }}>↵</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Existing Connections */}
|
||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||
<div style={{ fontSize: '12px', color: '#737373', marginBottom: '12px', letterSpacing: '0.02em' }}>
|
||
Existing Connections
|
||
</div>
|
||
{loadingEdges.has(activeTab) ? (
|
||
<div style={{ color: '#777', fontSize: '12px', fontStyle: 'italic' }}>Loading connections…</div>
|
||
) : (() => {
|
||
const list = edgesData[activeTab] || [];
|
||
if (list.length === 0) {
|
||
return <div style={{ color: '#777', fontSize: '12px', fontStyle: 'italic' }}>No connections yet. Search above to add one.</div>;
|
||
}
|
||
const visible = edgesExpanded[activeTab] ? list : list.slice(0, 5);
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||
{visible.map((connection) => (
|
||
<div key={connection.id} style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' }}>
|
||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<span style={{
|
||
display: 'inline-block',
|
||
fontSize: '10px',
|
||
fontWeight: 700,
|
||
color: '#000',
|
||
background: '#22c55e',
|
||
padding: '2px 6px',
|
||
borderRadius: '6px',
|
||
minWidth: '20px',
|
||
textAlign: 'center',
|
||
letterSpacing: '0.05em'
|
||
}}>
|
||
{connection.connected_node.id}
|
||
</span>
|
||
<span style={{ color: '#f8fafc', fontSize: '14px', fontWeight: 500 }}>{connection.connected_node.title}</span>
|
||
</div>
|
||
{edgeEditingId === connection.edge.id ? (
|
||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
||
<input
|
||
value={edgeEditingValue}
|
||
onChange={(e) => setEdgeEditingValue(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
saveEdgeExplanation(connection.edge.id, connection.edge.context);
|
||
} else if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
cancelEditEdgeExplanation();
|
||
}
|
||
}}
|
||
style={{
|
||
flex: 1,
|
||
fontSize: '11px',
|
||
color: '#ddd',
|
||
background: 'transparent',
|
||
border: '1px solid #1a1a1a',
|
||
borderRadius: '0',
|
||
padding: '4px',
|
||
outline: 'none',
|
||
fontFamily: 'inherit'
|
||
}}
|
||
placeholder="Add explanation…"
|
||
autoFocus
|
||
/>
|
||
<button
|
||
onClick={() => saveEdgeExplanation(connection.edge.id, connection.edge.context)}
|
||
disabled={edgeSavingId === connection.edge.id}
|
||
style={{
|
||
fontSize: '10px',
|
||
color: edgeSavingId === connection.edge.id ? '#555' : '#999',
|
||
background: 'transparent',
|
||
border: '1px solid #222',
|
||
borderRadius: '2px',
|
||
padding: '3px 6px',
|
||
cursor: edgeSavingId === connection.edge.id ? 'not-allowed' : 'pointer'
|
||
}}
|
||
>
|
||
{edgeSavingId === connection.edge.id ? 'saving…' : 'save'}
|
||
</button>
|
||
<button
|
||
onClick={cancelEditEdgeExplanation}
|
||
disabled={edgeSavingId === connection.edge.id}
|
||
style={{
|
||
fontSize: '10px',
|
||
color: '#666',
|
||
background: 'transparent',
|
||
border: 'none',
|
||
padding: '3px 4px',
|
||
cursor: 'pointer'
|
||
}}
|
||
>
|
||
cancel
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
{connection.edge.context?.explanation ? (
|
||
<span style={{ color: '#94a3b8', fontSize: '12px' }}>— {connection.edge.context.explanation}</span>
|
||
) : (
|
||
<span style={{ color: '#64748b', fontSize: '12px', fontStyle: 'italic' }}>No explanation</span>
|
||
)}
|
||
<button
|
||
onClick={() => startEditEdgeExplanation(connection.edge.id, connection.edge.context?.explanation)}
|
||
style={{
|
||
fontSize: '11px',
|
||
color: '#94a3b8',
|
||
background: 'transparent',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
textDecoration: 'underline'
|
||
}}
|
||
onMouseEnter={(e) => { e.currentTarget.style.color = '#cbd5f5'; }}
|
||
onMouseLeave={(e) => { e.currentTarget.style.color = '#94a3b8'; }}
|
||
>
|
||
edit
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button
|
||
onClick={() => deleteEdge(connection.edge.id)}
|
||
disabled={deletingEdge === connection.edge.id}
|
||
style={{
|
||
color: deletingEdge === connection.edge.id ? '#64748b' : '#94a3b8',
|
||
fontSize: '14px',
|
||
background: 'transparent',
|
||
border: 'none',
|
||
cursor: deletingEdge === connection.edge.id ? 'not-allowed' : 'pointer',
|
||
padding: '2px 4px',
|
||
transition: 'color 0.2s'
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (deletingEdge !== connection.edge.id) {
|
||
e.currentTarget.style.color = '#dc2626';
|
||
}
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (deletingEdge !== connection.edge.id) {
|
||
e.currentTarget.style.color = '#94a3b8';
|
||
}
|
||
}}
|
||
>
|
||
{deletingEdge === connection.edge.id ? '...' : '×'}
|
||
</button>
|
||
</div>
|
||
))}
|
||
{list.length > 5 && (
|
||
<div style={{ marginTop: '6px' }}>
|
||
<button
|
||
onClick={() => setEdgesExpanded(prev => ({ ...prev, [activeTab]: !prev[activeTab] }))}
|
||
style={{
|
||
fontSize: '9px',
|
||
color: '#666',
|
||
background: 'transparent',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
transition: 'color 0.2s'
|
||
}}
|
||
onMouseEnter={(e) => { e.currentTarget.style.color = '#999'; }}
|
||
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
||
>
|
||
{edgesExpanded[activeTab] ? 'show less' : `show ${list.length - 5} more`}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const handleConfirmNodeDelete = () => {
|
||
if (pendingDeleteNodeId === null) return;
|
||
executeDeleteNode(pendingDeleteNodeId);
|
||
setPendingDeleteNodeId(null);
|
||
};
|
||
|
||
const handleCancelNodeDelete = () => {
|
||
setPendingDeleteNodeId(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 newData = { ...prev };
|
||
delete newData[nodeId];
|
||
return newData;
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Error deleting node:', error);
|
||
alert('Failed to delete node. Please try again.');
|
||
} finally {
|
||
setDeletingNode(null);
|
||
}
|
||
};
|
||
|
||
const confirmDeleteNode = (nodeId: number) => {
|
||
setPendingDeleteNodeId(nodeId);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: 'transparent' }}>
|
||
{/* Tab Bar */}
|
||
<div style={{
|
||
display: 'flex',
|
||
borderBottom: '1px solid #1a1a1a',
|
||
background: '#0f0f0f',
|
||
flexShrink: 0,
|
||
overflowX: 'auto',
|
||
overflowY: 'hidden'
|
||
}}>
|
||
{openTabs.length === 0 ? (
|
||
<div style={{
|
||
padding: '10px 16px',
|
||
fontSize: '12px',
|
||
color: '#666'
|
||
}}>
|
||
No tabs open
|
||
</div>
|
||
) : (
|
||
openTabs.map((tabId) => {
|
||
const node = nodesData[tabId];
|
||
const isActive = activeTab === tabId;
|
||
const label = node ? truncateTitle(node.title || 'Untitled') : 'Loading...';
|
||
|
||
return (
|
||
<div
|
||
key={tabId}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
borderRight: '1px solid #1a1a1a',
|
||
background: isActive ? '#121212' : '#0f0f0f',
|
||
borderBottom: isActive ? '2px solid #666' : 'none',
|
||
paddingBottom: isActive ? '0' : '2px',
|
||
minWidth: '120px',
|
||
maxWidth: '200px'
|
||
}}
|
||
>
|
||
<button
|
||
onClick={() => onTabSelect(tabId)}
|
||
style={{
|
||
flex: 1,
|
||
padding: '8px 12px',
|
||
fontSize: '12px',
|
||
fontFamily: 'inherit',
|
||
color: isActive ? '#fff' : '#999',
|
||
background: 'transparent',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
textAlign: 'left',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap'
|
||
}}
|
||
>
|
||
{label}
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onTabClose(tabId);
|
||
}}
|
||
style={{
|
||
padding: '4px 8px',
|
||
fontSize: '14px',
|
||
color: '#666',
|
||
background: 'transparent',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
transition: 'color 0.2s'
|
||
}}
|
||
onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }}
|
||
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
|
||
{/* Content Area */}
|
||
<div style={{
|
||
flex: 1,
|
||
overflow: 'auto',
|
||
padding: '20px',
|
||
position: 'relative'
|
||
}}>
|
||
{!activeTab ? (
|
||
<div style={{
|
||
color: '#666',
|
||
fontSize: '13px',
|
||
textAlign: 'center',
|
||
marginTop: '40px'
|
||
}}>
|
||
Select a node from the left panel to view details
|
||
</div>
|
||
) : loadingNodes.has(activeTab) ? (
|
||
<div style={{
|
||
color: '#666',
|
||
fontSize: '13px'
|
||
}}>
|
||
Loading...
|
||
</div>
|
||
) : !currentNode ? (
|
||
<div style={{ color: '#666', fontSize: '13px' }}>Node not found.</div>
|
||
) : nodesData[activeTab] ? (
|
||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||
{/* Header with status, link, and delete */}
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '12px',
|
||
marginBottom: '12px',
|
||
flexWrap: 'nowrap'
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexShrink: 0 }}>
|
||
{(() => {
|
||
const node = nodesData[activeTab];
|
||
const chunkStatus = node?.chunk_status ?? null;
|
||
const hasChunk = Boolean(node?.chunk && node.chunk.trim().length > 0);
|
||
|
||
const StatusBadge = ({ color, label }: { color: string; label: string }) => (
|
||
<span style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '4px',
|
||
fontSize: '11px',
|
||
color: '#cbd5f5'
|
||
}}>
|
||
<span style={{ color }}>{'●'}</span>
|
||
{label}
|
||
</span>
|
||
);
|
||
|
||
if (embeddingNode === activeTab || chunkStatus === 'chunking') {
|
||
return (
|
||
<button
|
||
disabled
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
background: 'transparent',
|
||
border: 'none',
|
||
cursor: 'default',
|
||
padding: '4px'
|
||
}}
|
||
title="Embedding in progress..."
|
||
>
|
||
<div style={{ color: '#facc15' }}>
|
||
<Loader size={16} className="animate-spin" />
|
||
</div>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
if (chunkStatus === 'chunked') {
|
||
if (showReembedPrompt === activeTab) {
|
||
return (
|
||
<>
|
||
<StatusBadge color="#10b981" label="Embedded" />
|
||
<button
|
||
onClick={() => {
|
||
embedContent(activeTab);
|
||
setShowReembedPrompt(null);
|
||
}}
|
||
style={{
|
||
padding: '3px 8px',
|
||
fontSize: '10px',
|
||
color: '#10b981',
|
||
border: '1px solid #0f4c3a',
|
||
background: 'transparent',
|
||
borderRadius: '3px',
|
||
cursor: 'pointer',
|
||
fontFamily: 'inherit'
|
||
}}
|
||
>
|
||
Re-embed?
|
||
</button>
|
||
</>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<button
|
||
onClick={() => setShowReembedPrompt(activeTab)}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
background: 'transparent',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
padding: '4px'
|
||
}}
|
||
title="Embedded - click to re-embed"
|
||
>
|
||
<Database size={16} style={{ color: '#22c55e' }} />
|
||
</button>
|
||
);
|
||
}
|
||
|
||
if (chunkStatus === 'error') {
|
||
return (
|
||
<button
|
||
onClick={() => embedContent(activeTab)}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
background: 'transparent',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
padding: '4px'
|
||
}}
|
||
title="Embedding failed - click to retry"
|
||
>
|
||
<Database size={16} style={{ color: '#ef4444' }} />
|
||
</button>
|
||
);
|
||
}
|
||
|
||
if (hasChunk) {
|
||
return (
|
||
<button
|
||
onClick={() => embedContent(activeTab)}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
background: 'transparent',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
padding: '4px'
|
||
}}
|
||
title="Ready to embed - click to embed now"
|
||
>
|
||
<Database size={16} style={{ color: '#f97316' }} />
|
||
</button>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<button
|
||
onClick={() => embedContent(activeTab)}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
background: 'transparent',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
padding: '4px'
|
||
}}
|
||
title="Add content to embed"
|
||
>
|
||
<Database size={16} style={{ color: '#64748b' }} />
|
||
</button>
|
||
);
|
||
})()}
|
||
</div>
|
||
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
{editingField === 'link' ? (
|
||
<input
|
||
ref={inputRef as React.RefObject<HTMLInputElement>}
|
||
type="url"
|
||
value={editingValue}
|
||
onChange={(e) => setEditingValue(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
onBlur={handleBlur}
|
||
disabled={savingField === 'link'}
|
||
style={{
|
||
color: '#3b82f6',
|
||
fontSize: '11px',
|
||
background: 'transparent',
|
||
border: '1px solid #1a1a1a',
|
||
borderRadius: '6px',
|
||
padding: '4px 6px',
|
||
fontFamily: 'inherit',
|
||
width: '100%',
|
||
outline: 'none'
|
||
}}
|
||
placeholder="Enter URL..."
|
||
/>
|
||
) : nodesData[activeTab].link ? (
|
||
<a
|
||
href={nodesData[activeTab].link}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
onClick={(e) => {
|
||
if (e.metaKey || e.ctrlKey) {
|
||
e.preventDefault();
|
||
startEdit('link', nodesData[activeTab].link || '');
|
||
}
|
||
}}
|
||
style={{
|
||
color: '#3b82f6',
|
||
fontSize: '11px',
|
||
textDecoration: 'none',
|
||
whiteSpace: 'nowrap',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
display: 'block'
|
||
}}
|
||
title={`${nodesData[activeTab].link} (Cmd+Click to edit)`}
|
||
>
|
||
{nodesData[activeTab].link}
|
||
</a>
|
||
) : (
|
||
<span
|
||
onClick={() => startEdit('link', '')}
|
||
style={{
|
||
color: '#555',
|
||
fontSize: '11px',
|
||
cursor: 'pointer',
|
||
fontStyle: 'italic'
|
||
}}
|
||
>
|
||
Click to add URL
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Connections Button - Green CTA */}
|
||
<button
|
||
onClick={() => setShowConnectionsModal(true)}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '6px',
|
||
padding: '6px 10px',
|
||
fontSize: '11px',
|
||
fontWeight: 500,
|
||
color: '#22c55e',
|
||
background: 'transparent',
|
||
border: '1px solid #166534',
|
||
borderRadius: '6px',
|
||
cursor: 'pointer',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.05em',
|
||
transition: 'all 0.2s',
|
||
flexShrink: 0
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.background = '#0f2417';
|
||
e.currentTarget.style.borderColor = '#22c55e';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.background = 'transparent';
|
||
e.currentTarget.style.borderColor = '#166534';
|
||
}}
|
||
>
|
||
<Link size={14} />
|
||
{activeTab && edgesData[activeTab] && edgesData[activeTab].length > 0 && (
|
||
<span style={{
|
||
fontSize: '10px',
|
||
fontWeight: 600,
|
||
color: '#22c55e',
|
||
background: '#0f2417',
|
||
border: '1px solid #166534',
|
||
borderRadius: '8px',
|
||
padding: '1px 5px',
|
||
minWidth: '16px',
|
||
textAlign: 'center'
|
||
}}>
|
||
{edgesData[activeTab].length}
|
||
</span>
|
||
)}
|
||
</button>
|
||
|
||
{/* Delete Button */}
|
||
<button
|
||
onClick={() => confirmDeleteNode(activeTab)}
|
||
disabled={deletingNode === activeTab}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
padding: '6px',
|
||
color: deletingNode === activeTab ? '#994444' : '#525252',
|
||
background: 'transparent',
|
||
border: '1px solid #262626',
|
||
borderRadius: '6px',
|
||
cursor: deletingNode === activeTab ? 'wait' : 'pointer',
|
||
transition: 'all 0.2s',
|
||
flexShrink: 0
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (deletingNode !== activeTab) {
|
||
e.currentTarget.style.color = '#dc2626';
|
||
e.currentTarget.style.borderColor = '#dc2626';
|
||
e.currentTarget.style.background = 'rgba(220, 38, 38, 0.1)';
|
||
}
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (deletingNode !== activeTab) {
|
||
e.currentTarget.style.color = '#525252';
|
||
e.currentTarget.style.borderColor = '#262626';
|
||
e.currentTarget.style.background = 'transparent';
|
||
}
|
||
}}
|
||
title="Delete node"
|
||
>
|
||
{deletingNode === activeTab ? '...' : <Trash2 size={14} />}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Title Row with ID and Trash */}
|
||
<div style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
{/* Node ID */}
|
||
<span style={{
|
||
display: 'inline-block',
|
||
background: '#22c55e',
|
||
color: '#0a0a0a',
|
||
fontSize: '10px',
|
||
fontWeight: 600,
|
||
padding: '2px 6px',
|
||
borderRadius: '4px',
|
||
flexShrink: 0
|
||
}}>
|
||
#{activeTab}
|
||
</span>
|
||
|
||
{editingField === 'title' ? (
|
||
<input
|
||
ref={inputRef as React.RefObject<HTMLInputElement>}
|
||
type="text"
|
||
value={editingValue}
|
||
onChange={(e) => setEditingValue(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
onBlur={handleBlur}
|
||
disabled={savingField === 'title'}
|
||
style={{
|
||
fontSize: '20px',
|
||
fontWeight: 'bold',
|
||
color: '#fff',
|
||
background: 'transparent',
|
||
border: '1px solid #1a1a1a',
|
||
borderRadius: '0',
|
||
padding: '4px 8px',
|
||
fontFamily: 'inherit',
|
||
flex: 1,
|
||
outline: 'none'
|
||
}}
|
||
placeholder="Enter title..."
|
||
/>
|
||
) : (
|
||
<h1
|
||
onClick={() => {
|
||
if (titleExpanded[activeTab]) {
|
||
startEdit('title', nodesData[activeTab].title || '');
|
||
} else {
|
||
setTitleExpanded(prev => ({ ...prev, [activeTab]: true }));
|
||
setTimeout(() => {
|
||
setTitleExpanded(prev => ({ ...prev, [activeTab]: false }));
|
||
}, 3000);
|
||
}
|
||
}}
|
||
style={{
|
||
fontSize: '20px',
|
||
fontWeight: 'bold',
|
||
color: '#fff',
|
||
fontFamily: 'inherit',
|
||
cursor: 'pointer',
|
||
padding: '4px 8px',
|
||
margin: '0',
|
||
borderRadius: '0',
|
||
background: 'transparent',
|
||
border: '1px solid transparent',
|
||
transition: 'border-color 0.2s',
|
||
flex: 1,
|
||
...(titleExpanded[activeTab] ? {
|
||
whiteSpace: 'normal',
|
||
wordWrap: 'break-word'
|
||
} : {
|
||
whiteSpace: 'nowrap',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis'
|
||
})
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.borderColor = '#1a1a1a';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.borderColor = 'transparent';
|
||
}}
|
||
title={titleExpanded[activeTab] ? undefined : (nodesData[activeTab].title || 'Untitled')}
|
||
>
|
||
{nodesData[activeTab].title || 'Untitled'}
|
||
{savingField === 'title' && <span style={{ color: '#555', fontSize: '10px', marginLeft: '6px' }}>saving...</span>}
|
||
</h1>
|
||
)}
|
||
|
||
</div>
|
||
|
||
|
||
|
||
<div style={{ marginBottom: '16px' }}>
|
||
<div style={{
|
||
overflowX: 'auto',
|
||
overflowY: 'visible',
|
||
position: 'relative'
|
||
}}>
|
||
<DimensionTags
|
||
dimensions={nodesData[activeTab].dimensions || []}
|
||
priorityDimensions={priorityDimensions}
|
||
onUpdate={async (newDimensions) => {
|
||
try {
|
||
const response = await fetch(`/api/nodes/${activeTab}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ dimensions: newDimensions }),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to save');
|
||
}
|
||
|
||
const result = await response.json();
|
||
if (result.node) {
|
||
setNodesData(prev => ({ ...prev, [activeTab]: result.node }));
|
||
}
|
||
} catch (error) {
|
||
console.error('Error saving dimensions:', error);
|
||
alert('Failed to save dimensions. Please try again.');
|
||
}
|
||
}}
|
||
onPriorityToggle={async (dimension) => {
|
||
try {
|
||
const response = await fetch('/api/dimensions/popular', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ dimension }),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to toggle priority');
|
||
}
|
||
|
||
// Refresh priority dimensions list
|
||
await fetchPriorityDimensions();
|
||
} catch (error) {
|
||
console.error('Error toggling priority:', error);
|
||
alert('Failed to toggle priority. Please try again.');
|
||
}
|
||
}}
|
||
disabled={false}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
{/* Content - Full Height Scratchpad */}
|
||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||
<div style={{
|
||
fontSize: '9px',
|
||
color: '#555',
|
||
marginBottom: '4px'
|
||
}}>
|
||
content
|
||
</div>
|
||
{editingField === 'content' ? (
|
||
<div style={{ position: 'relative', flex: 1, display: 'flex' }}>
|
||
<textarea
|
||
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
|
||
value={editingValue}
|
||
onChange={handleTextareaChange}
|
||
onKeyDown={(e) => {
|
||
if (mentionActive) {
|
||
if (e.key === 'ArrowDown') { e.preventDefault(); setMentionIndex(i => Math.min(i + 1, Math.max(0, mentionResults.length - 1))); return; }
|
||
if (e.key === 'ArrowUp') { e.preventDefault(); setMentionIndex(i => Math.max(i - 1, 0)); return; }
|
||
if (e.key === 'Enter') {
|
||
if (mentionResults[mentionIndex]) {
|
||
e.preventDefault();
|
||
replaceMentionWithToken(mentionResults[mentionIndex].id, mentionResults[mentionIndex].title);
|
||
return;
|
||
}
|
||
}
|
||
if (e.key === 'Escape') { e.preventDefault(); resetMention(); return; }
|
||
}
|
||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||
e.preventDefault();
|
||
saveField();
|
||
} else if (e.key === 'Escape') {
|
||
cancelEdit();
|
||
}
|
||
}}
|
||
onBlur={handleBlur}
|
||
disabled={savingField === 'content'}
|
||
style={{
|
||
color: '#e5e5e5',
|
||
fontSize: '16px', /* Increased from 15px for better readability */
|
||
lineHeight: '1.7', /* Increased from 1.6 for more comfortable reading */
|
||
background: 'transparent',
|
||
border: 'none',
|
||
borderRadius: '0',
|
||
padding: '12px', /* Increased from 8px to 12px */
|
||
fontFamily: 'inherit',
|
||
width: '100%',
|
||
minHeight: '200px',
|
||
flex: 1,
|
||
resize: 'none',
|
||
outline: 'none',
|
||
overflow: 'auto'
|
||
}}
|
||
placeholder="Start writing..."
|
||
/>
|
||
{mentionActive && (
|
||
<div style={{ position: 'absolute', bottom: 8, left: 8, right: 'auto', zIndex: 20, background: '#1a1a1a', border: '1px solid #2a2a2a', maxHeight: 180, overflowY: 'auto', width: '60%' }}>
|
||
{mentionQuery.trim().length < 2 ? (
|
||
<div style={{ padding: 6, fontSize: 11, color: '#777' }}>Type 2+ chars to search…</div>
|
||
) : mentionResults.length === 0 ? (
|
||
<div style={{ padding: 6, fontSize: 11, color: '#777' }}>No nodes found</div>
|
||
) : (
|
||
mentionResults.map((n, idx) => (
|
||
<div key={n.id}
|
||
onMouseDown={(e) => { e.preventDefault(); replaceMentionWithToken(n.id, n.title); }}
|
||
onMouseEnter={() => setMentionIndex(idx)}
|
||
style={{ padding: '6px 8px', fontSize: 12, color: '#ddd', cursor: 'pointer', background: idx === mentionIndex ? '#252525' : 'transparent', borderBottom: '1px solid #2a2a2a' }}>
|
||
<span style={{ color: '#666', marginRight: 6 }}>#{n.id}</span>
|
||
<span>{n.title.length > 60 ? n.title.slice(0,60) + '…' : n.title}</span>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : nodesData[activeTab].content ? (
|
||
<div
|
||
onClick={() => startEdit('content', nodesData[activeTab].content || '')}
|
||
style={{
|
||
color: '#e5e5e5',
|
||
fontSize: '16px', /* Increased from 15px for better readability */
|
||
lineHeight: '1.7', /* Increased from 1.6 for more comfortable reading */
|
||
cursor: 'pointer',
|
||
padding: '12px', /* Increased from 8px to 12px */
|
||
margin: '0',
|
||
borderRadius: '0',
|
||
transition: 'all 0.2s',
|
||
background: 'transparent',
|
||
border: 'none',
|
||
whiteSpace: 'pre-wrap',
|
||
minHeight: '200px',
|
||
flex: 1,
|
||
outline: 'none'
|
||
}}
|
||
>
|
||
{parseAndRenderContent(nodesData[activeTab].content, onNodeClick || onTabSelect)}
|
||
{savingField === 'content' && <span style={{ color: '#555', fontSize: '9px', marginLeft: '6px' }}>saving...</span>}
|
||
</div>
|
||
) : (
|
||
<div
|
||
onClick={() => startEdit('content', '')}
|
||
style={{
|
||
color: '#555',
|
||
fontSize: '12px',
|
||
fontStyle: 'italic',
|
||
cursor: 'pointer',
|
||
padding: '8px',
|
||
margin: '0',
|
||
borderRadius: '0',
|
||
transition: 'all 0.2s',
|
||
minHeight: '200px',
|
||
border: '1px dashed #1a1a1a',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
background: 'transparent',
|
||
flex: 1
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.borderColor = '#222';
|
||
e.currentTarget.style.color = '#666';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.borderColor = '#1a1a1a';
|
||
e.currentTarget.style.color = '#555';
|
||
}}
|
||
>
|
||
Start writing...
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Chunk Content - Fixed at bottom */}
|
||
<div style={{
|
||
borderTop: '1px solid #1a1a1a',
|
||
paddingTop: '16px',
|
||
marginTop: '16px'
|
||
}}>
|
||
<div
|
||
onClick={() => setChunkExpanded(prev => ({ ...prev, [activeTab]: !prev[activeTab] }))}
|
||
style={{
|
||
fontSize: '10px',
|
||
color: '#555',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.05em',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '6px',
|
||
marginBottom: chunkExpanded[activeTab] ? '8px' : '0',
|
||
transition: 'color 0.2s'
|
||
}}
|
||
onMouseEnter={(e) => { e.currentTarget.style.color = '#777'; }}
|
||
onMouseLeave={(e) => { e.currentTarget.style.color = '#555'; }}
|
||
>
|
||
<span>{chunkExpanded[activeTab] ? '▼' : '▶'}</span>
|
||
chunk content
|
||
</div>
|
||
|
||
{chunkExpanded[activeTab] && (
|
||
<div>
|
||
{editingField === 'chunk' ? (
|
||
<textarea
|
||
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
|
||
value={editingValue}
|
||
onChange={(e) => setEditingValue(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||
e.preventDefault();
|
||
saveField();
|
||
} else if (e.key === 'Escape') {
|
||
cancelEdit();
|
||
}
|
||
}}
|
||
onBlur={handleBlur}
|
||
disabled={savingField === 'chunk'}
|
||
style={{
|
||
color: '#e5e5e5',
|
||
fontSize: '11px',
|
||
lineHeight: '1.4',
|
||
background: 'transparent',
|
||
border: '1px solid #1a1a1a',
|
||
borderRadius: '0',
|
||
padding: '8px',
|
||
fontFamily: 'inherit',
|
||
width: '100%',
|
||
minHeight: '80px',
|
||
resize: 'vertical',
|
||
outline: 'none'
|
||
}}
|
||
placeholder="Add chunk content..."
|
||
/>
|
||
) : nodesData[activeTab].chunk ? (
|
||
<div
|
||
onClick={() => startEdit('chunk', nodesData[activeTab].chunk || '')}
|
||
style={{
|
||
color: '#ccc',
|
||
fontSize: '11px',
|
||
lineHeight: '1.4',
|
||
cursor: 'pointer',
|
||
padding: '8px',
|
||
background: 'transparent',
|
||
border: '1px solid #1a1a1a',
|
||
borderRadius: '0',
|
||
transition: 'all 0.2s',
|
||
whiteSpace: 'pre-wrap'
|
||
}}
|
||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#2a2a2a'; }}
|
||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = '#1a1a1a'; }}
|
||
>
|
||
{nodesData[activeTab].chunk}
|
||
{savingField === 'chunk' && <span style={{ color: '#555', fontSize: '9px', marginLeft: '6px' }}>saving...</span>}
|
||
</div>
|
||
) : (
|
||
<div
|
||
onClick={() => startEdit('chunk', '')}
|
||
style={{
|
||
color: '#555',
|
||
fontSize: '11px',
|
||
fontStyle: 'italic',
|
||
cursor: 'pointer',
|
||
padding: '8px',
|
||
background: 'transparent',
|
||
border: '1px dashed #1a1a1a',
|
||
borderRadius: '0',
|
||
transition: 'all 0.2s'
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.borderColor = '#2a2a2a';
|
||
e.currentTarget.style.color = '#666';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.borderColor = '#1a1a1a';
|
||
e.currentTarget.style.color = '#555';
|
||
}}
|
||
>
|
||
Click to add chunk content...
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div style={{
|
||
color: '#666',
|
||
fontSize: '13px'
|
||
}}>
|
||
Node not found
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<ConfirmDialog
|
||
open={pendingDeleteNodeId !== null}
|
||
title="Delete this node?"
|
||
message="This will permanently remove the node and its data."
|
||
confirmLabel="Delete"
|
||
onConfirm={handleConfirmNodeDelete}
|
||
onCancel={handleCancelNodeDelete}
|
||
/>
|
||
{showConnectionsModal && activeTab && (
|
||
<div
|
||
onClick={(e) => {
|
||
if (e.target === e.currentTarget) {
|
||
setShowConnectionsModal(false);
|
||
setNodeSearchQuery('');
|
||
setNodeSearchSuggestions([]);
|
||
}
|
||
}}
|
||
style={{
|
||
position: 'fixed',
|
||
inset: 0,
|
||
background: 'rgba(0, 0, 0, 0.85)',
|
||
backdropFilter: 'blur(8px)',
|
||
display: 'flex',
|
||
justifyContent: 'center',
|
||
paddingTop: '10vh',
|
||
zIndex: 9999,
|
||
animation: 'backdropIn 200ms ease-out'
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: '100%',
|
||
maxWidth: '640px',
|
||
maxHeight: '80vh',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
animation: 'containerIn 200ms cubic-bezier(0.16, 1, 0.3, 1)'
|
||
}}
|
||
>
|
||
{/* Search Input */}
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '16px',
|
||
background: '#141414',
|
||
border: '1px solid #262626',
|
||
borderRadius: '16px',
|
||
padding: '20px 24px',
|
||
boxShadow: '0 0 0 1px rgba(255, 255, 255, 0.04), 0 24px 48px -12px rgba(0, 0, 0, 0.6)'
|
||
}}>
|
||
<svg width="22" height="22" viewBox="0 0 20 20" fill="#525252" style={{ flexShrink: 0 }}>
|
||
<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
|
||
autoFocus
|
||
type="text"
|
||
value={nodeSearchQuery}
|
||
onChange={(e) => setNodeSearchQuery(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Escape') {
|
||
setShowConnectionsModal(false);
|
||
setNodeSearchQuery('');
|
||
setNodeSearchSuggestions([]);
|
||
} else {
|
||
handleNodeSearchKeyDown(e);
|
||
}
|
||
}}
|
||
placeholder="Search to add connection..."
|
||
style={{
|
||
flex: 1,
|
||
background: 'none',
|
||
border: 'none',
|
||
outline: 'none',
|
||
color: '#fafafa',
|
||
fontSize: '18px',
|
||
fontFamily: 'inherit',
|
||
fontWeight: 400
|
||
}}
|
||
/>
|
||
<kbd style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
padding: '4px 8px',
|
||
background: '#262626',
|
||
borderRadius: '6px',
|
||
fontSize: '11px',
|
||
fontFamily: 'inherit',
|
||
color: '#737373',
|
||
border: '1px solid #333'
|
||
}}>
|
||
esc
|
||
</kbd>
|
||
</div>
|
||
|
||
{/* Search Results */}
|
||
{nodeSearchSuggestions.length > 0 && (
|
||
<div style={{
|
||
marginTop: '8px',
|
||
background: '#141414',
|
||
border: '1px solid #262626',
|
||
borderRadius: '16px',
|
||
overflow: 'hidden',
|
||
boxShadow: '0 0 0 1px rgba(255, 255, 255, 0.04), 0 24px 48px -12px rgba(0, 0, 0, 0.6)',
|
||
animation: 'resultsIn 150ms ease-out',
|
||
maxHeight: '200px',
|
||
overflowY: 'auto'
|
||
}}>
|
||
{nodeSearchSuggestions.map((suggestion, index) => (
|
||
<div
|
||
key={suggestion.id}
|
||
onClick={() => {
|
||
handleSelectNodeSuggestion(suggestion);
|
||
setNodeSearchQuery('');
|
||
setNodeSearchSuggestions([]);
|
||
}}
|
||
style={{
|
||
padding: '14px 20px',
|
||
cursor: 'pointer',
|
||
borderBottom: index < nodeSearchSuggestions.length - 1 ? '1px solid #1f1f1f' : 'none',
|
||
background: index === selectedSearchIndex ? '#1a1a1a' : 'transparent',
|
||
transition: 'background 100ms ease',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '14px'
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.background = '#1a1a1a';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (index !== selectedSearchIndex) {
|
||
e.currentTarget.style.background = 'transparent';
|
||
}
|
||
}}
|
||
>
|
||
<span style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
fontSize: '10px',
|
||
fontWeight: 600,
|
||
color: '#0a0a0a',
|
||
background: '#22c55e',
|
||
padding: '4px 8px',
|
||
borderRadius: '6px',
|
||
minWidth: '28px',
|
||
textAlign: 'center',
|
||
flexShrink: 0,
|
||
fontFamily: "'SF Mono', 'Fira Code', monospace"
|
||
}}>
|
||
#{suggestion.id}
|
||
</span>
|
||
<span style={{
|
||
fontSize: '14px',
|
||
color: '#e5e5e5',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
flex: 1
|
||
}}>
|
||
{suggestion.title}
|
||
</span>
|
||
{index === selectedSearchIndex && (
|
||
<span style={{ color: '#525252', fontSize: '13px' }}>↵</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Empty search state */}
|
||
{nodeSearchQuery && nodeSearchSuggestions.length === 0 && (
|
||
<div style={{
|
||
marginTop: '8px',
|
||
padding: '24px',
|
||
background: '#141414',
|
||
border: '1px solid #262626',
|
||
borderRadius: '16px',
|
||
color: '#525252',
|
||
fontSize: '14px',
|
||
textAlign: 'center'
|
||
}}>
|
||
No results for "{nodeSearchQuery}"
|
||
</div>
|
||
)}
|
||
|
||
{/* Existing Connections */}
|
||
{!nodeSearchQuery && (
|
||
<div style={{
|
||
marginTop: '16px',
|
||
background: '#141414',
|
||
border: '1px solid #262626',
|
||
borderRadius: '16px',
|
||
overflow: 'hidden',
|
||
boxShadow: '0 0 0 1px rgba(255, 255, 255, 0.04), 0 8px 24px -8px rgba(0, 0, 0, 0.4)',
|
||
flex: 1,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
minHeight: 0
|
||
}}>
|
||
<div style={{
|
||
padding: '16px 20px',
|
||
borderBottom: '1px solid #1f1f1f',
|
||
fontSize: '12px',
|
||
color: '#737373',
|
||
letterSpacing: '0.05em',
|
||
textTransform: 'uppercase',
|
||
fontWeight: 600
|
||
}}>
|
||
Existing Connections ({(edgesData[activeTab] || []).length})
|
||
</div>
|
||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||
{loadingEdges.has(activeTab) ? (
|
||
<div style={{ padding: '24px', color: '#666', fontSize: '13px', textAlign: 'center' }}>
|
||
Loading...
|
||
</div>
|
||
) : (edgesData[activeTab] || []).length === 0 ? (
|
||
<div style={{ padding: '32px 24px', color: '#525252', fontSize: '14px', textAlign: 'center' }}>
|
||
No connections yet. Search above to add one.
|
||
</div>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
{(edgesData[activeTab] || []).map((connection) => (
|
||
<div
|
||
key={connection.id}
|
||
style={{
|
||
padding: '14px 20px',
|
||
borderBottom: '1px solid #1f1f1f',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '8px'
|
||
}}
|
||
>
|
||
{/* Connection header row */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||
<span style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
fontSize: '10px',
|
||
fontWeight: 600,
|
||
color: '#0a0a0a',
|
||
background: '#22c55e',
|
||
padding: '4px 8px',
|
||
borderRadius: '6px',
|
||
minWidth: '28px',
|
||
textAlign: 'center',
|
||
flexShrink: 0,
|
||
fontFamily: "'SF Mono', 'Fira Code', monospace"
|
||
}}>
|
||
#{connection.connected_node.id}
|
||
</span>
|
||
<span
|
||
onClick={() => onNodeClick?.(connection.connected_node.id)}
|
||
style={{
|
||
flex: 1,
|
||
fontSize: '14px',
|
||
color: '#e5e5e5',
|
||
cursor: 'pointer',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap'
|
||
}}
|
||
onMouseEnter={(e) => { e.currentTarget.style.color = '#22c55e'; }}
|
||
onMouseLeave={(e) => { e.currentTarget.style.color = '#e5e5e5'; }}
|
||
>
|
||
{connection.connected_node.title}
|
||
</span>
|
||
<button
|
||
onClick={() => deleteEdge(connection.edge.id)}
|
||
disabled={deletingEdge === connection.edge.id}
|
||
style={{
|
||
padding: '6px',
|
||
background: 'transparent',
|
||
border: 'none',
|
||
color: '#525252',
|
||
cursor: deletingEdge === connection.edge.id ? 'not-allowed' : 'pointer',
|
||
borderRadius: '6px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
transition: 'all 0.15s ease',
|
||
opacity: deletingEdge === connection.edge.id ? 0.5 : 1
|
||
}}
|
||
onMouseEnter={(e) => { e.currentTarget.style.color = '#ef4444'; e.currentTarget.style.background = '#1f1f1f'; }}
|
||
onMouseLeave={(e) => { e.currentTarget.style.color = '#525252'; e.currentTarget.style.background = 'transparent'; }}
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
</div>
|
||
{/* Description row */}
|
||
{edgeEditingId === connection.edge.id ? (
|
||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||
<input
|
||
value={edgeEditingValue}
|
||
onChange={(e) => setEdgeEditingValue(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
saveEdgeExplanation(connection.edge.id, connection.edge.context);
|
||
} else if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
cancelEditEdgeExplanation();
|
||
}
|
||
}}
|
||
autoFocus
|
||
placeholder="Add explanation..."
|
||
style={{
|
||
flex: 1,
|
||
fontSize: '12px',
|
||
color: '#e5e5e5',
|
||
background: '#0a0a0a',
|
||
border: '1px solid #333',
|
||
borderRadius: '6px',
|
||
padding: '8px 10px',
|
||
outline: 'none',
|
||
fontFamily: 'inherit'
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={() => saveEdgeExplanation(connection.edge.id, connection.edge.context)}
|
||
style={{
|
||
padding: '6px 12px',
|
||
background: '#22c55e',
|
||
border: 'none',
|
||
borderRadius: '6px',
|
||
color: '#0a0a0a',
|
||
fontSize: '11px',
|
||
fontWeight: 600,
|
||
cursor: 'pointer'
|
||
}}
|
||
>
|
||
Save
|
||
</button>
|
||
<button
|
||
onClick={cancelEditEdgeExplanation}
|
||
style={{
|
||
padding: '6px 12px',
|
||
background: '#262626',
|
||
border: 'none',
|
||
borderRadius: '6px',
|
||
color: '#999',
|
||
fontSize: '11px',
|
||
fontWeight: 600,
|
||
cursor: 'pointer'
|
||
}}
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div
|
||
onClick={() => startEditEdgeExplanation(connection.edge.id, connection.edge.context?.explanation as string | undefined)}
|
||
style={{
|
||
fontSize: '12px',
|
||
color: connection.edge.context?.explanation ? '#888' : '#525252',
|
||
cursor: 'pointer',
|
||
padding: '4px 0',
|
||
fontStyle: connection.edge.context?.explanation ? 'normal' : 'italic'
|
||
}}
|
||
onMouseEnter={(e) => { e.currentTarget.style.color = '#aaa'; }}
|
||
onMouseLeave={(e) => { e.currentTarget.style.color = connection.edge.context?.explanation ? '#888' : '#525252'; }}
|
||
>
|
||
{(connection.edge.context?.explanation as string) || 'Click to add explanation...'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<style jsx>{`
|
||
@keyframes backdropIn {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
@keyframes containerIn {
|
||
from {
|
||
opacity: 0;
|
||
transform: scale(0.96) translateY(-8px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: scale(1) translateY(0);
|
||
}
|
||
}
|
||
@keyframes resultsIn {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(-4px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
`}</style>
|
||
</div>
|
||
)}
|
||
|
||
</>
|
||
);
|
||
}
|