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
This commit is contained in:
33
apps/web/src/app/mindmap/page.tsx
Normal file
33
apps/web/src/app/mindmap/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex flex-col h-screen">
|
||||||
|
<header className="border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-6 py-4">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
Knowledge Graph
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Explore and manage your knowledge network
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-hidden">
|
||||||
|
<MindmapViewer />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
apps/web/src/components/mindmap/MermaidViewer.tsx
Normal file
124
apps/web/src/components/mindmap/MermaidViewer.tsx
Normal file
@@ -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<HTMLDivElement>(null);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className={`flex items-center justify-center p-8 text-gray-500 ${className}`}>
|
||||||
|
No diagram data available
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col items-center justify-center p-8 ${className}`}>
|
||||||
|
<div className="text-red-500 mb-2">Failed to render diagram</div>
|
||||||
|
<div className="text-sm text-gray-500">{error}</div>
|
||||||
|
<pre className="mt-4 p-4 bg-gray-100 dark:bg-gray-800 rounded text-xs overflow-auto max-w-full">
|
||||||
|
{diagram}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`}>
|
||||||
|
{isLoading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-gray-900/50">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="mermaid-container overflow-auto"
|
||||||
|
style={{ minHeight: '200px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
253
apps/web/src/components/mindmap/MindmapViewer.tsx
Normal file
253
apps/web/src/components/mindmap/MindmapViewer.tsx
Normal file
@@ -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<ViewMode>('interactive');
|
||||||
|
const [mermaidStyle, setMermaidStyle] = useState<MermaidStyle>('flowchart');
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [selectedNode, setSelectedNode] = useState<KnowledgeNode | null>(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 (
|
||||||
|
<div className={`flex items-center justify-center p-8 ${className}`}>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-red-500 mb-2">Error loading graph</div>
|
||||||
|
<div className="text-sm text-gray-500">{error}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col h-full ${className}`}>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* View mode toggle */}
|
||||||
|
<div className="flex rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewModeChange('interactive')}
|
||||||
|
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||||
|
viewMode === 'interactive'
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Interactive
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewModeChange('mermaid')}
|
||||||
|
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||||
|
viewMode === 'mermaid'
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Diagram
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mermaid style selector (only shown in mermaid mode) */}
|
||||||
|
{viewMode === 'mermaid' && (
|
||||||
|
<select
|
||||||
|
value={mermaidStyle}
|
||||||
|
onChange={(e) => handleMermaidStyleChange(e.target.value as MermaidStyle)}
|
||||||
|
className="px-3 py-1.5 text-sm rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<option value="flowchart">Flowchart</option>
|
||||||
|
<option value="mindmap">Mindmap</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Statistics */}
|
||||||
|
{statistics && (
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{statistics.node_count} nodes, {statistics.edge_count} edges
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="px-3 py-1.5 text-sm font-medium bg-green-500 text-white rounded hover:bg-green-600 transition-colors"
|
||||||
|
>
|
||||||
|
+ Add Node
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<ExportButton graph={graph} mermaid={mermaid} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-gray-900/50 z-10">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === 'interactive' && graph && (
|
||||||
|
<ReactFlowEditor
|
||||||
|
graphData={graph}
|
||||||
|
onNodeSelect={setSelectedNode}
|
||||||
|
onNodeUpdate={readOnly ? undefined : updateNode}
|
||||||
|
onNodeDelete={readOnly ? undefined : handleDeleteNode}
|
||||||
|
onEdgeCreate={readOnly ? undefined : handleCreateEdge}
|
||||||
|
readOnly={readOnly}
|
||||||
|
className="h-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === 'mermaid' && mermaid && (
|
||||||
|
<MermaidViewer diagram={mermaid.diagram} className="h-full p-4" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!graph && !isLoading && (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400">
|
||||||
|
<svg
|
||||||
|
className="w-16 h-16 mb-4 opacity-50"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1}
|
||||||
|
d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="text-lg font-medium">No nodes yet</p>
|
||||||
|
<p className="text-sm mt-1">Create your first node to get started</p>
|
||||||
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
Create Node
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected node details panel */}
|
||||||
|
{selectedNode && (
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-800">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{selectedNode.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 capitalize">
|
||||||
|
{selectedNode.node_type}
|
||||||
|
{selectedNode.domain && ` • ${selectedNode.domain}`}
|
||||||
|
</p>
|
||||||
|
{selectedNode.content && (
|
||||||
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{selectedNode.content}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedNode(null)}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create node modal */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<NodeCreateModal
|
||||||
|
onClose={() => setShowCreateModal(false)}
|
||||||
|
onCreate={handleCreateNode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
299
apps/web/src/components/mindmap/ReactFlowEditor.tsx
Normal file
299
apps/web/src/components/mindmap/ReactFlowEditor.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<KnowledgeNode>) => 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<string | null>(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 (
|
||||||
|
<div className={`w-full h-full ${className}`} style={{ minHeight: '500px' }}>
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
onNodesChange={readOnly ? undefined : onNodesChange}
|
||||||
|
onEdgesChange={readOnly ? undefined : onEdgesChange}
|
||||||
|
onConnect={onConnect}
|
||||||
|
onNodeClick={onNodeClick}
|
||||||
|
onPaneClick={onPaneClick}
|
||||||
|
onNodeDragStop={onNodeDragStop}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
fitView
|
||||||
|
attributionPosition="bottom-left"
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
className="bg-gray-50 dark:bg-gray-900"
|
||||||
|
>
|
||||||
|
<Background
|
||||||
|
variant={BackgroundVariant.Dots}
|
||||||
|
gap={20}
|
||||||
|
size={1}
|
||||||
|
color={isDark ? '#374151' : '#e5e7eb'}
|
||||||
|
/>
|
||||||
|
<Controls
|
||||||
|
showZoom
|
||||||
|
showFitView
|
||||||
|
showInteractive={!readOnly}
|
||||||
|
className="bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
|
||||||
|
/>
|
||||||
|
<MiniMap
|
||||||
|
nodeColor={(node) => 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"
|
||||||
|
/>
|
||||||
|
<Panel position="top-right" className="bg-white dark:bg-gray-800 p-2 rounded shadow">
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{graphData.nodes.length} nodes, {graphData.edges.length} edges
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
{selectedNode && !readOnly && (
|
||||||
|
<Panel position="bottom-right" className="bg-white dark:bg-gray-800 p-2 rounded shadow">
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteSelected}
|
||||||
|
className="px-3 py-1 bg-red-500 text-white rounded hover:bg-red-600 text-sm"
|
||||||
|
>
|
||||||
|
Delete Node
|
||||||
|
</button>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
apps/web/src/components/mindmap/controls/ExportButton.tsx
Normal file
212
apps/web/src/components/mindmap/controls/ExportButton.tsx
Normal file
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
disabled={isExporting}
|
||||||
|
className="px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isExporting ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||||
|
</svg>
|
||||||
|
Exporting...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
|
</svg>
|
||||||
|
Export
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50">
|
||||||
|
<button
|
||||||
|
onClick={() => handleExport('json')}
|
||||||
|
disabled={!graph}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
Export as JSON
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleExport('mermaid')}
|
||||||
|
disabled={!mermaid}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||||
|
</svg>
|
||||||
|
Export as Mermaid
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<hr className="my-1 border-gray-200 dark:border-gray-700" />
|
||||||
|
<button
|
||||||
|
onClick={() => handleExport('svg')}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
Export as SVG
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleExport('png')}
|
||||||
|
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
Export as PNG
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
apps/web/src/components/mindmap/controls/NodeCreateModal.tsx
Normal file
168
apps/web/src/components/mindmap/controls/NodeCreateModal.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Create Node
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Title *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Type
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{NODE_TYPES.map((type) => (
|
||||||
|
<button
|
||||||
|
key={type.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setNodeType(type.value)}
|
||||||
|
className={`px-2 py-1.5 text-xs font-medium rounded border transition-colors ${
|
||||||
|
nodeType === type.value
|
||||||
|
? 'border-transparent text-white'
|
||||||
|
: 'border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: nodeType === type.value ? type.color : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{type.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Content
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
placeholder="Optional description or notes"
|
||||||
|
rows={3}
|
||||||
|
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 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Tags
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tags}
|
||||||
|
onChange={(e) => setTags(e.target.value)}
|
||||||
|
placeholder="Comma-separated tags"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Domain
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={domain}
|
||||||
|
onChange={(e) => setDomain(e.target.value)}
|
||||||
|
placeholder="e.g., Work, Personal, Project Name"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!title.trim() || isSubmitting}
|
||||||
|
className="px-4 py-2 text-sm font-medium bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Creating...' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
308
apps/web/src/components/mindmap/hooks/useGraphData.ts
Normal file
308
apps/web/src/components/mindmap/hooks/useGraphData.ts
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useSession } from '@/lib/auth-client';
|
||||||
|
import { handleSessionExpired, isSessionExpiring } from '@/lib/api';
|
||||||
|
|
||||||
|
export interface KnowledgeNode {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
node_type: string;
|
||||||
|
content: string | null;
|
||||||
|
tags: string[];
|
||||||
|
domain: string | null;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Input type for creating a new node (without server-generated fields) */
|
||||||
|
export type NodeCreateInput = Omit<KnowledgeNode, 'id' | 'created_at' | 'updated_at'>;
|
||||||
|
|
||||||
|
/** Input type for creating a new edge (without server-generated fields) */
|
||||||
|
export type EdgeCreateInput = Omit<KnowledgeEdge, 'created_at'>;
|
||||||
|
|
||||||
|
export interface KnowledgeEdge {
|
||||||
|
source_id: string;
|
||||||
|
target_id: string;
|
||||||
|
relation_type: string;
|
||||||
|
weight: number;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphData {
|
||||||
|
nodes: KnowledgeNode[];
|
||||||
|
edges: KnowledgeEdge[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MermaidData {
|
||||||
|
diagram: string;
|
||||||
|
style: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphStatistics {
|
||||||
|
node_count: number;
|
||||||
|
edge_count: number;
|
||||||
|
nodes_by_type: Record<string, number>;
|
||||||
|
edges_by_type: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseGraphDataOptions {
|
||||||
|
rootId?: string;
|
||||||
|
maxDepth?: number;
|
||||||
|
autoFetch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseGraphDataResult {
|
||||||
|
graph: GraphData | null;
|
||||||
|
mermaid: MermaidData | null;
|
||||||
|
statistics: GraphStatistics | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
fetchGraph: () => Promise<void>;
|
||||||
|
fetchMermaid: (style?: 'flowchart' | 'mindmap') => Promise<void>;
|
||||||
|
fetchStatistics: () => Promise<void>;
|
||||||
|
createNode: (node: Omit<KnowledgeNode, 'id' | 'created_at' | 'updated_at'>) => Promise<KnowledgeNode | null>;
|
||||||
|
updateNode: (id: string, updates: Partial<KnowledgeNode>) => Promise<KnowledgeNode | null>;
|
||||||
|
deleteNode: (id: string) => Promise<boolean>;
|
||||||
|
createEdge: (edge: Omit<KnowledgeEdge, 'created_at'>) => Promise<KnowledgeEdge | null>;
|
||||||
|
deleteEdge: (sourceId: string, targetId: string, relationType: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||||
|
|
||||||
|
async function apiFetch<T>(
|
||||||
|
endpoint: string,
|
||||||
|
accessToken: string | null,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<T> {
|
||||||
|
// Skip request if session is already expiring (prevents request storms)
|
||||||
|
if (isSessionExpiring()) {
|
||||||
|
throw new Error('Session expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers as Record<string, string>,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add Authorization header if we have a token
|
||||||
|
if (accessToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/api/v1/knowledge${endpoint}`, {
|
||||||
|
...options,
|
||||||
|
credentials: 'include',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Handle session expiration
|
||||||
|
if (response.status === 401) {
|
||||||
|
handleSessionExpired();
|
||||||
|
throw new Error('Session expired');
|
||||||
|
}
|
||||||
|
const error = await response.json().catch(() => ({ detail: response.statusText }));
|
||||||
|
throw new Error(error.detail || 'API request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGraphData(options: UseGraphDataOptions = {}): UseGraphDataResult {
|
||||||
|
const { rootId, maxDepth = 3, autoFetch = true } = options;
|
||||||
|
|
||||||
|
// Get access token from BetterAuth session
|
||||||
|
const { data: sessionData } = useSession();
|
||||||
|
const accessToken = (sessionData?.user as { accessToken?: string } | undefined)?.accessToken ?? null;
|
||||||
|
|
||||||
|
const [graph, setGraph] = useState<GraphData | null>(null);
|
||||||
|
const [mermaid, setMermaid] = useState<MermaidData | null>(null);
|
||||||
|
const [statistics, setStatistics] = useState<GraphStatistics | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchGraph = useCallback(async () => {
|
||||||
|
if (!accessToken) {
|
||||||
|
setError('Not authenticated');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (rootId) params.set('root_id', rootId);
|
||||||
|
params.set('max_depth', maxDepth.toString());
|
||||||
|
|
||||||
|
const data = await apiFetch<GraphData>(`/graph?${params}`, accessToken);
|
||||||
|
setGraph(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch graph');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [rootId, maxDepth, accessToken]);
|
||||||
|
|
||||||
|
const fetchMermaid = useCallback(async (style: 'flowchart' | 'mindmap' = 'flowchart') => {
|
||||||
|
if (!accessToken) {
|
||||||
|
setError('Not authenticated');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (rootId) params.set('root_id', rootId);
|
||||||
|
params.set('style', style);
|
||||||
|
params.set('max_depth', maxDepth.toString());
|
||||||
|
params.set('style_by_type', 'true');
|
||||||
|
|
||||||
|
const data = await apiFetch<MermaidData>(`/mermaid?${params}`, accessToken);
|
||||||
|
setMermaid(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch diagram');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [rootId, maxDepth, accessToken]);
|
||||||
|
|
||||||
|
const fetchStatistics = useCallback(async () => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<GraphStatistics>('/graph/statistics', accessToken);
|
||||||
|
setStatistics(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch statistics:', err);
|
||||||
|
}
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
const createNode = useCallback(async (
|
||||||
|
node: Omit<KnowledgeNode, 'id' | 'created_at' | 'updated_at'>
|
||||||
|
): Promise<KnowledgeNode | null> => {
|
||||||
|
if (!accessToken) {
|
||||||
|
setError('Not authenticated');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const created = await apiFetch<KnowledgeNode>('/nodes', accessToken, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(node),
|
||||||
|
});
|
||||||
|
await fetchGraph();
|
||||||
|
return created;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create node');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [fetchGraph, accessToken]);
|
||||||
|
|
||||||
|
const updateNode = useCallback(async (
|
||||||
|
id: string,
|
||||||
|
updates: Partial<KnowledgeNode>
|
||||||
|
): Promise<KnowledgeNode | null> => {
|
||||||
|
if (!accessToken) {
|
||||||
|
setError('Not authenticated');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = await apiFetch<KnowledgeNode>(`/nodes/${id}`, accessToken, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
});
|
||||||
|
await fetchGraph();
|
||||||
|
return updated;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to update node');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [fetchGraph, accessToken]);
|
||||||
|
|
||||||
|
const deleteNode = useCallback(async (id: string): Promise<boolean> => {
|
||||||
|
if (!accessToken) {
|
||||||
|
setError('Not authenticated');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiFetch(`/nodes/${id}`, accessToken, { method: 'DELETE' });
|
||||||
|
await fetchGraph();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to delete node');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [fetchGraph, accessToken]);
|
||||||
|
|
||||||
|
const createEdge = useCallback(async (
|
||||||
|
edge: Omit<KnowledgeEdge, 'created_at'>
|
||||||
|
): Promise<KnowledgeEdge | null> => {
|
||||||
|
if (!accessToken) {
|
||||||
|
setError('Not authenticated');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const created = await apiFetch<KnowledgeEdge>('/edges', accessToken, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(edge),
|
||||||
|
});
|
||||||
|
await fetchGraph();
|
||||||
|
return created;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create edge');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [fetchGraph, accessToken]);
|
||||||
|
|
||||||
|
const deleteEdge = useCallback(async (
|
||||||
|
sourceId: string,
|
||||||
|
targetId: string,
|
||||||
|
relationType: string
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (!accessToken) {
|
||||||
|
setError('Not authenticated');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
source_id: sourceId,
|
||||||
|
target_id: targetId,
|
||||||
|
relation_type: relationType,
|
||||||
|
});
|
||||||
|
await apiFetch(`/edges?${params}`, accessToken, { method: 'DELETE' });
|
||||||
|
await fetchGraph();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to delete edge');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [fetchGraph, accessToken]);
|
||||||
|
|
||||||
|
// Initial data fetch - only run when autoFetch is true and we have an access token
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFetch && accessToken) {
|
||||||
|
void fetchGraph();
|
||||||
|
void fetchStatistics();
|
||||||
|
}
|
||||||
|
}, [autoFetch, accessToken, fetchGraph, fetchStatistics]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
graph,
|
||||||
|
mermaid,
|
||||||
|
statistics,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
fetchGraph,
|
||||||
|
fetchMermaid,
|
||||||
|
fetchStatistics,
|
||||||
|
createNode,
|
||||||
|
updateNode,
|
||||||
|
deleteNode,
|
||||||
|
createEdge,
|
||||||
|
deleteEdge,
|
||||||
|
};
|
||||||
|
}
|
||||||
36
apps/web/src/components/mindmap/index.ts
Normal file
36
apps/web/src/components/mindmap/index.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Mindmap components for knowledge graph visualization
|
||||||
|
*
|
||||||
|
* Provides interactive and static diagram views of knowledge nodes and their relationships.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Main viewer components
|
||||||
|
export { MindmapViewer } from './MindmapViewer';
|
||||||
|
export { ReactFlowEditor } from './ReactFlowEditor';
|
||||||
|
export { MermaidViewer } from './MermaidViewer';
|
||||||
|
|
||||||
|
// Node components
|
||||||
|
export { BaseNode } from './nodes/BaseNode';
|
||||||
|
export { ConceptNode } from './nodes/ConceptNode';
|
||||||
|
export { TaskNode } from './nodes/TaskNode';
|
||||||
|
export { IdeaNode } from './nodes/IdeaNode';
|
||||||
|
export { ProjectNode } from './nodes/ProjectNode';
|
||||||
|
|
||||||
|
// Control components
|
||||||
|
export { NodeCreateModal } from './controls/NodeCreateModal';
|
||||||
|
export { ExportButton } from './controls/ExportButton';
|
||||||
|
|
||||||
|
// Hooks and types
|
||||||
|
export {
|
||||||
|
useGraphData,
|
||||||
|
type KnowledgeNode,
|
||||||
|
type KnowledgeEdge,
|
||||||
|
type NodeCreateInput,
|
||||||
|
type EdgeCreateInput,
|
||||||
|
type GraphData,
|
||||||
|
type MermaidData,
|
||||||
|
type GraphStatistics,
|
||||||
|
} from './hooks/useGraphData';
|
||||||
|
|
||||||
|
// Type exports for node data
|
||||||
|
export type { BaseNodeData } from './nodes/BaseNode';
|
||||||
89
apps/web/src/components/mindmap/nodes/BaseNode.tsx
Normal file
89
apps/web/src/components/mindmap/nodes/BaseNode.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Handle, Position, NodeProps } from '@xyflow/react';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface BaseNodeData {
|
||||||
|
label: string;
|
||||||
|
content?: string | null;
|
||||||
|
nodeType: string;
|
||||||
|
tags?: string[];
|
||||||
|
domain?: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseNodeProps extends NodeProps {
|
||||||
|
data: BaseNodeData;
|
||||||
|
icon: ReactNode;
|
||||||
|
color: string;
|
||||||
|
borderStyle?: 'solid' | 'dashed' | 'dotted';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BaseNode({
|
||||||
|
data,
|
||||||
|
selected,
|
||||||
|
icon,
|
||||||
|
color,
|
||||||
|
borderStyle = 'solid',
|
||||||
|
}: BaseNodeProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
px-4 py-3 rounded-lg shadow-md min-w-[150px] max-w-[250px]
|
||||||
|
bg-white dark:bg-gray-800
|
||||||
|
border-2 transition-all duration-200
|
||||||
|
${selected ? 'ring-2 ring-blue-500 ring-offset-2 dark:ring-offset-gray-900' : ''}
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
borderColor: color,
|
||||||
|
borderStyle,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Top}
|
||||||
|
className="w-3 h-3 !bg-gray-400 dark:!bg-gray-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 w-6 h-6 rounded flex items-center justify-center text-white text-sm"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{data.label}
|
||||||
|
</div>
|
||||||
|
{data.content && (
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">
|
||||||
|
{data.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.tags && data.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{data.tags.slice(0, 3).map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="px-1.5 py-0.5 text-xs rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{data.tags.length > 3 && (
|
||||||
|
<span className="text-xs text-gray-400">+{data.tags.length - 3}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
className="w-3 h-3 !bg-gray-400 dark:!bg-gray-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/web/src/components/mindmap/nodes/ConceptNode.tsx
Normal file
24
apps/web/src/components/mindmap/nodes/ConceptNode.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { NodeProps } from '@xyflow/react';
|
||||||
|
import { BaseNode, BaseNodeData } from './BaseNode';
|
||||||
|
|
||||||
|
export function ConceptNode(props: NodeProps) {
|
||||||
|
return (
|
||||||
|
<BaseNode
|
||||||
|
{...props}
|
||||||
|
data={props.data as BaseNodeData}
|
||||||
|
icon={
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
color="#6366f1"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/web/src/components/mindmap/nodes/IdeaNode.tsx
Normal file
25
apps/web/src/components/mindmap/nodes/IdeaNode.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { NodeProps } from '@xyflow/react';
|
||||||
|
import { BaseNode, BaseNodeData } from './BaseNode';
|
||||||
|
|
||||||
|
export function IdeaNode(props: NodeProps) {
|
||||||
|
return (
|
||||||
|
<BaseNode
|
||||||
|
{...props}
|
||||||
|
data={props.data as BaseNodeData}
|
||||||
|
icon={
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
color="#f59e0b"
|
||||||
|
borderStyle="dashed"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/web/src/components/mindmap/nodes/ProjectNode.tsx
Normal file
24
apps/web/src/components/mindmap/nodes/ProjectNode.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { NodeProps } from '@xyflow/react';
|
||||||
|
import { BaseNode, BaseNodeData } from './BaseNode';
|
||||||
|
|
||||||
|
export function ProjectNode(props: NodeProps) {
|
||||||
|
return (
|
||||||
|
<BaseNode
|
||||||
|
{...props}
|
||||||
|
data={props.data as BaseNodeData}
|
||||||
|
icon={
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
color="#3b82f6"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/web/src/components/mindmap/nodes/TaskNode.tsx
Normal file
24
apps/web/src/components/mindmap/nodes/TaskNode.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { NodeProps } from '@xyflow/react';
|
||||||
|
import { BaseNode, BaseNodeData } from './BaseNode';
|
||||||
|
|
||||||
|
export function TaskNode(props: NodeProps) {
|
||||||
|
return (
|
||||||
|
<BaseNode
|
||||||
|
{...props}
|
||||||
|
data={props.data as BaseNodeData}
|
||||||
|
icon={
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
color="#10b981"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
apps/web/src/lib/api.ts
Normal file
33
apps/web/src/lib/api.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* API utilities for session management and authentication
|
||||||
|
*/
|
||||||
|
|
||||||
|
let sessionExpiredHandled = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle session expiration by redirecting to login
|
||||||
|
*/
|
||||||
|
export function handleSessionExpired(): void {
|
||||||
|
if (sessionExpiredHandled) return;
|
||||||
|
sessionExpiredHandled = true;
|
||||||
|
|
||||||
|
// If we're in the browser, redirect to login
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.href = '/login?expired=true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a session expiration is already being handled
|
||||||
|
* (prevents multiple simultaneous redirects)
|
||||||
|
*/
|
||||||
|
export function isSessionExpiring(): boolean {
|
||||||
|
return sessionExpiredHandled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the session expiration flag (for testing)
|
||||||
|
*/
|
||||||
|
export function resetSessionExpirationFlag(): void {
|
||||||
|
sessionExpiredHandled = false;
|
||||||
|
}
|
||||||
106
apps/web/src/lib/auth-client.ts
Normal file
106
apps/web/src/lib/auth-client.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* BetterAuth client for frontend authentication.
|
||||||
|
*
|
||||||
|
* This client handles:
|
||||||
|
* - Sign in/out operations
|
||||||
|
* - Session management
|
||||||
|
* - Automatic token refresh
|
||||||
|
*/
|
||||||
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
import { credentialsClient } from "better-auth-credentials-plugin/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth client instance configured for Jarvis.
|
||||||
|
*/
|
||||||
|
export const authClient = createAuthClient({
|
||||||
|
// Base URL for auth API
|
||||||
|
baseURL: typeof window !== "undefined"
|
||||||
|
? window.location.origin
|
||||||
|
: process.env.BETTER_AUTH_URL || "http://localhost:3042",
|
||||||
|
|
||||||
|
// Plugins
|
||||||
|
plugins: [
|
||||||
|
// Credentials client for username/password auth
|
||||||
|
credentialsClient(),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export commonly used auth functions.
|
||||||
|
*/
|
||||||
|
export const {
|
||||||
|
signIn,
|
||||||
|
signOut,
|
||||||
|
useSession,
|
||||||
|
getSession,
|
||||||
|
} = authClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign in with username and password.
|
||||||
|
* Returns the session on success, throws on failure.
|
||||||
|
*
|
||||||
|
* Uses direct fetch since our server accepts username (not email)
|
||||||
|
* and the default BetterAuth client expects email.
|
||||||
|
*/
|
||||||
|
export async function signInWithCredentials(username: string, password: string) {
|
||||||
|
const baseURL = typeof window !== "undefined"
|
||||||
|
? window.location.origin
|
||||||
|
: process.env.BETTER_AUTH_URL || "http://localhost:3042";
|
||||||
|
|
||||||
|
const response = await fetch(`${baseURL}/api/auth/sign-in/credentials`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
credentials: "include", // Include cookies
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(error.message || "Authentication failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current access token for API calls.
|
||||||
|
* Returns null if not authenticated.
|
||||||
|
*/
|
||||||
|
export async function getAccessToken(): Promise<string | null> {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session?.data?.user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type assertion for custom user fields
|
||||||
|
const user = session.data.user as {
|
||||||
|
accessToken?: string;
|
||||||
|
tokenExpiresAt?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if token is expired (with 1 minute buffer)
|
||||||
|
if (user.tokenExpiresAt && user.tokenExpiresAt - Date.now() < 60000) {
|
||||||
|
// Token is expired or about to expire
|
||||||
|
// The session will be refreshed automatically by BetterAuth
|
||||||
|
// but we should return null to trigger a re-auth if needed
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.accessToken || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current user is an admin.
|
||||||
|
*/
|
||||||
|
export async function isAdmin(): Promise<boolean> {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session?.data?.user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = session.data.user as { isAdmin?: boolean };
|
||||||
|
return user.isAdmin === true;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user