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:
co-authored by
Claude Opus 4.6
parent
d6987a3dc1
commit
d723212ed3
+6
-1
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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
@@ -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 {
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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" />;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user