mirror of
https://github.com/philipredstone/relnet.git
synced 2025-06-17 05:01:24 +02:00
change ui
This commit is contained in:
parent
acbff34640
commit
9eddb1b547
@ -13,15 +13,16 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@headlessui/react": "^2.2.1",
|
||||||
"@tailwindcss/vite": "^4.1.4",
|
"@tailwindcss/vite": "^4.1.4",
|
||||||
"axios": "^1.8.4",
|
"axios": "^1.8.4",
|
||||||
"framer-motion": "^12.7.3",
|
"framer-motion": "^12.7.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
"react-datepicker": "^8.3.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-force-graph-2d": "^1.27.1",
|
"react-force-graph-2d": "^1.27.1",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-router-dom": "^7.5.0",
|
"react-router-dom": "^7.5.0",
|
||||||
"tailwindcss": "^4.1.4",
|
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^6.2.6"
|
"vite": "^6.2.6"
|
||||||
@ -33,7 +34,10 @@
|
|||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react": "^4.4.0",
|
"@vitejs/plugin-react": "^4.4.0",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"postcss": "^8.4.32",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
|
"tailwindcss": "^4.1.4",
|
||||||
"webpack": "^5.99.5",
|
"webpack": "^5.99.5",
|
||||||
"webpack-cli": "^6.0.1"
|
"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" />
|
<FaNetworkWired className="h-6 w-6 text-indigo-400" />
|
||||||
<span className="ml-2 text-white font-bold text-xl">RelNet</span>
|
<span className="ml-2 text-white font-bold text-xl">RelNet</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<nav className="ml-8 flex space-x-4">
|
<nav className="ml-8 flex space-x-4">
|
||||||
<Link
|
<Link
|
||||||
to="/networks"
|
to="/networks"
|
||||||
className={`px-3 py-2 rounded-md text-sm font-medium ${
|
className={`px-3 py-2 rounded-md text-sm font-medium ${
|
||||||
location.pathname.includes('/networks')
|
location.pathname.includes('/networks')
|
||||||
? 'bg-slate-700 text-white'
|
? 'bg-slate-700 text-white'
|
||||||
: 'text-slate-300 hover:bg-slate-700 hover:text-white'
|
: 'text-slate-300 hover:bg-slate-700 hover:text-white'
|
||||||
} transition-colors duration-200 flex items-center`}
|
} transition-colors duration-200 flex items-center`}
|
||||||
>
|
>
|
||||||
@ -49,7 +49,7 @@ const Header: React.FC = () => {
|
|||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{user ? (
|
{user ? (
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
@ -62,7 +62,7 @@ const Header: React.FC = () => {
|
|||||||
<FaUser />
|
<FaUser />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={handleLogout}
|
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>
|
||||||
|
|
||||||
<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)
|
Description (Optional)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@ -166,7 +169,9 @@ const NetworkList: React.FC = () => {
|
|||||||
checked={isPublic}
|
checked={isPublic}
|
||||||
onChange={e => setIsPublic(e.target.checked)}
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -224,7 +229,7 @@ const NetworkList: React.FC = () => {
|
|||||||
<h2 className="text-xl font-semibold mb-4 text-indigo-300 flex items-center">
|
<h2 className="text-xl font-semibold mb-4 text-indigo-300 flex items-center">
|
||||||
<FaNetworkWired className="mr-2" /> My Networks ({myNetworks.length})
|
<FaNetworkWired className="mr-2" /> My Networks ({myNetworks.length})
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{myNetworks.length === 0 ? (
|
{myNetworks.length === 0 ? (
|
||||||
<p className="text-slate-400 bg-slate-800 p-4 rounded-lg border border-slate-700">
|
<p className="text-slate-400 bg-slate-800 p-4 rounded-lg border border-slate-700">
|
||||||
You haven't created any networks yet.
|
You haven't created any networks yet.
|
||||||
@ -248,15 +253,15 @@ const NetworkList: React.FC = () => {
|
|||||||
<FaLock className="text-amber-400" title="Private" />
|
<FaLock className="text-amber-400" title="Private" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{network.description && (
|
{network.description && (
|
||||||
<p className="text-slate-300 mb-4 text-sm">{network.description}</p>
|
<p className="text-slate-300 mb-4 text-sm">{network.description}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="text-xs text-slate-400 mb-6">
|
<div className="text-xs text-slate-400 mb-6">
|
||||||
Created: {new Date(network.createdAt).toLocaleDateString()}
|
Created: {new Date(network.createdAt).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex space-x-3">
|
<div className="flex space-x-3">
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
@ -267,7 +272,7 @@ const NetworkList: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<FaEye className="mr-2" /> View
|
<FaEye className="mr-2" /> View
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
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">
|
<h2 className="text-xl font-semibold mb-4 text-indigo-300 flex items-center">
|
||||||
<FaGlobe className="mr-2" /> Public Networks ({publicNetworks.length})
|
<FaGlobe className="mr-2" /> Public Networks ({publicNetworks.length})
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{publicNetworks.map(network => (
|
{publicNetworks.map(network => (
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -306,20 +311,21 @@ const NetworkList: React.FC = () => {
|
|||||||
<h3 className="text-xl font-bold text-white">{network.name}</h3>
|
<h3 className="text-xl font-bold text-white">{network.name}</h3>
|
||||||
<FaGlobe className="text-green-400" title="Public" />
|
<FaGlobe className="text-green-400" title="Public" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{network.description && (
|
{network.description && (
|
||||||
<p className="text-slate-300 mb-4 text-sm">{network.description}</p>
|
<p className="text-slate-300 mb-4 text-sm">{network.description}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-between mb-6">
|
<div className="flex justify-between mb-6">
|
||||||
<span className="text-xs text-slate-400">
|
<span className="text-xs text-slate-400">
|
||||||
Created: {new Date(network.createdAt).toLocaleDateString()}
|
Created: {new Date(network.createdAt).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-medium text-green-400">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
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;
|
const DEFAULT_POLL_INTERVAL = 5000;
|
||||||
|
|
||||||
// Custom hook to manage friendship network data
|
// 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 [people, setPeople] = useState<PersonNode[]>([]);
|
||||||
const [relationships, setRelationships] = useState<RelationshipEdge[]>([]);
|
const [relationships, setRelationships] = useState<RelationshipEdge[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -32,172 +35,179 @@ export const useFriendshipNetwork = (networkId: string | null, pollInterval = DE
|
|||||||
const lastRelationshipsUpdateRef = useRef<string>('');
|
const lastRelationshipsUpdateRef = useRef<string>('');
|
||||||
|
|
||||||
// Load network data
|
// Load network data
|
||||||
const loadNetworkData = useCallback(async (isPolling = false) => {
|
const loadNetworkData = useCallback(
|
||||||
if (!networkId) {
|
async (isPolling = false) => {
|
||||||
setPeople([]);
|
if (!networkId) {
|
||||||
setRelationships([]);
|
setPeople([]);
|
||||||
setLoading(false);
|
setRelationships([]);
|
||||||
return;
|
setLoading(false);
|
||||||
}
|
return;
|
||||||
|
|
||||||
try {
|
|
||||||
if (!isPolling) {
|
|
||||||
setLoading(true);
|
|
||||||
}
|
}
|
||||||
setError(null);
|
|
||||||
|
|
||||||
// Fetch people and relationships in parallel
|
try {
|
||||||
const [peopleData, relationshipsData] = await Promise.all([
|
if (!isPolling) {
|
||||||
getPeople(networkId),
|
setLoading(true);
|
||||||
getRelationships(networkId),
|
}
|
||||||
]);
|
setError(null);
|
||||||
|
|
||||||
// Transform to add the id property needed by the visualization
|
// Fetch people and relationships in parallel
|
||||||
const peopleNodes: PersonNode[] = peopleData.map(person => ({
|
const [peopleData, relationshipsData] = await Promise.all([
|
||||||
...person,
|
getPeople(networkId),
|
||||||
id: person._id,
|
getRelationships(networkId),
|
||||||
}));
|
]);
|
||||||
|
|
||||||
const relationshipEdges: RelationshipEdge[] = relationshipsData.map(rel => ({
|
// Transform to add the id property needed by the visualization
|
||||||
...rel,
|
const peopleNodes: PersonNode[] = peopleData.map(person => ({
|
||||||
id: rel._id,
|
...person,
|
||||||
}));
|
id: person._id,
|
||||||
|
}));
|
||||||
|
|
||||||
// Generate hashes to detect changes
|
const relationshipEdges: RelationshipEdge[] = relationshipsData.map(rel => ({
|
||||||
const positionsHash = JSON.stringify(peopleNodes.map(p => ({ id: p.id, pos: p.position })));
|
...rel,
|
||||||
const relationshipsHash = JSON.stringify(relationshipEdges.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type })));
|
id: rel._id,
|
||||||
|
}));
|
||||||
// Handle people updates
|
|
||||||
const peopleChanged = positionsHash !== lastPeopleUpdateRef.current;
|
// Generate hashes to detect changes
|
||||||
const relsChanged = relationshipsHash !== lastRelationshipsUpdateRef.current;
|
const positionsHash = JSON.stringify(peopleNodes.map(p => ({ id: p.id, pos: p.position })));
|
||||||
|
const relationshipsHash = JSON.stringify(
|
||||||
// Update states only if data has changed or it's the initial load
|
relationshipEdges.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type }))
|
||||||
if (peopleChanged || !isPolling) {
|
);
|
||||||
if (isPolling && people.length > 0) {
|
|
||||||
// During polling, only update nodes that have changed positions
|
// Handle people updates
|
||||||
const currentPeopleMap = new Map(people.map(p => [p.id, p]));
|
const peopleChanged = positionsHash !== lastPeopleUpdateRef.current;
|
||||||
const updatedPeople = [...people];
|
const relsChanged = relationshipsHash !== lastRelationshipsUpdateRef.current;
|
||||||
let hasChanges = false;
|
|
||||||
|
// Update states only if data has changed or it's the initial load
|
||||||
// Check for position changes
|
if (peopleChanged || !isPolling) {
|
||||||
for (const newNode of peopleNodes) {
|
if (isPolling && people.length > 0) {
|
||||||
const currentNode = currentPeopleMap.get(newNode.id);
|
// During polling, only update nodes that have changed positions
|
||||||
if (currentNode) {
|
const currentPeopleMap = new Map(people.map(p => [p.id, p]));
|
||||||
const currentPos = currentNode.position;
|
const updatedPeople = [...people];
|
||||||
const newPos = newNode.position;
|
let hasChanges = false;
|
||||||
|
|
||||||
// Update if position changed
|
// Check for position changes
|
||||||
if (currentPos.x !== newPos.x || currentPos.y !== newPos.y) {
|
for (const newNode of peopleNodes) {
|
||||||
const idx = updatedPeople.findIndex(p => p.id === newNode.id);
|
const currentNode = currentPeopleMap.get(newNode.id);
|
||||||
if (idx !== -1) {
|
if (currentNode) {
|
||||||
updatedPeople[idx] = newNode;
|
const currentPos = currentNode.position;
|
||||||
hasChanges = true;
|
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;
|
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
|
lastPeopleUpdateRef.current = positionsHash;
|
||||||
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;
|
// Handle relationship updates similarly
|
||||||
}
|
if (relsChanged || !isPolling) {
|
||||||
|
if (isPolling && relationships.length > 0) {
|
||||||
// Handle relationship updates similarly
|
// Check for changes in relationships
|
||||||
if (relsChanged || !isPolling) {
|
const currentRelsMap = new Map(relationships.map(r => [r.id, r]));
|
||||||
if (isPolling && relationships.length > 0) {
|
const updatedRels = [...relationships];
|
||||||
// Check for changes in relationships
|
let hasRelChanges = false;
|
||||||
const currentRelsMap = new Map(relationships.map(r => [r.id, r]));
|
|
||||||
const updatedRels = [...relationships];
|
// Add new or changed relationships
|
||||||
let hasRelChanges = false;
|
for (const newRel of relationshipEdges) {
|
||||||
|
const currentRel = currentRelsMap.get(newRel.id);
|
||||||
// Add new or changed relationships
|
if (!currentRel) {
|
||||||
for (const newRel of relationshipEdges) {
|
// New relationship
|
||||||
const currentRel = currentRelsMap.get(newRel.id);
|
updatedRels.push(newRel);
|
||||||
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;
|
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
|
lastRelationshipsUpdateRef.current = relationshipsHash;
|
||||||
const newRelIds = new Set(relationshipEdges.map(r => r.id));
|
}
|
||||||
const removedRels = updatedRels.filter(r => !newRelIds.has(r.id));
|
} catch (err: any) {
|
||||||
if (removedRels.length > 0) {
|
setError(err.message || 'Failed to load network data');
|
||||||
const remainingRels = updatedRels.filter(r => newRelIds.has(r.id));
|
console.error('Error loading network data:', err);
|
||||||
updatedRels.length = 0;
|
} finally {
|
||||||
updatedRels.push(...remainingRels);
|
if (!isPolling) {
|
||||||
hasRelChanges = true;
|
setLoading(false);
|
||||||
}
|
|
||||||
|
|
||||||
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');
|
[networkId]
|
||||||
console.error('Error loading network data:', err);
|
);
|
||||||
} finally {
|
|
||||||
if (!isPolling) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [networkId]);
|
|
||||||
|
|
||||||
// Set up polling for network data
|
// Set up polling for network data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Initial load
|
// Initial load
|
||||||
loadNetworkData();
|
loadNetworkData();
|
||||||
|
|
||||||
// Set up polling if interval is provided and > 0
|
// Set up polling if interval is provided and > 0
|
||||||
if (networkId && pollInterval > 0) {
|
if (networkId && pollInterval > 0) {
|
||||||
// Clear any existing timer
|
// Clear any existing timer
|
||||||
if (pollTimerRef.current) {
|
if (pollTimerRef.current) {
|
||||||
window.clearInterval(pollTimerRef.current);
|
window.clearInterval(pollTimerRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new polling timer
|
// Create new polling timer
|
||||||
pollTimerRef.current = window.setInterval(() => {
|
pollTimerRef.current = window.setInterval(() => {
|
||||||
loadNetworkData(true);
|
loadNetworkData(true);
|
||||||
}, pollInterval);
|
}, pollInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup function
|
// Cleanup function
|
||||||
return () => {
|
return () => {
|
||||||
if (pollTimerRef.current) {
|
if (pollTimerRef.current) {
|
||||||
@ -221,10 +231,12 @@ export const useFriendshipNetwork = (networkId: string | null, pollInterval = DE
|
|||||||
const newPersonNode: PersonNode = { ...newPerson, id: newPerson._id };
|
const newPersonNode: PersonNode = { ...newPerson, id: newPerson._id };
|
||||||
const updatedPeople = [...people, newPersonNode];
|
const updatedPeople = [...people, newPersonNode];
|
||||||
setPeople(updatedPeople);
|
setPeople(updatedPeople);
|
||||||
|
|
||||||
// Update the reference hash to avoid unnecessary state updates on next poll
|
// 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;
|
return newPersonNode;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to create person');
|
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 };
|
const updatedPersonNode: PersonNode = { ...updatedPerson, id: updatedPerson._id };
|
||||||
|
|
||||||
// Update the local state
|
// Update the local state
|
||||||
const updatedPeople = people.map(person =>
|
const updatedPeople = people.map(person =>
|
||||||
(person._id === personId ? updatedPersonNode : person)
|
person._id === personId ? updatedPersonNode : person
|
||||||
);
|
);
|
||||||
setPeople(updatedPeople);
|
setPeople(updatedPeople);
|
||||||
|
|
||||||
// Update the reference hash if position changed to avoid unnecessary state updates on next poll
|
// Update the reference hash if position changed to avoid unnecessary state updates on next poll
|
||||||
if (personData.position) {
|
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;
|
return updatedPersonNode;
|
||||||
@ -282,12 +296,14 @@ export const useFriendshipNetwork = (networkId: string | null, pollInterval = DE
|
|||||||
rel => rel.source !== personId && rel.target !== personId
|
rel => rel.source !== personId && rel.target !== personId
|
||||||
);
|
);
|
||||||
setRelationships(updatedRelationships);
|
setRelationships(updatedRelationships);
|
||||||
|
|
||||||
// Update both reference hashes to avoid unnecessary state updates on next poll
|
// 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 })));
|
lastPeopleUpdateRef.current = JSON.stringify(
|
||||||
lastRelationshipsUpdateRef.current = JSON.stringify(updatedRelationships.map(r =>
|
updatedPeople.map(p => ({ id: p.id, pos: p.position }))
|
||||||
({ 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) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to delete person');
|
setError(err.message || 'Failed to delete person');
|
||||||
throw err;
|
throw err;
|
||||||
@ -309,12 +325,12 @@ export const useFriendshipNetwork = (networkId: string | null, pollInterval = DE
|
|||||||
|
|
||||||
const updatedRelationships = [...relationships, newRelationshipEdge];
|
const updatedRelationships = [...relationships, newRelationshipEdge];
|
||||||
setRelationships(updatedRelationships);
|
setRelationships(updatedRelationships);
|
||||||
|
|
||||||
// Update the relationship hash to avoid unnecessary state updates on next poll
|
// Update the relationship hash to avoid unnecessary state updates on next poll
|
||||||
lastRelationshipsUpdateRef.current = JSON.stringify(updatedRelationships.map(r =>
|
lastRelationshipsUpdateRef.current = JSON.stringify(
|
||||||
({ 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;
|
return newRelationshipEdge;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to create relationship');
|
setError(err.message || 'Failed to create relationship');
|
||||||
@ -343,15 +359,15 @@ export const useFriendshipNetwork = (networkId: string | null, pollInterval = DE
|
|||||||
id: updatedRelationship._id,
|
id: updatedRelationship._id,
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatedRelationships = relationships.map(rel =>
|
const updatedRelationships = relationships.map(rel =>
|
||||||
(rel._id === relationshipId ? updatedRelationshipEdge : rel)
|
rel._id === relationshipId ? updatedRelationshipEdge : rel
|
||||||
);
|
);
|
||||||
setRelationships(updatedRelationships);
|
setRelationships(updatedRelationships);
|
||||||
|
|
||||||
// Update the relationship hash to avoid unnecessary state updates on next poll
|
// Update the relationship hash to avoid unnecessary state updates on next poll
|
||||||
lastRelationshipsUpdateRef.current = JSON.stringify(updatedRelationships.map(r =>
|
lastRelationshipsUpdateRef.current = JSON.stringify(
|
||||||
({ 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;
|
return updatedRelationshipEdge;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -368,11 +384,11 @@ export const useFriendshipNetwork = (networkId: string | null, pollInterval = DE
|
|||||||
await removeRelationship(networkId, relationshipId);
|
await removeRelationship(networkId, relationshipId);
|
||||||
const updatedRelationships = relationships.filter(rel => rel._id !== relationshipId);
|
const updatedRelationships = relationships.filter(rel => rel._id !== relationshipId);
|
||||||
setRelationships(updatedRelationships);
|
setRelationships(updatedRelationships);
|
||||||
|
|
||||||
// Update the relationship hash to avoid unnecessary state updates on next poll
|
// Update the relationship hash to avoid unnecessary state updates on next poll
|
||||||
lastRelationshipsUpdateRef.current = JSON.stringify(updatedRelationships.map(r =>
|
lastRelationshipsUpdateRef.current = JSON.stringify(
|
||||||
({ 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) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to delete relationship');
|
setError(err.message || 'Failed to delete relationship');
|
||||||
throw err;
|
throw err;
|
||||||
@ -383,7 +399,7 @@ export const useFriendshipNetwork = (networkId: string | null, pollInterval = DE
|
|||||||
const refreshNetwork = async (): Promise<void> => {
|
const refreshNetwork = async (): Promise<void> => {
|
||||||
await loadNetworkData();
|
await loadNetworkData();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update the poll interval
|
// Update the poll interval
|
||||||
const setPollInterval = (newInterval: number) => {
|
const setPollInterval = (newInterval: number) => {
|
||||||
// Clear existing timer
|
// Clear existing timer
|
||||||
@ -391,7 +407,7 @@ export const useFriendshipNetwork = (networkId: string | null, pollInterval = DE
|
|||||||
window.clearInterval(pollTimerRef.current);
|
window.clearInterval(pollTimerRef.current);
|
||||||
pollTimerRef.current = null;
|
pollTimerRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up new timer if interval > 0
|
// Set up new timer if interval > 0
|
||||||
if (newInterval > 0 && networkId) {
|
if (newInterval > 0 && networkId) {
|
||||||
pollTimerRef.current = window.setInterval(() => {
|
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 peopleRoutes from './routes/people.routes';
|
||||||
import relationshipRoutes from './routes/relationship.routes';
|
import relationshipRoutes from './routes/relationship.routes';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import helmet from "helmet";
|
import helmet from 'helmet';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
const app: Application = express();
|
const app: Application = express();
|
||||||
|
|
||||||
// Middleware
|
// 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(express.json());
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use(
|
app.use(
|
||||||
@ -30,7 +51,6 @@ app.use('/api/networks', networkRoutes);
|
|||||||
app.use('/api/networks', peopleRoutes);
|
app.use('/api/networks', peopleRoutes);
|
||||||
app.use('/api/networks', relationshipRoutes);
|
app.use('/api/networks', relationshipRoutes);
|
||||||
|
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, '../frontend/dist/')));
|
app.use(express.static(path.join(__dirname, '../frontend/dist/')));
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
|
@ -1,8 +1,12 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import User, { IUser } from '../models/user.model';
|
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 { UserRequest } from '../types/express';
|
||||||
import { validationResult } from 'express-validator';
|
import { validationResult } from 'express-validator';
|
||||||
|
import mongoose from 'mongoose';
|
||||||
|
|
||||||
// JWT secret from environment variables
|
// JWT secret from environment variables
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'your_jwt_secret_key_change_this';
|
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
|
// Save user to database
|
||||||
await user.save();
|
await user.save();
|
||||||
|
|
||||||
|
// Create a sample demo network
|
||||||
|
// Fix: Ensure user._id is treated as ObjectId
|
||||||
|
await createSampleDemoNetwork(user._id);
|
||||||
|
|
||||||
// Generate JWT token
|
// Generate JWT token
|
||||||
const token = generateToken(user);
|
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' });
|
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,
|
lastName,
|
||||||
birthday: birthday || undefined,
|
birthday: birthday || undefined,
|
||||||
network: networkId,
|
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();
|
await person.save();
|
||||||
|
Loading…
x
Reference in New Issue
Block a user