refactor ui

This commit is contained in:
philipredstone 2025-04-17 14:14:01 +02:00
parent d55e58d099
commit 581433612b
18 changed files with 1879 additions and 1450 deletions

View File

@ -28,13 +28,14 @@ const App: React.FC = () => {
<AuthProvider>
<NetworkProvider>
<Router>
<div className="flex flex-col min-h-screen">
<Header />
<main className="flex-grow">
<div className="flex flex-col h-screen">
<header className="header-height">
<Header />
</header>
<main className="flex-1 overflow-hidden">
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route
path="/networks"
element={
@ -43,16 +44,16 @@ const App: React.FC = () => {
</ProtectedRoute>
}
/>
<Route
path="/networks/:id"
element={
<ProtectedRoute>
<FriendshipNetwork />
<div className="h-full">
<FriendshipNetwork />
</div>
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/networks" />} />
<Route path="*" element={<Navigate to="/networks" />} />
</Routes>

View File

@ -44,7 +44,7 @@ export const getUserNetworks = async (): Promise<Network[]> => {
export const createNetwork = async (data: CreateNetworkData): Promise<Network> => {
const response = await axios.post<{ success: boolean; data: Network }>(
`${API_URL}/networks`,
data,
data
);
return response.data.data;
};
@ -52,7 +52,7 @@ export const createNetwork = async (data: CreateNetworkData): Promise<Network> =
// Get a specific network
export const getNetwork = async (id: string): Promise<Network> => {
const response = await axios.get<{ success: boolean; data: Network }>(
`${API_URL}/networks/${id}`,
`${API_URL}/networks/${id}`
);
return response.data.data;
};
@ -61,7 +61,7 @@ export const getNetwork = async (id: string): Promise<Network> => {
export const updateNetwork = async (id: string, data: UpdateNetworkData): Promise<Network> => {
const response = await axios.put<{ success: boolean; data: Network }>(
`${API_URL}/networks/${id}`,
data,
data
);
return response.data.data;
};

View File

@ -44,7 +44,7 @@ export interface UpdatePersonData {
// Get all people in a network
export const getPeople = async (networkId: string): Promise<Person[]> => {
const response = await axios.get<{ success: boolean; data: Person[] }>(
`${API_URL}/networks/${networkId}/people`,
`${API_URL}/networks/${networkId}/people`
);
return response.data.data;
};
@ -53,7 +53,7 @@ export const getPeople = async (networkId: string): Promise<Person[]> => {
export const addPerson = async (networkId: string, data: CreatePersonData): Promise<Person> => {
const response = await axios.post<{ success: boolean; data: Person }>(
`${API_URL}/networks/${networkId}/people`,
data,
data
);
return response.data.data;
};
@ -62,11 +62,11 @@ export const addPerson = async (networkId: string, data: CreatePersonData): Prom
export const updatePerson = async (
networkId: string,
personId: string,
data: UpdatePersonData,
data: UpdatePersonData
): Promise<Person> => {
const response = await axios.put<{ success: boolean; data: Person }>(
`${API_URL}/networks/${networkId}/people/${personId}`,
data,
data
);
return response.data.data;
};

View File

@ -23,7 +23,7 @@ export interface UpdateRelationshipData {
// Get all relationships in a network
export const getRelationships = async (networkId: string): Promise<Relationship[]> => {
const response = await axios.get<{ success: boolean; data: Relationship[] }>(
`${API_URL}/networks/${networkId}/relationships`,
`${API_URL}/networks/${networkId}/relationships`
);
return response.data.data;
};
@ -31,11 +31,11 @@ export const getRelationships = async (networkId: string): Promise<Relationship[
// Add a relationship to the network
export const addRelationship = async (
networkId: string,
data: CreateRelationshipData,
data: CreateRelationshipData
): Promise<Relationship> => {
const response = await axios.post<{ success: boolean; data: Relationship }>(
`${API_URL}/networks/${networkId}/relationships`,
data,
data
);
return response.data.data;
};
@ -44,11 +44,11 @@ export const addRelationship = async (
export const updateRelationship = async (
networkId: string,
relationshipId: string,
data: UpdateRelationshipData,
data: UpdateRelationshipData
): Promise<Relationship> => {
const response = await axios.put<{ success: boolean; data: Relationship }>(
`${API_URL}/networks/${networkId}/relationships/${relationshipId}`,
data,
data
);
return response.data.data;
};
@ -56,7 +56,7 @@ export const updateRelationship = async (
// Remove a relationship
export const removeRelationship = async (
networkId: string,
relationshipId: string,
relationshipId: string
): Promise<void> => {
await axios.delete(`${API_URL}/networks/${networkId}/relationships/${relationshipId}`);
};

View File

@ -1 +1,9 @@
@import 'tailwindcss';
.header-height {
height: 64px;
}
.h-full-important {
height: 100% !important;
}

View File

@ -1,7 +1,10 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { GraphData } from 'react-force-graph-2d';
// Define types for graph elements
interface GraphData {
nodes: any[];
links?: any[];
}
interface NodeData {
id: string;
firstName: string;
@ -26,18 +29,15 @@ interface CustomGraphData extends GraphData {
}
interface CanvasGraphProps {
data: CustomGraphData,
width: number,
height: number,
zoomLevel: number,
onNodeClick: (nodeId: string) => void,
onNodeDrag: (nodeId, x, y) => void
data: CustomGraphData;
width: number;
height: number;
}
// Physics constants
const NODE_RADIUS = 45; // Node radius in pixels
const MIN_DISTANCE = 110; // Minimum distance between any two nodes
const MAX_DISTANCE = 500; // Maximum distance between connected nodes
const NODE_RADIUS = 30; // Node radius in pixels
const MIN_DISTANCE = 100; // Minimum distance between any two nodes
const MAX_DISTANCE = 300; // Maximum distance between connected nodes
const REPULSION_STRENGTH = 500; // How strongly nodes repel each other when too close
const ATTRACTION_STRENGTH = 0.1; // Default attraction between connected nodes
const CONSTRAINT_STRENGTH = 0.2; // Strength of distance constraints
@ -46,10 +46,9 @@ const CENTER_GRAVITY = 0.01; // Force pulling nodes to the center
const MAX_VELOCITY = 5; // Maximum velocity to prevent wild movement
const COOLING_FACTOR = 0.99; // System gradually cools down
const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLevel, onNodeClick, onNodeDrag }) => {
const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
// State for interactions
const [draggedNode, setDraggedNode] = useState<string | null>(null);
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
const [offsetX, setOffsetX] = useState(0);
@ -60,7 +59,6 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
const [scale, setScale] = useState(1);
const [autoLayout, setAutoLayout] = useState(true);
// Node physics state
const [nodePositions, setNodePositions] = useState<
Record<
string,
@ -73,19 +71,16 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
>
>({});
// Animation frame reference
const animationRef = useRef<number | null>(null);
useEffect(() => {
// Only run once when component mounts or when data.nodes changes
if (width <= 0 || height <= 0 || !data.nodes || data.nodes.length === 0) return;
console.log('Initializing node positions...');
// Skip if we already have positions for all nodes
const allNodesHavePositions = data.nodes.every(
node =>
nodePositions[node.id] && (nodePositions[node.id].x !== 0 || nodePositions[node.id].y !== 0),
nodePositions[node.id] && (nodePositions[node.id].x !== 0 || nodePositions[node.id].y !== 0)
);
if (allNodesHavePositions) {
@ -93,29 +88,21 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
return;
}
// Create initial positions object
const initialPositions: Record<string, { x: number; y: number; vx: number; vy: number }> = {};
// Determine the area to place nodes
const padding = NODE_RADIUS * 2;
const availableWidth = width - padding * 2;
const availableHeight = height - padding * 2;
// Calculate a grid layout - find grid dimensions based on node count
const nodeCount = data.nodes.length;
const aspectRatio = availableWidth / availableHeight;
const gridCols = Math.ceil(Math.sqrt(nodeCount * aspectRatio));
const gridRows = Math.ceil(nodeCount / gridCols);
console.log(`Creating a ${gridCols}x${gridRows} grid for ${nodeCount} nodes`);
// Calculate cell size
const cellWidth = availableWidth / gridCols;
const cellHeight = availableHeight / gridRows;
// Position each node in a grid cell with random offset
data.nodes.forEach((node, index) => {
// Only generate new position if node doesn't already have one
if (
nodePositions[node.id] &&
nodePositions[node.id].x !== 0 &&
@ -129,15 +116,12 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
return;
}
// Calculate grid position
const row = Math.floor(index / gridCols);
const col = index % gridCols;
// Add randomness within cell (20% of cell size)
const randomOffsetX = cellWidth * 0.4 * (Math.random() - 0.5);
const randomOffsetY = cellHeight * 0.4 * (Math.random() - 0.5);
// Calculate final position
const x = padding + cellWidth * (col + 0.5) + randomOffsetX;
const y = padding + cellHeight * (row + 0.5) + randomOffsetY;
@ -147,22 +131,14 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
vx: 0,
vy: 0,
};
console.log(`Node ${node.id} positioned at (${x.toFixed(2)}, ${y.toFixed(2)})`);
});
// Set positions in one batch update
setNodePositions(initialPositions);
console.log('Node positioning complete');
}, [data.nodes, width, height]);
// Run physics simulation - FIX: Added proper dependencies
useEffect(() => {
// Only proceed if we have valid dimensions and data
if (width <= 0 || height <= 0 || !data.nodes || data.nodes.length === 0) return;
// Debug: Force at least one draw call to make sure graph is initially visible
drawGraph();
if (!autoLayout || draggedNode) {
@ -177,34 +153,28 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
setNodePositions(prevPositions => {
const newPositions = { ...prevPositions };
// Apply forces to each node
data.nodes.forEach(node => {
if (!newPositions[node.id]) return;
// Skip if this node is being dragged
if (node.id === draggedNode) return;
let forceX = 0;
let forceY = 0;
// Center gravity force
const centerX = width / 2;
const centerY = height / 2;
forceX += (centerX - newPositions[node.id].x) * CENTER_GRAVITY;
forceY += (centerY - newPositions[node.id].y) * CENTER_GRAVITY;
// Repulsion forces (from ALL other nodes to prevent overlapping)
data.nodes.forEach(otherNode => {
if (node.id === otherNode.id || !newPositions[otherNode.id]) return;
const dx = newPositions[node.id].x - newPositions[otherNode.id].x;
const dy = newPositions[node.id].y - newPositions[otherNode.id].y;
const distanceSq = dx * dx + dy * dy;
const distance = Math.sqrt(distanceSq) || 1; // Avoid division by zero
const distance = Math.sqrt(distanceSq) || 1;
// Enforce minimum distance between any two nodes
if (distance < MIN_DISTANCE) {
// Strong repulsion force that increases as nodes get closer
const repulsionFactor = 1 - distance / MIN_DISTANCE;
const repulsionForce = REPULSION_STRENGTH * repulsionFactor * repulsionFactor;
@ -213,7 +183,6 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
}
});
// Find connected nodes (neighbors) for the current node
const connectedNodeIds = new Set<string>();
data.edges.forEach(edge => {
if (edge.source === node.id) {
@ -223,7 +192,6 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
}
});
// Attraction forces (only to connected nodes)
connectedNodeIds.forEach(targetId => {
if (!newPositions[targetId]) return;
@ -231,18 +199,13 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
const dy = newPositions[targetId].y - newPositions[node.id].y;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
// Enforce maximum distance constraint between connected nodes
if (distance > MAX_DISTANCE) {
// Strong attractive force that increases as distance exceeds max
const excessDistance = distance - MAX_DISTANCE;
const constraintForce = CONSTRAINT_STRENGTH * excessDistance;
forceX += (dx / distance) * constraintForce;
forceY += (dy / distance) * constraintForce;
}
// Regular attraction between connected nodes (weaker when close)
else {
// Linear attraction normalized by MAX_DISTANCE
} else {
const normalizedDistance = distance / MAX_DISTANCE;
const attractionForce = ATTRACTION_STRENGTH * normalizedDistance;
@ -251,33 +214,28 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
}
});
// Update velocity with applied forces and damping
newPositions[node.id].vx = newPositions[node.id].vx * DAMPING + forceX;
newPositions[node.id].vy = newPositions[node.id].vy * DAMPING + forceY;
// Limit maximum velocity to prevent wild movement
const speed = Math.sqrt(
newPositions[node.id].vx * newPositions[node.id].vx +
newPositions[node.id].vy * newPositions[node.id].vy,
newPositions[node.id].vy * newPositions[node.id].vy
);
if (speed > MAX_VELOCITY) {
newPositions[node.id].vx = (newPositions[node.id].vx / speed) * MAX_VELOCITY;
newPositions[node.id].vy = (newPositions[node.id].vy / speed) * MAX_VELOCITY;
}
// Apply cooling factor to gradually slow the system
newPositions[node.id].vx *= COOLING_FACTOR;
newPositions[node.id].vy *= COOLING_FACTOR;
// Update position
newPositions[node.id].x += newPositions[node.id].vx;
newPositions[node.id].y += newPositions[node.id].vy;
// Boundary constraints
const padding = NODE_RADIUS;
if (newPositions[node.id].x < padding) {
newPositions[node.id].x = padding;
newPositions[node.id].vx *= -0.5; // Bounce back
newPositions[node.id].vx *= -0.5;
}
if (newPositions[node.id].x > width - padding) {
newPositions[node.id].x = width - padding;
@ -307,28 +265,23 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
animationRef.current = null;
}
};
}, [data.nodes, data.edges, width, height, autoLayout, draggedNode]); // FIX: Added proper dependencies
}, [data.nodes, data.edges, width, height, autoLayout, draggedNode]);
// Find node at position function
const findNodeAtPosition = useCallback(
(x: number, y: number): string | null => {
// Transform coordinates based on scale and pan
const transformedX = (x - panOffset.x) / scale;
const transformedY = (y - panOffset.y) / scale;
// Iterate through nodes in reverse order (top-most first)
for (let i = data.nodes.length - 1; i >= 0; i--) {
const node = data.nodes[i];
const pos = nodePositions[node.id];
if (!pos) continue;
// Calculate distance from click to node center
const dx = pos.x - transformedX;
const dy = pos.y - transformedY;
const distance = Math.sqrt(dx * dx + dy * dy);
// If click is inside node radius, return node id
if (distance <= NODE_RADIUS) {
return node.id;
}
@ -336,32 +289,27 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
return null;
},
[data.nodes, nodePositions, panOffset, scale],
); // FIX: Added proper dependencies
[data.nodes, nodePositions, panOffset, scale]
);
// Mouse event handlers
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
// Get click position relative to canvas
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Find if a node was clicked
const nodeId = findNodeAtPosition(x, y);
if (nodeId) {
// Set dragged node and calculate offset
setDraggedNode(nodeId);
const transformedX = (x - panOffset.x) / scale;
const transformedY = (y - panOffset.y) / scale;
setOffsetX(transformedX - nodePositions[nodeId].x);
setOffsetY(transformedY - nodePositions[nodeId].y);
// Reset velocity when starting to drag
setNodePositions(prev => ({
...prev,
[nodeId]: {
@ -371,35 +319,29 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
},
}));
} else {
// Start panning
setIsPanning(true);
setPanStart({ x, y });
}
},
[findNodeAtPosition, nodePositions, panOffset, scale],
); // FIX: Added proper dependencies
[findNodeAtPosition, nodePositions, panOffset, scale]
);
const handleMouseMove = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
// Get mouse position relative to canvas
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Update hovered node
const nodeId = findNodeAtPosition(x, y);
setHoveredNode(nodeId);
// Handle dragging a node
if (draggedNode) {
// Transform coordinates based on scale and pan
const transformedX = (x - panOffset.x) / scale;
const transformedY = (y - panOffset.y) / scale;
// Update node position
setNodePositions(prev => ({
...prev,
[draggedNode]: {
@ -410,9 +352,7 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
vy: 0,
},
}));
}
// Handle panning
else if (isPanning) {
} else if (isPanning) {
const dx = x - panStart.x;
const dy = y - panStart.y;
setPanOffset(prev => ({
@ -422,11 +362,10 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
setPanStart({ x, y });
}
},
[findNodeAtPosition, draggedNode, isPanning, offsetX, offsetY, panOffset, panStart, scale],
); // FIX: Added proper dependencies
[findNodeAtPosition, draggedNode, isPanning, offsetX, offsetY, panOffset, panStart, scale]
);
const handleMouseUp = useCallback(() => {
// End any drag or pan operation
setDraggedNode(null);
setIsPanning(false);
}, []);
@ -435,37 +374,30 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
(e: React.WheelEvent<HTMLCanvasElement>) => {
e.preventDefault();
// Get mouse position relative to canvas
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Calculate zoom factor
const scaleFactor = e.deltaY < 0 ? 1.1 : 0.9;
const newScale = Math.max(0.1, Math.min(5, scale * scaleFactor));
// Calculate new pan offset so that point under mouse stays fixed
// This is the key part for zooming toward mouse position
const newPanOffsetX = mouseX - (mouseX - panOffset.x) * (newScale / scale);
const newPanOffsetY = mouseY - (mouseY - panOffset.y) * (newScale / scale);
// Update state
setScale(newScale);
setPanOffset({ x: newPanOffsetX, y: newPanOffsetY });
},
[scale, panOffset],
[scale, panOffset]
);
const toggleAutoLayout = useCallback(() => {
setAutoLayout(prev => !prev);
}, []);
// Draw controls function
const drawControls = useCallback(
(ctx: CanvasRenderingContext2D) => {
// Auto layout toggle button
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fillRect(width - 120, 20, 100, 40);
@ -475,10 +407,9 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
ctx.textBaseline = 'middle';
ctx.fillText(autoLayout ? 'Physics: ON' : 'Physics: OFF', width - 70, 40);
},
[autoLayout, width],
[autoLayout, width]
);
// Draw function - FIX: Properly memoized with all dependencies
const drawGraph = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
@ -486,26 +417,20 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Skip drawing if dimensions are invalid
if (width <= 0 || height <= 0) return;
// Set canvas dimensions to match container
// NOTE: Setting canvas width/height clears the canvas, so only do this if needed
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
// Clear canvas
ctx.fillStyle = '#0f172a'; // Slate-900
ctx.fillRect(0, 0, width, height);
// Apply transformation (scale and pan)
ctx.save();
ctx.translate(panOffset.x, panOffset.y);
ctx.scale(scale, scale);
// Draw edges
data.edges.forEach(edge => {
const sourceNode = data.nodes.find(n => n.id === edge.source);
const targetNode = data.nodes.find(n => n.id === edge.target);
@ -519,15 +444,12 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
ctx.moveTo(sourcePos.x, sourcePos.y);
ctx.lineTo(targetPos.x, targetPos.y);
// Edge styling
let highlighted = false;
if (hoveredNode) {
highlighted = edge.source === hoveredNode || edge.target === hoveredNode;
}
ctx.strokeStyle = highlighted
? '#3b82f6' // bright blue for highlighted edges
: edge.color || 'rgba(255, 255, 255, 0.5)';
ctx.strokeStyle = highlighted ? '#3b82f6' : edge.color || 'rgba(255, 255, 255, 0.5)';
ctx.lineWidth = highlighted ? (edge.width ? edge.width + 1 : 3) : edge.width || 1;
@ -536,56 +458,47 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
}
});
// Draw nodes
data.nodes.forEach(node => {
const pos = nodePositions[node.id];
if (!pos) return;
// Node styling based on state
const isHovered = node.id === hoveredNode;
const isDragged = node.id === draggedNode;
// Glow effect for hovered or dragged nodes
if (isHovered || isDragged) {
ctx.shadowColor = isDragged ? '#ff9900' : '#3b82f6';
ctx.shadowBlur = 15;
}
// Draw node circle
ctx.beginPath();
ctx.arc(pos.x, pos.y, NODE_RADIUS, 0, 2 * Math.PI);
// Fill style based on state
if (isDragged) {
ctx.fillStyle = '#ff9900'; // Orange for dragged node
ctx.fillStyle = '#ff9900';
} else if (isHovered) {
ctx.fillStyle = '#3b82f6'; // Blue for hovered node
ctx.fillStyle = '#3b82f6';
} else {
ctx.fillStyle = node.bgColor || '#475569'; // Default to slate-600
ctx.fillStyle = node.bgColor || '#475569';
}
ctx.fill();
// Draw border
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
ctx.strokeStyle = 'white';
ctx.lineWidth = 2;
ctx.stroke();
// Draw initials
const initials = `${node.firstName} ${node.lastName.charAt(0)}.`;
const initials = `${node.firstName.charAt(0)}${node.lastName.charAt(0)}`;
ctx.fillStyle = 'white';
ctx.font = 'bold 13px sans-serif';
ctx.font = 'bold 16px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(initials, pos.x, pos.y);
// Draw name label for hovered or dragged nodes
if (isHovered || isDragged) {
const fullName = `${node.firstName} ${node.lastName}`;
ctx.font = '14px sans-serif';
// Add a background for the label
const textMetrics = ctx.measureText(fullName);
const textWidth = textMetrics.width;
const textHeight = 20;
@ -596,7 +509,7 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
pos.x - textWidth / 2 - padding,
pos.y + NODE_RADIUS + 5,
textWidth + padding * 2,
textHeight + padding * 2,
textHeight + padding * 2
);
ctx.fillStyle = 'white';
@ -604,10 +517,8 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
}
});
// Restore canvas transformation
ctx.restore();
// Draw UI controls
drawControls(ctx);
}, [
data,
@ -619,25 +530,20 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
width,
height,
drawControls,
]); // FIX: Added all dependencies
// Handle clicks on controls
]);
const handleControlClick = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
const x = e.nativeEvent.offsetX;
const y = e.nativeEvent.offsetY;
// Check if auto layout button was clicked
if (x >= width - 120 && x <= width - 20 && y >= 20 && y <= 60) {
toggleAutoLayout();
}
},
[width, toggleAutoLayout],
); // FIX: Added proper dependencies
[width, toggleAutoLayout]
);
// FIX: Ensure continuous rendering with requestAnimationFrame
useEffect(() => {
// Create a continuous rendering loop that doesn't depend on physics updates
let animationFrameId: number;
const renderLoop = () => {
@ -645,10 +551,8 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
animationFrameId = requestAnimationFrame(renderLoop);
};
// Start the render loop
animationFrameId = requestAnimationFrame(renderLoop);
// Clean up
return () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
@ -656,7 +560,6 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
};
}, [drawGraph]);
// Get cursor style based on current state
const getCursorStyle = useCallback(() => {
if (draggedNode) return 'grabbing';
if (hoveredNode) return 'grab';
@ -664,19 +567,16 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
return 'default';
}, [draggedNode, hoveredNode, isPanning]);
// FIX: Initial rendering - make sure canvas is properly initialized
useEffect(() => {
// Force the initial draw
if (width > 0 && height > 0 && canvasRef.current) {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (ctx) {
canvas.width = width;
canvas.height = height;
ctx.fillStyle = '#0f172a'; // Slate-900
ctx.fillStyle = '#0f172a';
ctx.fillRect(0, 0, width, height);
// Draw a loading message until nodes are positioned
if (data.nodes.length > 0 && Object.keys(nodePositions).length === 0) {
ctx.fillStyle = 'white';
ctx.font = '16px sans-serif';

File diff suppressed because it is too large Load Diff

View File

@ -48,11 +48,11 @@ export interface NetworkStatsProps {
// Enhanced Tooltip with animation and positioning
export const Tooltip: React.FC<TooltipProps> = ({
children,
text,
position = 'top',
delay = 300,
}) => {
children,
text,
position = 'top',
delay = 300,
}) => {
const [show, setShow] = useState(false);
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | null>(null);
@ -218,15 +218,15 @@ export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children,
// Enhanced Confirmation dialog
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
variant = 'danger',
}) => {
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
variant = 'danger',
}) => {
const variantClasses = {
danger: 'bg-red-600 hover:bg-red-700 focus:ring-red-500',
warning: 'bg-amber-600 hover:bg-amber-700 focus:ring-amber-500',
@ -274,7 +274,7 @@ export const NetworkStats: React.FC<NetworkStatsProps> = ({ people, relationship
people.length > 0 ? (relationships.length / people.length).toFixed(1) : '0.0';
const isolatedPeople = people.filter(
person => !relationships.some(r => r.source === person._id || r.target === person._id),
person => !relationships.some(r => r.source === person._id || r.target === person._id)
).length;
// Find most connected person
@ -286,8 +286,8 @@ export const NetworkStats: React.FC<NetworkStatsProps> = ({ people, relationship
const mostConnected =
personConnectionCounts.length > 0
? personConnectionCounts.reduce((prev, current) =>
prev.count > current.count ? prev : current,
)
prev.count > current.count ? prev : current
)
: null;
return (
@ -351,12 +351,12 @@ export const NetworkStats: React.FC<NetworkStatsProps> = ({ people, relationship
// Enhanced Toast notification component
export const Toast: React.FC<ToastProps> = ({
message,
type,
onClose,
autoClose = true,
duration = 3000,
}) => {
message,
type,
onClose,
autoClose = true,
duration = 3000,
}) => {
useEffect(() => {
if (autoClose) {
const timer = setTimeout(() => {
@ -423,16 +423,16 @@ export interface ButtonProps {
}
export const Button: React.FC<ButtonProps> = ({
children,
onClick,
type = 'button',
variant = 'primary',
size = 'md',
icon,
className = '',
disabled = false,
fullWidth = false,
}) => {
children,
onClick,
type = 'button',
variant = 'primary',
size = 'md',
icon,
className = '',
disabled = false,
fullWidth = false,
}) => {
const variantClasses = {
primary: 'bg-indigo-600 hover:bg-indigo-700 text-white focus:ring-indigo-500',
secondary: 'bg-slate-700 hover:bg-slate-600 text-white focus:ring-slate-500',
@ -481,14 +481,14 @@ export interface FormFieldProps {
}
export const FormField: React.FC<FormFieldProps> = ({
label,
id,
error,
required = false,
className = '',
children,
labelClassName = '',
}) => {
label,
id,
error,
required = false,
className = '',
children,
labelClassName = '',
}) => {
return (
<div className={`mb-4 ${className}`}>
<label

View File

@ -0,0 +1,572 @@
import React from 'react';
import DatePicker from 'react-datepicker';
import {
FaPlus,
FaRegCalendarAlt,
FaSave,
FaStar,
FaTrash,
FaUserFriends,
FaUserPlus,
} from 'react-icons/fa';
import { Button, FormField, Modal } from '../components/FriendshipNetworkComponents';
import {
PersonNode,
RelationshipEdge,
FormErrors,
NewPersonForm,
NewRelationshipForm,
} from '../types/network';
import {
getRelationshipColor,
getRelationshipLabel,
RELATIONSHIP_TYPES,
RELATIONSHIPS,
} from '../types/RelationShipTypes';
import {
ErrorMessage,
KeyboardShortcut,
OptionGroup,
TipItem,
ToggleSetting,
} from './UIComponents';
// ==============================
// Person Form Modal
// ==============================
interface PersonFormModalProps {
isOpen: boolean;
onClose: () => void;
formData: NewPersonForm;
setFormData: React.Dispatch<React.SetStateAction<NewPersonForm>>;
errors: FormErrors;
onSubmit: (e: React.FormEvent) => void;
isEdit?: boolean;
}
export const PersonFormModal: React.FC<PersonFormModalProps> = ({
isOpen,
onClose,
formData,
setFormData,
errors,
onSubmit,
isEdit = false,
}) => {
return (
<Modal isOpen={isOpen} onClose={onClose} title={isEdit ? 'Edit Person' : 'Add New Person'}>
<form onSubmit={onSubmit} className="space-y-4">
<ErrorMessage message={errors.general} />
<FormField label="First Name" id="firstName" required error={errors.firstName}>
<input
id="firstName"
type="text"
className={`w-full bg-slate-700 border ${errors.firstName ? 'border-red-500' : 'border-slate-600'}
rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white`}
placeholder="Enter first name"
value={formData.firstName}
onChange={e => setFormData({ ...formData, firstName: e.target.value })}
/>
</FormField>
<FormField label="Last Name" id="lastName" required error={errors.lastName}>
<input
id="lastName"
type="text"
className={`w-full bg-slate-700 border ${errors.lastName ? 'border-red-500' : 'border-slate-600'}
rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white`}
placeholder="Enter last name"
value={formData.lastName}
onChange={e => setFormData({ ...formData, lastName: e.target.value })}
/>
</FormField>
<FormField label="Birthday (Optional)" id="birthday">
<div className="relative">
<DatePicker
id="birthday"
selected={formData.birthday}
onChange={(date: Date | null) => setFormData({ ...formData, birthday: date })}
dateFormat="MMMM d, yyyy"
placeholderText="Select birthday"
className="w-full bg-slate-700 border border-slate-600 rounded-md p-2
focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
showYearDropdown
dropdownMode="select"
wrapperClassName="w-full"
/>
<FaRegCalendarAlt className="absolute right-3 top-1/2 transform -translate-y-1/2 text-slate-400" />
</div>
</FormField>
<FormField label="Notes (Optional)" id="notes">
<textarea
id="notes"
className="w-full bg-slate-700 border border-slate-600 rounded-md p-2 min-h-[80px]
focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
placeholder="Add any additional information"
value={formData.notes}
onChange={e => setFormData({ ...formData, notes: e.target.value })}
/>
</FormField>
<div className="flex justify-end space-x-2 pt-2">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button type="submit" variant="primary" icon={isEdit ? <FaSave /> : <FaUserPlus />}>
{isEdit ? 'Save Changes' : 'Add Person'}
</Button>
</div>
</form>
</Modal>
);
};
// ==============================
// Relationship Form Modal
// ==============================
interface RelationshipFormModalProps {
isOpen: boolean;
onClose: () => void;
formData: NewRelationshipForm;
setFormData: React.Dispatch<React.SetStateAction<NewRelationshipForm>>;
errors: FormErrors;
onSubmit: (e: React.FormEvent) => void;
people: PersonNode[];
}
export const RelationshipFormModal: React.FC<RelationshipFormModalProps> = ({
isOpen,
onClose,
formData,
setFormData,
errors,
onSubmit,
people,
}) => {
return (
<Modal isOpen={isOpen} onClose={onClose} title="Add New Relationship">
<form onSubmit={onSubmit} className="space-y-4">
<ErrorMessage message={errors.general} />
<FormField label="Source Person" id="source" required error={errors.source}>
<select
id="source"
className={`w-full bg-slate-700 border ${errors.source ? 'border-red-500' : 'border-slate-600'}
rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white`}
value={formData.source}
onChange={e => setFormData({ ...formData, source: e.target.value })}
>
<option value="">Select person</option>
{people.map(person => (
<option key={`source-${person._id}`} value={person._id}>
{person.firstName} {person.lastName}
</option>
))}
</select>
</FormField>
<FormField label="Target Person" id="target" required error={errors.target}>
<select
id="target"
className={`w-full bg-slate-700 border ${errors.target ? 'border-red-500' : 'border-slate-600'}
rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white`}
value={formData.target}
onChange={e => setFormData({ ...formData, target: e.target.value })}
>
<option value="">Select person</option>
{people.map(person => (
<option key={`target-${person._id}`} value={person._id}>
{person.firstName} {person.lastName}
</option>
))}
</select>
</FormField>
<FormField label="Relationship Type" id="type" required>
<select
id="type"
className="w-full bg-slate-700 border border-slate-600 rounded-md p-2
focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
value={formData.type}
onChange={e =>
setFormData({
...formData,
type: e.target.value as RELATIONSHIP_TYPES,
})
}
>
{Object.entries(RELATIONSHIPS).map(([value, label]) => (
<option key={value} value={value}>
{label.label}
</option>
))}
</select>
</FormField>
{formData.type === 'custom' && (
<FormField label="Custom Type" id="customType" required error={errors.customType}>
<input
id="customType"
type="text"
className={`w-full bg-slate-700 border ${errors.customType ? 'border-red-500' : 'border-slate-600'}
rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white`}
placeholder="Enter custom relationship type"
value={formData.customType}
onChange={e =>
setFormData({
...formData,
customType: e.target.value,
})
}
/>
</FormField>
)}
<FormField label="Notes (Optional)" id="relationNotes">
<textarea
id="relationNotes"
className="w-full bg-slate-700 border border-slate-600 rounded-md p-2 min-h-[60px]
focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
placeholder="Add any additional information"
value={formData.notes}
onChange={e => setFormData({ ...formData, notes: e.target.value })}
/>
</FormField>
<div className="flex items-center mt-2">
<input
type="checkbox"
id="bidirectional"
className="h-4 w-4 rounded border-gray-500 text-indigo-600 focus:ring-indigo-500 bg-slate-700"
checked={formData.bidirectional}
onChange={e =>
setFormData({
...formData,
bidirectional: e.target.checked,
})
}
/>
<label htmlFor="bidirectional" className="ml-2 block text-sm text-gray-300">
Create bidirectional relationship (recommended)
</label>
</div>
<div className="flex justify-end space-x-2 pt-2">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button type="submit" variant="primary" icon={<FaUserFriends />}>
Add Relationship
</Button>
</div>
</form>
</Modal>
);
};
// ==============================
// Person Detail Modal
// ==============================
interface PersonDetailModalProps {
isOpen: boolean;
onClose: () => void;
person: PersonNode;
setPerson: React.Dispatch<React.SetStateAction<PersonNode | null>>;
errors: FormErrors;
onSubmit: (e: React.FormEvent) => void;
onDelete: (id: string) => void;
relationships: RelationshipEdge[];
people: PersonNode[];
onDeleteRelationship: (id: string) => void;
onAddNewConnection: () => void;
onNavigateToPerson: (id: string) => void;
}
export const PersonDetailModal: React.FC<PersonDetailModalProps> = ({
isOpen,
onClose,
person,
setPerson,
errors,
onSubmit,
onDelete,
relationships,
people,
onDeleteRelationship,
onAddNewConnection,
onNavigateToPerson,
}) => {
return (
<Modal isOpen={isOpen} onClose={onClose} title={`${person.firstName} ${person.lastName}`}>
<div className="space-y-6">
<div className="space-y-4">
<form onSubmit={onSubmit} className="space-y-4">
<ErrorMessage message={errors.general} />
<FormField label="First Name" id="editFirstName" required error={errors.firstName}>
<input
id="editFirstName"
type="text"
className={`w-full bg-slate-700 border ${errors.firstName ? 'border-red-500' : 'border-slate-600'}
rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white`}
value={person.firstName || ''}
onChange={e => setPerson({ ...person, firstName: e.target.value })}
/>
</FormField>
<FormField label="Last Name" id="editLastName" required error={errors.lastName}>
<input
id="editLastName"
type="text"
className={`w-full bg-slate-700 border ${errors.lastName ? 'border-red-500' : 'border-slate-600'}
rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white`}
value={person.lastName || ''}
onChange={e => setPerson({ ...person, lastName: e.target.value })}
/>
</FormField>
<FormField label="Birthday" id="editBirthday">
<div className="relative">
<DatePicker
id="editBirthday"
selected={person.birthday ? new Date(person.birthday) : null}
onChange={(date: Date | null) => setPerson({ ...person, birthday: date })}
dateFormat="MMMM d, yyyy"
placeholderText="Select birthday"
className="w-full bg-slate-700 border border-slate-600 rounded-md p-2
focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
showYearDropdown
dropdownMode="select"
wrapperClassName="w-full"
/>
<FaRegCalendarAlt className="absolute right-3 top-1/2 transform -translate-y-1/2 text-slate-400" />
</div>
</FormField>
<FormField label="Notes" id="editNotes">
<textarea
id="editNotes"
className="w-full bg-slate-700 border border-slate-600 rounded-md p-2 min-h-[80px]
focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
value={person.notes || ''}
onChange={e => setPerson({ ...person, notes: e.target.value })}
/>
</FormField>
<div className="flex justify-between pt-2">
<Button variant="danger" onClick={() => onDelete(person._id)} icon={<FaTrash />}>
Delete
</Button>
<div className="flex space-x-2">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button type="submit" variant="primary" icon={<FaSave />}>
Save Changes
</Button>
</div>
</div>
</form>
</div>
<div>
<h4 className="font-medium text-indigo-400 mb-2">Connections</h4>
<div className="max-h-40 overflow-y-auto space-y-1 bg-slate-900 rounded-lg p-2">
{relationships.filter(
(r: RelationshipEdge) => r.source === person._id || r.target === person._id
).length > 0 ? (
relationships
.filter((r: RelationshipEdge) => r.source === person._id || r.target === person._id)
.map((rel: RelationshipEdge) => {
const isSource = rel.source === person._id;
const otherPersonId = isSource ? rel.target : rel.source;
const otherPerson = people.find((p: PersonNode) => p._id === otherPersonId);
if (!otherPerson) return null;
return (
<div
key={rel._id}
className="flex justify-between items-center py-1 px-2 hover:bg-slate-800 rounded"
>
<div className="flex items-center">
<span
className="inline-block w-2 h-2 rounded-full mr-2"
style={{ backgroundColor: getRelationshipColor(rel.type) }}
></span>
<span className="text-sm">
{isSource ? 'To: ' : 'From: '}
<span
className="font-medium hover:text-indigo-400 cursor-pointer"
onClick={() => onNavigateToPerson(otherPersonId)}
>
{otherPerson.firstName} {otherPerson.lastName}
</span>
{rel.type === 'custom'
? ` (${rel.customType})`
: ` (${getRelationshipLabel(rel.type)})`}
</span>
</div>
<button
className="text-red-400 hover:text-red-300 transition-colors"
onClick={() => onDeleteRelationship(rel._id)}
>
<FaTrash size={12} />
</button>
</div>
);
})
) : (
<div className="text-center py-2 text-slate-400 text-sm">No connections yet</div>
)}
</div>
<div className="mt-3 flex justify-center">
<Button variant="secondary" size="sm" onClick={onAddNewConnection} icon={<FaPlus />}>
Add New Connection
</Button>
</div>
</div>
</div>
</Modal>
);
};
// ==============================
// Settings Modal
// ==============================
interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
settings: {
darkMode: boolean;
autoLayout: boolean;
showLabels: boolean;
animationSpeed: string;
highlightConnections: boolean;
nodeSize: string;
};
setSettings: React.Dispatch<
React.SetStateAction<{
darkMode: boolean;
autoLayout: boolean;
showLabels: boolean;
animationSpeed: string;
highlightConnections: boolean;
nodeSize: string;
}>
>;
}
export const SettingsModal: React.FC<SettingsModalProps> = ({
isOpen,
onClose,
settings,
setSettings,
}) => {
return (
<Modal isOpen={isOpen} onClose={onClose} title="Network Settings">
<div className="space-y-4">
{/* Toggle settings */}
<ToggleSetting
label="Show Labels"
id="showLabels"
checked={settings.showLabels}
onChange={() => setSettings({ ...settings, showLabels: !settings.showLabels })}
/>
<ToggleSetting
label="Auto Layout"
id="autoLayout"
checked={settings.autoLayout}
onChange={() => setSettings({ ...settings, autoLayout: !settings.autoLayout })}
/>
<ToggleSetting
label="Highlight Connections"
id="highlightConnections"
checked={settings.highlightConnections}
onChange={() =>
setSettings({
...settings,
highlightConnections: !settings.highlightConnections,
})
}
/>
{/* Option groups */}
<OptionGroup
label="Animation Speed"
options={['slow', 'medium', 'fast']}
currentValue={settings.animationSpeed}
onChange={value => setSettings({ ...settings, animationSpeed: value })}
/>
<OptionGroup
label="Node Size"
options={['small', 'medium', 'large']}
currentValue={settings.nodeSize}
onChange={value => setSettings({ ...settings, nodeSize: value })}
/>
<div className="pt-4 flex justify-end">
<Button variant="primary" onClick={onClose} icon={<FaSave />}>
Save Settings
</Button>
</div>
</div>
</Modal>
);
};
// ==============================
// Help Modal
// ==============================
interface HelpModalProps {
isOpen: boolean;
onClose: () => void;
}
export const HelpModal: React.FC<HelpModalProps> = ({ isOpen, onClose }) => {
return (
<Modal isOpen={isOpen} onClose={onClose} title="Keyboard Shortcuts & Help" size="lg">
<div className="space-y-6">
<div>
<h3 className="text-md font-semibold text-indigo-400 mb-2">Keyboard Shortcuts</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<KeyboardShortcut shortcut="n" description="Add new person" />
<KeyboardShortcut shortcut="r" description="Add new relationship" />
<KeyboardShortcut shortcut="s" description="Toggle sidebar" />
<KeyboardShortcut shortcut="+" description="Zoom in" />
<KeyboardShortcut shortcut="-" description="Zoom out" />
<KeyboardShortcut shortcut="0" description="Reset zoom" />
<KeyboardShortcut shortcut="Ctrl+/" description="Show this help" />
</div>
</div>
<div>
<h3 className="text-md font-semibold text-indigo-400 mb-2">Tips & Tricks</h3>
<ul className="space-y-2 text-sm text-slate-300">
<TipItem text="Click on a person in the graph to see their details and edit their information" />
<TipItem text="Drag people around in the graph to organize your network visually" />
<TipItem text="Use the sidebar to filter and manage your network's people and relationships" />
<TipItem text="Create bidirectional relationships to show mutual connections (recommended)" />
<TipItem text="Customize the appearance and behavior in Settings" />
</ul>
</div>
<div className="text-center pt-2">
<Button variant="primary" onClick={onClose} icon={<FaStar />}>
Got it
</Button>
</div>
</div>
</Modal>
);
};

View File

@ -0,0 +1,424 @@
import React from 'react';
import {
FaEdit,
FaHome,
FaSearch,
FaTrash,
FaUserCircle,
FaUserFriends,
FaUserPlus,
} from 'react-icons/fa';
import { Button, EmptyState, Tooltip, NetworkStats } from './FriendshipNetworkComponents';
import { PersonNode, RelationshipEdge } from '../types/network';
import {
getRelationshipColor,
getRelationshipLabel,
RELATIONSHIP_TYPES,
RELATIONSHIPS,
} from '../types/RelationShipTypes';
interface NetworkSidebarProps {
isOpen: boolean;
currentNetwork: any;
sidebarTab: string;
people: PersonNode[];
relationships: RelationshipEdge[];
selectedPersonId: string | null;
peopleFilter: string;
relationshipFilter: string;
relationshipTypeFilter: string;
onTabChange: (tab: string) => void;
onPeopleFilterChange: (filter: string) => void;
onRelationshipFilterChange: (filter: string) => void;
onRelationshipTypeFilterChange: (type: string) => void;
onAddPerson: () => void;
onAddRelationship: () => void;
onOpenSettings: () => void;
onOpenHelp: () => void;
onPersonDelete: (id: string) => void;
onRelationshipDelete: (id: string) => void;
onOpenPersonDetail: (person: PersonNode) => void;
onNavigateBack: () => void;
}
const NetworkSidebar: React.FC<NetworkSidebarProps> = ({
isOpen,
currentNetwork,
sidebarTab,
people,
relationships,
selectedPersonId,
peopleFilter,
relationshipFilter,
relationshipTypeFilter,
onTabChange,
onPeopleFilterChange,
onRelationshipFilterChange,
onRelationshipTypeFilterChange,
onAddPerson,
onAddRelationship,
onPersonDelete,
onRelationshipDelete,
onOpenPersonDetail,
onNavigateBack,
}) => {
// Filter logic for people and relationships
const filteredPeople = people.filter(person =>
`${person.firstName} ${person.lastName}`.toLowerCase().includes(peopleFilter.toLowerCase())
);
const filteredRelationships = relationships.filter(rel => {
const source = people.find(p => p._id === rel.source);
const target = people.find(p => p._id === rel.target);
if (!source || !target) return false;
const matchesFilter =
`${source.firstName} ${source.lastName} ${target.firstName} ${target.lastName}`
.toLowerCase()
.includes(relationshipFilter.toLowerCase());
const matchesType = relationshipTypeFilter === 'all' || rel.type === relationshipTypeFilter;
return matchesFilter && matchesType;
});
// Sort people alphabetically
const sortedPeople = [...filteredPeople].sort((a, b) => {
const nameA = `${a.firstName} ${a.lastName}`.toLowerCase();
const nameB = `${b.firstName} ${b.lastName}`.toLowerCase();
return nameA.localeCompare(nameB);
});
return (
<div
className={`bg-slate-800 border-r border-slate-700 h-full transition-all duration-300
ease-in-out z-30 ${isOpen ? 'w-100' : 'w-0'}`}
>
<div className="h-full overflow-y-auto p-4">
{/* Network Header */}
<div className="mb-6 mt-8">
<div className="flex items-center justify-between mb-1">
<h2 className="text-2xl font-bold text-white flex items-center">
<span className="truncate">{currentNetwork?.name || 'Relationship Network'}</span>
</h2>
<Tooltip text="Back to networks">
<button
onClick={onNavigateBack}
className="p-2 text-slate-400 hover:text-indigo-400 transition-colors"
>
<FaHome />
</button>
</Tooltip>
</div>
<p className="text-slate-400 text-sm">Visualize your connections</p>
</div>
{/* Network Stats */}
<NetworkStats people={people} relationships={relationships} />
{/* Action Buttons */}
<div className="flex space-x-2 mb-6">
<Button variant="primary" fullWidth onClick={onAddPerson} icon={<FaUserPlus />}>
Add Person
</Button>
<Button
variant="secondary"
fullWidth
onClick={onAddRelationship}
icon={<FaUserFriends />}
>
Add Relation
</Button>
</div>
{/* Sidebar Tabs */}
<div className="flex border-b border-slate-700 mb-4">
<button
className={`flex-1 py-2 font-medium flex items-center justify-center ${
sidebarTab === 'people'
? 'text-indigo-400 border-b-2 border-indigo-400'
: 'text-slate-400 hover:text-slate-300'
}`}
onClick={() => onTabChange('people')}
>
<FaUserCircle className="mr-2" /> People
</button>
<button
className={`flex-1 py-2 font-medium flex items-center justify-center ${
sidebarTab === 'relations'
? 'text-indigo-400 border-b-2 border-indigo-400'
: 'text-slate-400 hover:text-slate-300'
}`}
onClick={() => onTabChange('relations')}
>
<FaUserFriends className="mr-2" /> Relations
</button>
</div>
{/* Tab Content */}
{sidebarTab === 'people' && (
<div>
<div className="flex items-center mb-3">
<div className="relative flex-1">
<input
type="text"
className="w-full bg-slate-700 border border-slate-600 rounded-md py-2 pl-8 pr-3
text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
placeholder="Search people..."
value={peopleFilter}
onChange={e => onPeopleFilterChange(e.target.value)}
/>
<FaSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400" />
</div>
</div>
<div className="space-y-2 max-h-[calc(100vh-350px)] overflow-y-auto pr-1">
{sortedPeople.length > 0 ? (
sortedPeople.map(person => {
const connectionCount = relationships.filter(
r => r.source === person._id || r.target === person._id
).length;
return (
<div
key={person._id}
className={`bg-slate-700 rounded-lg p-3 group hover:bg-slate-600 transition-colors
cursor-pointer border-l-4 ${
selectedPersonId === person._id
? 'border-l-pink-500'
: connectionCount > 0
? 'border-l-indigo-500'
: 'border-l-slate-700'
}`}
onClick={() => {
onOpenPersonDetail(person);
}}
>
<div className="flex justify-between items-center">
<div>
<h4 className="font-medium">
{person.firstName} {person.lastName}
</h4>
<div className="flex items-center text-xs text-slate-400 mt-1">
<span
className="inline-block w-2 h-2 rounded-full mr-1"
style={{
backgroundColor: connectionCount > 0 ? '#60A5FA' : '#94A3B8',
}}
></span>
{connectionCount} connection{connectionCount !== 1 ? 's' : ''}
</div>
</div>
<div className="flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Tooltip text="Edit">
<button
className="p-1 text-slate-400 hover:text-indigo-400 transition-colors"
onClick={e => {
e.stopPropagation();
onOpenPersonDetail(person);
}}
>
<FaEdit size={14} />
</button>
</Tooltip>
<Tooltip text="Delete">
<button
className="p-1 text-slate-400 hover:text-red-400 transition-colors"
onClick={e => {
e.stopPropagation();
onPersonDelete(person._id);
}}
>
<FaTrash size={14} />
</button>
</Tooltip>
</div>
</div>
</div>
);
})
) : (
<EmptyState
title={peopleFilter ? 'No matches found' : 'No people yet'}
description={
peopleFilter
? 'Try adjusting your search criteria'
: 'Add people to start building your network'
}
icon={<FaUserCircle className="text-2xl text-slate-400" />}
action={
!peopleFilter && (
<Button
variant="primary"
size="sm"
onClick={onAddPerson}
icon={<FaUserPlus />}
>
Add Person
</Button>
)
}
/>
)}
</div>
</div>
)}
{sidebarTab === 'relations' && (
<div>
<div className="flex items-center mb-3">
<div className="relative flex-1">
<input
type="text"
className="w-full bg-slate-700 border border-slate-600 rounded-md py-2 pl-8 pr-3
text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
placeholder="Search relationships..."
value={relationshipFilter}
onChange={e => onRelationshipFilterChange(e.target.value)}
/>
<FaSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400" />
</div>
</div>
<div className="flex mb-3 overflow-x-auto pb-2 space-x-1">
<button
className={`px-3 py-1 text-xs rounded-full whitespace-nowrap ${
relationshipTypeFilter === 'all'
? 'bg-indigo-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
onClick={() => onRelationshipTypeFilterChange('all')}
>
All Types
</button>
{Object.entries(RELATIONSHIPS).map(([type, relationship]) => (
<button
key={type}
className={`px-3 py-1 text-xs rounded-full whitespace-nowrap flex items-center ${
relationshipTypeFilter === type
? 'bg-indigo-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
onClick={() => onRelationshipTypeFilterChange(type as RELATIONSHIP_TYPES)}
>
<span
className="w-2 h-2 rounded-full mr-1"
style={{ backgroundColor: relationship.color }}
></span>
<span className="capitalize">
{getRelationshipLabel(type as RELATIONSHIP_TYPES)}
</span>
</button>
))}
</div>
<div className="space-y-2 max-h-[calc(100vh-390px)] overflow-y-auto pr-1">
{filteredRelationships.length > 0 ? (
filteredRelationships.map(rel => {
const source = people.find(p => p._id === rel.source);
const target = people.find(p => p._id === rel.target);
if (!source || !target) return null;
return (
<div
key={rel._id}
className={`bg-slate-700 rounded-lg p-3 group hover:bg-slate-600 transition-colors
border-l-4 ${
selectedPersonId === rel.source || selectedPersonId === rel.target
? 'border-l-pink-500'
: 'border-l-slate-700'
}`}
>
<div className="flex justify-between items-center">
<div>
<div className="flex items-center">
<span
className={`font-medium ${selectedPersonId === rel.source ? 'text-pink-400' : ''}`}
onClick={e => {
e.stopPropagation();
const sourcePerson = people.find(p => p._id === rel.source);
if (sourcePerson) onOpenPersonDetail(sourcePerson);
}}
>
{source.firstName} {source.lastName}
</span>
<span className="mx-2 text-slate-400"></span>
<span
className={`font-medium ${selectedPersonId === rel.target ? 'text-pink-400' : ''}`}
onClick={e => {
e.stopPropagation();
const targetPerson = people.find(p => p._id === rel.target);
if (targetPerson) onOpenPersonDetail(targetPerson);
}}
>
{target.firstName} {target.lastName}
</span>
</div>
<div className="flex items-center text-xs text-slate-400 mt-1">
<span
className="inline-block w-2 h-2 rounded-full mr-1"
style={{ backgroundColor: getRelationshipColor(rel.type) }}
></span>
<span className="capitalize">
{rel.type === 'custom'
? rel.customType
: getRelationshipLabel(rel.type)}
</span>
</div>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<Tooltip text="Delete">
<button
className="p-1 text-slate-400 hover:text-red-400 transition-colors"
onClick={() => onRelationshipDelete(rel._id)}
>
<FaTrash size={14} />
</button>
</Tooltip>
</div>
</div>
</div>
);
})
) : (
<EmptyState
title={
relationshipFilter || relationshipTypeFilter !== 'all'
? 'No matches found'
: 'No relationships yet'
}
description={
relationshipFilter || relationshipTypeFilter !== 'all'
? 'Try adjusting your search criteria'
: 'Create relationships between people to visualize connections'
}
icon={<FaUserFriends className="text-2xl text-slate-400" />}
action={
!relationshipFilter &&
relationshipTypeFilter === 'all' && (
<Button
variant="primary"
size="sm"
onClick={onAddRelationship}
icon={<FaUserFriends />}
>
Add Relationship
</Button>
)
}
/>
)}
</div>
</div>
)}
</div>
</div>
);
};
export default NetworkSidebar;

View File

@ -0,0 +1,228 @@
import React from 'react';
import { FormErrors } from '../types/network';
/**
* Toggle setting component with a switch-style toggle
*/
export interface ToggleSettingProps {
label: string;
id: string;
checked: boolean;
onChange: () => void;
}
export const ToggleSetting: React.FC<ToggleSettingProps> = ({ label, id, checked, onChange }) => {
return (
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-gray-300">{label}</label>
<div className="relative inline-block w-12 align-middle select-none">
<input
type="checkbox"
id={id}
name={id}
className="sr-only"
checked={checked}
onChange={onChange}
/>
<div className="block h-6 bg-slate-700 rounded-full w-12"></div>
<div
className={`absolute left-1 top-1 w-4 h-4 rounded-full transition-transform ${
checked ? 'transform translate-x-6 bg-indigo-500' : 'bg-gray-400'
}`}
></div>
</div>
</div>
);
};
/**
* Option group component for selecting from a group of options
*/
export interface OptionGroupProps {
label: string;
options: string[];
currentValue: string;
onChange: (value: string) => void;
}
export const OptionGroup: React.FC<OptionGroupProps> = ({
label,
options,
currentValue,
onChange,
}) => {
return (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">{label}</label>
<div className="flex space-x-2">
{options.map(option => (
<button
key={option}
className={`flex-1 py-2 px-3 rounded-md text-sm ${
currentValue === option
? 'bg-indigo-600 text-white'
: 'bg-slate-700 text-gray-300 hover:bg-slate-600'
}`}
onClick={() => onChange(option)}
>
{option.charAt(0).toUpperCase() + option.slice(1)}
</button>
))}
</div>
</div>
);
};
/**
* Keyboard shortcut item component for the help modal
*/
export interface KeyboardShortcutProps {
shortcut: string;
description: string;
}
export const KeyboardShortcut: React.FC<KeyboardShortcutProps> = ({ shortcut, description }) => {
return (
<div className="bg-slate-900 p-2 rounded">
<span className="inline-block bg-slate-700 px-2 py-1 rounded mr-2 text-xs font-mono">
{shortcut}
</span>
{description}
</div>
);
};
/**
* Tip item component for the help modal
*/
export interface TipItemProps {
text: string;
}
export const TipItem: React.FC<TipItemProps> = ({ text }) => {
return (
<li className="flex items-start">
<span className="text-indigo-400 mr-2"></span>
<span>{text}</span>
</li>
);
};
/**
* Error message display component
*/
export interface ErrorMessageProps {
message: string;
}
export const ErrorMessage: React.FC<ErrorMessageProps> = ({ message }) => {
return message ? (
<div className="bg-red-500/20 border border-red-500 text-white p-3 rounded-lg text-sm mb-4">
{message}
</div>
) : null;
};
/**
* Loading spinner component
*/
export const LoadingSpinner: React.FC<{ message?: string }> = ({ message = 'Loading...' }) => {
return (
<div className="flex flex-col items-center space-y-4">
<div className="w-16 h-16 border-t-4 border-b-4 border-indigo-500 border-solid rounded-full animate-spin"></div>
<p className="text-white text-lg">{message}</p>
</div>
);
};
/**
* Form field group with validation
*/
export interface FormGroupProps {
id: string;
label: string;
required?: boolean;
error?: string;
children: React.ReactNode;
}
export const FormGroup: React.FC<FormGroupProps> = ({
id,
label,
required = false,
error,
children,
}) => {
return (
<div className="mb-4">
<label htmlFor={id} className="block text-sm font-medium text-gray-300 mb-1">
{label} {required && <span className="text-red-500">*</span>}
</label>
{children}
{error && <p className="mt-1 text-sm text-red-500">{error}</p>}
</div>
);
};
/**
* Form validation helpers
*/
export const validatePersonForm = (person: { firstName: string; lastName: string }): FormErrors => {
const errors: FormErrors = {};
if (!person.firstName.trim()) {
errors.firstName = 'First name is required';
}
if (!person.lastName.trim()) {
errors.lastName = 'Last name is required';
}
return errors;
};
export const validateRelationshipForm = (
relationship: {
source: string;
target: string;
type: string;
customType: string;
bidirectional: boolean;
},
existingRelationships: any[]
): FormErrors => {
const errors: FormErrors = {};
if (!relationship.source) {
errors.source = 'Source person is required';
}
if (!relationship.target) {
errors.target = 'Target person is required';
}
if (relationship.source === relationship.target) {
errors.target = 'Source and target cannot be the same person';
}
if (relationship.type === 'custom' && !relationship.customType.trim()) {
errors.customType = 'Custom relationship type is required';
}
// Check if relationship already exists
if (relationship.source && relationship.target) {
const existingRelationship = existingRelationships.find(
r =>
(r.source === relationship.source && r.target === relationship.target) ||
(relationship.bidirectional &&
r.source === relationship.target &&
r.target === relationship.source)
);
if (existingRelationship) {
errors.general = 'This relationship already exists';
}
}
return errors;
};

View File

@ -63,8 +63,7 @@ const Header: React.FC = () => {
</div>
</button>
<div
className="absolute right-0 mt-2 w-48 bg-slate-800 rounded-md shadow-lg py-1 z-10 border border-slate-700 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
<div className="absolute right-0 mt-2 w-48 bg-slate-800 rounded-md shadow-lg py-1 z-10 border border-slate-700 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
<button
onClick={handleLogout}
className="w-full text-left px-4 py-2 text-sm text-slate-300 hover:bg-slate-700 flex items-center"

View File

@ -1,6 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { addPerson, getPeople, Person, removePerson, updatePerson } from '../api/people';
import { addRelationship, getRelationships, removeRelationship, updateRelationship } from '../api/relationships';
import {
addRelationship,
getRelationships,
removeRelationship,
updateRelationship,
} from '../api/relationships';
import { Relationship } from '../interfaces/IRelationship';
import { RELATIONSHIP_TYPES } from '../types/RelationShipTypes';
@ -20,7 +25,7 @@ const DEFAULT_POLL_INTERVAL = 5000;
// Custom hook to manage friendship network data
export const useFriendshipNetwork = (
networkId: string | null,
pollInterval = DEFAULT_POLL_INTERVAL,
pollInterval = DEFAULT_POLL_INTERVAL
) => {
const [people, setPeople] = useState<PersonNode[]>([]);
const [relationships, setRelationships] = useState<RelationshipEdge[]>([]);
@ -66,7 +71,7 @@ export const useFriendshipNetwork = (
// Generate hashes to detect changes
const positionsHash = JSON.stringify(peopleNodes.map(p => ({ id: p.id, pos: p.position })));
const relationshipsHash = JSON.stringify(
relationshipEdges.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type })),
relationshipEdges.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type }))
);
// Handle people updates
@ -183,7 +188,7 @@ export const useFriendshipNetwork = (
}
}
},
[networkId],
[networkId]
);
// Set up polling for network data
@ -230,7 +235,7 @@ export const useFriendshipNetwork = (
// Update the reference hash to avoid unnecessary state updates on next poll
lastPeopleUpdateRef.current = JSON.stringify(
updatedPeople.map(p => ({ id: p.id, pos: p.position })),
updatedPeople.map(p => ({ id: p.id, pos: p.position }))
);
return newPersonNode;
@ -248,7 +253,7 @@ export const useFriendshipNetwork = (
lastName?: string;
birthday?: string | null;
position?: { x: number; y: number };
},
}
): Promise<PersonNode> => {
if (!networkId) throw new Error('No network selected');
@ -258,14 +263,14 @@ export const useFriendshipNetwork = (
// Update the local state
const updatedPeople = people.map(person =>
person._id === personId ? updatedPersonNode : person,
person._id === personId ? updatedPersonNode : person
);
setPeople(updatedPeople);
// Update the reference hash if position changed to avoid unnecessary state updates on next poll
if (personData.position) {
lastPeopleUpdateRef.current = JSON.stringify(
updatedPeople.map(p => ({ id: p.id, pos: p.position })),
updatedPeople.map(p => ({ id: p.id, pos: p.position }))
);
}
@ -289,16 +294,16 @@ export const useFriendshipNetwork = (
// Remove all relationships involving this person
const updatedRelationships = relationships.filter(
rel => rel.source !== personId && rel.target !== personId,
rel => rel.source !== personId && rel.target !== personId
);
setRelationships(updatedRelationships);
// Update both reference hashes to avoid unnecessary state updates on next poll
lastPeopleUpdateRef.current = JSON.stringify(
updatedPeople.map(p => ({ id: p.id, pos: p.position })),
updatedPeople.map(p => ({ id: p.id, pos: p.position }))
);
lastRelationshipsUpdateRef.current = JSON.stringify(
updatedRelationships.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type })),
updatedRelationships.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type }))
);
} catch (err: any) {
setError(err.message || 'Failed to delete person');
@ -324,7 +329,7 @@ export const useFriendshipNetwork = (
// Update the relationship hash to avoid unnecessary state updates on next poll
lastRelationshipsUpdateRef.current = JSON.stringify(
updatedRelationships.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type })),
updatedRelationships.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type }))
);
return newRelationshipEdge;
@ -340,7 +345,7 @@ export const useFriendshipNetwork = (
relationshipData: {
type?: RELATIONSHIP_TYPES;
customType?: string;
},
}
): Promise<RelationshipEdge> => {
if (!networkId) throw new Error('No network selected');
@ -348,7 +353,7 @@ export const useFriendshipNetwork = (
const updatedRelationship = await updateRelationship(
networkId,
relationshipId,
relationshipData,
relationshipData
);
const updatedRelationshipEdge: RelationshipEdge = {
...updatedRelationship,
@ -356,13 +361,13 @@ export const useFriendshipNetwork = (
};
const updatedRelationships = relationships.map(rel =>
rel._id === relationshipId ? updatedRelationshipEdge : rel,
rel._id === relationshipId ? updatedRelationshipEdge : rel
);
setRelationships(updatedRelationships);
// Update the relationship hash to avoid unnecessary state updates on next poll
lastRelationshipsUpdateRef.current = JSON.stringify(
updatedRelationships.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type })),
updatedRelationships.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type }))
);
return updatedRelationshipEdge;
@ -383,7 +388,7 @@ export const useFriendshipNetwork = (
// Update the relationship hash to avoid unnecessary state updates on next poll
lastRelationshipsUpdateRef.current = JSON.stringify(
updatedRelationships.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type })),
updatedRelationships.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type }))
);
} catch (err: any) {
setError(err.message || 'Failed to delete relationship');

View File

@ -5,11 +5,12 @@ export interface PersonNode {
birthday?: Date | string | null;
notes?: string;
position?: {
x: number; y: number;
x: number;
y: number;
};
}
// Type for form errors
export interface FormErrors {
[key: string]: string;
}
}

