change ui

This commit is contained in:
philipredstone 2025-04-16 10:07:28 +02:00
parent acbff34640
commit 9eddb1b547
11 changed files with 3477 additions and 1019 deletions

View File

@ -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"
}

View File

@ -1 +1 @@
@import "tailwindcss";
@import 'tailwindcss';

View 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

View 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">
&#8203;
</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>;
};

View File

@ -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;

View File

@ -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;

View File

@ -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(() => {

View File

@ -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) => {

View File

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

View File

@ -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();