Release: CI/CD Pipeline & Architecture Updates #177
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