sync: contextual substrate UX from private repo

- Add DimensionIconsContext for app-wide dimension icon sharing
- Enhanced getNodeIcon() with favicon/YouTube/PDF detection + dimension icons
- Propagate dynamic icons to GridView, ListView, SearchModal, FocusPanel, ViewsOverlay, FolderViewOverlay
- Add RahEdge component with hover labels for map edges
- Update RahNode with dimension-colored borders and icon support
- Add MiniMap with dimension color coding to MapPane
- MCP server: add description field to rah_update_node, bump standalone to v1.4.2

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
“BeeRad”
2026-02-15 08:55:47 +11:00
co-authored by Claude Opus 4.6
parent d6987a3dc1
commit d723212ed3
17 changed files with 492 additions and 53 deletions
+6 -1
View File
@@ -1,4 +1,5 @@
import './globals.css'; import './globals.css';
import { DimensionIconsProvider } from '@/context/DimensionIconsContext';
export const metadata = { export const metadata = {
title: 'RA-H Open Source', title: 'RA-H Open Source',
@@ -12,7 +13,11 @@ export default function RootLayout({
}) { }) {
return ( return (
<html lang="en"> <html lang="en">
<body>{children}</body> <body>
<DimensionIconsProvider>
{children}
</DimensionIconsProvider>
</body>
</html> </html>
); );
} }
+1
View File
@@ -78,6 +78,7 @@ const updateNodeInputSchema = {
id: z.number().int().positive().describe('Node ID'), id: z.number().int().positive().describe('Node ID'),
updates: z.object({ updates: z.object({
title: z.string().optional().describe('New title'), title: z.string().optional().describe('New title'),
description: z.string().optional().describe('New description (overwrites existing)'),
content: z.string().optional().describe('Content to APPEND'), content: z.string().optional().describe('Content to APPEND'),
link: z.string().optional().describe('New link'), link: z.string().optional().describe('New link'),
dimensions: z.array(z.string()).optional().describe('New dimensions (replaces existing)'), dimensions: z.array(z.string()).optional().describe('New dimensions (replaces existing)'),
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ra-h-mcp-server", "name": "ra-h-mcp-server",
"version": "1.4.1", "version": "1.4.2",
"description": "Connect Claude Code/Desktop to your RA-H knowledge base. Direct SQLite access - no web app required.", "description": "Connect Claude Code/Desktop to your RA-H knowledge base. Direct SQLite access - no web app required.",
"main": "index.js", "main": "index.js",
"bin": { "bin": {
+171 -3
View File
@@ -43,9 +43,12 @@ let lastErrorMessage = null;
let logger = (message) => console.log(`[mcp] ${message}`); let logger = (message) => console.log(`[mcp] ${message}`);
const instructions = [ const instructions = [
'Use rah.add_node to summarize conversations or files into nodes with dimensions.', 'RA-H is a personal knowledge graph — local-first, vendor-neutral.',
'Use rah.search_nodes to recall prior notes before you suggest creating new ones.', 'Core concepts: nodes (knowledge units), edges (connections with explanations), dimensions (categories).',
'All operations happen locally on this device; data never leaves 127.0.0.1.' 'Always call rah_get_context first to orient yourself — it returns hub nodes, dimensions, stats, and available guides.',
'Search before creating: use rah_search_nodes to check if content already exists.',
'Every edge needs an explanation: why does this connection exist?',
'All data stays local on this device; nothing leaves 127.0.0.1.',
].join(' '); ].join(' ');
const serverInfo = { const serverInfo = {
@@ -123,6 +126,7 @@ const updateNodeInputSchema = {
id: z.number().int().positive().describe('The ID of the node to update'), id: z.number().int().positive().describe('The ID of the node to update'),
updates: z.object({ updates: z.object({
title: z.string().optional().describe('New title'), title: z.string().optional().describe('New title'),
description: z.string().optional().describe('New description (overwrites existing)'),
content: z.string().optional().describe('Content to APPEND (not replace)'), content: z.string().optional().describe('Content to APPEND (not replace)'),
link: z.string().optional().describe('New link'), link: z.string().optional().describe('New link'),
dimensions: z.array(z.string()).optional().describe('New dimensions (replaces existing)'), dimensions: z.array(z.string()).optional().describe('New dimensions (replaces existing)'),
@@ -253,6 +257,45 @@ const searchEmbeddingsOutputSchema = {
) )
}; };
// rah_extract_url schemas
const extractUrlInputSchema = {
url: z.string().url().describe('URL of the webpage to extract content from')
};
const extractUrlOutputSchema = {
success: z.boolean(),
title: z.string(),
content: z.string(),
chunk: z.string(),
metadata: z.record(z.any())
};
// rah_extract_youtube schemas
const extractYoutubeInputSchema = {
url: z.string().describe('YouTube video URL to extract transcript from')
};
const extractYoutubeOutputSchema = {
success: z.boolean(),
title: z.string(),
channel: z.string(),
transcript: z.string(),
metadata: z.record(z.any())
};
// rah_extract_pdf schemas
const extractPdfInputSchema = {
url: z.string().url().describe('URL of the PDF file to extract content from')
};
const extractPdfOutputSchema = {
success: z.boolean(),
title: z.string(),
content: z.string(),
chunk: z.string(),
metadata: z.record(z.any())
};
async function resolveBaseUrl() { async function resolveBaseUrl() {
try { try {
const value = await baseUrlResolver(); const value = await baseUrlResolver();
@@ -683,6 +726,131 @@ mcpServer.registerTool(
} }
); );
mcpServer.registerTool(
'rah_extract_url',
{
title: 'Extract URL content',
description: 'Extract content from a webpage URL. Returns title, content, and metadata for creating nodes.',
inputSchema: extractUrlInputSchema,
outputSchema: extractUrlOutputSchema
},
async ({ url }) => {
const result = await callRaHApi('/api/extract/url', {
method: 'POST',
body: JSON.stringify({ url })
});
const summary = `Extracted content from: ${result.title || 'webpage'}`;
return {
content: [{ type: 'text', text: summary }],
structuredContent: {
success: true,
title: result.title || 'Untitled',
content: result.content || '',
chunk: result.chunk || '',
metadata: result.metadata || {}
}
};
}
);
mcpServer.registerTool(
'rah_extract_youtube',
{
title: 'Extract YouTube transcript',
description: 'Extract transcript from a YouTube video. Returns title, channel, transcript, and metadata.',
inputSchema: extractYoutubeInputSchema,
outputSchema: extractYoutubeOutputSchema
},
async ({ url }) => {
const result = await callRaHApi('/api/extract/youtube', {
method: 'POST',
body: JSON.stringify({ url })
});
const summary = `Extracted transcript from: ${result.title || 'YouTube video'}`;
return {
content: [{ type: 'text', text: summary }],
structuredContent: {
success: true,
title: result.title || 'Untitled',
channel: result.channel || 'Unknown',
transcript: result.transcript || '',
metadata: result.metadata || {}
}
};
}
);
mcpServer.registerTool(
'rah_extract_pdf',
{
title: 'Extract PDF content',
description: 'Extract content from a PDF file URL. Returns title, content, and metadata for creating nodes.',
inputSchema: extractPdfInputSchema,
outputSchema: extractPdfOutputSchema
},
async ({ url }) => {
const result = await callRaHApi('/api/extract/pdf', {
method: 'POST',
body: JSON.stringify({ url })
});
const summary = `Extracted content from: ${result.title || 'PDF document'}`;
return {
content: [{ type: 'text', text: summary }],
structuredContent: {
success: true,
title: result.title || 'Untitled PDF',
content: result.content || '',
chunk: result.chunk || '',
metadata: result.metadata || {}
}
};
}
);
// rah_get_context — orientation tool for external agents
mcpServer.registerTool(
'rah_get_context',
{
title: 'Get RA-H context',
description: 'Get orientation context: hub nodes, dimensions, stats, and available guides. Call this first.',
inputSchema: {},
outputSchema: {
schema: z.object({ nodeCount: z.number(), edgeCount: z.number(), dimensionCount: z.number() }),
hubNodes: z.array(z.object({ id: z.number(), title: z.string(), description: z.string().nullable(), edgeCount: z.number() })),
dimensions: z.array(z.object({ name: z.string(), nodeCount: z.number(), description: z.string().nullable() })),
guides: z.array(z.string())
}
},
async () => {
const hubResult = await callRaHApi('/api/nodes?sortBy=edges&limit=5', { method: 'GET' });
const hubNodes = Array.isArray(hubResult.data) ? hubResult.data.map(n => ({
id: n.id, title: n.title, description: n.description ?? null, edgeCount: n.edge_count ?? 0
})) : [];
const dimResult = await callRaHApi('/api/dimensions', { method: 'GET' });
const dimensions = Array.isArray(dimResult.data) ? dimResult.data.map(d => ({
name: d.name, nodeCount: d.node_count ?? 0, description: d.description ?? null
})) : [];
const guideResult = await callRaHApi('/api/guides', { method: 'GET' });
const guides = Array.isArray(guideResult.data) ? guideResult.data.map(g => g.name) : [];
const stats = { nodeCount: 0, edgeCount: 0, dimensionCount: dimensions.length };
try {
const countResult = await callRaHApi('/api/nodes?limit=1', { method: 'GET' });
if (countResult.total !== undefined) stats.nodeCount = countResult.total;
} catch { /* use defaults */ }
return {
content: [{ type: 'text', text: `Knowledge graph: ${stats.dimensionCount} dimensions, ${hubNodes.length} hub nodes. ${guides.length} guides available.` }],
structuredContent: { schema: stats, hubNodes, dimensions, guides }
};
}
);
async function readRequestBody(req) { async function readRequestBody(req) {
if (req.method !== 'POST') return undefined; if (req.method !== 'POST') return undefined;
try { try {
+15
View File
@@ -9,6 +9,7 @@ import { parseNodeMarkers } from '@/tools/infrastructure/nodeFormatter';
import { Node, NodeConnection, Chunk } from '@/types/database'; import { Node, NodeConnection, Chunk } from '@/types/database';
import DimensionTags from './dimensions/DimensionTags'; import DimensionTags from './dimensions/DimensionTags';
import { getNodeIcon } from '@/utils/nodeIcons'; import { getNodeIcon } from '@/utils/nodeIcons';
import { useDimensionIcons } from '@/context/DimensionIconsContext';
import ConfirmDialog from '../common/ConfirmDialog'; import ConfirmDialog from '../common/ConfirmDialog';
import { SourceReader } from './source'; import { SourceReader } from './source';
@@ -44,6 +45,7 @@ interface FocusPanelProps {
} }
export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeClick, onTabClose, refreshTrigger, onOpenInOtherSlot, hideTabBar, onTextSelect, highlightedPassage }: FocusPanelProps) { export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeClick, onTabClose, refreshTrigger, onOpenInOtherSlot, hideTabBar, onTextSelect, highlightedPassage }: FocusPanelProps) {
const { dimensionIcons } = useDimensionIcons();
const [nodesData, setNodesData] = useState<Record<number, Node>>({}); const [nodesData, setNodesData] = useState<Record<number, Node>>({});
// Context menu state // Context menu state
@@ -1408,6 +1410,9 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
}}> }}>
{connection.connected_node.id} {connection.connected_node.id}
</span> </span>
<span style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
{getNodeIcon(connection.connected_node, dimensionIcons, 12)}
</span>
<span style={{ color: '#f8fafc', fontSize: '13px', fontWeight: 500 }}>{connection.connected_node.title}</span> <span style={{ color: '#f8fafc', fontSize: '13px', fontWeight: 500 }}>{connection.connected_node.title}</span>
</div> </div>
{edgeEditingId === connection.edge.id ? ( {edgeEditingId === connection.edge.id ? (
@@ -1880,6 +1885,13 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
{activeTab} {activeTab}
</span> </span>
{/* Node type icon */}
{nodesData[activeTab] && (
<span style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
{getNodeIcon(nodesData[activeTab], dimensionIcons, 18)}
</span>
)}
{editingField === 'title' ? ( {editingField === 'title' ? (
<input <input
ref={inputRef as React.RefObject<HTMLInputElement>} ref={inputRef as React.RefObject<HTMLInputElement>}
@@ -3209,6 +3221,9 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli
}}> }}>
{connection.connected_node.id} {connection.connected_node.id}
</span> </span>
<span style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
{getNodeIcon(connection.connected_node, dimensionIcons, 12)}
</span>
<span <span
onClick={() => onNodeClick?.(connection.connected_node.id)} onClick={() => onNodeClick?.(connection.connected_node.id)}
style={{ style={{
+8 -7
View File
@@ -7,6 +7,7 @@ import ConfirmDialog from '../common/ConfirmDialog';
import InputDialog from '../common/InputDialog'; import InputDialog from '../common/InputDialog';
import { getNodeIcon } from '@/utils/nodeIcons'; import { getNodeIcon } from '@/utils/nodeIcons';
import LucideIconPicker, { DynamicIcon } from '../common/LucideIconPicker'; import LucideIconPicker, { DynamicIcon } from '../common/LucideIconPicker';
import { useDimensionIcons } from '@/context/DimensionIconsContext';
import { usePersistentState } from '@/hooks/usePersistentState'; import { usePersistentState } from '@/hooks/usePersistentState';
type DimensionViewMode = 'grid' | 'list' | 'kanban'; type DimensionViewMode = 'grid' | 'list' | 'kanban';
@@ -121,8 +122,8 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
// Node priority ordering within dimensions (persisted) // Node priority ordering within dimensions (persisted)
const [dimensionOrders, setDimensionOrders] = usePersistentState<Record<string, number[]>>('ui.dimensionOrders', {}); const [dimensionOrders, setDimensionOrders] = usePersistentState<Record<string, number[]>>('ui.dimensionOrders', {});
// Dimension icons (persisted) - maps dimension name to Lucide icon name // Dimension icons from shared context
const [dimensionIcons, setDimensionIcons] = usePersistentState<Record<string, string>>('ui.dimensionIcons', {}); const { dimensionIcons, setDimensionIcons } = useDimensionIcons();
// Dimension edit modal state // Dimension edit modal state
const [editingDimensionModal, setEditingDimensionModal] = useState<DimensionSummary | null>(null); const [editingDimensionModal, setEditingDimensionModal] = useState<DimensionSummary | null>(null);
@@ -1235,7 +1236,7 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
</div> </div>
{node.link && ( {node.link && (
<span style={{ flexShrink: 0 }}> <span style={{ flexShrink: 0 }}>
{getNodeIcon(node)} {getNodeIcon(node, dimensionIcons)}
</span> </span>
)} )}
</div> </div>
@@ -1359,7 +1360,7 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
borderRadius: '8px', borderRadius: '8px',
flexShrink: 0 flexShrink: 0
}}> }}>
{getNodeIcon(node)} {getNodeIcon(node, dimensionIcons)}
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
@@ -1829,7 +1830,7 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
borderRadius: '8px', borderRadius: '8px',
flexShrink: 0 flexShrink: 0
}}> }}>
{getNodeIcon(node)} {getNodeIcon(node, dimensionIcons)}
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
@@ -2560,7 +2561,7 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
justifyContent: 'center', justifyContent: 'center',
flexShrink: 0 flexShrink: 0
}}> }}>
{getNodeIcon(node)} {getNodeIcon(node, dimensionIcons)}
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ <div style={{
@@ -2848,7 +2849,7 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o
}}> }}>
#{node.id} #{node.id}
</span> </span>
<span style={{ flexShrink: 0, marginTop: '1px' }}>{getNodeIcon(node)}</span> <span style={{ flexShrink: 0, marginTop: '1px' }}>{getNodeIcon(node, dimensionIcons)}</span>
<div style={{ <div style={{
fontSize: '12px', fontSize: '12px',
fontWeight: 500, fontWeight: 500,
+14 -2
View File
@@ -3,6 +3,8 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import Chip from '../common/Chip'; import Chip from '../common/Chip';
import { getNodeIcon } from '@/utils/nodeIcons';
import { useDimensionIcons } from '@/context/DimensionIconsContext';
interface SearchModalProps { interface SearchModalProps {
isOpen: boolean; isOpen: boolean;
@@ -15,9 +17,11 @@ interface NodeSuggestion {
id: number; id: number;
title: string; title: string;
dimensions?: string[]; dimensions?: string[];
link?: string;
} }
export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFilters }: SearchModalProps) { export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFilters }: SearchModalProps) {
const { dimensionIcons } = useDimensionIcons();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [suggestions, setSuggestions] = useState<NodeSuggestion[]>([]); const [suggestions, setSuggestions] = useState<NodeSuggestion[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
@@ -107,7 +111,8 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
const nodeSuggestions: NodeSuggestion[] = result.data.map((node: any) => ({ const nodeSuggestions: NodeSuggestion[] = result.data.map((node: any) => ({
id: node.id, id: node.id,
title: node.title, title: node.title,
dimensions: node.dimensions || [] dimensions: node.dimensions || [],
link: node.link || undefined,
})); }));
setSuggestions(nodeSuggestions); setSuggestions(nodeSuggestions);
@@ -202,7 +207,8 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
onMouseEnter={() => setSelectedIndex(index)} onMouseEnter={() => setSelectedIndex(index)}
className={`search-result-item ${index === selectedIndex ? 'selected' : ''}`} className={`search-result-item ${index === selectedIndex ? 'selected' : ''}`}
> >
<span className="result-id">#{suggestion.id}</span> <span className="result-id">{suggestion.id}</span>
<span className="result-icon">{getNodeIcon(suggestion as any, dimensionIcons, 14)}</span>
<span className="result-title">{suggestion.title}</span> <span className="result-title">{suggestion.title}</span>
{index === selectedIndex && ( {index === selectedIndex && (
<span className="result-hint"></span> <span className="result-hint"></span>
@@ -345,6 +351,12 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil
flex-shrink: 0; flex-shrink: 0;
} }
.result-icon {
display: flex;
align-items: center;
flex-shrink: 0;
}
.result-title { .result-title {
flex: 1; flex: 1;
color: #e5e5e5; color: #e5e5e5;
+18 -1
View File
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState, useCallback, type CSSProperties }
import { import {
ReactFlow, ReactFlow,
Background, Background,
MiniMap,
useNodesState, useNodesState,
useEdgesState, useEdgesState,
addEdge as rfAddEdge, addEdge as rfAddEdge,
@@ -22,8 +23,10 @@ import type { MapPaneProps } from './types';
import { ChevronDown } from 'lucide-react'; import { ChevronDown } from 'lucide-react';
import { RahNode } from './map/RahNode'; import { RahNode } from './map/RahNode';
import { RahEdge } from './map/RahEdge';
import EdgeExplanationModal from './map/EdgeExplanationModal'; import EdgeExplanationModal from './map/EdgeExplanationModal';
import { toRFNodes, toRFEdges, NODE_LIMIT, type RahNodeData } from './map/utils'; import { toRFNodes, toRFEdges, NODE_LIMIT, type RahNodeData } from './map/utils';
import { useDimensionIcons } from '@/context/DimensionIconsContext';
import './map/map-styles.css'; import './map/map-styles.css';
interface DimensionInfo { interface DimensionInfo {
@@ -34,6 +37,7 @@ interface DimensionInfo {
} }
const nodeTypes = { rahNode: RahNode }; const nodeTypes = { rahNode: RahNode };
const edgeTypes = { rahEdge: RahEdge };
// Debounce helper // Debounce helper
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -57,6 +61,7 @@ function MapPaneInner({
activeTabId, activeTabId,
}: MapPaneProps) { }: MapPaneProps) {
const reactFlowInstance = useReactFlow(); const reactFlowInstance = useReactFlow();
const { dimensionIcons } = useDimensionIcons();
// --- Data state (DB-level) --- // --- Data state (DB-level) ---
const [baseNodes, setBaseNodes] = useState<DbNode[]>([]); const [baseNodes, setBaseNodes] = useState<DbNode[]>([]);
@@ -190,6 +195,7 @@ function MapPaneInner({
selectedNodeId, selectedNodeId,
connectedNodeIds, connectedNodeIds,
rfPositionsRef.current, rfPositionsRef.current,
dimensionIcons,
); );
const nodeIdSet = new Set(newRfNodes.map(n => n.id)); const nodeIdSet = new Set(newRfNodes.map(n => n.id));
@@ -590,15 +596,26 @@ function MapPaneInner({
onNodeDragStop={onNodeDragStop} onNodeDragStop={onNodeDragStop}
onConnect={onConnect} onConnect={onConnect}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView fitView
fitViewOptions={{ padding: 0.2 }} fitViewOptions={{ padding: 0.2 }}
minZoom={0.1} minZoom={0.1}
maxZoom={3} maxZoom={3}
defaultEdgeOptions={{ type: 'default' }} defaultEdgeOptions={{ type: 'rahEdge' }}
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
colorMode="dark" colorMode="dark"
> >
<Background color="#1a1a1a" gap={40} size={1} /> <Background color="#1a1a1a" gap={40} size={1} />
<MiniMap
style={{ background: '#0a0a0a', border: '1px solid #1f1f1f', borderRadius: 6 }}
maskColor="rgba(0,0,0,0.6)"
nodeColor={(n) => {
const data = n.data as RahNodeData | undefined;
return data?.primaryDimensionColor || '#374151';
}}
pannable
zoomable
/>
</ReactFlow> </ReactFlow>
{/* Selected node info panel */} {/* Selected node info panel */}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import { memo, useState } from 'react';
import {
BaseEdge,
getStraightPath,
type EdgeProps,
} from '@xyflow/react';
interface RahEdgeData {
explanation?: string;
[key: string]: unknown;
}
function RahEdgeComponent({
id,
sourceX,
sourceY,
targetX,
targetY,
style,
data,
...rest
}: EdgeProps) {
const [hovered, setHovered] = useState(false);
const explanation = (data as RahEdgeData | undefined)?.explanation;
const [edgePath, labelX, labelY] = getStraightPath({
sourceX,
sourceY,
targetX,
targetY,
});
return (
<g
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* Invisible wider path for easier hover targeting */}
<path
d={edgePath}
fill="none"
stroke="transparent"
strokeWidth={12}
style={{ cursor: 'default' }}
/>
<BaseEdge id={id} path={edgePath} style={style} />
{hovered && explanation && (
<foreignObject
x={labelX - 80}
y={labelY - 14}
width={160}
height={28}
style={{ overflow: 'visible', pointerEvents: 'none' }}
>
<div
style={{
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: 4,
padding: '2px 8px',
fontSize: 10,
color: '#ccc',
textAlign: 'center',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: 160,
}}
>
{explanation}
</div>
</foreignObject>
)}
</g>
);
}
export const RahEdge = memo(RahEdgeComponent);
+7 -2
View File
@@ -4,11 +4,12 @@ import { memo } from 'react';
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'; import { Handle, Position, type NodeProps, type Node } from '@xyflow/react';
import type { RahNodeData } from './utils'; import type { RahNodeData } from './utils';
import { LABEL_THRESHOLD } from './utils'; import { LABEL_THRESHOLD } from './utils';
import { getNodeIcon } from '@/utils/nodeIcons';
type RahNodeType = Node<RahNodeData, 'rahNode'>; type RahNodeType = Node<RahNodeData, 'rahNode'>;
function RahNodeComponent({ data, selected }: NodeProps<RahNodeType>) { function RahNodeComponent({ data, selected }: NodeProps<RahNodeType>) {
const { label, dimensions, edgeCount, isExpanded } = data; const { label, dimensions, edgeCount, isExpanded, dbNode, dimensionIcons, primaryDimensionColor } = data;
const isTop = !isExpanded && edgeCount > 3; const isTop = !isExpanded && edgeCount > 3;
return ( return (
@@ -19,6 +20,7 @@ function RahNodeComponent({ data, selected }: NodeProps<RahNodeType>) {
isTop && 'rah-map-node--top', isTop && 'rah-map-node--top',
selected && 'rah-map-node--selected', selected && 'rah-map-node--selected',
].filter(Boolean).join(' ')} ].filter(Boolean).join(' ')}
style={primaryDimensionColor ? { borderLeftColor: primaryDimensionColor, borderLeftWidth: 3 } : undefined}
> >
<Handle <Handle
type="target" type="target"
@@ -26,7 +28,10 @@ function RahNodeComponent({ data, selected }: NodeProps<RahNodeType>) {
className="rah-map-handle" className="rah-map-handle"
/> />
<div className="rah-map-node__title"> <div className="rah-map-node__title">
{label.length > 28 ? label.slice(0, 26) + '\u2026' : label} <span className="rah-map-node__icon">
{getNodeIcon(dbNode, dimensionIcons, 14)}
</span>
{label.length > 26 ? label.slice(0, 24) + '\u2026' : label}
</div> </div>
{(isTop || isExpanded) && dimensions.length > 0 && ( {(isTop || isExpanded) && dimensions.length > 0 && (
<div className="rah-map-node__dims"> <div className="rah-map-node__dims">
+16
View File
@@ -89,6 +89,15 @@
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 6px;
}
.rah-map-node__icon {
flex-shrink: 0;
display: flex;
align-items: center;
} }
.rah-map-node__dims { .rah-map-node__dims {
@@ -130,3 +139,10 @@
.rah-map-wrapper .react-flow__node.dimmed:hover { .rah-map-wrapper .react-flow__node.dimmed:hover {
opacity: 0.6; opacity: 0.6;
} }
/* MiniMap dark theme */
.rah-map-wrapper .react-flow__minimap {
background: #0a0a0a;
border: 1px solid #1f1f1f;
border-radius: 6px;
}
+39
View File
@@ -1,12 +1,42 @@
import type { Node as DbNode, Edge as DbEdge } from '@/types/database'; import type { Node as DbNode, Edge as DbEdge } from '@/types/database';
import type { Node as RFNode, Edge as RFEdge } from '@xyflow/react'; import type { Node as RFNode, Edge as RFEdge } from '@xyflow/react';
// Fixed palette for dimension border colors (muted, dark-theme-friendly)
const DIMENSION_COLORS = [
'#22c55e', // green
'#3b82f6', // blue
'#f59e0b', // amber
'#ef4444', // red
'#8b5cf6', // violet
'#ec4899', // pink
'#06b6d4', // cyan
'#f97316', // orange
'#14b8a6', // teal
'#a855f7', // purple
];
function hashDimensionColor(dimension: string): string {
let hash = 0;
for (let i = 0; i < dimension.length; i++) {
hash = ((hash << 5) - hash + dimension.charCodeAt(i)) | 0;
}
return DIMENSION_COLORS[Math.abs(hash) % DIMENSION_COLORS.length];
}
export function getDimensionColor(dimensions: string[] | undefined): string | undefined {
if (!dimensions || dimensions.length === 0) return undefined;
// Use first dimension for border color
return hashDimensionColor(dimensions[0]);
}
export interface RahNodeData { export interface RahNodeData {
label: string; label: string;
dimensions: string[]; dimensions: string[];
edgeCount: number; edgeCount: number;
isExpanded: boolean; isExpanded: boolean;
dbNode: DbNode; dbNode: DbNode;
dimensionIcons?: Record<string, string>;
primaryDimensionColor?: string;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -83,6 +113,7 @@ export function toRFNodes(
selectedNodeId: number | null, selectedNodeId: number | null,
connectedNodeIds: Set<number>, connectedNodeIds: Set<number>,
existingPositions: Map<string, { x: number; y: number }>, existingPositions: Map<string, { x: number; y: number }>,
dimensionIcons?: Record<string, string>,
): RFNode<RahNodeData>[] { ): RFNode<RahNodeData>[] {
const sortedBase = [...baseNodes].sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0)); const sortedBase = [...baseNodes].sort((a, b) => (b.edge_count ?? 0) - (a.edge_count ?? 0));
const maxEdges = Math.max(...sortedBase.map(n => n.edge_count ?? 0), 1); const maxEdges = Math.max(...sortedBase.map(n => n.edge_count ?? 0), 1);
@@ -109,6 +140,8 @@ export function toRFNodes(
edgeCount: node.edge_count ?? 0, edgeCount: node.edge_count ?? 0,
isExpanded: false, isExpanded: false,
dbNode: node, dbNode: node,
dimensionIcons,
primaryDimensionColor: getDimensionColor(node.dimensions),
}, },
}; };
}); });
@@ -154,6 +187,8 @@ export function toRFNodes(
edgeCount: node.edge_count ?? 0, edgeCount: node.edge_count ?? 0,
isExpanded: true, isExpanded: true,
dbNode: node, dbNode: node,
dimensionIcons,
primaryDimensionColor: getDimensionColor(node.dimensions),
}, },
}); });
}); });
@@ -181,11 +216,15 @@ export function toRFEdges(
); );
const isDimmed = hasSelection && !isConnected; const isDimmed = hasSelection && !isConnected;
const explanation = typeof e.context?.explanation === 'string' ? e.context.explanation : '';
return { return {
id: String(e.id), id: String(e.id),
source: String(e.from_node_id), source: String(e.from_node_id),
target: String(e.to_node_id), target: String(e.to_node_id),
type: 'rahEdge',
animated: isConnected, animated: isConnected,
data: { explanation },
style: isConnected style: isConnected
? { stroke: '#22c55e', strokeWidth: 2.5, opacity: 1 } ? { stroke: '#22c55e', strokeWidth: 2.5, opacity: 1 }
: isDimmed : isDimmed
+4 -2
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
import { Node } from '@/types/database'; import { Node } from '@/types/database';
import { File } from 'lucide-react'; import { getNodeIcon } from '@/utils/nodeIcons';
import { useDimensionIcons } from '@/context/DimensionIconsContext';
interface GridViewProps { interface GridViewProps {
nodes: Node[]; nodes: Node[];
@@ -9,6 +10,7 @@ interface GridViewProps {
} }
export default function GridView({ nodes, onNodeClick }: GridViewProps) { export default function GridView({ nodes, onNodeClick }: GridViewProps) {
const { dimensionIcons } = useDimensionIcons();
const truncateContent = (content?: string, maxLength: number = 120) => { const truncateContent = (content?: string, maxLength: number = 120) => {
if (!content) return ''; if (!content) return '';
if (content.length <= maxLength) return content; if (content.length <= maxLength) return content;
@@ -85,7 +87,7 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) {
borderRadius: '6px', borderRadius: '6px',
flexShrink: 0 flexShrink: 0
}}> }}>
<File size={14} color="#666" /> {getNodeIcon(node, dimensionIcons, 14)}
</div> </div>
<div style={{ <div style={{
fontSize: '13px', fontSize: '13px',
+4 -2
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
import { Node } from '@/types/database'; import { Node } from '@/types/database';
import { File } from 'lucide-react'; import { getNodeIcon } from '@/utils/nodeIcons';
import { useDimensionIcons } from '@/context/DimensionIconsContext';
interface ListViewProps { interface ListViewProps {
nodes: Node[]; nodes: Node[];
@@ -9,6 +10,7 @@ interface ListViewProps {
} }
export default function ListView({ nodes, onNodeClick }: ListViewProps) { export default function ListView({ nodes, onNodeClick }: ListViewProps) {
const { dimensionIcons } = useDimensionIcons();
const formatDate = (dateString?: string) => { const formatDate = (dateString?: string) => {
if (!dateString) return ''; if (!dateString) return '';
const date = new Date(dateString); const date = new Date(dateString);
@@ -84,7 +86,7 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) {
borderRadius: '6px', borderRadius: '6px',
flexShrink: 0 flexShrink: 0
}}> }}>
<File size={16} color="#666" /> {getNodeIcon(node, dimensionIcons, 16)}
</div> </div>
{/* Content */} {/* Content */}
+3 -1
View File
@@ -5,6 +5,7 @@ import { Plus, Trash2, LayoutGrid, List, Columns3, Save, Filter, ChevronDown, X
import type { Node } from '@/types/database'; import type { Node } from '@/types/database';
import InputDialog from '../common/InputDialog'; import InputDialog from '../common/InputDialog';
import { getNodeIcon } from '@/utils/nodeIcons'; import { getNodeIcon } from '@/utils/nodeIcons';
import { useDimensionIcons } from '@/context/DimensionIconsContext';
type ViewMode = 'grid' | 'list' | 'kanban'; type ViewMode = 'grid' | 'list' | 'kanban';
@@ -39,6 +40,7 @@ interface ViewsOverlayProps {
} }
export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refreshToken = 0 }: ViewsOverlayProps) { export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refreshToken = 0 }: ViewsOverlayProps) {
const { dimensionIcons } = useDimensionIcons();
// Dimensions for filter picker // Dimensions for filter picker
const [dimensions, setDimensions] = useState<DimensionSummary[]>([]); const [dimensions, setDimensions] = useState<DimensionSummary[]>([]);
const [dimensionsLoading, setDimensionsLoading] = useState(true); const [dimensionsLoading, setDimensionsLoading] = useState(true);
@@ -401,7 +403,7 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre
// Render node card // Render node card
const renderNodeCard = (node: Node, columnId?: string, dimension?: string) => { const renderNodeCard = (node: Node, columnId?: string, dimension?: string) => {
const nodeIcon = getNodeIcon(node); const nodeIcon = getNodeIcon(node, dimensionIcons);
return ( return (
<div <div
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { createContext, useContext, type ReactNode } from 'react';
import { usePersistentState } from '@/hooks/usePersistentState';
interface DimensionIconsContextValue {
dimensionIcons: Record<string, string>;
setDimensionIcons: React.Dispatch<React.SetStateAction<Record<string, string>>>;
}
const DimensionIconsContext = createContext<DimensionIconsContextValue>({
dimensionIcons: {},
setDimensionIcons: () => {},
});
export function DimensionIconsProvider({ children }: { children: ReactNode }) {
const [dimensionIcons, setDimensionIcons] = usePersistentState<Record<string, string>>('ui.dimensionIcons', {});
return (
<DimensionIconsContext.Provider value={{ dimensionIcons, setDimensionIcons }}>
{children}
</DimensionIconsContext.Provider>
);
}
export function useDimensionIcons() {
return useContext(DimensionIconsContext);
}
+62 -16
View File
@@ -1,26 +1,28 @@
"use client"; "use client";
import { useState } from 'react'; import { useState } from 'react';
import { Video, FileText, File, Globe } from 'lucide-react'; import { Video, FileText, File, Globe, Folder } from 'lucide-react';
import { Node } from '@/types/database'; import { Node } from '@/types/database';
import { getIconByName } from '@/components/common/LucideIconPicker';
interface FaviconIconProps { interface FaviconIconProps {
domain: string; domain: string;
size?: number;
} }
const FaviconIcon = ({ domain }: FaviconIconProps) => { const FaviconIcon = ({ domain, size = 16 }: FaviconIconProps) => {
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
if (failed) { if (failed) {
return <Globe size={16} color="#94a3b8" />; return <Globe size={size} color="#94a3b8" />;
} }
return ( return (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`} src={`https://www.google.com/s2/favicons?domain=${domain}&sz=${size}`}
width={16} width={size}
height={16} height={size}
alt="" alt=""
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
onError={() => setFailed(true)} onError={() => setFailed(true)}
@@ -28,29 +30,73 @@ const FaviconIcon = ({ domain }: FaviconIconProps) => {
); );
}; };
export function getNodeIcon(node: Node): React.ReactElement { /**
// No link - show generic file icon * Resolve the icon for a node.
if (!node.link) { *
return <File size={16} color="#94a3b8" />; * Priority:
} * 1. URL-derived icon (favicon, YouTube, PDF) if node has a link
* 2. Dimension-derived icon from the node's most popular dimension that has an icon set
* 3. Fallback to generic File icon
*
* @param node - The database node
* @param dimensionIcons - Map of dimension name Lucide icon name (from DimensionIconsContext)
* @param size - Icon size in px (default 16)
*/
export function getNodeIcon(
node: Node,
dimensionIcons?: Record<string, string>,
size: number = 16,
): React.ReactElement {
// If node has a link, use URL-derived icon (primary)
if (node.link) {
const url = node.link.toLowerCase(); const url = node.link.toLowerCase();
// YouTube videos // YouTube videos
if (url.includes('youtube.com') || url.includes('youtu.be')) { if (url.includes('youtube.com') || url.includes('youtu.be')) {
return <Video size={16} color="#FF0000" />; return <Video size={size} color="#FF0000" />;
} }
// PDFs and papers // PDFs and papers
if (url.endsWith('.pdf') || node.metadata?.type === 'paper') { if (url.endsWith('.pdf') || node.metadata?.type === 'paper') {
return <FileText size={16} color="#94a3b8" />; return <FileText size={size} color="#94a3b8" />;
} }
// Website favicon with graceful fallback // Website favicon with graceful fallback
try { try {
const domain = new URL(node.link).hostname; const domain = new URL(node.link).hostname;
return <FaviconIcon domain={domain} />; return <FaviconIcon domain={domain} size={size} />;
} catch { } catch {
return <Globe size={16} color="#94a3b8" />; return <Globe size={size} color="#94a3b8" />;
} }
} }
// No link — try dimension-derived icon
if (dimensionIcons && node.dimensions?.length) {
// Find the first dimension that has an icon set
// (dimensions are already ordered, so first match wins)
for (const dim of node.dimensions) {
const iconName = dimensionIcons[dim];
if (iconName && iconName !== 'Folder') {
const IconComponent = getIconByName(iconName);
return <IconComponent size={size} color="#94a3b8" />;
}
}
}
// Fallback
return <File size={size} color="#94a3b8" />;
}
/**
* Get the dimension icon for a given dimension name.
* Returns Folder if no icon is set.
*/
export function getDimensionIcon(
dimensionName: string,
dimensionIcons: Record<string, string>,
size: number = 16,
): React.ReactElement {
const iconName = dimensionIcons[dimensionName] || 'Folder';
const IconComponent = getIconByName(iconName);
return <IconComponent size={size} color="#94a3b8" />;
}