mirror of
https://github.com/philipredstone/relnet.git
synced 2025-07-09 23:26:41 +02:00
Compare commits
25 Commits
9eddb1b547
...
main
Author | SHA1 | Date | |
---|---|---|---|
c71593ffd5 | |||
b89f6b19a4 | |||
47ef38df75 | |||
f1ead87340 | |||
581433612b | |||
d55e58d099 | |||
006b648dd0 | |||
c31b5c5b14 | |||
0333d37aae | |||
3da29516ec | |||
00e7294f41 | |||
b054d55018 | |||
bbb3645d99 | |||
9ce80b4c59 | |||
56c0867a20 | |||
faae6ec930 | |||
8cbb83715f | |||
11b83eeffc | |||
ad3ced0650 | |||
3fd311e312 | |||
88d0c58a35 | |||
ca7b5fcf70 | |||
6ca54dfe9d | |||
0cc99458e0 | |||
1243cdc5ae |
1
.gitignore
vendored
1
.gitignore
vendored
@ -9,6 +9,5 @@ frontend/bun.lockb
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
.env
|
.env
|
||||||
*bun.lockb
|
|
||||||
*yarn.lock
|
*yarn.lock
|
||||||
*package-lock.json
|
*package-lock.json
|
77
Dockerfile
77
Dockerfile
@ -1,47 +1,78 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
FROM node:22 AS frontend-builder
|
|
||||||
|
|
||||||
# Workingdir
|
# Frontend builder stage
|
||||||
|
FROM oven/bun:1 AS frontend-builder
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
|
|
||||||
# Copy files
|
# Install dependencies
|
||||||
|
COPY frontend/package.json frontend/bun.lockb* ./
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source files
|
||||||
COPY frontend/src/ src/
|
COPY frontend/src/ src/
|
||||||
COPY frontend/package.json .
|
|
||||||
COPY frontend/index.html .
|
COPY frontend/index.html .
|
||||||
COPY frontend/tsconfig.json .
|
COPY frontend/tsconfig.json .
|
||||||
COPY frontend/vite.config.mjs .
|
COPY frontend/vite.config.mjs .
|
||||||
|
|
||||||
# Install libs
|
# Build the frontend
|
||||||
RUN yarn install
|
RUN bun run build
|
||||||
# Build to dist/
|
|
||||||
RUN yarn build
|
|
||||||
|
|
||||||
|
# Backend builder stage
|
||||||
FROM node:22 AS backend-builder
|
FROM oven/bun:1 AS backend-builder
|
||||||
|
|
||||||
# Workingdir
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json .
|
# Copy package files first for better caching
|
||||||
RUN yarn install
|
COPY package.json bun.lockb* ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source files
|
||||||
COPY tsconfig.json .
|
COPY tsconfig.json .
|
||||||
COPY src/ src/
|
COPY src/ src/
|
||||||
|
|
||||||
# Build to dist/
|
# Build the backend
|
||||||
RUN yarn run build
|
RUN bun run build
|
||||||
|
|
||||||
|
# Final production stage
|
||||||
|
FROM oven/bun:1-slim
|
||||||
|
LABEL "org.opencontainers.image.title"="Relnet"
|
||||||
|
LABEL "org.opencontainers.image.source"="https://github.com/philipredstone/relnet"
|
||||||
|
LABEL "org.opencontainers.image.url"="https://github.com/philipredstone/relnet"
|
||||||
|
LABEL "org.opencontainers.image.description"="A dynamic web application for visualizing and managing a friendship network. This project uses graphs to display interactive connection graphs between friends, and includes a backend server built with Node.js, TypeScript, Express, and Mongoose for data persistence."
|
||||||
|
LABEL "org.opencontainers.image.version"="1.0.0"
|
||||||
|
LABEL "VERSION"="1.0.0"
|
||||||
|
LABEL maintainer="Tobias Hopp and Philip Rothstein"
|
||||||
|
|
||||||
|
# Install curl for healthcheck
|
||||||
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get -qq -y install curl && \
|
||||||
|
rm -rf /var/cache/apt/archives /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|
||||||
# Final stage
|
WORKDIR /app
|
||||||
FROM node:22-slim
|
|
||||||
COPY --from=frontend-builder /frontend/dist/ frontend/dist
|
|
||||||
COPY --from=backend-builder /app/dist/ dist/
|
|
||||||
COPY --from=backend-builder /app/node_modules node_modules
|
|
||||||
COPY package.json .
|
|
||||||
|
|
||||||
|
# Copy built artifacts from previous stages
|
||||||
|
COPY --from=frontend-builder /frontend/dist/ ./frontend/dist
|
||||||
|
COPY --from=backend-builder /app/dist/ ./dist/
|
||||||
|
|
||||||
|
# Only copy production dependencies
|
||||||
|
COPY package.json ./
|
||||||
|
RUN bun install --production --frozen-lockfile
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
ENV PORT=80
|
ENV PORT=80
|
||||||
ENV MONGODB_URI=mongodb://db:27017/friendship-network
|
ENV MONGODB_URI=mongodb://db:27017/friendship-network
|
||||||
ENV APP_URL=http://localhost:80
|
ENV APP_URL=http://localhost:80
|
||||||
ENV ENABLE_REGISTRATION=true
|
ENV ENABLE_REGISTRATION=true
|
||||||
|
|
||||||
CMD ["yarn", "run", "start"]
|
# Expose the port
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Health check to verify the application is running
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
|
CMD /bin/sh -c 'curl -f http://localhost:$PORT/api/health || exit 1'
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
CMD ["bun", "dist/server.js"]
|
@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter as Router, Navigate, Route, Routes } from 'react-router-dom';
|
||||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||||
import { NetworkProvider } from './context/NetworkContext';
|
import { NetworkProvider } from './context/NetworkContext';
|
||||||
import Login from './components/auth/Login';
|
import Login from './components/auth/Login';
|
||||||
@ -28,13 +28,14 @@ const App: React.FC = () => {
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<NetworkProvider>
|
<NetworkProvider>
|
||||||
<Router>
|
<Router>
|
||||||
<div className="flex flex-col min-h-screen">
|
<div className="flex flex-col h-screen">
|
||||||
<Header />
|
<header className="header-height">
|
||||||
<main className="flex-grow">
|
<Header />
|
||||||
|
</header>
|
||||||
|
<main className="flex-1 overflow-hidden">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/networks"
|
path="/networks"
|
||||||
element={
|
element={
|
||||||
@ -43,16 +44,16 @@ const App: React.FC = () => {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/networks/:id"
|
path="/networks/:id"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<FriendshipNetwork />
|
<div className="h-full">
|
||||||
|
<FriendshipNetwork />
|
||||||
|
</div>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route path="/" element={<Navigate to="/networks" />} />
|
<Route path="/" element={<Navigate to="/networks" />} />
|
||||||
<Route path="*" element={<Navigate to="/networks" />} />
|
<Route path="*" element={<Navigate to="/networks" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { RELATIONSHIP_TYPES } from '../types/RelationShipTypes';
|
||||||
|
import { Relationship } from '../interfaces/IRelationship';
|
||||||
|
|
||||||
const protocol = window.location.protocol;
|
const protocol = window.location.protocol;
|
||||||
const hostname = window.location.hostname;
|
const hostname = window.location.hostname;
|
||||||
@ -6,27 +8,15 @@ const port = window.location.port;
|
|||||||
|
|
||||||
const API_URL = protocol + '//' + hostname + (port ? ':' + port : '') + '/api';
|
const API_URL = protocol + '//' + hostname + (port ? ':' + port : '') + '/api';
|
||||||
|
|
||||||
// Types
|
|
||||||
export interface Relationship {
|
|
||||||
_id: string;
|
|
||||||
source: string;
|
|
||||||
target: string;
|
|
||||||
type: 'freund' | 'partner' | 'familie' | 'arbeitskolleg' | 'custom';
|
|
||||||
customType?: string;
|
|
||||||
network: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateRelationshipData {
|
export interface CreateRelationshipData {
|
||||||
source: string;
|
source: string;
|
||||||
target: string;
|
target: string;
|
||||||
type: 'freund' | 'partner' | 'familie' | 'arbeitskolleg' | 'custom';
|
type: RELATIONSHIP_TYPES;
|
||||||
customType?: string;
|
customType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateRelationshipData {
|
export interface UpdateRelationshipData {
|
||||||
type?: 'freund' | 'partner' | 'familie' | 'arbeitskolleg' | 'custom';
|
type?: RELATIONSHIP_TYPES;
|
||||||
customType?: string;
|
customType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1 +1,9 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
.header-height {
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-full-important {
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface GraphData {
|
||||||
|
nodes: any[];
|
||||||
|
links?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
// Define types for graph elements
|
|
||||||
interface NodeData {
|
interface NodeData {
|
||||||
id: string;
|
id: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
@ -19,33 +23,32 @@ interface EdgeData {
|
|||||||
width: number;
|
width: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GraphData {
|
interface CustomGraphData extends GraphData {
|
||||||
nodes: NodeData[];
|
nodes: NodeData[];
|
||||||
edges: EdgeData[];
|
edges: EdgeData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CanvasGraphProps {
|
interface CanvasGraphProps {
|
||||||
data: GraphData;
|
data: CustomGraphData;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Physics constants
|
// Physics constants
|
||||||
const NODE_RADIUS = 30; // Node radius in pixels
|
const NODE_RADIUS = 50; // Node radius in pixels
|
||||||
const MIN_DISTANCE = 100; // Minimum distance between any two nodes
|
const MIN_DISTANCE = 250; // Minimum distance between any two nodes
|
||||||
const MAX_DISTANCE = 300; // Maximum distance between connected nodes
|
const MAX_DISTANCE = 800; // Maximum distance between connected nodes
|
||||||
const REPULSION_STRENGTH = 500; // How strongly nodes repel each other when too close
|
const REPULSION_STRENGTH = 400; // How strongly nodes repel each other when too close
|
||||||
const ATTRACTION_STRENGTH = 0.1; // Default attraction between connected nodes
|
const ATTRACTION_STRENGTH = 0.1; // Default attraction between connected nodes
|
||||||
const CONSTRAINT_STRENGTH = 0.2; // Strength of distance constraints
|
const CONSTRAINT_STRENGTH = 0.2; // Strength of distance constraints
|
||||||
const DAMPING = 0.6; // Damping factor for velocity (0-1)
|
const DAMPING = 0.6; // Damping factor for velocity (0-1)
|
||||||
const CENTER_GRAVITY = 0.01; // Force pulling nodes to the center
|
const CENTER_GRAVITY = 0.01; // Force pulling nodes to the center
|
||||||
const MAX_VELOCITY = 5; // Maximum velocity to prevent wild movement
|
const MAX_VELOCITY = 4; // Maximum velocity to prevent wild movement
|
||||||
const COOLING_FACTOR = 0.99; // System gradually cools down
|
const COOLING_FACTOR = 0.99; // System gradually cools down
|
||||||
|
|
||||||
const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
// State for interactions
|
|
||||||
const [draggedNode, setDraggedNode] = useState<string | null>(null);
|
const [draggedNode, setDraggedNode] = useState<string | null>(null);
|
||||||
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
|
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
|
||||||
const [offsetX, setOffsetX] = useState(0);
|
const [offsetX, setOffsetX] = useState(0);
|
||||||
@ -56,7 +59,6 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
const [scale, setScale] = useState(1);
|
const [scale, setScale] = useState(1);
|
||||||
const [autoLayout, setAutoLayout] = useState(true);
|
const [autoLayout, setAutoLayout] = useState(true);
|
||||||
|
|
||||||
// Node physics state
|
|
||||||
const [nodePositions, setNodePositions] = useState<
|
const [nodePositions, setNodePositions] = useState<
|
||||||
Record<
|
Record<
|
||||||
string,
|
string,
|
||||||
@ -69,16 +71,13 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
>
|
>
|
||||||
>({});
|
>({});
|
||||||
|
|
||||||
// Animation frame reference
|
|
||||||
const animationRef = useRef<number | null>(null);
|
const animationRef = useRef<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only run once when component mounts or when data.nodes changes
|
|
||||||
if (width <= 0 || height <= 0 || !data.nodes || data.nodes.length === 0) return;
|
if (width <= 0 || height <= 0 || !data.nodes || data.nodes.length === 0) return;
|
||||||
|
|
||||||
console.log('Initializing node positions...');
|
console.log('Initializing node positions...');
|
||||||
|
|
||||||
// Skip if we already have positions for all nodes
|
|
||||||
const allNodesHavePositions = data.nodes.every(
|
const allNodesHavePositions = data.nodes.every(
|
||||||
node =>
|
node =>
|
||||||
nodePositions[node.id] && (nodePositions[node.id].x !== 0 || nodePositions[node.id].y !== 0)
|
nodePositions[node.id] && (nodePositions[node.id].x !== 0 || nodePositions[node.id].y !== 0)
|
||||||
@ -89,29 +88,21 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create initial positions object
|
|
||||||
const initialPositions: Record<string, { x: number; y: number; vx: number; vy: number }> = {};
|
const initialPositions: Record<string, { x: number; y: number; vx: number; vy: number }> = {};
|
||||||
|
|
||||||
// Determine the area to place nodes
|
|
||||||
const padding = NODE_RADIUS * 2;
|
const padding = NODE_RADIUS * 2;
|
||||||
const availableWidth = width - padding * 2;
|
const availableWidth = width - padding * 2;
|
||||||
const availableHeight = height - padding * 2;
|
const availableHeight = height - padding * 2;
|
||||||
|
|
||||||
// Calculate a grid layout - find grid dimensions based on node count
|
|
||||||
const nodeCount = data.nodes.length;
|
const nodeCount = data.nodes.length;
|
||||||
const aspectRatio = availableWidth / availableHeight;
|
const aspectRatio = availableWidth / availableHeight;
|
||||||
const gridCols = Math.ceil(Math.sqrt(nodeCount * aspectRatio));
|
const gridCols = Math.ceil(Math.sqrt(nodeCount * aspectRatio));
|
||||||
const gridRows = Math.ceil(nodeCount / gridCols);
|
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 cellWidth = availableWidth / gridCols;
|
||||||
const cellHeight = availableHeight / gridRows;
|
const cellHeight = availableHeight / gridRows;
|
||||||
|
|
||||||
// Position each node in a grid cell with random offset
|
|
||||||
data.nodes.forEach((node, index) => {
|
data.nodes.forEach((node, index) => {
|
||||||
// Only generate new position if node doesn't already have one
|
|
||||||
if (
|
if (
|
||||||
nodePositions[node.id] &&
|
nodePositions[node.id] &&
|
||||||
nodePositions[node.id].x !== 0 &&
|
nodePositions[node.id].x !== 0 &&
|
||||||
@ -125,15 +116,12 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate grid position
|
|
||||||
const row = Math.floor(index / gridCols);
|
const row = Math.floor(index / gridCols);
|
||||||
const col = index % gridCols;
|
const col = index % gridCols;
|
||||||
|
|
||||||
// Add randomness within cell (20% of cell size)
|
|
||||||
const randomOffsetX = cellWidth * 0.4 * (Math.random() - 0.5);
|
const randomOffsetX = cellWidth * 0.4 * (Math.random() - 0.5);
|
||||||
const randomOffsetY = cellHeight * 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 x = padding + cellWidth * (col + 0.5) + randomOffsetX;
|
||||||
const y = padding + cellHeight * (row + 0.5) + randomOffsetY;
|
const y = padding + cellHeight * (row + 0.5) + randomOffsetY;
|
||||||
|
|
||||||
@ -143,22 +131,14 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
vx: 0,
|
vx: 0,
|
||||||
vy: 0,
|
vy: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`Node ${node.id} positioned at (${x.toFixed(2)}, ${y.toFixed(2)})`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set positions in one batch update
|
|
||||||
setNodePositions(initialPositions);
|
setNodePositions(initialPositions);
|
||||||
|
|
||||||
console.log('Node positioning complete');
|
|
||||||
}, [data.nodes, width, height]);
|
}, [data.nodes, width, height]);
|
||||||
|
|
||||||
// Run physics simulation - FIX: Added proper dependencies
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only proceed if we have valid dimensions and data
|
|
||||||
if (width <= 0 || height <= 0 || !data.nodes || data.nodes.length === 0) return;
|
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();
|
drawGraph();
|
||||||
|
|
||||||
if (!autoLayout || draggedNode) {
|
if (!autoLayout || draggedNode) {
|
||||||
@ -173,34 +153,28 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
setNodePositions(prevPositions => {
|
setNodePositions(prevPositions => {
|
||||||
const newPositions = { ...prevPositions };
|
const newPositions = { ...prevPositions };
|
||||||
|
|
||||||
// Apply forces to each node
|
|
||||||
data.nodes.forEach(node => {
|
data.nodes.forEach(node => {
|
||||||
if (!newPositions[node.id]) return;
|
if (!newPositions[node.id]) return;
|
||||||
|
|
||||||
// Skip if this node is being dragged
|
|
||||||
if (node.id === draggedNode) return;
|
if (node.id === draggedNode) return;
|
||||||
|
|
||||||
let forceX = 0;
|
let forceX = 0;
|
||||||
let forceY = 0;
|
let forceY = 0;
|
||||||
|
|
||||||
// Center gravity force
|
|
||||||
const centerX = width / 2;
|
const centerX = width / 2;
|
||||||
const centerY = height / 2;
|
const centerY = height / 2;
|
||||||
forceX += (centerX - newPositions[node.id].x) * CENTER_GRAVITY;
|
forceX += (centerX - newPositions[node.id].x) * CENTER_GRAVITY;
|
||||||
forceY += (centerY - newPositions[node.id].y) * CENTER_GRAVITY;
|
forceY += (centerY - newPositions[node.id].y) * CENTER_GRAVITY;
|
||||||
|
|
||||||
// Repulsion forces (from ALL other nodes to prevent overlapping)
|
|
||||||
data.nodes.forEach(otherNode => {
|
data.nodes.forEach(otherNode => {
|
||||||
if (node.id === otherNode.id || !newPositions[otherNode.id]) return;
|
if (node.id === otherNode.id || !newPositions[otherNode.id]) return;
|
||||||
|
|
||||||
const dx = newPositions[node.id].x - newPositions[otherNode.id].x;
|
const dx = newPositions[node.id].x - newPositions[otherNode.id].x;
|
||||||
const dy = newPositions[node.id].y - newPositions[otherNode.id].y;
|
const dy = newPositions[node.id].y - newPositions[otherNode.id].y;
|
||||||
const distanceSq = dx * dx + dy * dy;
|
const distanceSq = dx * dx + dy * dy;
|
||||||
const distance = Math.sqrt(distanceSq) || 1; // Avoid division by zero
|
const distance = Math.sqrt(distanceSq) || 1;
|
||||||
|
|
||||||
// Enforce minimum distance between any two nodes
|
|
||||||
if (distance < MIN_DISTANCE) {
|
if (distance < MIN_DISTANCE) {
|
||||||
// Strong repulsion force that increases as nodes get closer
|
|
||||||
const repulsionFactor = 1 - distance / MIN_DISTANCE;
|
const repulsionFactor = 1 - distance / MIN_DISTANCE;
|
||||||
const repulsionForce = REPULSION_STRENGTH * repulsionFactor * repulsionFactor;
|
const repulsionForce = REPULSION_STRENGTH * repulsionFactor * repulsionFactor;
|
||||||
|
|
||||||
@ -209,7 +183,6 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Find connected nodes (neighbors) for the current node
|
|
||||||
const connectedNodeIds = new Set<string>();
|
const connectedNodeIds = new Set<string>();
|
||||||
data.edges.forEach(edge => {
|
data.edges.forEach(edge => {
|
||||||
if (edge.source === node.id) {
|
if (edge.source === node.id) {
|
||||||
@ -219,7 +192,6 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Attraction forces (only to connected nodes)
|
|
||||||
connectedNodeIds.forEach(targetId => {
|
connectedNodeIds.forEach(targetId => {
|
||||||
if (!newPositions[targetId]) return;
|
if (!newPositions[targetId]) return;
|
||||||
|
|
||||||
@ -227,18 +199,13 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
const dy = newPositions[targetId].y - newPositions[node.id].y;
|
const dy = newPositions[targetId].y - newPositions[node.id].y;
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
|
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||||
|
|
||||||
// Enforce maximum distance constraint between connected nodes
|
|
||||||
if (distance > MAX_DISTANCE) {
|
if (distance > MAX_DISTANCE) {
|
||||||
// Strong attractive force that increases as distance exceeds max
|
|
||||||
const excessDistance = distance - MAX_DISTANCE;
|
const excessDistance = distance - MAX_DISTANCE;
|
||||||
const constraintForce = CONSTRAINT_STRENGTH * excessDistance;
|
const constraintForce = CONSTRAINT_STRENGTH * excessDistance;
|
||||||
|
|
||||||
forceX += (dx / distance) * constraintForce;
|
forceX += (dx / distance) * constraintForce;
|
||||||
forceY += (dy / distance) * constraintForce;
|
forceY += (dy / distance) * constraintForce;
|
||||||
}
|
} else {
|
||||||
// Regular attraction between connected nodes (weaker when close)
|
|
||||||
else {
|
|
||||||
// Linear attraction normalized by MAX_DISTANCE
|
|
||||||
const normalizedDistance = distance / MAX_DISTANCE;
|
const normalizedDistance = distance / MAX_DISTANCE;
|
||||||
const attractionForce = ATTRACTION_STRENGTH * normalizedDistance;
|
const attractionForce = ATTRACTION_STRENGTH * normalizedDistance;
|
||||||
|
|
||||||
@ -247,11 +214,9 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update velocity with applied forces and damping
|
|
||||||
newPositions[node.id].vx = newPositions[node.id].vx * DAMPING + forceX;
|
newPositions[node.id].vx = newPositions[node.id].vx * DAMPING + forceX;
|
||||||
newPositions[node.id].vy = newPositions[node.id].vy * DAMPING + forceY;
|
newPositions[node.id].vy = newPositions[node.id].vy * DAMPING + forceY;
|
||||||
|
|
||||||
// Limit maximum velocity to prevent wild movement
|
|
||||||
const speed = Math.sqrt(
|
const speed = Math.sqrt(
|
||||||
newPositions[node.id].vx * newPositions[node.id].vx +
|
newPositions[node.id].vx * newPositions[node.id].vx +
|
||||||
newPositions[node.id].vy * newPositions[node.id].vy
|
newPositions[node.id].vy * newPositions[node.id].vy
|
||||||
@ -261,19 +226,16 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
newPositions[node.id].vy = (newPositions[node.id].vy / 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].vx *= COOLING_FACTOR;
|
||||||
newPositions[node.id].vy *= COOLING_FACTOR;
|
newPositions[node.id].vy *= COOLING_FACTOR;
|
||||||
|
|
||||||
// Update position
|
|
||||||
newPositions[node.id].x += newPositions[node.id].vx;
|
newPositions[node.id].x += newPositions[node.id].vx;
|
||||||
newPositions[node.id].y += newPositions[node.id].vy;
|
newPositions[node.id].y += newPositions[node.id].vy;
|
||||||
|
|
||||||
// Boundary constraints
|
|
||||||
const padding = NODE_RADIUS;
|
const padding = NODE_RADIUS;
|
||||||
if (newPositions[node.id].x < padding) {
|
if (newPositions[node.id].x < padding) {
|
||||||
newPositions[node.id].x = padding;
|
newPositions[node.id].x = padding;
|
||||||
newPositions[node.id].vx *= -0.5; // Bounce back
|
newPositions[node.id].vx *= -0.5;
|
||||||
}
|
}
|
||||||
if (newPositions[node.id].x > width - padding) {
|
if (newPositions[node.id].x > width - padding) {
|
||||||
newPositions[node.id].x = width - padding;
|
newPositions[node.id].x = width - padding;
|
||||||
@ -303,28 +265,23 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
animationRef.current = null;
|
animationRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [data.nodes, data.edges, width, height, autoLayout, draggedNode]); // FIX: Added proper dependencies
|
}, [data.nodes, data.edges, width, height, autoLayout, draggedNode]);
|
||||||
|
|
||||||
// Find node at position function
|
|
||||||
const findNodeAtPosition = useCallback(
|
const findNodeAtPosition = useCallback(
|
||||||
(x: number, y: number): string | null => {
|
(x: number, y: number): string | null => {
|
||||||
// Transform coordinates based on scale and pan
|
|
||||||
const transformedX = (x - panOffset.x) / scale;
|
const transformedX = (x - panOffset.x) / scale;
|
||||||
const transformedY = (y - panOffset.y) / 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--) {
|
for (let i = data.nodes.length - 1; i >= 0; i--) {
|
||||||
const node = data.nodes[i];
|
const node = data.nodes[i];
|
||||||
const pos = nodePositions[node.id];
|
const pos = nodePositions[node.id];
|
||||||
|
|
||||||
if (!pos) continue;
|
if (!pos) continue;
|
||||||
|
|
||||||
// Calculate distance from click to node center
|
|
||||||
const dx = pos.x - transformedX;
|
const dx = pos.x - transformedX;
|
||||||
const dy = pos.y - transformedY;
|
const dy = pos.y - transformedY;
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
// If click is inside node radius, return node id
|
|
||||||
if (distance <= NODE_RADIUS) {
|
if (distance <= NODE_RADIUS) {
|
||||||
return node.id;
|
return node.id;
|
||||||
}
|
}
|
||||||
@ -333,31 +290,26 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
[data.nodes, nodePositions, panOffset, scale]
|
[data.nodes, nodePositions, panOffset, scale]
|
||||||
); // FIX: Added proper dependencies
|
);
|
||||||
|
|
||||||
// Mouse event handlers
|
|
||||||
const handleMouseDown = useCallback(
|
const handleMouseDown = useCallback(
|
||||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
|
||||||
// Get click position relative to canvas
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const x = e.clientX - rect.left;
|
const x = e.clientX - rect.left;
|
||||||
const y = e.clientY - rect.top;
|
const y = e.clientY - rect.top;
|
||||||
|
|
||||||
// Find if a node was clicked
|
|
||||||
const nodeId = findNodeAtPosition(x, y);
|
const nodeId = findNodeAtPosition(x, y);
|
||||||
|
|
||||||
if (nodeId) {
|
if (nodeId) {
|
||||||
// Set dragged node and calculate offset
|
|
||||||
setDraggedNode(nodeId);
|
setDraggedNode(nodeId);
|
||||||
const transformedX = (x - panOffset.x) / scale;
|
const transformedX = (x - panOffset.x) / scale;
|
||||||
const transformedY = (y - panOffset.y) / scale;
|
const transformedY = (y - panOffset.y) / scale;
|
||||||
setOffsetX(transformedX - nodePositions[nodeId].x);
|
setOffsetX(transformedX - nodePositions[nodeId].x);
|
||||||
setOffsetY(transformedY - nodePositions[nodeId].y);
|
setOffsetY(transformedY - nodePositions[nodeId].y);
|
||||||
|
|
||||||
// Reset velocity when starting to drag
|
|
||||||
setNodePositions(prev => ({
|
setNodePositions(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[nodeId]: {
|
[nodeId]: {
|
||||||
@ -367,35 +319,29 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
// Start panning
|
|
||||||
setIsPanning(true);
|
setIsPanning(true);
|
||||||
setPanStart({ x, y });
|
setPanStart({ x, y });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[findNodeAtPosition, nodePositions, panOffset, scale]
|
[findNodeAtPosition, nodePositions, panOffset, scale]
|
||||||
); // FIX: Added proper dependencies
|
);
|
||||||
|
|
||||||
const handleMouseMove = useCallback(
|
const handleMouseMove = useCallback(
|
||||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
|
||||||
// Get mouse position relative to canvas
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const x = e.clientX - rect.left;
|
const x = e.clientX - rect.left;
|
||||||
const y = e.clientY - rect.top;
|
const y = e.clientY - rect.top;
|
||||||
|
|
||||||
// Update hovered node
|
|
||||||
const nodeId = findNodeAtPosition(x, y);
|
const nodeId = findNodeAtPosition(x, y);
|
||||||
setHoveredNode(nodeId);
|
setHoveredNode(nodeId);
|
||||||
|
|
||||||
// Handle dragging a node
|
|
||||||
if (draggedNode) {
|
if (draggedNode) {
|
||||||
// Transform coordinates based on scale and pan
|
|
||||||
const transformedX = (x - panOffset.x) / scale;
|
const transformedX = (x - panOffset.x) / scale;
|
||||||
const transformedY = (y - panOffset.y) / scale;
|
const transformedY = (y - panOffset.y) / scale;
|
||||||
|
|
||||||
// Update node position
|
|
||||||
setNodePositions(prev => ({
|
setNodePositions(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[draggedNode]: {
|
[draggedNode]: {
|
||||||
@ -406,9 +352,7 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
vy: 0,
|
vy: 0,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}
|
} else if (isPanning) {
|
||||||
// Handle panning
|
|
||||||
else if (isPanning) {
|
|
||||||
const dx = x - panStart.x;
|
const dx = x - panStart.x;
|
||||||
const dy = y - panStart.y;
|
const dy = y - panStart.y;
|
||||||
setPanOffset(prev => ({
|
setPanOffset(prev => ({
|
||||||
@ -419,10 +363,9 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
[findNodeAtPosition, draggedNode, isPanning, offsetX, offsetY, panOffset, panStart, scale]
|
[findNodeAtPosition, draggedNode, isPanning, offsetX, offsetY, panOffset, panStart, scale]
|
||||||
); // FIX: Added proper dependencies
|
);
|
||||||
|
|
||||||
const handleMouseUp = useCallback(() => {
|
const handleMouseUp = useCallback(() => {
|
||||||
// End any drag or pan operation
|
|
||||||
setDraggedNode(null);
|
setDraggedNode(null);
|
||||||
setIsPanning(false);
|
setIsPanning(false);
|
||||||
}, []);
|
}, []);
|
||||||
@ -431,23 +374,18 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
(e: React.WheelEvent<HTMLCanvasElement>) => {
|
(e: React.WheelEvent<HTMLCanvasElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Get mouse position relative to canvas
|
|
||||||
const rect = canvasRef.current?.getBoundingClientRect();
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
|
|
||||||
const mouseX = e.clientX - rect.left;
|
const mouseX = e.clientX - rect.left;
|
||||||
const mouseY = e.clientY - rect.top;
|
const mouseY = e.clientY - rect.top;
|
||||||
|
|
||||||
// Calculate zoom factor
|
|
||||||
const scaleFactor = e.deltaY < 0 ? 1.1 : 0.9;
|
const scaleFactor = e.deltaY < 0 ? 1.1 : 0.9;
|
||||||
const newScale = Math.max(0.1, Math.min(5, scale * scaleFactor));
|
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 newPanOffsetX = mouseX - (mouseX - panOffset.x) * (newScale / scale);
|
||||||
const newPanOffsetY = mouseY - (mouseY - panOffset.y) * (newScale / scale);
|
const newPanOffsetY = mouseY - (mouseY - panOffset.y) * (newScale / scale);
|
||||||
|
|
||||||
// Update state
|
|
||||||
setScale(newScale);
|
setScale(newScale);
|
||||||
setPanOffset({ x: newPanOffsetX, y: newPanOffsetY });
|
setPanOffset({ x: newPanOffsetX, y: newPanOffsetY });
|
||||||
},
|
},
|
||||||
@ -458,10 +396,8 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
setAutoLayout(prev => !prev);
|
setAutoLayout(prev => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Draw controls function
|
|
||||||
const drawControls = useCallback(
|
const drawControls = useCallback(
|
||||||
(ctx: CanvasRenderingContext2D) => {
|
(ctx: CanvasRenderingContext2D) => {
|
||||||
// Auto layout toggle button
|
|
||||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
|
||||||
ctx.fillRect(width - 120, 20, 100, 40);
|
ctx.fillRect(width - 120, 20, 100, 40);
|
||||||
|
|
||||||
@ -474,7 +410,6 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
[autoLayout, width]
|
[autoLayout, width]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Draw function - FIX: Properly memoized with all dependencies
|
|
||||||
const drawGraph = useCallback(() => {
|
const drawGraph = useCallback(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
@ -482,26 +417,20 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
// Skip drawing if dimensions are invalid
|
|
||||||
if (width <= 0 || height <= 0) return;
|
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) {
|
if (canvas.width !== width || canvas.height !== height) {
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear canvas
|
|
||||||
ctx.fillStyle = '#0f172a'; // Slate-900
|
ctx.fillStyle = '#0f172a'; // Slate-900
|
||||||
ctx.fillRect(0, 0, width, height);
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
// Apply transformation (scale and pan)
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.translate(panOffset.x, panOffset.y);
|
ctx.translate(panOffset.x, panOffset.y);
|
||||||
ctx.scale(scale, scale);
|
ctx.scale(scale, scale);
|
||||||
|
|
||||||
// Draw edges
|
|
||||||
data.edges.forEach(edge => {
|
data.edges.forEach(edge => {
|
||||||
const sourceNode = data.nodes.find(n => n.id === edge.source);
|
const sourceNode = data.nodes.find(n => n.id === edge.source);
|
||||||
const targetNode = data.nodes.find(n => n.id === edge.target);
|
const targetNode = data.nodes.find(n => n.id === edge.target);
|
||||||
@ -515,15 +444,12 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
ctx.moveTo(sourcePos.x, sourcePos.y);
|
ctx.moveTo(sourcePos.x, sourcePos.y);
|
||||||
ctx.lineTo(targetPos.x, targetPos.y);
|
ctx.lineTo(targetPos.x, targetPos.y);
|
||||||
|
|
||||||
// Edge styling
|
|
||||||
let highlighted = false;
|
let highlighted = false;
|
||||||
if (hoveredNode) {
|
if (hoveredNode) {
|
||||||
highlighted = edge.source === hoveredNode || edge.target === hoveredNode;
|
highlighted = edge.source === hoveredNode || edge.target === hoveredNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.strokeStyle = highlighted
|
ctx.strokeStyle = highlighted ? '#3b82f6' : edge.color || 'rgba(255, 255, 255, 0.5)';
|
||||||
? '#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.lineWidth = highlighted ? (edge.width ? edge.width + 1 : 3) : edge.width || 1;
|
||||||
|
|
||||||
@ -532,36 +458,30 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Draw nodes
|
|
||||||
data.nodes.forEach(node => {
|
data.nodes.forEach(node => {
|
||||||
const pos = nodePositions[node.id];
|
const pos = nodePositions[node.id];
|
||||||
if (!pos) return;
|
if (!pos) return;
|
||||||
|
|
||||||
// Node styling based on state
|
|
||||||
const isHovered = node.id === hoveredNode;
|
const isHovered = node.id === hoveredNode;
|
||||||
const isDragged = node.id === draggedNode;
|
const isDragged = node.id === draggedNode;
|
||||||
|
|
||||||
// Glow effect for hovered or dragged nodes
|
|
||||||
if (isHovered || isDragged) {
|
if (isHovered || isDragged) {
|
||||||
ctx.shadowColor = isDragged ? '#ff9900' : '#3b82f6';
|
ctx.shadowColor = isDragged ? '#ff9900' : '#3b82f6';
|
||||||
ctx.shadowBlur = 15;
|
ctx.shadowBlur = 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw node circle
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(pos.x, pos.y, NODE_RADIUS, 0, 2 * Math.PI);
|
ctx.arc(pos.x, pos.y, NODE_RADIUS, 0, 2 * Math.PI);
|
||||||
|
|
||||||
// Fill style based on state
|
|
||||||
if (isDragged) {
|
if (isDragged) {
|
||||||
ctx.fillStyle = '#ff9900'; // Orange for dragged node
|
ctx.fillStyle = '#ff9900';
|
||||||
} else if (isHovered) {
|
} else if (isHovered) {
|
||||||
ctx.fillStyle = '#3b82f6'; // Blue for hovered node
|
ctx.fillStyle = '#3b82f6';
|
||||||
} else {
|
} else {
|
||||||
ctx.fillStyle = node.bgColor || '#475569'; // Default to slate-600
|
ctx.fillStyle = node.bgColor || '#475569';
|
||||||
}
|
}
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
// Draw border
|
|
||||||
ctx.shadowColor = 'transparent';
|
ctx.shadowColor = 'transparent';
|
||||||
ctx.shadowBlur = 0;
|
ctx.shadowBlur = 0;
|
||||||
ctx.strokeStyle = 'white';
|
ctx.strokeStyle = 'white';
|
||||||
@ -569,19 +489,17 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Draw initials
|
// Draw initials
|
||||||
const initials = `${node.firstName.charAt(0)}${node.lastName.charAt(0)}`;
|
const initials = `${node.firstName} ${node.lastName.charAt(0)}.`;
|
||||||
ctx.fillStyle = 'white';
|
ctx.fillStyle = 'white';
|
||||||
ctx.font = 'bold 16px sans-serif';
|
ctx.font = 'bold 16px sans-serif';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
ctx.fillText(initials, pos.x, pos.y);
|
ctx.fillText(initials, pos.x, pos.y);
|
||||||
|
|
||||||
// Draw name label for hovered or dragged nodes
|
|
||||||
if (isHovered || isDragged) {
|
if (isHovered || isDragged) {
|
||||||
const fullName = `${node.firstName} ${node.lastName}`;
|
const fullName = `${node.firstName} ${node.lastName}`;
|
||||||
ctx.font = '14px sans-serif';
|
ctx.font = '14px sans-serif';
|
||||||
|
|
||||||
// Add a background for the label
|
|
||||||
const textMetrics = ctx.measureText(fullName);
|
const textMetrics = ctx.measureText(fullName);
|
||||||
const textWidth = textMetrics.width;
|
const textWidth = textMetrics.width;
|
||||||
const textHeight = 20;
|
const textHeight = 20;
|
||||||
@ -600,10 +518,8 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore canvas transformation
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
// Draw UI controls
|
|
||||||
drawControls(ctx);
|
drawControls(ctx);
|
||||||
}, [
|
}, [
|
||||||
data,
|
data,
|
||||||
@ -615,25 +531,20 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
drawControls,
|
drawControls,
|
||||||
]); // FIX: Added all dependencies
|
]);
|
||||||
|
|
||||||
// Handle clicks on controls
|
|
||||||
const handleControlClick = useCallback(
|
const handleControlClick = useCallback(
|
||||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
const x = e.nativeEvent.offsetX;
|
const x = e.nativeEvent.offsetX;
|
||||||
const y = e.nativeEvent.offsetY;
|
const y = e.nativeEvent.offsetY;
|
||||||
|
|
||||||
// Check if auto layout button was clicked
|
|
||||||
if (x >= width - 120 && x <= width - 20 && y >= 20 && y <= 60) {
|
if (x >= width - 120 && x <= width - 20 && y >= 20 && y <= 60) {
|
||||||
toggleAutoLayout();
|
toggleAutoLayout();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[width, toggleAutoLayout]
|
[width, toggleAutoLayout]
|
||||||
); // FIX: Added proper dependencies
|
);
|
||||||
|
|
||||||
// FIX: Ensure continuous rendering with requestAnimationFrame
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Create a continuous rendering loop that doesn't depend on physics updates
|
|
||||||
let animationFrameId: number;
|
let animationFrameId: number;
|
||||||
|
|
||||||
const renderLoop = () => {
|
const renderLoop = () => {
|
||||||
@ -641,10 +552,8 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
animationFrameId = requestAnimationFrame(renderLoop);
|
animationFrameId = requestAnimationFrame(renderLoop);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start the render loop
|
|
||||||
animationFrameId = requestAnimationFrame(renderLoop);
|
animationFrameId = requestAnimationFrame(renderLoop);
|
||||||
|
|
||||||
// Clean up
|
|
||||||
return () => {
|
return () => {
|
||||||
if (animationFrameId) {
|
if (animationFrameId) {
|
||||||
cancelAnimationFrame(animationFrameId);
|
cancelAnimationFrame(animationFrameId);
|
||||||
@ -652,7 +561,6 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
};
|
};
|
||||||
}, [drawGraph]);
|
}, [drawGraph]);
|
||||||
|
|
||||||
// Get cursor style based on current state
|
|
||||||
const getCursorStyle = useCallback(() => {
|
const getCursorStyle = useCallback(() => {
|
||||||
if (draggedNode) return 'grabbing';
|
if (draggedNode) return 'grabbing';
|
||||||
if (hoveredNode) return 'grab';
|
if (hoveredNode) return 'grab';
|
||||||
@ -660,19 +568,16 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
|
|||||||
return 'default';
|
return 'default';
|
||||||
}, [draggedNode, hoveredNode, isPanning]);
|
}, [draggedNode, hoveredNode, isPanning]);
|
||||||
|
|
||||||
// FIX: Initial rendering - make sure canvas is properly initialized
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Force the initial draw
|
|
||||||
if (width > 0 && height > 0 && canvasRef.current) {
|
if (width > 0 && height > 0 && canvasRef.current) {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
ctx.fillStyle = '#0f172a'; // Slate-900
|
ctx.fillStyle = '#0f172a';
|
||||||
ctx.fillRect(0, 0, width, height);
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
// Draw a loading message until nodes are positioned
|
|
||||||
if (data.nodes.length > 0 && Object.keys(nodePositions).length === 0) {
|
if (data.nodes.length > 0 && Object.keys(nodePositions).length === 0) {
|
||||||
ctx.fillStyle = 'white';
|
ctx.fillStyle = 'white';
|
||||||
ctx.font = '16px sans-serif';
|
ctx.font = '16px sans-serif';
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import { FaTimes } from 'react-icons/fa';
|
import { FaTimes } from 'react-icons/fa';
|
||||||
|
|
||||||
|
552
frontend/src/components/Modals.tsx
Normal file
552
frontend/src/components/Modals.tsx
Normal file
@ -0,0 +1,552 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import DatePicker from 'react-datepicker';
|
||||||
|
import { FaPlus, FaRegCalendarAlt, FaSave, FaStar, FaTrash, FaUserFriends, FaUserPlus } from 'react-icons/fa';
|
||||||
|
|
||||||
|
import { Button, FormField, Modal } from '../components/FriendshipNetworkComponents';
|
||||||
|
|
||||||
|
import { FormErrors, NewPersonForm, NewRelationshipForm, PersonNode, RelationshipEdge } from '../types/network';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getRelationshipColor,
|
||||||
|
getRelationshipLabel,
|
||||||
|
RELATIONSHIP_TYPES,
|
||||||
|
RELATIONSHIPS,
|
||||||
|
} from '../types/RelationShipTypes';
|
||||||
|
import { ErrorMessage, KeyboardShortcut, OptionGroup, TipItem, ToggleSetting } from './UIComponents';
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Person Form Modal
|
||||||
|
// ==============================
|
||||||
|
interface PersonFormModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
formData: NewPersonForm;
|
||||||
|
setFormData: React.Dispatch<React.SetStateAction<NewPersonForm>>;
|
||||||
|
errors: FormErrors;
|
||||||
|
onSubmit: (e: React.FormEvent) => void;
|
||||||
|
isEdit?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PersonFormModal: React.FC<PersonFormModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
formData,
|
||||||
|
setFormData,
|
||||||
|
errors,
|
||||||
|
onSubmit,
|
||||||
|
isEdit = false,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title={isEdit ? 'Edit Person' : 'Add New Person'}>
|
||||||
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
|
<ErrorMessage message={errors.general} />
|
||||||
|
|
||||||
|
<FormField label="First Name" id="firstName" required error={errors.firstName}>
|
||||||
|
<input
|
||||||
|
id="firstName"
|
||||||
|
type="text"
|
||||||
|
className={`w-full bg-slate-700 border ${errors.firstName ? 'border-red-500' : 'border-slate-600'}
|
||||||
|
rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white`}
|
||||||
|
placeholder="Enter first name"
|
||||||
|
value={formData.firstName}
|
||||||
|
onChange={e => setFormData({ ...formData, firstName: e.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Last Name" id="lastName" required error={errors.lastName}>
|
||||||
|
<input
|
||||||
|
id="lastName"
|
||||||
|
type="text"
|
||||||
|
className={`w-full bg-slate-700 border ${errors.lastName ? 'border-red-500' : 'border-slate-600'}
|
||||||
|
rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white`}
|
||||||
|
placeholder="Enter last name"
|
||||||
|
value={formData.lastName}
|
||||||
|
onChange={e => setFormData({ ...formData, lastName: e.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Birthday (Optional)" id="birthday">
|
||||||
|
<div className="relative">
|
||||||
|
<DatePicker
|
||||||
|
id="birthday"
|
||||||
|
selected={formData.birthday}
|
||||||
|
onChange={(date: Date | null) => setFormData({ ...formData, birthday: date })}
|
||||||
|
dateFormat="dd.MM.yyyy"
|
||||||
|
placeholderText="Select birthday"
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded-md p-2
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
|
||||||
|
showYearDropdown
|
||||||
|
dropdownMode="select"
|
||||||
|
wrapperClassName="w-full"
|
||||||
|
/>
|
||||||
|
<FaRegCalendarAlt className="absolute right-3 top-1/2 transform -translate-y-1/2 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Notes (Optional)" id="notes">
|
||||||
|
<textarea
|
||||||
|
id="notes"
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded-md p-2 min-h-[80px]
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
|
||||||
|
placeholder="Add any additional information"
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={e => setFormData({ ...formData, notes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2 pt-2">
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" icon={isEdit ? <FaSave /> : <FaUserPlus />}>
|
||||||
|
{isEdit ? 'Save Changes' : 'Add Person'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Relationship Form Modal
|
||||||
|
// ==============================
|
||||||
|
interface RelationshipFormModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
formData: NewRelationshipForm;
|
||||||
|
setFormData: React.Dispatch<React.SetStateAction<NewRelationshipForm>>;
|
||||||
|
errors: FormErrors;
|
||||||
|
onSubmit: (e: React.FormEvent) => void;
|
||||||
|
people: PersonNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RelationshipFormModal: React.FC<RelationshipFormModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
formData,
|
||||||
|
setFormData,
|
||||||
|
errors,
|
||||||
|
onSubmit,
|
||||||
|
people,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title="Add New Relationship">
|
||||||
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
|
<ErrorMessage message={errors.general} />
|
||||||
|
|
||||||
|
<FormField label="Source Person" id="source" required error={errors.source}>
|
||||||
|
<select
|
||||||
|
id="source"
|
||||||
|
className={`w-full bg-slate-700 border ${errors.source ? 'border-red-500' : 'border-slate-600'}
|
||||||
|
rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white`}
|
||||||
|
value={formData.source}
|
||||||
|
onChange={e => setFormData({ ...formData, source: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Select person</option>
|
||||||
|
{people.map(person => (
|
||||||
|
<option key={`source-${person._id}`} value={person._id}>
|
||||||
|
{person.firstName} {person.lastName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Target Person" id="target" required error={errors.target}>
|
||||||
|
<select
|
||||||
|
id="target"
|
||||||
|
className={`w-full bg-slate-700 border ${errors.target ? 'border-red-500' : 'border-slate-600'}
|
||||||
|
rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white`}
|
||||||
|
value={formData.target}
|
||||||
|
onChange={e => setFormData({ ...formData, target: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Select person</option>
|
||||||
|
{people.map(person => (
|
||||||
|
<option key={`target-${person._id}`} value={person._id}>
|
||||||
|
{person.firstName} {person.lastName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Relationship Type" id="type" required>
|
||||||
|
<select
|
||||||
|
id="type"
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded-md p-2
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
|
||||||
|
value={formData.type}
|
||||||
|
onChange={e =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
type: e.target.value as RELATIONSHIP_TYPES,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Object.entries(RELATIONSHIPS).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
{formData.type === 'custom' && (
|
||||||
|
<FormField label="Custom Type" id="customType" required error={errors.customType}>
|
||||||
|
<input
|
||||||
|
id="customType"
|
||||||
|
type="text"
|
||||||
|
className={`w-full bg-slate-700 border ${errors.customType ? 'border-red-500' : 'border-slate-600'}
|
||||||
|
rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white`}
|
||||||
|
placeholder="Enter custom relationship type"
|
||||||
|
value={formData.customType}
|
||||||
|
onChange={e =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
customType: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField label="Notes (Optional)" id="relationNotes">
|
||||||
|
<textarea
|
||||||
|
id="relationNotes"
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded-md p-2 min-h-[60px]
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
|
||||||
|
placeholder="Add any additional information"
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={e => setFormData({ ...formData, notes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="flex items-center mt-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="bidirectional"
|
||||||
|
className="h-4 w-4 rounded border-gray-500 text-indigo-600 focus:ring-indigo-500 bg-slate-700"
|
||||||
|
checked={formData.bidirectional}
|
||||||
|
onChange={e =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
bidirectional: e.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label htmlFor="bidirectional" className="ml-2 block text-sm text-gray-300">
|
||||||
|
Create bidirectional relationship (recommended)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2 pt-2">
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" icon={<FaUserFriends />}>
|
||||||
|
Add Relationship
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Person Detail Modal
|
||||||
|
// ==============================
|
||||||
|
interface PersonDetailModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
person: PersonNode;
|
||||||
|
setPerson: React.Dispatch<React.SetStateAction<PersonNode | null>>;
|
||||||
|
errors: FormErrors;
|
||||||
|
onSubmit: (e: React.FormEvent) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
relationships: RelationshipEdge[];
|
||||||
|
people: PersonNode[];
|
||||||
|
onDeleteRelationship: (id: string) => void;
|
||||||
|
onAddNewConnection: () => void;
|
||||||
|
onNavigateToPerson: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PersonDetailModal: React.FC<PersonDetailModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
person,
|
||||||
|
setPerson,
|
||||||
|
errors,
|
||||||
|
onSubmit,
|
||||||
|
onDelete,
|
||||||
|
relationships,
|
||||||
|
people,
|
||||||
|
onDeleteRelationship,
|
||||||
|
onAddNewConnection,
|
||||||
|
onNavigateToPerson,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title={`${person.firstName} ${person.lastName}`}>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
|
<ErrorMessage message={errors.general} />
|
||||||
|
|
||||||
|
<FormField label="First Name" id="editFirstName" required error={errors.firstName}>
|
||||||
|
<input
|
||||||
|
id="editFirstName"
|
||||||
|
type="text"
|
||||||
|
className={`w-full bg-slate-700 border ${errors.firstName ? 'border-red-500' : 'border-slate-600'}
|
||||||
|
rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white`}
|
||||||
|
value={person.firstName || ''}
|
||||||
|
onChange={e => setPerson({ ...person, firstName: e.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Last Name" id="editLastName" required error={errors.lastName}>
|
||||||
|
<input
|
||||||
|
id="editLastName"
|
||||||
|
type="text"
|
||||||
|
className={`w-full bg-slate-700 border ${errors.lastName ? 'border-red-500' : 'border-slate-600'}
|
||||||
|
rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white`}
|
||||||
|
value={person.lastName || ''}
|
||||||
|
onChange={e => setPerson({ ...person, lastName: e.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Birthday" id="editBirthday">
|
||||||
|
<div className="relative">
|
||||||
|
<DatePicker
|
||||||
|
id="editBirthday"
|
||||||
|
selected={person.birthday ? new Date(person.birthday) : null}
|
||||||
|
onChange={(date: Date | null) => setPerson({ ...person, birthday: date })}
|
||||||
|
dateFormat="dd.MM.yyyy"
|
||||||
|
placeholderText="Select birthday"
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded-md p-2
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
|
||||||
|
showYearDropdown
|
||||||
|
dropdownMode="select"
|
||||||
|
wrapperClassName="w-full"
|
||||||
|
/>
|
||||||
|
<FaRegCalendarAlt className="absolute right-3 top-1/2 transform -translate-y-1/2 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Notes" id="editNotes">
|
||||||
|
<textarea
|
||||||
|
id="editNotes"
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded-md p-2 min-h-[80px]
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
|
||||||
|
value={person.notes || ''}
|
||||||
|
onChange={e => setPerson({ ...person, notes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className="flex justify-between pt-2">
|
||||||
|
<Button variant="danger" onClick={() => onDelete(person._id)} icon={<FaTrash />}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" icon={<FaSave />}>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-indigo-400 mb-2">Connections</h4>
|
||||||
|
<div className="max-h-40 overflow-y-auto space-y-1 bg-slate-900 rounded-lg p-2">
|
||||||
|
{relationships.filter(
|
||||||
|
(r: RelationshipEdge) => r.source === person._id || r.target === person._id,
|
||||||
|
).length > 0 ? (
|
||||||
|
relationships
|
||||||
|
.filter((r: RelationshipEdge) => r.source === person._id || r.target === person._id)
|
||||||
|
.map((rel: RelationshipEdge) => {
|
||||||
|
const isSource = rel.source === person._id;
|
||||||
|
const otherPersonId = isSource ? rel.target : rel.source;
|
||||||
|
const otherPerson = people.find((p: PersonNode) => p._id === otherPersonId);
|
||||||
|
|
||||||
|
if (!otherPerson) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={rel._id}
|
||||||
|
className="flex justify-between items-center py-1 px-2 hover:bg-slate-800 rounded"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span
|
||||||
|
className="inline-block w-2 h-2 rounded-full mr-2"
|
||||||
|
style={{ backgroundColor: getRelationshipColor(rel.type) }}
|
||||||
|
></span>
|
||||||
|
<span className="text-sm">
|
||||||
|
{isSource ? 'To: ' : 'From: '}
|
||||||
|
<span
|
||||||
|
className="font-medium hover:text-indigo-400 cursor-pointer"
|
||||||
|
onClick={() => onNavigateToPerson(otherPersonId)}
|
||||||
|
>
|
||||||
|
{otherPerson.firstName} {otherPerson.lastName}
|
||||||
|
</span>
|
||||||
|
{rel.type === 'custom'
|
||||||
|
? ` (${rel.customType})`
|
||||||
|
: ` (${getRelationshipLabel(rel.type)})`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="text-red-400 hover:text-red-300 transition-colors"
|
||||||
|
onClick={() => onDeleteRelationship(rel._id)}
|
||||||
|
>
|
||||||
|
<FaTrash size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-2 text-slate-400 text-sm">No connections yet</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex justify-center">
|
||||||
|
<Button variant="secondary" size="sm" onClick={onAddNewConnection} icon={<FaPlus />}>
|
||||||
|
Add New Connection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Settings Modal
|
||||||
|
// ==============================
|
||||||
|
interface SettingsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
settings: {
|
||||||
|
darkMode: boolean;
|
||||||
|
autoLayout: boolean;
|
||||||
|
showLabels: boolean;
|
||||||
|
animationSpeed: string;
|
||||||
|
highlightConnections: boolean;
|
||||||
|
nodeSize: string;
|
||||||
|
};
|
||||||
|
setSettings: React.Dispatch<
|
||||||
|
React.SetStateAction<{
|
||||||
|
darkMode: boolean;
|
||||||
|
autoLayout: boolean;
|
||||||
|
showLabels: boolean;
|
||||||
|
animationSpeed: string;
|
||||||
|
highlightConnections: boolean;
|
||||||
|
nodeSize: string;
|
||||||
|
}>
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsModal: React.FC<SettingsModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
settings,
|
||||||
|
setSettings,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title="Network Settings">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Toggle settings */}
|
||||||
|
<ToggleSetting
|
||||||
|
label="Show Labels"
|
||||||
|
id="showLabels"
|
||||||
|
checked={settings.showLabels}
|
||||||
|
onChange={() => setSettings({ ...settings, showLabels: !settings.showLabels })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ToggleSetting
|
||||||
|
label="Auto Layout"
|
||||||
|
id="autoLayout"
|
||||||
|
checked={settings.autoLayout}
|
||||||
|
onChange={() => setSettings({ ...settings, autoLayout: !settings.autoLayout })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ToggleSetting
|
||||||
|
label="Highlight Connections"
|
||||||
|
id="highlightConnections"
|
||||||
|
checked={settings.highlightConnections}
|
||||||
|
onChange={() =>
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
highlightConnections: !settings.highlightConnections,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Option groups */}
|
||||||
|
<OptionGroup
|
||||||
|
label="Animation Speed"
|
||||||
|
options={['slow', 'medium', 'fast']}
|
||||||
|
currentValue={settings.animationSpeed}
|
||||||
|
onChange={value => setSettings({ ...settings, animationSpeed: value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OptionGroup
|
||||||
|
label="Node Size"
|
||||||
|
options={['small', 'medium', 'large']}
|
||||||
|
currentValue={settings.nodeSize}
|
||||||
|
onChange={value => setSettings({ ...settings, nodeSize: value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="pt-4 flex justify-end">
|
||||||
|
<Button variant="primary" onClick={onClose} icon={<FaSave />}>
|
||||||
|
Save Settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Help Modal
|
||||||
|
// ==============================
|
||||||
|
interface HelpModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HelpModal: React.FC<HelpModalProps> = ({ isOpen, onClose }) => {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title="Keyboard Shortcuts & Help" size="lg">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-md font-semibold text-indigo-400 mb-2">Keyboard Shortcuts</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<KeyboardShortcut shortcut="n" description="Add new person" />
|
||||||
|
<KeyboardShortcut shortcut="r" description="Add new relationship" />
|
||||||
|
<KeyboardShortcut shortcut="s" description="Toggle sidebar" />
|
||||||
|
<KeyboardShortcut shortcut="+" description="Zoom in" />
|
||||||
|
<KeyboardShortcut shortcut="-" description="Zoom out" />
|
||||||
|
<KeyboardShortcut shortcut="0" description="Reset zoom" />
|
||||||
|
<KeyboardShortcut shortcut="Ctrl+/" description="Show this help" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-md font-semibold text-indigo-400 mb-2">Tips & Tricks</h3>
|
||||||
|
<ul className="space-y-2 text-sm text-slate-300">
|
||||||
|
<TipItem text="Click on a person in the graph to see their details and edit their information" />
|
||||||
|
<TipItem text="Drag people around in the graph to organize your network visually" />
|
||||||
|
<TipItem text="Use the sidebar to filter and manage your network's people and relationships" />
|
||||||
|
<TipItem text="Create bidirectional relationships to show mutual connections (recommended)" />
|
||||||
|
<TipItem text="Customize the appearance and behavior in Settings" />
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center pt-2">
|
||||||
|
<Button variant="primary" onClick={onClose} icon={<FaStar />}>
|
||||||
|
Got it
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
424
frontend/src/components/NetworkSidebar.tsx
Normal file
424
frontend/src/components/NetworkSidebar.tsx
Normal file
@ -0,0 +1,424 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
FaEdit,
|
||||||
|
FaHome,
|
||||||
|
FaSearch,
|
||||||
|
FaTrash,
|
||||||
|
FaUserCircle,
|
||||||
|
FaUserFriends,
|
||||||
|
FaUserPlus,
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
|
||||||
|
import { Button, EmptyState, Tooltip, NetworkStats } from './FriendshipNetworkComponents';
|
||||||
|
import { PersonNode, RelationshipEdge } from '../types/network';
|
||||||
|
import {
|
||||||
|
getRelationshipColor,
|
||||||
|
getRelationshipLabel,
|
||||||
|
RELATIONSHIP_TYPES,
|
||||||
|
RELATIONSHIPS,
|
||||||
|
} from '../types/RelationShipTypes';
|
||||||
|
|
||||||
|
interface NetworkSidebarProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
currentNetwork: any;
|
||||||
|
sidebarTab: string;
|
||||||
|
people: PersonNode[];
|
||||||
|
relationships: RelationshipEdge[];
|
||||||
|
selectedPersonId: string | null;
|
||||||
|
peopleFilter: string;
|
||||||
|
relationshipFilter: string;
|
||||||
|
relationshipTypeFilter: string;
|
||||||
|
|
||||||
|
onTabChange: (tab: string) => void;
|
||||||
|
onPeopleFilterChange: (filter: string) => void;
|
||||||
|
onRelationshipFilterChange: (filter: string) => void;
|
||||||
|
onRelationshipTypeFilterChange: (type: string) => void;
|
||||||
|
onAddPerson: () => void;
|
||||||
|
onAddRelationship: () => void;
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
onOpenHelp: () => void;
|
||||||
|
onPersonDelete: (id: string) => void;
|
||||||
|
onRelationshipDelete: (id: string) => void;
|
||||||
|
onOpenPersonDetail: (person: PersonNode) => void;
|
||||||
|
onNavigateBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NetworkSidebar: React.FC<NetworkSidebarProps> = ({
|
||||||
|
isOpen,
|
||||||
|
currentNetwork,
|
||||||
|
sidebarTab,
|
||||||
|
people,
|
||||||
|
relationships,
|
||||||
|
selectedPersonId,
|
||||||
|
peopleFilter,
|
||||||
|
relationshipFilter,
|
||||||
|
relationshipTypeFilter,
|
||||||
|
|
||||||
|
onTabChange,
|
||||||
|
onPeopleFilterChange,
|
||||||
|
onRelationshipFilterChange,
|
||||||
|
onRelationshipTypeFilterChange,
|
||||||
|
onAddPerson,
|
||||||
|
onAddRelationship,
|
||||||
|
onPersonDelete,
|
||||||
|
onRelationshipDelete,
|
||||||
|
onOpenPersonDetail,
|
||||||
|
onNavigateBack,
|
||||||
|
}) => {
|
||||||
|
// Filter logic for people and relationships
|
||||||
|
const filteredPeople = people.filter(person =>
|
||||||
|
`${person.firstName} ${person.lastName}`.toLowerCase().includes(peopleFilter.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredRelationships = relationships.filter(rel => {
|
||||||
|
const source = people.find(p => p._id === rel.source);
|
||||||
|
const target = people.find(p => p._id === rel.target);
|
||||||
|
|
||||||
|
if (!source || !target) return false;
|
||||||
|
|
||||||
|
const matchesFilter =
|
||||||
|
`${source.firstName} ${source.lastName} ${target.firstName} ${target.lastName}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(relationshipFilter.toLowerCase());
|
||||||
|
|
||||||
|
const matchesType = relationshipTypeFilter === 'all' || rel.type === relationshipTypeFilter;
|
||||||
|
|
||||||
|
return matchesFilter && matchesType;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort people alphabetically
|
||||||
|
const sortedPeople = [...filteredPeople].sort((a, b) => {
|
||||||
|
const nameA = `${a.firstName} ${a.lastName}`.toLowerCase();
|
||||||
|
const nameB = `${b.firstName} ${b.lastName}`.toLowerCase();
|
||||||
|
return nameA.localeCompare(nameB);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-slate-800 border-r border-slate-700 h-full transition-all duration-300
|
||||||
|
ease-in-out z-30 ${isOpen ? 'w-100' : 'w-0'}`}
|
||||||
|
>
|
||||||
|
<div className="h-full overflow-y-auto p-4">
|
||||||
|
{/* Network Header */}
|
||||||
|
<div className="mb-6 mt-8">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<h2 className="text-2xl font-bold text-white flex items-center">
|
||||||
|
<span className="truncate">{currentNetwork?.name || 'Relationship Network'}</span>
|
||||||
|
</h2>
|
||||||
|
<Tooltip text="Back to networks">
|
||||||
|
<button
|
||||||
|
onClick={onNavigateBack}
|
||||||
|
className="p-2 text-slate-400 hover:text-indigo-400 transition-colors"
|
||||||
|
>
|
||||||
|
<FaHome />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-400 text-sm">Visualize your connections</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Network Stats */}
|
||||||
|
<NetworkStats people={people} relationships={relationships} />
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex space-x-2 mb-6">
|
||||||
|
<Button variant="primary" fullWidth onClick={onAddPerson} icon={<FaUserPlus />}>
|
||||||
|
Add Person
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
fullWidth
|
||||||
|
onClick={onAddRelationship}
|
||||||
|
icon={<FaUserFriends />}
|
||||||
|
>
|
||||||
|
Add Relation
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar Tabs */}
|
||||||
|
<div className="flex border-b border-slate-700 mb-4">
|
||||||
|
<button
|
||||||
|
className={`flex-1 py-2 font-medium flex items-center justify-center ${
|
||||||
|
sidebarTab === 'people'
|
||||||
|
? 'text-indigo-400 border-b-2 border-indigo-400'
|
||||||
|
: 'text-slate-400 hover:text-slate-300'
|
||||||
|
}`}
|
||||||
|
onClick={() => onTabChange('people')}
|
||||||
|
>
|
||||||
|
<FaUserCircle className="mr-2" /> People
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`flex-1 py-2 font-medium flex items-center justify-center ${
|
||||||
|
sidebarTab === 'relations'
|
||||||
|
? 'text-indigo-400 border-b-2 border-indigo-400'
|
||||||
|
: 'text-slate-400 hover:text-slate-300'
|
||||||
|
}`}
|
||||||
|
onClick={() => onTabChange('relations')}
|
||||||
|
>
|
||||||
|
<FaUserFriends className="mr-2" /> Relations
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
|
||||||
|
{sidebarTab === 'people' && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center mb-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded-md py-2 pl-8 pr-3
|
||||||
|
text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
|
||||||
|
placeholder="Search people..."
|
||||||
|
value={peopleFilter}
|
||||||
|
onChange={e => onPeopleFilterChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<FaSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 max-h-[calc(100vh-350px)] overflow-y-auto pr-1">
|
||||||
|
{sortedPeople.length > 0 ? (
|
||||||
|
sortedPeople.map(person => {
|
||||||
|
const connectionCount = relationships.filter(
|
||||||
|
r => r.source === person._id || r.target === person._id
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={person._id}
|
||||||
|
className={`bg-slate-700 rounded-lg p-3 group hover:bg-slate-600 transition-colors
|
||||||
|
cursor-pointer border-l-4 ${
|
||||||
|
selectedPersonId === person._id
|
||||||
|
? 'border-l-pink-500'
|
||||||
|
: connectionCount > 0
|
||||||
|
? 'border-l-indigo-500'
|
||||||
|
: 'border-l-slate-700'
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
onOpenPersonDetail(person);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium">
|
||||||
|
{person.firstName} {person.lastName}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center text-xs text-slate-400 mt-1">
|
||||||
|
<span
|
||||||
|
className="inline-block w-2 h-2 rounded-full mr-1"
|
||||||
|
style={{
|
||||||
|
backgroundColor: connectionCount > 0 ? '#60A5FA' : '#94A3B8',
|
||||||
|
}}
|
||||||
|
></span>
|
||||||
|
{connectionCount} connection{connectionCount !== 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Tooltip text="Edit">
|
||||||
|
<button
|
||||||
|
className="p-1 text-slate-400 hover:text-indigo-400 transition-colors"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenPersonDetail(person);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaEdit size={14} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip text="Delete">
|
||||||
|
<button
|
||||||
|
className="p-1 text-slate-400 hover:text-red-400 transition-colors"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onPersonDelete(person._id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaTrash size={14} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title={peopleFilter ? 'No matches found' : 'No people yet'}
|
||||||
|
description={
|
||||||
|
peopleFilter
|
||||||
|
? 'Try adjusting your search criteria'
|
||||||
|
: 'Add people to start building your network'
|
||||||
|
}
|
||||||
|
icon={<FaUserCircle className="text-2xl text-slate-400" />}
|
||||||
|
action={
|
||||||
|
!peopleFilter && (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={onAddPerson}
|
||||||
|
icon={<FaUserPlus />}
|
||||||
|
>
|
||||||
|
Add Person
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sidebarTab === 'relations' && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center mb-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded-md py-2 pl-8 pr-3
|
||||||
|
text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
|
||||||
|
placeholder="Search relationships..."
|
||||||
|
value={relationshipFilter}
|
||||||
|
onChange={e => onRelationshipFilterChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<FaSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex mb-3 overflow-x-auto pb-2 space-x-1">
|
||||||
|
<button
|
||||||
|
className={`px-3 py-1 text-xs rounded-full whitespace-nowrap ${
|
||||||
|
relationshipTypeFilter === 'all'
|
||||||
|
? 'bg-indigo-600 text-white'
|
||||||
|
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
|
||||||
|
}`}
|
||||||
|
onClick={() => onRelationshipTypeFilterChange('all')}
|
||||||
|
>
|
||||||
|
All Types
|
||||||
|
</button>
|
||||||
|
{Object.entries(RELATIONSHIPS).map(([type, relationship]) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
className={`px-3 py-1 text-xs rounded-full whitespace-nowrap flex items-center ${
|
||||||
|
relationshipTypeFilter === type
|
||||||
|
? 'bg-indigo-600 text-white'
|
||||||
|
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
|
||||||
|
}`}
|
||||||
|
onClick={() => onRelationshipTypeFilterChange(type as RELATIONSHIP_TYPES)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full mr-1"
|
||||||
|
style={{ backgroundColor: relationship.color }}
|
||||||
|
></span>
|
||||||
|
<span className="capitalize">
|
||||||
|
{getRelationshipLabel(type as RELATIONSHIP_TYPES)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 max-h-[calc(100vh-390px)] overflow-y-auto pr-1">
|
||||||
|
{filteredRelationships.length > 0 ? (
|
||||||
|
filteredRelationships.map(rel => {
|
||||||
|
const source = people.find(p => p._id === rel.source);
|
||||||
|
const target = people.find(p => p._id === rel.target);
|
||||||
|
if (!source || !target) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={rel._id}
|
||||||
|
className={`bg-slate-700 rounded-lg p-3 group hover:bg-slate-600 transition-colors
|
||||||
|
border-l-4 ${
|
||||||
|
selectedPersonId === rel.source || selectedPersonId === rel.target
|
||||||
|
? 'border-l-pink-500'
|
||||||
|
: 'border-l-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span
|
||||||
|
className={`font-medium ${selectedPersonId === rel.source ? 'text-pink-400' : ''}`}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const sourcePerson = people.find(p => p._id === rel.source);
|
||||||
|
if (sourcePerson) onOpenPersonDetail(sourcePerson);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{source.firstName} {source.lastName}
|
||||||
|
</span>
|
||||||
|
<span className="mx-2 text-slate-400">→</span>
|
||||||
|
<span
|
||||||
|
className={`font-medium ${selectedPersonId === rel.target ? 'text-pink-400' : ''}`}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const targetPerson = people.find(p => p._id === rel.target);
|
||||||
|
if (targetPerson) onOpenPersonDetail(targetPerson);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{target.firstName} {target.lastName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-xs text-slate-400 mt-1">
|
||||||
|
<span
|
||||||
|
className="inline-block w-2 h-2 rounded-full mr-1"
|
||||||
|
style={{ backgroundColor: getRelationshipColor(rel.type) }}
|
||||||
|
></span>
|
||||||
|
<span className="capitalize">
|
||||||
|
{rel.type === 'custom'
|
||||||
|
? rel.customType
|
||||||
|
: getRelationshipLabel(rel.type)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Tooltip text="Delete">
|
||||||
|
<button
|
||||||
|
className="p-1 text-slate-400 hover:text-red-400 transition-colors"
|
||||||
|
onClick={() => onRelationshipDelete(rel._id)}
|
||||||
|
>
|
||||||
|
<FaTrash size={14} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title={
|
||||||
|
relationshipFilter || relationshipTypeFilter !== 'all'
|
||||||
|
? 'No matches found'
|
||||||
|
: 'No relationships yet'
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
relationshipFilter || relationshipTypeFilter !== 'all'
|
||||||
|
? 'Try adjusting your search criteria'
|
||||||
|
: 'Create relationships between people to visualize connections'
|
||||||
|
}
|
||||||
|
icon={<FaUserFriends className="text-2xl text-slate-400" />}
|
||||||
|
action={
|
||||||
|
!relationshipFilter &&
|
||||||
|
relationshipTypeFilter === 'all' && (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={onAddRelationship}
|
||||||
|
icon={<FaUserFriends />}
|
||||||
|
>
|
||||||
|
Add Relationship
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NetworkSidebar;
|
228
frontend/src/components/UIComponents.tsx
Normal file
228
frontend/src/components/UIComponents.tsx
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FormErrors } from '../types/network';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle setting component with a switch-style toggle
|
||||||
|
*/
|
||||||
|
export interface ToggleSettingProps {
|
||||||
|
label: string;
|
||||||
|
id: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ToggleSetting: React.FC<ToggleSettingProps> = ({ label, id, checked, onChange }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium text-gray-300">{label}</label>
|
||||||
|
<div className="relative inline-block w-12 align-middle select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={id}
|
||||||
|
name={id}
|
||||||
|
className="sr-only"
|
||||||
|
checked={checked}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
<div className="block h-6 bg-slate-700 rounded-full w-12"></div>
|
||||||
|
<div
|
||||||
|
className={`absolute left-1 top-1 w-4 h-4 rounded-full transition-transform ${
|
||||||
|
checked ? 'transform translate-x-6 bg-indigo-500' : 'bg-gray-400'
|
||||||
|
}`}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Option group component for selecting from a group of options
|
||||||
|
*/
|
||||||
|
export interface OptionGroupProps {
|
||||||
|
label: string;
|
||||||
|
options: string[];
|
||||||
|
currentValue: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OptionGroup: React.FC<OptionGroupProps> = ({
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
currentValue,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">{label}</label>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
{options.map(option => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
className={`flex-1 py-2 px-3 rounded-md text-sm ${
|
||||||
|
currentValue === option
|
||||||
|
? 'bg-indigo-600 text-white'
|
||||||
|
: 'bg-slate-700 text-gray-300 hover:bg-slate-600'
|
||||||
|
}`}
|
||||||
|
onClick={() => onChange(option)}
|
||||||
|
>
|
||||||
|
{option.charAt(0).toUpperCase() + option.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyboard shortcut item component for the help modal
|
||||||
|
*/
|
||||||
|
export interface KeyboardShortcutProps {
|
||||||
|
shortcut: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KeyboardShortcut: React.FC<KeyboardShortcutProps> = ({ shortcut, description }) => {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-900 p-2 rounded">
|
||||||
|
<span className="inline-block bg-slate-700 px-2 py-1 rounded mr-2 text-xs font-mono">
|
||||||
|
{shortcut}
|
||||||
|
</span>
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tip item component for the help modal
|
||||||
|
*/
|
||||||
|
export interface TipItemProps {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TipItem: React.FC<TipItemProps> = ({ text }) => {
|
||||||
|
return (
|
||||||
|
<li className="flex items-start">
|
||||||
|
<span className="text-indigo-400 mr-2">•</span>
|
||||||
|
<span>{text}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error message display component
|
||||||
|
*/
|
||||||
|
export interface ErrorMessageProps {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorMessage: React.FC<ErrorMessageProps> = ({ message }) => {
|
||||||
|
return message ? (
|
||||||
|
<div className="bg-red-500/20 border border-red-500 text-white p-3 rounded-lg text-sm mb-4">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading spinner component
|
||||||
|
*/
|
||||||
|
export const LoadingSpinner: React.FC<{ message?: string }> = ({ message = 'Loading...' }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center space-y-4">
|
||||||
|
<div className="w-16 h-16 border-t-4 border-b-4 border-indigo-500 border-solid rounded-full animate-spin"></div>
|
||||||
|
<p className="text-white text-lg">{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form field group with validation
|
||||||
|
*/
|
||||||
|
export interface FormGroupProps {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
error?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FormGroup: React.FC<FormGroupProps> = ({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
required = false,
|
||||||
|
error,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="mb-4">
|
||||||
|
<label htmlFor={id} className="block text-sm font-medium text-gray-300 mb-1">
|
||||||
|
{label} {required && <span className="text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
{error && <p className="mt-1 text-sm text-red-500">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form validation helpers
|
||||||
|
*/
|
||||||
|
export const validatePersonForm = (person: { firstName: string; lastName: string }): FormErrors => {
|
||||||
|
const errors: FormErrors = {};
|
||||||
|
|
||||||
|
if (!person.firstName.trim()) {
|
||||||
|
errors.firstName = 'First name is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!person.lastName.trim()) {
|
||||||
|
errors.lastName = 'Last name is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateRelationshipForm = (
|
||||||
|
relationship: {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
type: string;
|
||||||
|
customType: string;
|
||||||
|
bidirectional: boolean;
|
||||||
|
},
|
||||||
|
existingRelationships: any[]
|
||||||
|
): FormErrors => {
|
||||||
|
const errors: FormErrors = {};
|
||||||
|
|
||||||
|
if (!relationship.source) {
|
||||||
|
errors.source = 'Source person is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relationship.target) {
|
||||||
|
errors.target = 'Target person is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relationship.source === relationship.target) {
|
||||||
|
errors.target = 'Source and target cannot be the same person';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relationship.type === 'custom' && !relationship.customType.trim()) {
|
||||||
|
errors.customType = 'Custom relationship type is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if relationship already exists
|
||||||
|
if (relationship.source && relationship.target) {
|
||||||
|
const existingRelationship = existingRelationships.find(
|
||||||
|
r =>
|
||||||
|
(r.source === relationship.source && r.target === relationship.target) ||
|
||||||
|
(relationship.bidirectional &&
|
||||||
|
r.source === relationship.target &&
|
||||||
|
r.target === relationship.source)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingRelationship) {
|
||||||
|
errors.general = 'This relationship already exists';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
};
|
@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { FaUser, FaSignOutAlt, FaNetworkWired } from 'react-icons/fa';
|
import { FaNetworkWired, FaSignOutAlt, FaUser } from 'react-icons/fa';
|
||||||
|
|
||||||
const Header: React.FC = () => {
|
const Header: React.FC = () => {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
@ -2,8 +2,8 @@ import React, { useState } from 'react';
|
|||||||
import { useNetworks } from '../../context/NetworkContext';
|
import { useNetworks } from '../../context/NetworkContext';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { FaPlus, FaNetworkWired, FaTrash, FaEye, FaGlobe, FaLock, FaTimes } from 'react-icons/fa';
|
import { FaEye, FaGlobe, FaLock, FaNetworkWired, FaPlus, FaTimes, FaTrash } from 'react-icons/fa';
|
||||||
|
|
||||||
const NetworkList: React.FC = () => {
|
const NetworkList: React.FC = () => {
|
||||||
const { networks, loading, error, createNetwork, deleteNetwork } = useNetworks();
|
const { networks, loading, error, createNetwork, deleteNetwork } = useNetworks();
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import React, { createContext, useState, useEffect, useContext, ReactNode } from 'react';
|
import React, { createContext, ReactNode, useContext, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
User,
|
|
||||||
getCurrentUser,
|
getCurrentUser,
|
||||||
login as apiLogin,
|
login as apiLogin,
|
||||||
register as apiRegister,
|
|
||||||
logout as apiLogout,
|
|
||||||
LoginData,
|
LoginData,
|
||||||
|
logout as apiLogout,
|
||||||
|
register as apiRegister,
|
||||||
RegisterData,
|
RegisterData,
|
||||||
|
User,
|
||||||
} from '../api/auth';
|
} from '../api/auth';
|
||||||
|
|
||||||
interface AuthContextProps {
|
interface AuthContextProps {
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import React, { createContext, useState, useEffect, useContext, ReactNode } from 'react';
|
import React, { createContext, ReactNode, useContext, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Network,
|
|
||||||
getUserNetworks,
|
|
||||||
createNetwork as apiCreateNetwork,
|
createNetwork as apiCreateNetwork,
|
||||||
updateNetwork as apiUpdateNetwork,
|
|
||||||
deleteNetwork as apiDeleteNetwork,
|
|
||||||
CreateNetworkData,
|
CreateNetworkData,
|
||||||
|
deleteNetwork as apiDeleteNetwork,
|
||||||
|
getUserNetworks,
|
||||||
|
Network,
|
||||||
|
updateNetwork as apiUpdateNetwork,
|
||||||
UpdateNetworkData,
|
UpdateNetworkData,
|
||||||
} from '../api/network';
|
} from '../api/network';
|
||||||
import { useAuth } from './AuthContext';
|
import { useAuth } from './AuthContext';
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Person, getPeople, addPerson, updatePerson, removePerson } from '../api/people';
|
import { addPerson, getPeople, Person, removePerson, updatePerson } from '../api/people';
|
||||||
import {
|
import {
|
||||||
Relationship,
|
|
||||||
getRelationships,
|
|
||||||
addRelationship,
|
addRelationship,
|
||||||
updateRelationship,
|
getRelationships,
|
||||||
removeRelationship,
|
removeRelationship,
|
||||||
|
updateRelationship,
|
||||||
} from '../api/relationships';
|
} from '../api/relationships';
|
||||||
|
import { Relationship } from '../interfaces/IRelationship';
|
||||||
|
import { RELATIONSHIP_TYPES } from '../types/RelationShipTypes';
|
||||||
|
|
||||||
interface PersonNode extends Person {
|
interface PersonNode extends Person {
|
||||||
// Additional properties needed for the visualization
|
// Additional properties needed for the visualization
|
||||||
@ -314,7 +315,7 @@ export const useFriendshipNetwork = (
|
|||||||
const createRelationship = async (relationshipData: {
|
const createRelationship = async (relationshipData: {
|
||||||
source: string;
|
source: string;
|
||||||
target: string;
|
target: string;
|
||||||
type: 'freund' | 'partner' | 'familie' | 'arbeitskolleg' | 'custom';
|
type: RELATIONSHIP_TYPES;
|
||||||
customType?: string;
|
customType?: string;
|
||||||
}): Promise<RelationshipEdge> => {
|
}): Promise<RelationshipEdge> => {
|
||||||
if (!networkId) throw new Error('No network selected');
|
if (!networkId) throw new Error('No network selected');
|
||||||
@ -342,7 +343,7 @@ export const useFriendshipNetwork = (
|
|||||||
const updateRelationshipData = async (
|
const updateRelationshipData = async (
|
||||||
relationshipId: string,
|
relationshipId: string,
|
||||||
relationshipData: {
|
relationshipData: {
|
||||||
type?: 'freund' | 'partner' | 'familie' | 'arbeitskolleg' | 'custom';
|
type?: RELATIONSHIP_TYPES;
|
||||||
customType?: string;
|
customType?: string;
|
||||||
}
|
}
|
||||||
): Promise<RelationshipEdge> => {
|
): Promise<RelationshipEdge> => {
|
||||||
|
16
frontend/src/interfaces/IPersonNode.tsx
Normal file
16
frontend/src/interfaces/IPersonNode.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export interface PersonNode {
|
||||||
|
_id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
birthday?: Date | string | null;
|
||||||
|
notes?: string;
|
||||||
|
position?: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type for form errors
|
||||||
|
export interface FormErrors {
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
13
frontend/src/interfaces/IRelationship.ts
Normal file
13
frontend/src/interfaces/IRelationship.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// Types
|
||||||
|
import { RELATIONSHIP_TYPES } from '../types/RelationShipTypes';
|
||||||
|
|
||||||
|
export interface Relationship {
|
||||||
|
_id: string;
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
type: RELATIONSHIP_TYPES;
|
||||||
|
customType?: string;
|
||||||
|
network: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
24
frontend/src/types/RelationShipTypes.ts
Normal file
24
frontend/src/types/RelationShipTypes.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
export type RELATIONSHIP_TYPES =
|
||||||
|
| 'acquaintance'
|
||||||
|
| 'friend'
|
||||||
|
| 'partner'
|
||||||
|
| 'family'
|
||||||
|
| 'secondDegree'
|
||||||
|
| 'colleague'
|
||||||
|
| 'teacher'
|
||||||
|
| 'exPartner'
|
||||||
|
| 'custom';
|
||||||
|
|
||||||
|
export const RELATIONSHIPS: Record<RELATIONSHIP_TYPES, { label: string; color: string }> = {
|
||||||
|
acquaintance: { label: 'Bekannter', color: '#60A5FA' }, // Light blue
|
||||||
|
friend: { label: 'Freund', color: '#60A5FA' }, // Light blue
|
||||||
|
partner: { label: 'Partner', color: '#F472B6' }, // Pink
|
||||||
|
family: { label: 'Familie', color: '#34D399' }, // Green
|
||||||
|
secondDegree: { label: 'Verwandter', color: '#34D399' }, // Green
|
||||||
|
colleague: { label: 'Kollege/Klassenkamerad', color: '#FBBF24' }, // Yellow
|
||||||
|
teacher: { label: 'Lehrer', color: '#FBBF24' }, // Yellow
|
||||||
|
exPartner: { label: 'Ex-Partner', color: '#ce8c13' }, // Orange
|
||||||
|
custom: { label: 'Benutzerdefiniert', color: '#9CA3AF' }, // Gray
|
||||||
|
};
|
||||||
|
export const getRelationshipLabel = (type: RELATIONSHIP_TYPES): string => RELATIONSHIPS[type].label;
|
||||||
|
export const getRelationshipColor = (type: RELATIONSHIP_TYPES): string => RELATIONSHIPS[type].color;
|
85
frontend/src/types/network.ts
Normal file
85
frontend/src/types/network.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { RELATIONSHIP_TYPES } from './RelationShipTypes';
|
||||||
|
|
||||||
|
export interface PersonNode {
|
||||||
|
_id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
birthday?: Date | string | null;
|
||||||
|
notes?: string;
|
||||||
|
position?: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelationshipEdge {
|
||||||
|
_id: string;
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
type: RELATIONSHIP_TYPES;
|
||||||
|
customType?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphNode {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
connectionCount: number;
|
||||||
|
bgColor: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
showLabel: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphEdge {
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
color: string;
|
||||||
|
width: number;
|
||||||
|
type: RELATIONSHIP_TYPES;
|
||||||
|
customType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanvasGraphData {
|
||||||
|
nodes: GraphNode[];
|
||||||
|
edges: GraphEdge[];
|
||||||
|
links: GraphEdge[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormErrors {
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetworkSettings {
|
||||||
|
darkMode: boolean;
|
||||||
|
autoLayout: boolean;
|
||||||
|
showLabels: boolean;
|
||||||
|
animationSpeed: string;
|
||||||
|
highlightConnections: boolean;
|
||||||
|
nodeSize: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewPersonForm {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
birthday: Date | null;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewRelationshipForm {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
type: RELATIONSHIP_TYPES;
|
||||||
|
customType: string;
|
||||||
|
notes: string;
|
||||||
|
bidirectional: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToastItem {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
type: 'error' | 'success' | 'warning' | 'info';
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
@ -51,6 +51,11 @@ 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);
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/api/health', (req, res) => {
|
||||||
|
res.send('OK');
|
||||||
|
});
|
||||||
|
|
||||||
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) => {
|
||||||
|
@ -217,12 +217,12 @@ const createSampleDemoNetwork = async (userId: mongoose.Types.ObjectId | string)
|
|||||||
|
|
||||||
// Create relationships between people
|
// Create relationships between people
|
||||||
const relationships = [
|
const relationships = [
|
||||||
{ source: 'JohnSmith', target: 'EmmaJohnson', type: 'freund' },
|
{ source: 'JohnSmith', target: 'EmmaJohnson', type: 'friend' },
|
||||||
{ source: 'EmmaJohnson', target: 'MichaelWilliams', type: 'familie' },
|
{ source: 'EmmaJohnson', target: 'MichaelWilliams', type: 'family' },
|
||||||
{ source: 'MichaelWilliams', target: 'SarahBrown', type: 'arbeitskolleg' },
|
{ source: 'MichaelWilliams', target: 'SarahBrown', type: 'colleague' },
|
||||||
{ source: 'SarahBrown', target: 'DavidJones', type: 'freund' },
|
{ source: 'SarahBrown', target: 'DavidJones', type: 'friend' },
|
||||||
{ source: 'DavidJones', target: 'LisaGarcia', type: 'partner' },
|
{ source: 'DavidJones', target: 'LisaGarcia', type: 'partner' },
|
||||||
{ source: 'JohnSmith', target: 'DavidJones', type: 'arbeitskolleg' },
|
{ source: 'JohnSmith', target: 'DavidJones', type: 'colleague' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Create each relationship
|
// Create each relationship
|
||||||
|
@ -3,7 +3,6 @@ import Person from '../models/person.model';
|
|||||||
import Relationship from '../models/relationship.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';
|
|
||||||
|
|
||||||
// Get all people in a network
|
// Get all people in a network
|
||||||
export const getPeople = async (req: UserRequest, res: Response): Promise<void> => {
|
export const getPeople = async (req: UserRequest, res: Response): Promise<void> => {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Request, Response, NextFunction } from 'express';
|
import { Response, NextFunction } from 'express';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import User from '../models/user.model';
|
import User from '../models/user.model';
|
||||||
import { UserRequest } from '../types/express';
|
import { UserRequest } from '../types/express';
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
import mongoose, { Document, Schema } from 'mongoose';
|
import mongoose, { Document, Schema } from 'mongoose';
|
||||||
|
|
||||||
|
|
||||||
|
export const RELATIONSHIP_TYPES = [
|
||||||
|
'acquaintance', 'friend', 'partner', 'family', 'secondDegree', 'colleague', 'teacher', 'exPartner', 'custom',
|
||||||
|
];
|
||||||
|
|
||||||
export interface IRelationship extends Document {
|
export interface IRelationship extends Document {
|
||||||
_id: string;
|
_id: string;
|
||||||
source: mongoose.Types.ObjectId;
|
source: mongoose.Types.ObjectId;
|
||||||
@ -24,7 +29,7 @@ const RelationshipSchema = new Schema(
|
|||||||
type: {
|
type: {
|
||||||
type: String,
|
type: String,
|
||||||
required: [true, 'Relationship type is required'],
|
required: [true, 'Relationship type is required'],
|
||||||
enum: ['freund', 'partner', 'familie', 'arbeitskolleg', 'custom'],
|
enum: RELATIONSHIP_TYPES,
|
||||||
},
|
},
|
||||||
customType: {
|
customType: {
|
||||||
type: String,
|
type: String,
|
||||||
@ -36,7 +41,7 @@ const RelationshipSchema = new Schema(
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create compound index to ensure unique relationships in a network
|
// Create compound index to ensure unique relationships in a network
|
||||||
|
@ -3,6 +3,8 @@ import { check } from 'express-validator';
|
|||||||
import * as relationshipController from '../controllers/relationship.controller';
|
import * as relationshipController from '../controllers/relationship.controller';
|
||||||
import { auth } from '../middleware/auth.middleware';
|
import { auth } from '../middleware/auth.middleware';
|
||||||
import { checkNetworkAccess } from '../middleware/network-access.middleware';
|
import { checkNetworkAccess } from '../middleware/network-access.middleware';
|
||||||
|
import { RELATIONSHIP_TYPES } from '../models/relationship.model';
|
||||||
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@ -22,13 +24,7 @@ router.post(
|
|||||||
[
|
[
|
||||||
check('source', 'Source person ID is required').not().isEmpty().isMongoId(),
|
check('source', 'Source person ID is required').not().isEmpty().isMongoId(),
|
||||||
check('target', 'Target person ID is required').not().isEmpty().isMongoId(),
|
check('target', 'Target person ID is required').not().isEmpty().isMongoId(),
|
||||||
check('type', 'Relationship type is required').isIn([
|
check('type', 'Relationship type is required').isIn(RELATIONSHIP_TYPES),
|
||||||
'freund',
|
|
||||||
'partner',
|
|
||||||
'familie',
|
|
||||||
'arbeitskolleg',
|
|
||||||
'custom',
|
|
||||||
]),
|
|
||||||
check('customType', 'Custom type is required when type is custom')
|
check('customType', 'Custom type is required when type is custom')
|
||||||
.if(check('type').equals('custom'))
|
.if(check('type').equals('custom'))
|
||||||
.not()
|
.not()
|
||||||
@ -45,7 +41,7 @@ router.put(
|
|||||||
[
|
[
|
||||||
check('type', 'Relationship type must be valid if provided')
|
check('type', 'Relationship type must be valid if provided')
|
||||||
.optional()
|
.optional()
|
||||||
.isIn(['freund', 'partner', 'familie', 'arbeitskolleg', 'custom']),
|
.isIn(RELATIONSHIP_TYPES),
|
||||||
check('customType', 'Custom type is required when type is custom')
|
check('customType', 'Custom type is required when type is custom')
|
||||||
.if(check('type').equals('custom'))
|
.if(check('type').equals('custom'))
|
||||||
.not()
|
.not()
|
||||||
|
1
src/types/express.d.ts
vendored
1
src/types/express.d.ts
vendored
@ -1,7 +1,6 @@
|
|||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { IUser } from '../models/user.model';
|
import { IUser } from '../models/user.model';
|
||||||
import { INetwork } from '../models/network.model';
|
import { INetwork } from '../models/network.model';
|
||||||
import { Document } from 'mongoose';
|
|
||||||
|
|
||||||
export interface UserRequest extends Request {
|
export interface UserRequest extends Request {
|
||||||
user?: IUser;
|
user?: IUser;
|
||||||
|
Reference in New Issue
Block a user