View File

@ -10,4 +10,4 @@ export interface Relationship {
network: string;
createdAt: string;
updatedAt: string;
}
}

View File

@ -11,7 +11,7 @@ if (rootElement) {
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
</React.StrictMode>
);
} else {
console.error('Root element not found');

View File

@ -1,4 +1,13 @@
export type RELATIONSHIP_TYPES = 'acquaintance' | 'friend' | 'partner' | 'family' | 'secondDegree' | 'colleague' | 'teacher' | 'exPartner' | 'custom';
export type RELATIONSHIP_TYPES =
| 'acquaintance'
| 'friend'
| 'partner'
| 'family'
| 'secondDegree'
| 'colleague'
| 'teacher'
| 'exPartner'
| 'custom';
export const RELATIONSHIPS: Record<RELATIONSHIP_TYPES, { label: string; color: string }> = {
acquaintance: { label: 'Bekannter', color: '#60A5FA' }, // Light blue

View File

@ -0,0 +1,85 @@
import { RELATIONSHIP_TYPES } from './RelationShipTypes';
export interface PersonNode {
_id: string;
firstName: string;
lastName: string;
birthday?: Date | string | null;
notes?: string;
position?: {
x: number;
y: number;
};
}
export interface RelationshipEdge {
_id: string;
source: string;
target: string;
type: RELATIONSHIP_TYPES;
customType?: string;
notes?: string;
}
export interface GraphNode {
id: string;
firstName: string;
lastName: string;
connectionCount: number;
bgColor: string;
x: number;
y: number;
showLabel: boolean;
}
export interface GraphEdge {
id: string;
source: string;
target: string;
color: string;
width: number;
type: RELATIONSHIP_TYPES;
customType?: string;
}
export interface CanvasGraphData {
nodes: GraphNode[];
edges: GraphEdge[];
links: GraphEdge[];
}
export interface FormErrors {
[key: string]: string;
}
export interface NetworkSettings {
darkMode: boolean;
autoLayout: boolean;
showLabels: boolean;
animationSpeed: string;
highlightConnections: boolean;
nodeSize: string;
}
export interface NewPersonForm {
firstName: string;
lastName: string;
birthday: Date | null;
notes: string;
}
export interface NewRelationshipForm {
source: string;
target: string;
type: RELATIONSHIP_TYPES;
customType: string;
notes: string;
bidirectional: boolean;
}
export interface ToastItem {
id: number;
message: string;
type: 'error' | 'success' | 'warning' | 'info';
onClose: () => void;
}