From aa267b56d835161e64ff31e03e1d839ffbe0f1c0 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 29 Jan 2026 21:45:56 -0600 Subject: [PATCH] feat: add mindmap components from jarvis frontend - Copied mindmap visualization components (ReactFlow-based interactive graph) - Added MindmapViewer, ReactFlowEditor, MermaidViewer - Included all node types: Concept, Task, Idea, Project - Added controls: NodeCreateModal, ExportButton - Created mindmap route at /mindmap - Added useGraphData hook for knowledge graph API - Copied auth-client and api utilities (dependencies) Note: Requires better-auth packages to be installed for full compilation --- apps/web/src/app/mindmap/page.tsx | 33 ++ .../src/components/mindmap/MermaidViewer.tsx | 124 +++++++ .../src/components/mindmap/MindmapViewer.tsx | 253 ++++++++++++++ .../components/mindmap/ReactFlowEditor.tsx | 299 +++++++++++++++++ .../mindmap/controls/ExportButton.tsx | 212 ++++++++++++ .../mindmap/controls/NodeCreateModal.tsx | 168 ++++++++++ .../components/mindmap/hooks/useGraphData.ts | 308 ++++++++++++++++++ apps/web/src/components/mindmap/index.ts | 36 ++ .../src/components/mindmap/nodes/BaseNode.tsx | 89 +++++ .../components/mindmap/nodes/ConceptNode.tsx | 24 ++ .../src/components/mindmap/nodes/IdeaNode.tsx | 25 ++ .../components/mindmap/nodes/ProjectNode.tsx | 24 ++ .../src/components/mindmap/nodes/TaskNode.tsx | 24 ++ apps/web/src/lib/api.ts | 33 ++ apps/web/src/lib/auth-client.ts | 106 ++++++ 15 files changed, 1758 insertions(+) create mode 100644 apps/web/src/app/mindmap/page.tsx create mode 100644 apps/web/src/components/mindmap/MermaidViewer.tsx create mode 100644 apps/web/src/components/mindmap/MindmapViewer.tsx create mode 100644 apps/web/src/components/mindmap/ReactFlowEditor.tsx create mode 100644 apps/web/src/components/mindmap/controls/ExportButton.tsx create mode 100644 apps/web/src/components/mindmap/controls/NodeCreateModal.tsx create mode 100644 apps/web/src/components/mindmap/hooks/useGraphData.ts create mode 100644 apps/web/src/components/mindmap/index.ts create mode 100644 apps/web/src/components/mindmap/nodes/BaseNode.tsx create mode 100644 apps/web/src/components/mindmap/nodes/ConceptNode.tsx create mode 100644 apps/web/src/components/mindmap/nodes/IdeaNode.tsx create mode 100644 apps/web/src/components/mindmap/nodes/ProjectNode.tsx create mode 100644 apps/web/src/components/mindmap/nodes/TaskNode.tsx create mode 100644 apps/web/src/lib/api.ts create mode 100644 apps/web/src/lib/auth-client.ts diff --git a/apps/web/src/app/mindmap/page.tsx b/apps/web/src/app/mindmap/page.tsx new file mode 100644 index 0000000..c1bf931 --- /dev/null +++ b/apps/web/src/app/mindmap/page.tsx @@ -0,0 +1,33 @@ +import { Metadata } from 'next'; +import { MindmapViewer } from '@/components/mindmap'; + +export const metadata: Metadata = { + title: 'Mindmap | Mosaic', + description: 'Knowledge graph visualization', +}; + +/** + * Mindmap page - Interactive knowledge graph visualization + * + * Displays an interactive mindmap/knowledge graph using ReactFlow, + * with support for multiple node types (concepts, tasks, ideas, projects) + * and relationship visualization. + */ +export default function MindmapPage() { + return ( +
+
+

+ Knowledge Graph +

+

+ Explore and manage your knowledge network +

+
+ +
+ +
+
+ ); +} diff --git a/apps/web/src/components/mindmap/MermaidViewer.tsx b/apps/web/src/components/mindmap/MermaidViewer.tsx new file mode 100644 index 0000000..4a55d6a --- /dev/null +++ b/apps/web/src/components/mindmap/MermaidViewer.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import mermaid from 'mermaid'; + +interface MermaidViewerProps { + diagram: string; + className?: string; + onNodeClick?: (nodeId: string) => void; +} + +export function MermaidViewer({ diagram, className = '', onNodeClick }: MermaidViewerProps) { + const containerRef = useRef(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const renderDiagram = useCallback(async () => { + if (!containerRef.current || !diagram) { + setIsLoading(false); + return; + } + + setIsLoading(true); + setError(null); + + try { + // Initialize mermaid with theme based on document + const isDark = document.documentElement.classList.contains('dark'); + mermaid.initialize({ + startOnLoad: false, + theme: isDark ? 'dark' : 'default', + flowchart: { + useMaxWidth: true, + htmlLabels: true, + curve: 'basis', + }, + securityLevel: 'loose', + }); + + // Generate unique ID for this render + const id = `mermaid-${Date.now()}`; + + // Render the diagram + const { svg } = await mermaid.render(id, diagram); + + if (containerRef.current) { + containerRef.current.innerHTML = svg; + + // Add click handlers to nodes if callback provided + if (onNodeClick) { + const nodes = containerRef.current.querySelectorAll('.node'); + nodes.forEach((node) => { + node.addEventListener('click', () => { + const nodeId = node.id?.replace(/^flowchart-/, '').replace(/-\d+$/, ''); + if (nodeId) { + onNodeClick(nodeId); + } + }); + (node as HTMLElement).style.cursor = 'pointer'; + }); + } + } + } catch (err) { + console.error('Mermaid rendering error:', err); + setError(err instanceof Error ? err.message : 'Failed to render diagram'); + } finally { + setIsLoading(false); + } + }, [diagram, onNodeClick]); + + useEffect(() => { + renderDiagram(); + }, [renderDiagram]); + + // Re-render on theme change + useEffect(() => { + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.attributeName === 'class') { + renderDiagram(); + } + }); + }); + + observer.observe(document.documentElement, { attributes: true }); + + return () => observer.disconnect(); + }, [renderDiagram]); + + if (!diagram) { + return ( +
+ No diagram data available +
+ ); + } + + if (error) { + return ( +
+
Failed to render diagram
+
{error}
+
+          {diagram}
+        
+
+ ); + } + + return ( +
+ {isLoading && ( +
+
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/mindmap/MindmapViewer.tsx b/apps/web/src/components/mindmap/MindmapViewer.tsx new file mode 100644 index 0000000..dac2d0b --- /dev/null +++ b/apps/web/src/components/mindmap/MindmapViewer.tsx @@ -0,0 +1,253 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { MermaidViewer } from './MermaidViewer'; +import { ReactFlowEditor } from './ReactFlowEditor'; +import { useGraphData, KnowledgeNode, NodeCreateInput, EdgeCreateInput } from './hooks/useGraphData'; +import { NodeCreateModal } from './controls/NodeCreateModal'; +import { ExportButton } from './controls/ExportButton'; + +type ViewMode = 'interactive' | 'mermaid'; +type MermaidStyle = 'flowchart' | 'mindmap'; + +interface MindmapViewerProps { + rootId?: string; + maxDepth?: number; + className?: string; + readOnly?: boolean; +} + +export function MindmapViewer({ + rootId, + maxDepth = 3, + className = '', + readOnly = false, +}: MindmapViewerProps) { + const [viewMode, setViewMode] = useState('interactive'); + const [mermaidStyle, setMermaidStyle] = useState('flowchart'); + const [showCreateModal, setShowCreateModal] = useState(false); + const [selectedNode, setSelectedNode] = useState(null); + + const { + graph, + mermaid, + statistics, + isLoading, + error, + fetchMermaid, + createNode, + updateNode, + deleteNode, + createEdge, + } = useGraphData({ rootId, maxDepth }); + + const handleViewModeChange = useCallback( + async (mode: ViewMode) => { + setViewMode(mode); + if (mode === 'mermaid') { + await fetchMermaid(mermaidStyle); + } + }, + [fetchMermaid, mermaidStyle] + ); + + const handleMermaidStyleChange = useCallback( + async (style: MermaidStyle) => { + setMermaidStyle(style); + if (viewMode === 'mermaid') { + await fetchMermaid(style); + } + }, + [viewMode, fetchMermaid] + ); + + const handleCreateNode = useCallback( + async (nodeData: NodeCreateInput) => { + await createNode(nodeData); + setShowCreateModal(false); + }, + [createNode] + ); + + const handleDeleteNode = useCallback( + async (id: string) => { + await deleteNode(id); + setSelectedNode(null); + }, + [deleteNode] + ); + + const handleCreateEdge = useCallback( + async (edgeData: EdgeCreateInput) => { + await createEdge(edgeData); + }, + [createEdge] + ); + + + if (error) { + return ( +
+
+
Error loading graph
+
{error}
+
+
+ ); + } + + return ( +
+ {/* Toolbar */} +
+
+ {/* View mode toggle */} +
+ + +
+ + {/* Mermaid style selector (only shown in mermaid mode) */} + {viewMode === 'mermaid' && ( + + )} + + {/* Statistics */} + {statistics && ( +
+ {statistics.node_count} nodes, {statistics.edge_count} edges +
+ )} +
+ +
+ {!readOnly && ( + + )} + +
+
+ + {/* Main content */} +
+ {isLoading && ( +
+
+
+ )} + + {viewMode === 'interactive' && graph && ( + + )} + + {viewMode === 'mermaid' && mermaid && ( + + )} + + {!graph && !isLoading && ( +
+ + + +

No nodes yet

+

Create your first node to get started

+ {!readOnly && ( + + )} +
+ )} +
+ + {/* Selected node details panel */} + {selectedNode && ( +
+
+
+

+ {selectedNode.title} +

+

+ {selectedNode.node_type} + {selectedNode.domain && ` • ${selectedNode.domain}`} +

+ {selectedNode.content && ( +

+ {selectedNode.content} +

+ )} +
+ +
+
+ )} + + {/* Create node modal */} + {showCreateModal && ( + setShowCreateModal(false)} + onCreate={handleCreateNode} + /> + )} +
+ ); +} diff --git a/apps/web/src/components/mindmap/ReactFlowEditor.tsx b/apps/web/src/components/mindmap/ReactFlowEditor.tsx new file mode 100644 index 0000000..082aa92 --- /dev/null +++ b/apps/web/src/components/mindmap/ReactFlowEditor.tsx @@ -0,0 +1,299 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + ReactFlow, + Background, + Controls, + MiniMap, + Panel, + useNodesState, + useEdgesState, + addEdge, + Connection, + Node, + Edge, + MarkerType, + NodeTypes, + BackgroundVariant, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; + +import { GraphData, KnowledgeNode, KnowledgeEdge, EdgeCreateInput } from './hooks/useGraphData'; +import { ConceptNode } from './nodes/ConceptNode'; +import { TaskNode } from './nodes/TaskNode'; +import { IdeaNode } from './nodes/IdeaNode'; +import { ProjectNode } from './nodes/ProjectNode'; + +// Node type to color mapping +const NODE_COLORS: Record = { + concept: '#6366f1', // indigo + idea: '#f59e0b', // amber + task: '#10b981', // emerald + project: '#3b82f6', // blue + person: '#ec4899', // pink + note: '#8b5cf6', // violet + question: '#f97316', // orange +}; + +// Relation type to label mapping +const RELATION_LABELS: Record = { + relates_to: 'relates to', + part_of: 'part of', + depends_on: 'depends on', + mentions: 'mentions', + blocks: 'blocks', + similar_to: 'similar to', + derived_from: 'derived from', +}; + +interface ReactFlowEditorProps { + graphData: GraphData; + onNodeSelect?: (node: KnowledgeNode | null) => void; + onNodeUpdate?: (id: string, updates: Partial) => void; + onNodeDelete?: (id: string) => void; + onEdgeCreate?: (edge: EdgeCreateInput) => void; + className?: string; + readOnly?: boolean; +} + +// Custom node types +const nodeTypes: NodeTypes = { + concept: ConceptNode, + task: TaskNode, + idea: IdeaNode, + project: ProjectNode, + default: ConceptNode, +}; + +function convertToReactFlowNodes(nodes: KnowledgeNode[]): Node[] { + // Simple grid layout for initial positioning + const COLS = 4; + const X_SPACING = 250; + const Y_SPACING = 150; + + return nodes.map((node, index) => ({ + id: node.id, + type: node.node_type in nodeTypes ? node.node_type : 'default', + position: { + x: (index % COLS) * X_SPACING + Math.random() * 50, + y: Math.floor(index / COLS) * Y_SPACING + Math.random() * 30, + }, + data: { + label: node.title, + content: node.content, + nodeType: node.node_type, + tags: node.tags, + domain: node.domain, + id: node.id, + metadata: node.metadata, + created_at: node.created_at, + updated_at: node.updated_at, + }, + style: { + borderColor: NODE_COLORS[node.node_type] || NODE_COLORS.concept, + }, + })); +} + +function convertToReactFlowEdges(edges: KnowledgeEdge[]): Edge[] { + return edges.map((edge) => ({ + // Use stable ID based on source, target, and relation type + id: `${edge.source_id}-${edge.target_id}-${edge.relation_type}`, + source: edge.source_id, + target: edge.target_id, + label: RELATION_LABELS[edge.relation_type] || edge.relation_type, + type: 'smoothstep', + animated: edge.relation_type === 'depends_on' || edge.relation_type === 'blocks', + markerEnd: { + type: MarkerType.ArrowClosed, + width: 20, + height: 20, + }, + data: { + relationType: edge.relation_type, + weight: edge.weight, + }, + style: { + strokeWidth: Math.max(1, edge.weight * 3), + opacity: 0.6 + edge.weight * 0.4, + }, + })); +} + +export function ReactFlowEditor({ + graphData, + onNodeSelect, + onNodeUpdate, + onNodeDelete, + onEdgeCreate, + className = '', + readOnly = false, +}: ReactFlowEditorProps) { + const [selectedNode, setSelectedNode] = useState(null); + + const initialNodes = useMemo( + () => convertToReactFlowNodes(graphData.nodes), + [graphData.nodes] + ); + + const initialEdges = useMemo( + () => convertToReactFlowEdges(graphData.edges), + [graphData.edges] + ); + + const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + + // Update nodes/edges when graphData changes + useEffect(() => { + setNodes(convertToReactFlowNodes(graphData.nodes)); + setEdges(convertToReactFlowEdges(graphData.edges)); + }, [graphData, setNodes, setEdges]); + + const onConnect = useCallback( + (params: Connection) => { + if (readOnly || !params.source || !params.target) return; + + // Create edge in backend + if (onEdgeCreate) { + onEdgeCreate({ + source_id: params.source, + target_id: params.target, + relation_type: 'relates_to', + weight: 1.0, + metadata: {}, + }); + } + + setEdges((eds) => + addEdge( + { + ...params, + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed }, + }, + eds + ) + ); + }, + [readOnly, onEdgeCreate, setEdges] + ); + + const onNodeClick = useCallback( + (_event: React.MouseEvent, node: Node) => { + setSelectedNode(node.id); + if (onNodeSelect) { + const knowledgeNode = graphData.nodes.find((n) => n.id === node.id); + onNodeSelect(knowledgeNode || null); + } + }, + [graphData.nodes, onNodeSelect] + ); + + const onPaneClick = useCallback(() => { + setSelectedNode(null); + if (onNodeSelect) { + onNodeSelect(null); + } + }, [onNodeSelect]); + + const onNodeDragStop = useCallback( + (_event: React.MouseEvent, node: Node) => { + if (readOnly) return; + // Could save position to metadata if needed + if (onNodeUpdate) { + onNodeUpdate(node.id, { + metadata: { position: node.position }, + }); + } + }, + [readOnly, onNodeUpdate] + ); + + const handleDeleteSelected = useCallback(() => { + if (readOnly || !selectedNode) return; + + if (onNodeDelete) { + onNodeDelete(selectedNode); + } + + setNodes((nds) => nds.filter((n) => n.id !== selectedNode)); + setEdges((eds) => + eds.filter((e) => e.source !== selectedNode && e.target !== selectedNode) + ); + setSelectedNode(null); + }, [readOnly, selectedNode, onNodeDelete, setNodes, setEdges]); + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (readOnly) return; + + if (event.key === 'Delete' || event.key === 'Backspace') { + if (selectedNode && document.activeElement === document.body) { + event.preventDefault(); + handleDeleteSelected(); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [readOnly, selectedNode, handleDeleteSelected]); + + const isDark = typeof window !== 'undefined' && document.documentElement.classList.contains('dark'); + + return ( +
+ + + + NODE_COLORS[node.data?.nodeType as string] || '#6366f1'} + maskColor={isDark ? 'rgba(0, 0, 0, 0.8)' : 'rgba(255, 255, 255, 0.8)'} + className="bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700" + /> + +
+ {graphData.nodes.length} nodes, {graphData.edges.length} edges +
+
+ {selectedNode && !readOnly && ( + + + + )} +
+
+ ); +} diff --git a/apps/web/src/components/mindmap/controls/ExportButton.tsx b/apps/web/src/components/mindmap/controls/ExportButton.tsx new file mode 100644 index 0000000..67ebadb --- /dev/null +++ b/apps/web/src/components/mindmap/controls/ExportButton.tsx @@ -0,0 +1,212 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { GraphData, MermaidData } from '../hooks/useGraphData'; + +interface ExportButtonProps { + graph: GraphData | null; + mermaid: MermaidData | null; +} + +type ExportFormat = 'json' | 'mermaid' | 'png' | 'svg'; + +export function ExportButton({ graph, mermaid }: ExportButtonProps) { + const [isOpen, setIsOpen] = useState(false); + const [isExporting, setIsExporting] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const downloadFile = (content: string, filename: string, mimeType: string) => { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + + const exportAsJson = () => { + if (!graph) return; + const content = JSON.stringify(graph, null, 2); + downloadFile(content, 'knowledge-graph.json', 'application/json'); + }; + + const exportAsMermaid = () => { + if (!mermaid) return; + downloadFile(mermaid.diagram, 'knowledge-graph.mmd', 'text/plain'); + }; + + const exportAsPng = async () => { + const svgElement = document.querySelector('.mermaid-container svg') as SVGElement; + if (!svgElement) { + alert('Please switch to Diagram view first'); + return; + } + + setIsExporting(true); + try { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const svgData = new XMLSerializer().serializeToString(svgElement); + const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }); + const url = URL.createObjectURL(svgBlob); + + const img = new Image(); + img.onload = () => { + canvas.width = img.width * 2; + canvas.height = img.height * 2; + ctx.scale(2, 2); + ctx.fillStyle = 'white'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(img, 0, 0); + URL.revokeObjectURL(url); + + canvas.toBlob((blob) => { + if (blob) { + const pngUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = pngUrl; + link.download = 'knowledge-graph.png'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(pngUrl); + } + setIsExporting(false); + }, 'image/png'); + }; + img.onerror = () => { + setIsExporting(false); + alert('Failed to export image'); + }; + img.src = url; + } catch (error) { + console.error('Export error:', error); + setIsExporting(false); + alert('Failed to export image'); + } + }; + + const exportAsSvg = () => { + const svgElement = document.querySelector('.mermaid-container svg') as SVGElement; + if (!svgElement) { + alert('Please switch to Diagram view first'); + return; + } + + const svgData = new XMLSerializer().serializeToString(svgElement); + downloadFile(svgData, 'knowledge-graph.svg', 'image/svg+xml'); + }; + + const handleExport = async (format: ExportFormat) => { + setIsOpen(false); + switch (format) { + case 'json': + exportAsJson(); + break; + case 'mermaid': + exportAsMermaid(); + break; + case 'png': + await exportAsPng(); + break; + case 'svg': + exportAsSvg(); + break; + } + }; + + return ( +
+ + + {isOpen && ( +
+ + +
+ + +
+ )} +
+ ); +} diff --git a/apps/web/src/components/mindmap/controls/NodeCreateModal.tsx b/apps/web/src/components/mindmap/controls/NodeCreateModal.tsx new file mode 100644 index 0000000..6c5010f --- /dev/null +++ b/apps/web/src/components/mindmap/controls/NodeCreateModal.tsx @@ -0,0 +1,168 @@ +'use client'; + +import { useState } from 'react'; +import { NodeCreateInput } from '../hooks/useGraphData'; + +const NODE_TYPES = [ + { value: 'concept', label: 'Concept', color: '#6366f1' }, + { value: 'idea', label: 'Idea', color: '#f59e0b' }, + { value: 'task', label: 'Task', color: '#10b981' }, + { value: 'project', label: 'Project', color: '#3b82f6' }, + { value: 'person', label: 'Person', color: '#ec4899' }, + { value: 'note', label: 'Note', color: '#8b5cf6' }, + { value: 'question', label: 'Question', color: '#f97316' }, +]; + +interface NodeCreateModalProps { + onClose: () => void; + onCreate: (node: NodeCreateInput) => void; +} + +export function NodeCreateModal({ onClose, onCreate }: NodeCreateModalProps) { + const [title, setTitle] = useState(''); + const [nodeType, setNodeType] = useState('concept'); + const [content, setContent] = useState(''); + const [tags, setTags] = useState(''); + const [domain, setDomain] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!title.trim()) return; + + setIsSubmitting(true); + try { + await onCreate({ + title: title.trim(), + node_type: nodeType, + content: content.trim() || null, + tags: tags + .split(',') + .map((t) => t.trim()) + .filter(Boolean), + domain: domain.trim() || null, + metadata: {}, + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+
+

+ Create Node +

+ +
+ +
+
+ + setTitle(e.target.value)} + placeholder="Enter node title" + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent" + required + autoFocus + /> +
+ +
+ +
+ {NODE_TYPES.map((type) => ( + + ))} +
+
+ +
+ +