mirror of
https://github.com/philipredstone/relnet.git
synced 2025-06-16 20:51:16 +02:00
change ui
This commit is contained in:
parent
acbff34640
commit
9eddb1b547
@ -13,15 +13,16 @@
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.1",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"axios": "^1.8.4",
|
||||
"framer-motion": "^12.7.3",
|
||||
"react": "^19.1.0",
|
||||
"react-datepicker": "^8.3.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-force-graph-2d": "^1.27.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^7.5.0",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.2.6"
|
||||
@ -33,7 +34,10 @@
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^4.4.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.4.32",
|
||||
"prettier": "^3.5.3",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"webpack": "^5.99.5",
|
||||
"webpack-cli": "^6.0.1"
|
||||
}
|
||||
|
@ -1 +1 @@
|
||||
@import "tailwindcss";
|
||||
@import 'tailwindcss';
|
||||
|
709
frontend/src/components/CanvasGraph.tsx
Normal file
709
frontend/src/components/CanvasGraph.tsx
Normal file
@ -0,0 +1,709 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
|
||||
// Define types for graph elements
|
||||
interface NodeData {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
connectionCount: number;
|
||||
bgColor: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface EdgeData {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
color: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface GraphData {
|
||||
nodes: NodeData[];
|
||||
edges: EdgeData[];
|
||||
}
|
||||
|
||||
interface CanvasGraphProps {
|
||||
data: GraphData;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// Physics constants
|
||||
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
|
||||
const DAMPING = 0.6; // Damping factor for velocity (0-1)
|
||||
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 }) => {
|
||||
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);
|
||||
const [offsetY, setOffsetY] = useState(0);
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
|
||||
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
|
||||
const [scale, setScale] = useState(1);
|
||||
const [autoLayout, setAutoLayout] = useState(true);
|
||||
|
||||
// Node physics state
|
||||
const [nodePositions, setNodePositions] = useState<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
}
|
||||
>
|
||||
>({});
|
||||
|
||||
// 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)
|
||||
);
|
||||
|
||||
if (allNodesHavePositions) {
|
||||
console.log('All nodes already have positions');
|
||||
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 &&
|
||||
nodePositions[node.id].y !== 0
|
||||
) {
|
||||
initialPositions[node.id] = {
|
||||
...nodePositions[node.id],
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
};
|
||||
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;
|
||||
|
||||
initialPositions[node.id] = {
|
||||
x: x,
|
||||
y: y,
|
||||
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) {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
animationRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const simulatePhysics = () => {
|
||||
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
|
||||
|
||||
// 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;
|
||||
|
||||
forceX += (dx / distance) * repulsionForce;
|
||||
forceY += (dy / distance) * repulsionForce;
|
||||
}
|
||||
});
|
||||
|
||||
// Find connected nodes (neighbors) for the current node
|
||||
const connectedNodeIds = new Set<string>();
|
||||
data.edges.forEach(edge => {
|
||||
if (edge.source === node.id) {
|
||||
connectedNodeIds.add(edge.target);
|
||||
} else if (edge.target === node.id) {
|
||||
connectedNodeIds.add(edge.source);
|
||||
}
|
||||
});
|
||||
|
||||
// Attraction forces (only to connected nodes)
|
||||
connectedNodeIds.forEach(targetId => {
|
||||
if (!newPositions[targetId]) return;
|
||||
|
||||
const dx = newPositions[targetId].x - newPositions[node.id].x;
|
||||
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
|
||||
const normalizedDistance = distance / MAX_DISTANCE;
|
||||
const attractionForce = ATTRACTION_STRENGTH * normalizedDistance;
|
||||
|
||||
forceX += (dx / distance) * attractionForce;
|
||||
forceY += (dy / distance) * attractionForce;
|
||||
}
|
||||
});
|
||||
|
||||
// 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
|
||||
);
|
||||
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
|
||||
}
|
||||
if (newPositions[node.id].x > width - padding) {
|
||||
newPositions[node.id].x = width - padding;
|
||||
newPositions[node.id].vx *= -0.5;
|
||||
}
|
||||
if (newPositions[node.id].y < padding) {
|
||||
newPositions[node.id].y = padding;
|
||||
newPositions[node.id].vy *= -0.5;
|
||||
}
|
||||
if (newPositions[node.id].y > height - padding) {
|
||||
newPositions[node.id].y = height - padding;
|
||||
newPositions[node.id].vy *= -0.5;
|
||||
}
|
||||
});
|
||||
|
||||
return newPositions;
|
||||
});
|
||||
|
||||
animationRef.current = requestAnimationFrame(simulatePhysics);
|
||||
};
|
||||
|
||||
animationRef.current = requestAnimationFrame(simulatePhysics);
|
||||
|
||||
return () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
animationRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [data.nodes, data.edges, width, height, autoLayout, draggedNode]); // FIX: Added proper dependencies
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[data.nodes, nodePositions, panOffset, scale]
|
||||
); // FIX: Added proper dependencies
|
||||
|
||||
// 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]: {
|
||||
...prev[nodeId],
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
// Start panning
|
||||
setIsPanning(true);
|
||||
setPanStart({ x, y });
|
||||
}
|
||||
},
|
||||
[findNodeAtPosition, nodePositions, panOffset, scale]
|
||||
); // FIX: Added proper dependencies
|
||||
|
||||
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]: {
|
||||
...prev[draggedNode],
|
||||
x: transformedX - offsetX,
|
||||
y: transformedY - offsetY,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
},
|
||||
}));
|
||||
}
|
||||
// Handle panning
|
||||
else if (isPanning) {
|
||||
const dx = x - panStart.x;
|
||||
const dy = y - panStart.y;
|
||||
setPanOffset(prev => ({
|
||||
x: prev.x + dx,
|
||||
y: prev.y + dy,
|
||||
}));
|
||||
setPanStart({ x, y });
|
||||
}
|
||||
},
|
||||
[findNodeAtPosition, draggedNode, isPanning, offsetX, offsetY, panOffset, panStart, scale]
|
||||
); // FIX: Added proper dependencies
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
// End any drag or pan operation
|
||||
setDraggedNode(null);
|
||||
setIsPanning(false);
|
||||
}, []);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(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]
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
ctx.fillStyle = autoLayout ? '#4ade80' : '#cbd5e1';
|
||||
ctx.font = '16px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(autoLayout ? 'Physics: ON' : 'Physics: OFF', width - 70, 40);
|
||||
},
|
||||
[autoLayout, width]
|
||||
);
|
||||
|
||||
// Draw function - FIX: Properly memoized with all dependencies
|
||||
const drawGraph = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
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);
|
||||
|
||||
if (sourceNode && targetNode) {
|
||||
const sourcePos = nodePositions[sourceNode.id];
|
||||
const targetPos = nodePositions[targetNode.id];
|
||||
|
||||
if (sourcePos && targetPos) {
|
||||
ctx.beginPath();
|
||||
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.lineWidth = highlighted ? (edge.width ? edge.width + 1 : 3) : edge.width || 1;
|
||||
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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
|
||||
} else if (isHovered) {
|
||||
ctx.fillStyle = '#3b82f6'; // Blue for hovered node
|
||||
} else {
|
||||
ctx.fillStyle = node.bgColor || '#475569'; // Default to slate-600
|
||||
}
|
||||
ctx.fill();
|
||||
|
||||
// Draw border
|
||||
ctx.shadowColor = 'transparent';
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.strokeStyle = 'white';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// Draw initials
|
||||
const initials = `${node.firstName.charAt(0)}${node.lastName.charAt(0)}`;
|
||||
ctx.fillStyle = 'white';
|
||||
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;
|
||||
const padding = 6;
|
||||
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||||
ctx.fillRect(
|
||||
pos.x - textWidth / 2 - padding,
|
||||
pos.y + NODE_RADIUS + 5,
|
||||
textWidth + padding * 2,
|
||||
textHeight + padding * 2
|
||||
);
|
||||
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillText(fullName, pos.x, pos.y + NODE_RADIUS + 15 + padding);
|
||||
}
|
||||
});
|
||||
|
||||
// Restore canvas transformation
|
||||
ctx.restore();
|
||||
|
||||
// Draw UI controls
|
||||
drawControls(ctx);
|
||||
}, [
|
||||
data,
|
||||
nodePositions,
|
||||
hoveredNode,
|
||||
draggedNode,
|
||||
scale,
|
||||
panOffset,
|
||||
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
|
||||
|
||||
// FIX: Ensure continuous rendering with requestAnimationFrame
|
||||
useEffect(() => {
|
||||
// Create a continuous rendering loop that doesn't depend on physics updates
|
||||
let animationFrameId: number;
|
||||
|
||||
const renderLoop = () => {
|
||||
drawGraph();
|
||||
animationFrameId = requestAnimationFrame(renderLoop);
|
||||
};
|
||||
|
||||
// Start the render loop
|
||||
animationFrameId = requestAnimationFrame(renderLoop);
|
||||
|
||||
// Clean up
|
||||
return () => {
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
};
|
||||
}, [drawGraph]);
|
||||
|
||||
// Get cursor style based on current state
|
||||
const getCursorStyle = useCallback(() => {
|
||||
if (draggedNode) return 'grabbing';
|
||||
if (hoveredNode) return 'grab';
|
||||
if (isPanning) return 'move';
|
||||
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.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';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('Initializing graph...', width / 2, height / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [width, height, data.nodes.length, nodePositions]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onClick={handleControlClick}
|
||||
onWheel={handleWheel}
|
||||
width={width}
|
||||
height={height}
|
||||
className="absolute top-0 left-0 w-full h-full"
|
||||
style={{
|
||||
background: 'linear-gradient(to bottom, #0f172a, #1e293b)',
|
||||
cursor: getCursorStyle(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CanvasGraph;
|
File diff suppressed because it is too large
Load Diff
582
frontend/src/components/FriendshipNetworkComponents.tsx
Normal file
582
frontend/src/components/FriendshipNetworkComponents.tsx
Normal file
@ -0,0 +1,582 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { FaTimes } from 'react-icons/fa';
|
||||
|
||||
// Define types
|
||||
export interface TooltipProps {
|
||||
children: React.ReactNode;
|
||||
text: string;
|
||||
position?: 'top' | 'bottom' | 'left' | 'right';
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
}
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: 'danger' | 'warning' | 'info';
|
||||
}
|
||||
|
||||
export interface ToastProps {
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
onClose: () => void;
|
||||
autoClose?: boolean;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface ToastItem extends ToastProps {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface NetworkStatsProps {
|
||||
people: any[];
|
||||
relationships: any[];
|
||||
}
|
||||
|
||||
// Enhanced Tooltip with animation and positioning
|
||||
export const Tooltip: React.FC<TooltipProps> = ({
|
||||
children,
|
||||
text,
|
||||
position = 'top',
|
||||
delay = 300,
|
||||
}) => {
|
||||
const [show, setShow] = useState(false);
|
||||
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
const id = setTimeout(() => setShow(true), delay);
|
||||
setTimeoutId(id);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
const id = setTimeout(() => setShow(false), 100);
|
||||
setTimeoutId(id);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
}, [timeoutId]);
|
||||
|
||||
const positionClasses = {
|
||||
top: '-top-8 left-1/2 transform -translate-x-1/2',
|
||||
bottom: 'top-full mt-2 left-1/2 transform -translate-x-1/2',
|
||||
left: 'right-full mr-2 top-1/2 transform -translate-y-1/2',
|
||||
right: 'left-full ml-2 top-1/2 transform -translate-y-1/2',
|
||||
};
|
||||
|
||||
const arrowClasses = {
|
||||
top: 'absolute w-2 h-2 bg-gray-900 transform rotate-45 -bottom-1 left-1/2 -translate-x-1/2',
|
||||
bottom: 'absolute w-2 h-2 bg-gray-900 transform rotate-45 -top-1 left-1/2 -translate-x-1/2',
|
||||
left: 'absolute w-2 h-2 bg-gray-900 transform rotate-45 right-0 top-1/2 -translate-y-1/2 -mr-1',
|
||||
right: 'absolute w-2 h-2 bg-gray-900 transform rotate-45 left-0 top-1/2 -translate-y-1/2 -ml-1',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative inline-block">
|
||||
<div
|
||||
className="inline-block"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onFocus={handleMouseEnter}
|
||||
onBlur={handleMouseLeave}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
show={show}
|
||||
enter="transition duration-200 ease-out"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition duration-150 ease-in"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
className={`absolute z-50 px-2 py-1 text-xs font-medium text-white bg-gray-900 rounded-md shadow-lg
|
||||
whitespace-nowrap max-w-xs ${positionClasses[position]}`}
|
||||
>
|
||||
{text}
|
||||
<div className={arrowClasses[position]}></div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Enhanced Modal with animations and responsive design
|
||||
export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, size = 'md' }) => {
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
};
|
||||
|
||||
// Close on escape key
|
||||
useEffect(() => {
|
||||
const handleEsc = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEsc);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] overflow-y-auto pointer-events-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{ isolation: 'isolate' }}
|
||||
>
|
||||
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<Transition
|
||||
show={isOpen}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div
|
||||
className="fixed inset-0 transition-opacity bg-gray-900 bg-opacity-75 z-[9998]"
|
||||
aria-hidden="true"
|
||||
onClick={onClose}
|
||||
></div>
|
||||
</Transition>
|
||||
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
|
||||
<Transition
|
||||
show={isOpen}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<div
|
||||
className={`relative inline-block px-4 pt-5 pb-4 overflow-hidden text-left align-bottom
|
||||
bg-slate-800 rounded-lg shadow-xl transform transition-all sm:my-8 sm:align-middle sm:p-6
|
||||
${sizeClasses[size]} z-[10000] pointer-events-auto`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium leading-6 text-indigo-400" id="modal-title">
|
||||
{title}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 text-gray-400 bg-slate-700 rounded-full hover:text-white focus:outline-none
|
||||
focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
|
||||
onClick={onClose}
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<FaTimes className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Enhanced Confirmation dialog
|
||||
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
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',
|
||||
info: 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500',
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-300">{message}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex justify-end space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 text-sm font-medium text-gray-300 bg-slate-700 rounded-md
|
||||
hover:bg-slate-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-400
|
||||
transition-colors"
|
||||
onClick={onClose}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`px-4 py-2 text-sm font-medium text-white rounded-md
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors ${variantClasses[variant]}`}
|
||||
onClick={() => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// Enhanced Network statistics card
|
||||
export const NetworkStats: React.FC<NetworkStatsProps> = ({ people, relationships }) => {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
// Calculate statistics
|
||||
const avgConnections =
|
||||
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)
|
||||
).length;
|
||||
|
||||
// Find most connected person
|
||||
const personConnectionCounts = people.map(person => ({
|
||||
person,
|
||||
count: relationships.filter(r => r.source === person._id || r.target === person._id).length,
|
||||
}));
|
||||
|
||||
const mostConnected =
|
||||
personConnectionCounts.length > 0
|
||||
? personConnectionCounts.reduce((prev, current) =>
|
||||
prev.count > current.count ? prev : current
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="bg-slate-800 rounded-lg p-4 shadow-md mb-4 transition-all duration-300">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h3 className="text-md font-semibold text-indigo-400">Network Statistics</h3>
|
||||
<button
|
||||
className="text-slate-400 hover:text-indigo-400 transition-colors text-sm"
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
>
|
||||
{showDetails ? 'Hide details' : 'Show details'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-center">
|
||||
<div className="bg-slate-900 p-2 rounded transition-all duration-300 hover:bg-slate-800">
|
||||
<div className="text-2xl font-bold text-indigo-400">{people.length}</div>
|
||||
<div className="text-xs text-slate-400">People</div>
|
||||
</div>
|
||||
<div className="bg-slate-900 p-2 rounded transition-all duration-300 hover:bg-slate-800">
|
||||
<div className="text-2xl font-bold text-pink-400">{relationships.length}</div>
|
||||
<div className="text-xs text-slate-400">Relationships</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
show={showDetails}
|
||||
enter="transition-all duration-300 ease-out"
|
||||
enterFrom="opacity-0 max-h-0"
|
||||
enterTo="opacity-100 max-h-96"
|
||||
leave="transition-all duration-200 ease-in"
|
||||
leaveFrom="opacity-100 max-h-96"
|
||||
leaveTo="opacity-0 max-h-0"
|
||||
>
|
||||
<div className="overflow-hidden pt-2">
|
||||
<div className="grid grid-cols-2 gap-2 mt-2 text-center">
|
||||
<div className="bg-slate-900 p-2 rounded transition-all duration-300 hover:bg-slate-800">
|
||||
<div className="text-xl font-bold text-blue-400">{avgConnections}</div>
|
||||
<div className="text-xs text-slate-400">Avg. Connections</div>
|
||||
</div>
|
||||
<div className="bg-slate-900 p-2 rounded transition-all duration-300 hover:bg-slate-800">
|
||||
<div className="text-xl font-bold text-amber-400">{isolatedPeople}</div>
|
||||
<div className="text-xs text-slate-400">Isolated People</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mostConnected && mostConnected.count > 0 && (
|
||||
<div className="mt-2 bg-slate-900 p-2 rounded transition-all duration-300 hover:bg-slate-800">
|
||||
<div className="text-xs text-slate-400 mb-1">Most Connected</div>
|
||||
<div className="text-sm font-medium text-indigo-300">
|
||||
{mostConnected.person.firstName} {mostConnected.person.lastName}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">{mostConnected.count} connections</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Enhanced Toast notification component
|
||||
export const Toast: React.FC<ToastProps> = ({
|
||||
message,
|
||||
type,
|
||||
onClose,
|
||||
autoClose = true,
|
||||
duration = 3000,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (autoClose) {
|
||||
const timer = setTimeout(() => {
|
||||
onClose();
|
||||
}, duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [onClose, autoClose, duration]);
|
||||
|
||||
const typeStyles = {
|
||||
success: 'bg-green-600',
|
||||
error: 'bg-red-600',
|
||||
warning: 'bg-amber-600',
|
||||
info: 'bg-blue-600',
|
||||
};
|
||||
|
||||
const icon = {
|
||||
success: '✓',
|
||||
error: '✕',
|
||||
warning: '⚠',
|
||||
info: 'ℹ',
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition
|
||||
show={true}
|
||||
appear={true}
|
||||
enter="transform transition duration-300 ease-out"
|
||||
enterFrom="translate-y-2 opacity-0"
|
||||
enterTo="translate-y-0 opacity-100"
|
||||
leave="transform transition duration-200 ease-in"
|
||||
leaveFrom="translate-y-0 opacity-100"
|
||||
leaveTo="translate-y-2 opacity-0"
|
||||
>
|
||||
<div
|
||||
className={`${typeStyles[type]} text-white px-4 py-3 rounded-lg shadow-lg pointer-events-auto
|
||||
flex items-center max-w-md`}
|
||||
>
|
||||
<span className="mr-2 font-bold">{icon[type]}</span>
|
||||
<span className="flex-1">{message}</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-3 text-white opacity-70 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<FaTimes />
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
|
||||
// Reusable Button component with variants
|
||||
export interface ButtonProps {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'success' | 'outline';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
icon?: React.ReactNode;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
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',
|
||||
danger: 'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500',
|
||||
success: 'bg-green-600 hover:bg-green-700 text-white focus:ring-green-500',
|
||||
outline:
|
||||
'bg-transparent border border-slate-600 text-slate-300 hover:bg-slate-800 focus:ring-slate-400',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-1 text-xs',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-5 py-2.5 text-base',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={`
|
||||
${variantClasses[variant]}
|
||||
${sizeClasses[size]}
|
||||
${fullWidth ? 'w-full' : ''}
|
||||
font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2
|
||||
transition-all duration-200 ease-in-out flex items-center justify-center
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
${className}
|
||||
`}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{icon && <span className={`${children ? 'mr-2' : ''}`}>{icon}</span>}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// FormField component for consistent form styling
|
||||
export interface FormFieldProps {
|
||||
label: string;
|
||||
id: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
labelClassName?: string;
|
||||
}
|
||||
|
||||
export const FormField: React.FC<FormFieldProps> = ({
|
||||
label,
|
||||
id,
|
||||
error,
|
||||
required = false,
|
||||
className = '',
|
||||
children,
|
||||
labelClassName = '',
|
||||
}) => {
|
||||
return (
|
||||
<div className={`mb-4 ${className}`}>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={`block text-sm font-medium text-gray-300 mb-1 ${labelClassName}`}
|
||||
>
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
{children}
|
||||
{error && <p className="mt-1 text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Badge component for tags, status indicators, etc.
|
||||
export interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
color?: 'blue' | 'green' | 'red' | 'yellow' | 'purple' | 'gray';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Badge: React.FC<BadgeProps> = ({ children, color = 'blue', className = '' }) => {
|
||||
const colorClasses = {
|
||||
blue: 'bg-blue-100 text-blue-800',
|
||||
green: 'bg-green-100 text-green-800',
|
||||
red: 'bg-red-100 text-red-800',
|
||||
yellow: 'bg-yellow-100 text-yellow-800',
|
||||
purple: 'bg-purple-100 text-purple-800',
|
||||
gray: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
${colorClasses[color]} ${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Empty state component
|
||||
export interface EmptyStateProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const EmptyState: React.FC<EmptyStateProps> = ({ title, description, icon, action }) => {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
{icon && (
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="p-3 bg-slate-800 rounded-full inline-flex">{icon}</div>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg font-medium text-slate-300 mb-1">{title}</h3>
|
||||
<p className="text-sm text-slate-400 mb-4 max-w-md mx-auto">{description}</p>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Card component
|
||||
export interface CardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Card: React.FC<CardProps> = ({ children, className = '' }) => {
|
||||
return (
|
||||
<div className={`bg-slate-800 rounded-lg overflow-hidden shadow-md ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CardHeader: React.FC<CardProps> = ({ children, className = '' }) => {
|
||||
return <div className={`p-4 border-b border-slate-700 ${className}`}>{children}</div>;
|
||||
};
|
||||
|
||||
export const CardBody: React.FC<CardProps> = ({ children, className = '' }) => {
|
||||
return <div className={`p-4 ${className}`}>{children}</div>;
|
||||
};
|
||||
|
||||
export const CardFooter: React.FC<CardProps> = ({ children, className = '' }) => {
|
||||
return <div className={`p-4 border-t border-slate-700 ${className}`}>{children}</div>;
|
||||
};
|
@ -33,14 +33,14 @@ const Header: React.FC = () => {
|
||||
<FaNetworkWired className="h-6 w-6 text-indigo-400" />
|
||||
<span className="ml-2 text-white font-bold text-xl">RelNet</span>
|
||||
</Link>
|
||||
|
||||
|
||||
{user && (
|
||||
<nav className="ml-8 flex space-x-4">
|
||||
<Link
|
||||
to="/networks"
|
||||
<Link
|
||||
to="/networks"
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium ${
|
||||
location.pathname.includes('/networks')
|
||||
? 'bg-slate-700 text-white'
|
||||
location.pathname.includes('/networks')
|
||||
? 'bg-slate-700 text-white'
|
||||
: 'text-slate-300 hover:bg-slate-700 hover:text-white'
|
||||
} transition-colors duration-200 flex items-center`}
|
||||
>
|
||||
@ -49,7 +49,7 @@ const Header: React.FC = () => {
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center">
|
||||
{user ? (
|
||||
<div className="flex items-center space-x-4">
|
||||
@ -62,7 +62,7 @@ const Header: React.FC = () => {
|
||||
<FaUser />
|
||||
</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">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
@ -96,4 +96,4 @@ const Header: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
export default Header;
|
||||
|
@ -143,7 +143,10 @@ const NetworkList: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-300 text-sm font-medium mb-2" htmlFor="description">
|
||||
<label
|
||||
className="block text-slate-300 text-sm font-medium mb-2"
|
||||
htmlFor="description"
|
||||
>
|
||||
Description (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
@ -166,7 +169,9 @@ const NetworkList: React.FC = () => {
|
||||
checked={isPublic}
|
||||
onChange={e => setIsPublic(e.target.checked)}
|
||||
/>
|
||||
<span className="ml-2 text-slate-300 text-sm font-medium">Make this network public</span>
|
||||
<span className="ml-2 text-slate-300 text-sm font-medium">
|
||||
Make this network public
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@ -224,7 +229,7 @@ const NetworkList: React.FC = () => {
|
||||
<h2 className="text-xl font-semibold mb-4 text-indigo-300 flex items-center">
|
||||
<FaNetworkWired className="mr-2" /> My Networks ({myNetworks.length})
|
||||
</h2>
|
||||
|
||||
|
||||
{myNetworks.length === 0 ? (
|
||||
<p className="text-slate-400 bg-slate-800 p-4 rounded-lg border border-slate-700">
|
||||
You haven't created any networks yet.
|
||||
@ -248,15 +253,15 @@ const NetworkList: React.FC = () => {
|
||||
<FaLock className="text-amber-400" title="Private" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{network.description && (
|
||||
<p className="text-slate-300 mb-4 text-sm">{network.description}</p>
|
||||
)}
|
||||
|
||||
|
||||
<div className="text-xs text-slate-400 mb-6">
|
||||
Created: {new Date(network.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
@ -267,7 +272,7 @@ const NetworkList: React.FC = () => {
|
||||
>
|
||||
<FaEye className="mr-2" /> View
|
||||
</motion.button>
|
||||
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
@ -291,7 +296,7 @@ const NetworkList: React.FC = () => {
|
||||
<h2 className="text-xl font-semibold mb-4 text-indigo-300 flex items-center">
|
||||
<FaGlobe className="mr-2" /> Public Networks ({publicNetworks.length})
|
||||
</h2>
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{publicNetworks.map(network => (
|
||||
<motion.div
|
||||
@ -306,20 +311,21 @@ const NetworkList: React.FC = () => {
|
||||
<h3 className="text-xl font-bold text-white">{network.name}</h3>
|
||||
<FaGlobe className="text-green-400" title="Public" />
|
||||
</div>
|
||||
|
||||
|
||||
{network.description && (
|
||||
<p className="text-slate-300 mb-4 text-sm">{network.description}</p>
|
||||
)}
|
||||
|
||||
|
||||
<div className="flex justify-between mb-6">
|
||||
<span className="text-xs text-slate-400">
|
||||
Created: {new Date(network.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-green-400">
|
||||
By: {typeof network.owner === 'string' ? 'Unknown' : network.owner.username}
|
||||
By:{' '}
|
||||
{typeof network.owner === 'string' ? 'Unknown' : network.owner.username}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
@ -342,4 +348,4 @@ const NetworkList: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkList;
|
||||
export default NetworkList;
|
||||
|
@ -22,7 +22,10 @@ interface RelationshipEdge extends Relationship {
|
||||
const DEFAULT_POLL_INTERVAL = 5000;
|
||||
|
||||
// Custom hook to manage friendship network data
|
||||
export const useFriendshipNetwork = (networkId: string | null, pollInterval = DEFAULT_POLL_INTERVAL) => {
|
||||
export const useFriendshipNetwork = (
|
||||
networkId: string | null,
|
||||
pollInterval = DEFAULT_POLL_INTERVAL
|
||||
) => {
|
||||
const [people, setPeople] = useState<PersonNode[]>([]);
|
||||
const [relationships, setRelationships] = useState<RelationshipEdge[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -32,172 +35,179 @@ export const useFriendshipNetwork = (networkId: string | null, pollInterval = DE
|
||||
const lastRelationshipsUpdateRef = useRef<string>('');
|
||||
|
||||
// Load network data
|
||||
const loadNetworkData = useCallback(async (isPolling = false) => {
|
||||
if (!networkId) {
|
||||
setPeople([]);
|
||||
setRelationships([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isPolling) {
|
||||
setLoading(true);
|
||||
const loadNetworkData = useCallback(
|
||||
async (isPolling = false) => {
|
||||
if (!networkId) {
|
||||
setPeople([]);
|
||||
setRelationships([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
|
||||
// Fetch people and relationships in parallel
|
||||
const [peopleData, relationshipsData] = await Promise.all([
|
||||
getPeople(networkId),
|
||||
getRelationships(networkId),
|
||||
]);
|
||||
try {
|
||||
if (!isPolling) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
// Transform to add the id property needed by the visualization
|
||||
const peopleNodes: PersonNode[] = peopleData.map(person => ({
|
||||
...person,
|
||||
id: person._id,
|
||||
}));
|
||||
// Fetch people and relationships in parallel
|
||||
const [peopleData, relationshipsData] = await Promise.all([
|
||||
getPeople(networkId),
|
||||
getRelationships(networkId),
|
||||
]);
|
||||
|
||||
const relationshipEdges: RelationshipEdge[] = relationshipsData.map(rel => ({
|
||||
...rel,
|
||||
id: rel._id,
|
||||
}));
|
||||
// Transform to add the id property needed by the visualization
|
||||
const peopleNodes: PersonNode[] = peopleData.map(person => ({
|
||||
...person,
|
||||
id: person._id,
|
||||
}));
|
||||
|
||||
// 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 })));
|
||||
|
||||
// Handle people updates
|
||||
const peopleChanged = positionsHash !== lastPeopleUpdateRef.current;
|
||||
const relsChanged = relationshipsHash !== lastRelationshipsUpdateRef.current;
|
||||
|
||||
// Update states only if data has changed or it's the initial load
|
||||
if (peopleChanged || !isPolling) {
|
||||
if (isPolling && people.length > 0) {
|
||||
// During polling, only update nodes that have changed positions
|
||||
const currentPeopleMap = new Map(people.map(p => [p.id, p]));
|
||||
const updatedPeople = [...people];
|
||||
let hasChanges = false;
|
||||
|
||||
// Check for position changes
|
||||
for (const newNode of peopleNodes) {
|
||||
const currentNode = currentPeopleMap.get(newNode.id);
|
||||
if (currentNode) {
|
||||
const currentPos = currentNode.position;
|
||||
const newPos = newNode.position;
|
||||
|
||||
// Update if position changed
|
||||
if (currentPos.x !== newPos.x || currentPos.y !== newPos.y) {
|
||||
const idx = updatedPeople.findIndex(p => p.id === newNode.id);
|
||||
if (idx !== -1) {
|
||||
updatedPeople[idx] = newNode;
|
||||
hasChanges = true;
|
||||
const relationshipEdges: RelationshipEdge[] = relationshipsData.map(rel => ({
|
||||
...rel,
|
||||
id: rel._id,
|
||||
}));
|
||||
|
||||
// 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 }))
|
||||
);
|
||||
|
||||
// Handle people updates
|
||||
const peopleChanged = positionsHash !== lastPeopleUpdateRef.current;
|
||||
const relsChanged = relationshipsHash !== lastRelationshipsUpdateRef.current;
|
||||
|
||||
// Update states only if data has changed or it's the initial load
|
||||
if (peopleChanged || !isPolling) {
|
||||
if (isPolling && people.length > 0) {
|
||||
// During polling, only update nodes that have changed positions
|
||||
const currentPeopleMap = new Map(people.map(p => [p.id, p]));
|
||||
const updatedPeople = [...people];
|
||||
let hasChanges = false;
|
||||
|
||||
// Check for position changes
|
||||
for (const newNode of peopleNodes) {
|
||||
const currentNode = currentPeopleMap.get(newNode.id);
|
||||
if (currentNode) {
|
||||
const currentPos = currentNode.position;
|
||||
const newPos = newNode.position;
|
||||
|
||||
// Update if position changed
|
||||
if (currentPos.x !== newPos.x || currentPos.y !== newPos.y) {
|
||||
const idx = updatedPeople.findIndex(p => p.id === newNode.id);
|
||||
if (idx !== -1) {
|
||||
updatedPeople[idx] = newNode;
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// New node not in current state, add it
|
||||
updatedPeople.push(newNode);
|
||||
hasChanges = true;
|
||||
}
|
||||
} else {
|
||||
// New node not in current state, add it
|
||||
updatedPeople.push(newNode);
|
||||
}
|
||||
|
||||
// Check for removed nodes
|
||||
const newNodeIds = new Set(peopleNodes.map(p => p.id));
|
||||
const removedNodes = updatedPeople.filter(p => !newNodeIds.has(p.id));
|
||||
if (removedNodes.length > 0) {
|
||||
const remainingPeople = updatedPeople.filter(p => newNodeIds.has(p.id));
|
||||
updatedPeople.length = 0;
|
||||
updatedPeople.push(...remainingPeople);
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Update state only if positions changed
|
||||
if (hasChanges) {
|
||||
setPeople(updatedPeople);
|
||||
}
|
||||
} else {
|
||||
// Initial load or major change - set everything
|
||||
setPeople(peopleNodes);
|
||||
}
|
||||
|
||||
// Check for removed nodes
|
||||
const newNodeIds = new Set(peopleNodes.map(p => p.id));
|
||||
const removedNodes = updatedPeople.filter(p => !newNodeIds.has(p.id));
|
||||
if (removedNodes.length > 0) {
|
||||
const remainingPeople = updatedPeople.filter(p => newNodeIds.has(p.id));
|
||||
updatedPeople.length = 0;
|
||||
updatedPeople.push(...remainingPeople);
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Update state only if positions changed
|
||||
if (hasChanges) {
|
||||
setPeople(updatedPeople);
|
||||
}
|
||||
} else {
|
||||
// Initial load or major change - set everything
|
||||
setPeople(peopleNodes);
|
||||
|
||||
lastPeopleUpdateRef.current = positionsHash;
|
||||
}
|
||||
|
||||
lastPeopleUpdateRef.current = positionsHash;
|
||||
}
|
||||
|
||||
// Handle relationship updates similarly
|
||||
if (relsChanged || !isPolling) {
|
||||
if (isPolling && relationships.length > 0) {
|
||||
// Check for changes in relationships
|
||||
const currentRelsMap = new Map(relationships.map(r => [r.id, r]));
|
||||
const updatedRels = [...relationships];
|
||||
let hasRelChanges = false;
|
||||
|
||||
// Add new or changed relationships
|
||||
for (const newRel of relationshipEdges) {
|
||||
const currentRel = currentRelsMap.get(newRel.id);
|
||||
if (!currentRel) {
|
||||
// New relationship
|
||||
updatedRels.push(newRel);
|
||||
hasRelChanges = true;
|
||||
} else if (currentRel.type !== newRel.type ||
|
||||
currentRel.source !== newRel.source ||
|
||||
currentRel.target !== newRel.target) {
|
||||
// Changed relationship
|
||||
const idx = updatedRels.findIndex(r => r.id === newRel.id);
|
||||
if (idx !== -1) {
|
||||
updatedRels[idx] = newRel;
|
||||
|
||||
// Handle relationship updates similarly
|
||||
if (relsChanged || !isPolling) {
|
||||
if (isPolling && relationships.length > 0) {
|
||||
// Check for changes in relationships
|
||||
const currentRelsMap = new Map(relationships.map(r => [r.id, r]));
|
||||
const updatedRels = [...relationships];
|
||||
let hasRelChanges = false;
|
||||
|
||||
// Add new or changed relationships
|
||||
for (const newRel of relationshipEdges) {
|
||||
const currentRel = currentRelsMap.get(newRel.id);
|
||||
if (!currentRel) {
|
||||
// New relationship
|
||||
updatedRels.push(newRel);
|
||||
hasRelChanges = true;
|
||||
} else if (
|
||||
currentRel.type !== newRel.type ||
|
||||
currentRel.source !== newRel.source ||
|
||||
currentRel.target !== newRel.target
|
||||
) {
|
||||
// Changed relationship
|
||||
const idx = updatedRels.findIndex(r => r.id === newRel.id);
|
||||
if (idx !== -1) {
|
||||
updatedRels[idx] = newRel;
|
||||
hasRelChanges = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove deleted relationships
|
||||
const newRelIds = new Set(relationshipEdges.map(r => r.id));
|
||||
const removedRels = updatedRels.filter(r => !newRelIds.has(r.id));
|
||||
if (removedRels.length > 0) {
|
||||
const remainingRels = updatedRels.filter(r => newRelIds.has(r.id));
|
||||
updatedRels.length = 0;
|
||||
updatedRels.push(...remainingRels);
|
||||
hasRelChanges = true;
|
||||
}
|
||||
|
||||
if (hasRelChanges) {
|
||||
setRelationships(updatedRels);
|
||||
}
|
||||
} else {
|
||||
// Initial load or major change
|
||||
setRelationships(relationshipEdges);
|
||||
}
|
||||
|
||||
// Remove deleted relationships
|
||||
const newRelIds = new Set(relationshipEdges.map(r => r.id));
|
||||
const removedRels = updatedRels.filter(r => !newRelIds.has(r.id));
|
||||
if (removedRels.length > 0) {
|
||||
const remainingRels = updatedRels.filter(r => newRelIds.has(r.id));
|
||||
updatedRels.length = 0;
|
||||
updatedRels.push(...remainingRels);
|
||||
hasRelChanges = true;
|
||||
}
|
||||
|
||||
if (hasRelChanges) {
|
||||
setRelationships(updatedRels);
|
||||
}
|
||||
} else {
|
||||
// Initial load or major change
|
||||
setRelationships(relationshipEdges);
|
||||
|
||||
lastRelationshipsUpdateRef.current = relationshipsHash;
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load network data');
|
||||
console.error('Error loading network data:', err);
|
||||
} finally {
|
||||
if (!isPolling) {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
lastRelationshipsUpdateRef.current = relationshipsHash;
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load network data');
|
||||
console.error('Error loading network data:', err);
|
||||
} finally {
|
||||
if (!isPolling) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [networkId]);
|
||||
},
|
||||
[networkId]
|
||||
);
|
||||
|
||||
// Set up polling for network data
|
||||
useEffect(() => {
|
||||
// Initial load
|
||||
loadNetworkData();
|
||||
|
||||
|
||||
// Set up polling if interval is provided and > 0
|
||||
if (networkId && pollInterval > 0) {
|
||||
// Clear any existing timer
|
||||
if (pollTimerRef.current) {
|
||||
window.clearInterval(pollTimerRef.current);
|
||||
}
|
||||
|
||||
|
||||
// Create new polling timer
|
||||
pollTimerRef.current = window.setInterval(() => {
|
||||
loadNetworkData(true);
|
||||
}, pollInterval);
|
||||
}
|
||||
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (pollTimerRef.current) {
|
||||
@ -221,10 +231,12 @@ export const useFriendshipNetwork = (networkId: string | null, pollInterval = DE
|
||||
const newPersonNode: PersonNode = { ...newPerson, id: newPerson._id };
|
||||
const updatedPeople = [...people, newPersonNode];
|
||||
setPeople(updatedPeople);
|
||||
|
||||
|
||||
// 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 })));
|
||||
|
||||
lastPeopleUpdateRef.current = JSON.stringify(
|
||||
updatedPeople.map(p => ({ id: p.id, pos: p.position }))
|
||||
);
|
||||
|
||||
return newPersonNode;
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to create person');
|
||||
@ -249,14 +261,16 @@ export const useFriendshipNetwork = (networkId: string | null, pollInterval = DE
|
||||
const updatedPersonNode: PersonNode = { ...updatedPerson, id: updatedPerson._id };
|
||||
|
||||
// Update the local state
|
||||
const updatedPeople = people.map(person =>
|
||||
(person._id === personId ? updatedPersonNode : person)
|
||||
const updatedPeople = people.map(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 })));
|
||||
lastPeopleUpdateRef.current = JSON.stringify(
|
||||
updatedPeople.map(p => ({ id: p.id, pos: p.position }))
|
||||
);
|
||||
}
|
||||
|
||||
return updatedPersonNode;
|
||||
@ -282,12 +296,14 @@ export const useFriendshipNetwork = (networkId: string | null, pollInterval = DE
|
||||
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 })));
|
||||
lastRelationshipsUpdateRef.current = JSON.stringify(updatedRelationships.map(r =>
|
||||
({ id: r.id, src: r.source, tgt: r.target, type: r.type })
|
||||
));
|
||||
lastPeopleUpdateRef.current = JSON.stringify(
|
||||
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 }))
|
||||
);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to delete person');
|
||||
throw err;
|
||||
@ -309,12 +325,12 @@ export const useFriendshipNetwork = (networkId: string | null, pollInterval = DE
|
||||
|
||||
const updatedRelationships = [...relationships, newRelationshipEdge];
|
||||
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 })
|
||||
));
|
||||
|
||||
lastRelationshipsUpdateRef.current = JSON.stringify(
|
||||
updatedRelationships.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type }))
|
||||
);
|
||||
|
||||
return newRelationshipEdge;
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to create relationship');
|
||||
@ -343,15 +359,15 @@ export const useFriendshipNetwork = (networkId: string | null, pollInterval = DE
|
||||
id: updatedRelationship._id,
|
||||
};
|
||||
|
||||
const updatedRelationships = relationships.map(rel =>
|
||||
(rel._id === relationshipId ? updatedRelationshipEdge : rel)
|
||||
const updatedRelationships = relationships.map(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 })
|
||||
));
|
||||
lastRelationshipsUpdateRef.current = JSON.stringify(
|
||||
updatedRelationships.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type }))
|
||||
);
|
||||
|
||||
return updatedRelationshipEdge;
|
||||
} catch (err: any) {
|
||||
@ -368,11 +384,11 @@ export const useFriendshipNetwork = (networkId: string | null, pollInterval = DE
|
||||
await removeRelationship(networkId, relationshipId);
|
||||
const updatedRelationships = relationships.filter(rel => rel._id !== relationshipId);
|
||||
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 })
|
||||
));
|
||||
lastRelationshipsUpdateRef.current = JSON.stringify(
|
||||
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');
|
||||
throw err;
|
||||
@ -383,7 +399,7 @@ export const useFriendshipNetwork = (networkId: string | null, pollInterval = DE
|
||||
const refreshNetwork = async (): Promise<void> => {
|
||||
await loadNetworkData();
|
||||
};
|
||||
|
||||
|
||||
// Update the poll interval
|
||||
const setPollInterval = (newInterval: number) => {
|
||||
// Clear existing timer
|
||||
@ -391,7 +407,7 @@ export const useFriendshipNetwork = (networkId: string | null, pollInterval = DE
|
||||
window.clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
|
||||
|
||||
// Set up new timer if interval > 0
|
||||
if (newInterval > 0 && networkId) {
|
||||
pollTimerRef.current = window.setInterval(() => {
|
||||
|
26
src/app.ts
26
src/app.ts
@ -7,14 +7,35 @@ import networkRoutes from './routes/network.routes';
|
||||
import peopleRoutes from './routes/people.routes';
|
||||
import relationshipRoutes from './routes/relationship.routes';
|
||||
import path from 'node:path';
|
||||
import helmet from "helmet";
|
||||
import helmet from 'helmet';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app: Application = express();
|
||||
|
||||
// Middleware
|
||||
app.use(helmet());
|
||||
// Apply Helmet to API routes only
|
||||
app.use(
|
||||
'/api',
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:'],
|
||||
connectSrc: ["'self'", 'http://localhost:*', 'ws://localhost:*'],
|
||||
fontSrc: ["'self'", 'data:'],
|
||||
objectSrc: ["'none'"],
|
||||
mediaSrc: ["'self'"],
|
||||
frameSrc: ["'none'"],
|
||||
},
|
||||
},
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
crossOriginEmbedderPolicy: false,
|
||||
})
|
||||
);
|
||||
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use(
|
||||
@ -30,7 +51,6 @@ app.use('/api/networks', networkRoutes);
|
||||
app.use('/api/networks', peopleRoutes);
|
||||
app.use('/api/networks', relationshipRoutes);
|
||||
|
||||
|
||||
app.use(express.static(path.join(__dirname, '../frontend/dist/')));
|
||||
|
||||
app.use((req, res, next) => {
|
||||
|
@ -1,8 +1,12 @@
|
||||
import { Request, Response } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import User, { IUser } from '../models/user.model';
|
||||
import Network from '../models/network.model';
|
||||
import Person from '../models/person.model';
|
||||
import Relationship from '../models/relationship.model';
|
||||
import { UserRequest } from '../types/express';
|
||||
import { validationResult } from 'express-validator';
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
// JWT secret from environment variables
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your_jwt_secret_key_change_this';
|
||||
@ -62,6 +66,10 @@ export const register = async (req: Request, res: Response): Promise<void> => {
|
||||
// Save user to database
|
||||
await user.save();
|
||||
|
||||
// Create a sample demo network
|
||||
// Fix: Ensure user._id is treated as ObjectId
|
||||
await createSampleDemoNetwork(user._id);
|
||||
|
||||
// Generate JWT token
|
||||
const token = generateToken(user);
|
||||
|
||||
@ -163,3 +171,73 @@ export const getCurrentUser = async (req: UserRequest, res: Response): Promise<v
|
||||
res.status(500).json({ message: 'Server error' });
|
||||
}
|
||||
};
|
||||
|
||||
// Create a sample demo network for new users
|
||||
// Fix: Update parameter type to accept both string and ObjectId
|
||||
const createSampleDemoNetwork = async (userId: mongoose.Types.ObjectId | string): Promise<void> => {
|
||||
try {
|
||||
// Ensure userId is an ObjectId
|
||||
const userObjectId = typeof userId === 'string' ? new mongoose.Types.ObjectId(userId) : userId;
|
||||
|
||||
// Create a demo network
|
||||
const network = new Network({
|
||||
name: 'My Sample Network',
|
||||
description: 'A demo network to help you get started',
|
||||
owner: userObjectId,
|
||||
isPublic: false,
|
||||
});
|
||||
|
||||
await network.save();
|
||||
|
||||
// Create sample people with better spacing
|
||||
const people = [
|
||||
{ firstName: 'John', lastName: 'Smith', position: { x: 200, y: 200 } },
|
||||
{ firstName: 'Emma', lastName: 'Johnson', position: { x: 600, y: 200 } },
|
||||
{ firstName: 'Michael', lastName: 'Williams', position: { x: 200, y: 600 } },
|
||||
{ firstName: 'Sarah', lastName: 'Brown', position: { x: 600, y: 600 } },
|
||||
{ firstName: 'David', lastName: 'Jones', position: { x: 800, y: 400 } },
|
||||
{ firstName: 'Lisa', lastName: 'Garcia', position: { x: 400, y: 400 } },
|
||||
];
|
||||
|
||||
// Fix: Update the type to accept string or ObjectId
|
||||
const savedPeople: { [key: string]: mongoose.Types.ObjectId | string } = {};
|
||||
|
||||
// Create each person
|
||||
for (const person of people) {
|
||||
const newPerson = new Person({
|
||||
firstName: person.firstName,
|
||||
lastName: person.lastName,
|
||||
network: network._id,
|
||||
position: person.position,
|
||||
});
|
||||
|
||||
await newPerson.save();
|
||||
savedPeople[`${person.firstName}${person.lastName}`] = newPerson._id;
|
||||
}
|
||||
|
||||
// Create relationships between people
|
||||
const relationships = [
|
||||
{ source: 'JohnSmith', target: 'EmmaJohnson', type: 'freund' },
|
||||
{ source: 'EmmaJohnson', target: 'MichaelWilliams', type: 'familie' },
|
||||
{ source: 'MichaelWilliams', target: 'SarahBrown', type: 'arbeitskolleg' },
|
||||
{ source: 'SarahBrown', target: 'DavidJones', type: 'freund' },
|
||||
{ source: 'DavidJones', target: 'LisaGarcia', type: 'partner' },
|
||||
{ source: 'JohnSmith', target: 'DavidJones', type: 'arbeitskolleg' },
|
||||
];
|
||||
|
||||
// Create each relationship
|
||||
for (const rel of relationships) {
|
||||
const newRelationship = new Relationship({
|
||||
source: savedPeople[rel.source],
|
||||
target: savedPeople[rel.target],
|
||||
type: rel.type,
|
||||
network: network._id,
|
||||
});
|
||||
|
||||
await newRelationship.save();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating sample network:', error);
|
||||
// Don't throw the error, just log it so that registration can continue
|
||||
}
|
||||
};
|
||||
|
@ -66,7 +66,7 @@ export const addPerson = async (req: UserRequest, res: Response): Promise<void>
|
||||
lastName,
|
||||
birthday: birthday || undefined,
|
||||
network: networkId,
|
||||
position: position || { x: 100 + Math.random() * 500, y: 100 + Math.random() * 400 },
|
||||
position: position || { x: 100 + Math.random() * 800, y: 100 + Math.random() * 600 },
|
||||
});
|
||||
|
||||
await person.save();
|
||||
|
Loading…
x
Reference in New Issue
Block a user