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:
Jason Woltje
2026-01-29 21:45:56 -06:00
parent af8f5df111
commit aa267b56d8
15 changed files with 1758 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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,
};
}

View 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';

View 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>
);
}

View 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"
/>
);
}

View 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"
/>
);
}

View 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"
/>
);
}

View 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
View 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;
}

View 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;
}