From d723212ed34049118f61da2250f15ef7d928a32d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CBeeRad=E2=80=9D?= Date: Sun, 15 Feb 2026 08:55:47 +1100 Subject: [PATCH] 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 --- app/layout.tsx | 7 +- apps/mcp-server-standalone/index.js | 1 + apps/mcp-server-standalone/package.json | 2 +- apps/mcp-server/server.js | 174 ++++++++++++++++++++- src/components/focus/FocusPanel.tsx | 15 ++ src/components/nodes/FolderViewOverlay.tsx | 15 +- src/components/nodes/SearchModal.tsx | 16 +- src/components/panes/MapPane.tsx | 19 ++- src/components/panes/map/RahEdge.tsx | 80 ++++++++++ src/components/panes/map/RahNode.tsx | 9 +- src/components/panes/map/map-styles.css | 16 ++ src/components/panes/map/utils.ts | 39 +++++ src/components/views/GridView.tsx | 6 +- src/components/views/ListView.tsx | 6 +- src/components/views/ViewsOverlay.tsx | 4 +- src/context/DimensionIconsContext.tsx | 28 ++++ src/utils/nodeIcons.tsx | 108 +++++++++---- 17 files changed, 492 insertions(+), 53 deletions(-) create mode 100644 src/components/panes/map/RahEdge.tsx create mode 100644 src/context/DimensionIconsContext.tsx diff --git a/app/layout.tsx b/app/layout.tsx index f70c2fb..b0b3e38 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,5 @@ import './globals.css'; +import { DimensionIconsProvider } from '@/context/DimensionIconsContext'; export const metadata = { title: 'RA-H Open Source', @@ -12,7 +13,11 @@ export default function RootLayout({ }) { return ( - {children} + + + {children} + + ); } diff --git a/apps/mcp-server-standalone/index.js b/apps/mcp-server-standalone/index.js index 89cfff4..c42e54e 100755 --- a/apps/mcp-server-standalone/index.js +++ b/apps/mcp-server-standalone/index.js @@ -78,6 +78,7 @@ const updateNodeInputSchema = { id: z.number().int().positive().describe('Node ID'), updates: z.object({ title: z.string().optional().describe('New title'), + description: z.string().optional().describe('New description (overwrites existing)'), content: z.string().optional().describe('Content to APPEND'), link: z.string().optional().describe('New link'), dimensions: z.array(z.string()).optional().describe('New dimensions (replaces existing)'), diff --git a/apps/mcp-server-standalone/package.json b/apps/mcp-server-standalone/package.json index 62fce37..591373c 100644 --- a/apps/mcp-server-standalone/package.json +++ b/apps/mcp-server-standalone/package.json @@ -1,6 +1,6 @@ { "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.", "main": "index.js", "bin": { diff --git a/apps/mcp-server/server.js b/apps/mcp-server/server.js index 590ec79..c1622eb 100644 --- a/apps/mcp-server/server.js +++ b/apps/mcp-server/server.js @@ -43,9 +43,12 @@ let lastErrorMessage = null; let logger = (message) => console.log(`[mcp] ${message}`); const instructions = [ - 'Use rah.add_node to summarize conversations or files into nodes with dimensions.', - 'Use rah.search_nodes to recall prior notes before you suggest creating new ones.', - 'All operations happen locally on this device; data never leaves 127.0.0.1.' + 'RA-H is a personal knowledge graph — local-first, vendor-neutral.', + 'Core concepts: nodes (knowledge units), edges (connections with explanations), dimensions (categories).', + '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(' '); const serverInfo = { @@ -123,6 +126,7 @@ const updateNodeInputSchema = { id: z.number().int().positive().describe('The ID of the node to update'), updates: z.object({ 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)'), link: z.string().optional().describe('New link'), 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() { try { 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) { if (req.method !== 'POST') return undefined; try { diff --git a/src/components/focus/FocusPanel.tsx b/src/components/focus/FocusPanel.tsx index f383841..c93fb23 100644 --- a/src/components/focus/FocusPanel.tsx +++ b/src/components/focus/FocusPanel.tsx @@ -9,6 +9,7 @@ import { parseNodeMarkers } from '@/tools/infrastructure/nodeFormatter'; import { Node, NodeConnection, Chunk } from '@/types/database'; import DimensionTags from './dimensions/DimensionTags'; import { getNodeIcon } from '@/utils/nodeIcons'; +import { useDimensionIcons } from '@/context/DimensionIconsContext'; import ConfirmDialog from '../common/ConfirmDialog'; 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) { + const { dimensionIcons } = useDimensionIcons(); const [nodesData, setNodesData] = useState>({}); // Context menu state @@ -1408,6 +1410,9 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli }}> {connection.connected_node.id} + + {getNodeIcon(connection.connected_node, dimensionIcons, 12)} + {connection.connected_node.title} {edgeEditingId === connection.edge.id ? ( @@ -1880,6 +1885,13 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli {activeTab} + {/* Node type icon */} + {nodesData[activeTab] && ( + + {getNodeIcon(nodesData[activeTab], dimensionIcons, 18)} + + )} + {editingField === 'title' ? ( } @@ -3209,6 +3221,9 @@ export default function FocusPanel({ openTabs, activeTab, onTabSelect, onNodeCli }}> {connection.connected_node.id} + + {getNodeIcon(connection.connected_node, dimensionIcons, 12)} + onNodeClick?.(connection.connected_node.id)} style={{ diff --git a/src/components/nodes/FolderViewOverlay.tsx b/src/components/nodes/FolderViewOverlay.tsx index ec9adf1..0514091 100644 --- a/src/components/nodes/FolderViewOverlay.tsx +++ b/src/components/nodes/FolderViewOverlay.tsx @@ -7,6 +7,7 @@ import ConfirmDialog from '../common/ConfirmDialog'; import InputDialog from '../common/InputDialog'; import { getNodeIcon } from '@/utils/nodeIcons'; import LucideIconPicker, { DynamicIcon } from '../common/LucideIconPicker'; +import { useDimensionIcons } from '@/context/DimensionIconsContext'; import { usePersistentState } from '@/hooks/usePersistentState'; type DimensionViewMode = 'grid' | 'list' | 'kanban'; @@ -121,8 +122,8 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o // Node priority ordering within dimensions (persisted) const [dimensionOrders, setDimensionOrders] = usePersistentState>('ui.dimensionOrders', {}); - // Dimension icons (persisted) - maps dimension name to Lucide icon name - const [dimensionIcons, setDimensionIcons] = usePersistentState>('ui.dimensionIcons', {}); + // Dimension icons from shared context + const { dimensionIcons, setDimensionIcons } = useDimensionIcons(); // Dimension edit modal state const [editingDimensionModal, setEditingDimensionModal] = useState(null); @@ -1235,7 +1236,7 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o {node.link && ( - {getNodeIcon(node)} + {getNodeIcon(node, dimensionIcons)} )} @@ -1359,7 +1360,7 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o borderRadius: '8px', flexShrink: 0 }}> - {getNodeIcon(node)} + {getNodeIcon(node, dimensionIcons)}
@@ -1829,7 +1830,7 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o borderRadius: '8px', flexShrink: 0 }}> - {getNodeIcon(node)} + {getNodeIcon(node, dimensionIcons)}
@@ -2560,7 +2561,7 @@ export default function FolderViewOverlay({ onClose, onNodeOpen, refreshToken, o justifyContent: 'center', flexShrink: 0 }}> - {getNodeIcon(node)} + {getNodeIcon(node, dimensionIcons)}
#{node.id} - {getNodeIcon(node)} + {getNodeIcon(node, dimensionIcons)}
([]); 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) => ({ id: node.id, title: node.title, - dimensions: node.dimensions || [] + dimensions: node.dimensions || [], + link: node.link || undefined, })); setSuggestions(nodeSuggestions); @@ -202,7 +207,8 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil onMouseEnter={() => setSelectedIndex(index)} className={`search-result-item ${index === selectedIndex ? 'selected' : ''}`} > - #{suggestion.id} + {suggestion.id} + {getNodeIcon(suggestion as any, dimensionIcons, 14)} {suggestion.title} {index === selectedIndex && ( @@ -345,6 +351,12 @@ export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFil flex-shrink: 0; } + .result-icon { + display: flex; + align-items: center; + flex-shrink: 0; + } + .result-title { flex: 1; color: #e5e5e5; diff --git a/src/components/panes/MapPane.tsx b/src/components/panes/MapPane.tsx index be572a9..9dc126e 100644 --- a/src/components/panes/MapPane.tsx +++ b/src/components/panes/MapPane.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState, useCallback, type CSSProperties } import { ReactFlow, Background, + MiniMap, useNodesState, useEdgesState, addEdge as rfAddEdge, @@ -22,8 +23,10 @@ import type { MapPaneProps } from './types'; import { ChevronDown } from 'lucide-react'; import { RahNode } from './map/RahNode'; +import { RahEdge } from './map/RahEdge'; import EdgeExplanationModal from './map/EdgeExplanationModal'; import { toRFNodes, toRFEdges, NODE_LIMIT, type RahNodeData } from './map/utils'; +import { useDimensionIcons } from '@/context/DimensionIconsContext'; import './map/map-styles.css'; interface DimensionInfo { @@ -34,6 +37,7 @@ interface DimensionInfo { } const nodeTypes = { rahNode: RahNode }; +const edgeTypes = { rahEdge: RahEdge }; // Debounce helper // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -57,6 +61,7 @@ function MapPaneInner({ activeTabId, }: MapPaneProps) { const reactFlowInstance = useReactFlow(); + const { dimensionIcons } = useDimensionIcons(); // --- Data state (DB-level) --- const [baseNodes, setBaseNodes] = useState([]); @@ -190,6 +195,7 @@ function MapPaneInner({ selectedNodeId, connectedNodeIds, rfPositionsRef.current, + dimensionIcons, ); const nodeIdSet = new Set(newRfNodes.map(n => n.id)); @@ -590,15 +596,26 @@ function MapPaneInner({ onNodeDragStop={onNodeDragStop} onConnect={onConnect} nodeTypes={nodeTypes} + edgeTypes={edgeTypes} fitView fitViewOptions={{ padding: 0.2 }} minZoom={0.1} maxZoom={3} - defaultEdgeOptions={{ type: 'default' }} + defaultEdgeOptions={{ type: 'rahEdge' }} proOptions={{ hideAttribution: true }} colorMode="dark" > + { + const data = n.data as RahNodeData | undefined; + return data?.primaryDimensionColor || '#374151'; + }} + pannable + zoomable + /> {/* Selected node info panel */} diff --git a/src/components/panes/map/RahEdge.tsx b/src/components/panes/map/RahEdge.tsx new file mode 100644 index 0000000..d19a994 --- /dev/null +++ b/src/components/panes/map/RahEdge.tsx @@ -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 ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {/* Invisible wider path for easier hover targeting */} + + + {hovered && explanation && ( + +
+ {explanation} +
+
+ )} +
+ ); +} + +export const RahEdge = memo(RahEdgeComponent); diff --git a/src/components/panes/map/RahNode.tsx b/src/components/panes/map/RahNode.tsx index 7e4574b..c0bbc0c 100644 --- a/src/components/panes/map/RahNode.tsx +++ b/src/components/panes/map/RahNode.tsx @@ -4,11 +4,12 @@ import { memo } from 'react'; import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'; import type { RahNodeData } from './utils'; import { LABEL_THRESHOLD } from './utils'; +import { getNodeIcon } from '@/utils/nodeIcons'; type RahNodeType = Node; function RahNodeComponent({ data, selected }: NodeProps) { - const { label, dimensions, edgeCount, isExpanded } = data; + const { label, dimensions, edgeCount, isExpanded, dbNode, dimensionIcons, primaryDimensionColor } = data; const isTop = !isExpanded && edgeCount > 3; return ( @@ -19,6 +20,7 @@ function RahNodeComponent({ data, selected }: NodeProps) { isTop && 'rah-map-node--top', selected && 'rah-map-node--selected', ].filter(Boolean).join(' ')} + style={primaryDimensionColor ? { borderLeftColor: primaryDimensionColor, borderLeftWidth: 3 } : undefined} > ) { className="rah-map-handle" />
- {label.length > 28 ? label.slice(0, 26) + '\u2026' : label} + + {getNodeIcon(dbNode, dimensionIcons, 14)} + + {label.length > 26 ? label.slice(0, 24) + '\u2026' : label}
{(isTop || isExpanded) && dimensions.length > 0 && (
diff --git a/src/components/panes/map/map-styles.css b/src/components/panes/map/map-styles.css index bef4752..7b0b379 100644 --- a/src/components/panes/map/map-styles.css +++ b/src/components/panes/map/map-styles.css @@ -89,6 +89,15 @@ white-space: nowrap; overflow: hidden; 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 { @@ -130,3 +139,10 @@ .rah-map-wrapper .react-flow__node.dimmed:hover { opacity: 0.6; } + +/* MiniMap dark theme */ +.rah-map-wrapper .react-flow__minimap { + background: #0a0a0a; + border: 1px solid #1f1f1f; + border-radius: 6px; +} diff --git a/src/components/panes/map/utils.ts b/src/components/panes/map/utils.ts index d10ed7e..b8fd153 100644 --- a/src/components/panes/map/utils.ts +++ b/src/components/panes/map/utils.ts @@ -1,12 +1,42 @@ import type { Node as DbNode, Edge as DbEdge } from '@/types/database'; 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 { label: string; dimensions: string[]; edgeCount: number; isExpanded: boolean; dbNode: DbNode; + dimensionIcons?: Record; + primaryDimensionColor?: string; [key: string]: unknown; } @@ -83,6 +113,7 @@ export function toRFNodes( selectedNodeId: number | null, connectedNodeIds: Set, existingPositions: Map, + dimensionIcons?: Record, ): RFNode[] { 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); @@ -109,6 +140,8 @@ export function toRFNodes( edgeCount: node.edge_count ?? 0, isExpanded: false, dbNode: node, + dimensionIcons, + primaryDimensionColor: getDimensionColor(node.dimensions), }, }; }); @@ -154,6 +187,8 @@ export function toRFNodes( edgeCount: node.edge_count ?? 0, isExpanded: true, dbNode: node, + dimensionIcons, + primaryDimensionColor: getDimensionColor(node.dimensions), }, }); }); @@ -181,11 +216,15 @@ export function toRFEdges( ); const isDimmed = hasSelection && !isConnected; + const explanation = typeof e.context?.explanation === 'string' ? e.context.explanation : ''; + return { id: String(e.id), source: String(e.from_node_id), target: String(e.to_node_id), + type: 'rahEdge', animated: isConnected, + data: { explanation }, style: isConnected ? { stroke: '#22c55e', strokeWidth: 2.5, opacity: 1 } : isDimmed diff --git a/src/components/views/GridView.tsx b/src/components/views/GridView.tsx index bfd3433..9f953fa 100644 --- a/src/components/views/GridView.tsx +++ b/src/components/views/GridView.tsx @@ -1,7 +1,8 @@ "use client"; import { Node } from '@/types/database'; -import { File } from 'lucide-react'; +import { getNodeIcon } from '@/utils/nodeIcons'; +import { useDimensionIcons } from '@/context/DimensionIconsContext'; interface GridViewProps { nodes: Node[]; @@ -9,6 +10,7 @@ interface GridViewProps { } export default function GridView({ nodes, onNodeClick }: GridViewProps) { + const { dimensionIcons } = useDimensionIcons(); const truncateContent = (content?: string, maxLength: number = 120) => { if (!content) return ''; if (content.length <= maxLength) return content; @@ -85,7 +87,7 @@ export default function GridView({ nodes, onNodeClick }: GridViewProps) { borderRadius: '6px', flexShrink: 0 }}> - + {getNodeIcon(node, dimensionIcons, 14)}
{ if (!dateString) return ''; const date = new Date(dateString); @@ -84,7 +86,7 @@ export default function ListView({ nodes, onNodeClick }: ListViewProps) { borderRadius: '6px', flexShrink: 0 }}> - + {getNodeIcon(node, dimensionIcons, 16)}
{/* Content */} diff --git a/src/components/views/ViewsOverlay.tsx b/src/components/views/ViewsOverlay.tsx index c73955f..d2488dc 100644 --- a/src/components/views/ViewsOverlay.tsx +++ b/src/components/views/ViewsOverlay.tsx @@ -5,6 +5,7 @@ import { Plus, Trash2, LayoutGrid, List, Columns3, Save, Filter, ChevronDown, X import type { Node } from '@/types/database'; import InputDialog from '../common/InputDialog'; import { getNodeIcon } from '@/utils/nodeIcons'; +import { useDimensionIcons } from '@/context/DimensionIconsContext'; type ViewMode = 'grid' | 'list' | 'kanban'; @@ -39,6 +40,7 @@ interface ViewsOverlayProps { } export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refreshToken = 0 }: ViewsOverlayProps) { + const { dimensionIcons } = useDimensionIcons(); // Dimensions for filter picker const [dimensions, setDimensions] = useState([]); const [dimensionsLoading, setDimensionsLoading] = useState(true); @@ -401,7 +403,7 @@ export default function ViewsOverlay({ onNodeClick, onNodeOpenInOtherPane, refre // Render node card const renderNodeCard = (node: Node, columnId?: string, dimension?: string) => { - const nodeIcon = getNodeIcon(node); + const nodeIcon = getNodeIcon(node, dimensionIcons); return (
; + setDimensionIcons: React.Dispatch>>; +} + +const DimensionIconsContext = createContext({ + dimensionIcons: {}, + setDimensionIcons: () => {}, +}); + +export function DimensionIconsProvider({ children }: { children: ReactNode }) { + const [dimensionIcons, setDimensionIcons] = usePersistentState>('ui.dimensionIcons', {}); + + return ( + + {children} + + ); +} + +export function useDimensionIcons() { + return useContext(DimensionIconsContext); +} diff --git a/src/utils/nodeIcons.tsx b/src/utils/nodeIcons.tsx index 92ccac5..02e2fd8 100644 --- a/src/utils/nodeIcons.tsx +++ b/src/utils/nodeIcons.tsx @@ -1,26 +1,28 @@ "use client"; 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 { getIconByName } from '@/components/common/LucideIconPicker'; interface FaviconIconProps { domain: string; + size?: number; } -const FaviconIcon = ({ domain }: FaviconIconProps) => { +const FaviconIcon = ({ domain, size = 16 }: FaviconIconProps) => { const [failed, setFailed] = useState(false); - + if (failed) { - return ; + return ; } - + return ( // eslint-disable-next-line @next/next/no-img-element setFailed(true)} @@ -28,29 +30,73 @@ const FaviconIcon = ({ domain }: FaviconIconProps) => { ); }; -export function getNodeIcon(node: Node): React.ReactElement { - // No link - show generic file icon - if (!node.link) { - return ; +/** + * Resolve the icon for a node. + * + * 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, + size: number = 16, +): React.ReactElement { + // If node has a link, use URL-derived icon (primary) + if (node.link) { + const url = node.link.toLowerCase(); + + // YouTube videos + if (url.includes('youtube.com') || url.includes('youtu.be')) { + return