mirror of
https://github.com/philipredstone/relnet.git
synced 2025-07-09 15:16:41 +02:00
Compare commits
14 Commits
dev
...
c71593ffd5
Author | SHA1 | Date | |
---|---|---|---|
c71593ffd5 | |||
b89f6b19a4 | |||
47ef38df75 | |||
f1ead87340 | |||
581433612b | |||
d55e58d099 | |||
006b648dd0 | |||
c31b5c5b14 | |||
0333d37aae | |||
3da29516ec | |||
00e7294f41 | |||
b054d55018 | |||
bbb3645d99 | |||
9ce80b4c59 |
@ -44,6 +44,12 @@ LABEL "org.opencontainers.image.version"="1.0.0"
|
|||||||
LABEL "VERSION"="1.0.0"
|
LABEL "VERSION"="1.0.0"
|
||||||
LABEL maintainer="Tobias Hopp and Philip Rothstein"
|
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/*
|
||||||
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
1
frontend/.dockerignore
Normal file
1
frontend/.dockerignore
Normal file
@ -0,0 +1 @@
|
|||||||
|
node_modules
|
16
frontend/.prettierignore
Normal file
16
frontend/.prettierignore
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# Ignore build outputs
|
||||||
|
/dist
|
||||||
|
/build
|
||||||
|
|
||||||
|
# Ignore dependencies
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
# Ignore coverage reports
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# Ignore logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Ignore configuration files
|
||||||
|
.env
|
||||||
|
.env.*
|
12
frontend/.prettierrc
Normal file
12
frontend/.prettierrc
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"printWidth": 100,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"jsxSingleQuote": false,
|
||||||
|
"bracketSameLine": false
|
||||||
|
}
|
44
frontend/package.json
Normal file
44
frontend/package.json
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"format": "prettier --write \"src/**/*.{tsx,ts,js,jsx,json,css,html}\"",
|
||||||
|
"format:check": "prettier --check \"src/**/*.{tsx,ts,js,jsx,json,css,html}\""
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": "",
|
||||||
|
"dependencies": {
|
||||||
|
"@headlessui/react": "^2.2.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.4",
|
||||||
|
"axios": "^1.8.4",
|
||||||
|
"framer-motion": "^12.7.3",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-datepicker": "^8.3.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-force-graph-2d": "^1.27.1",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
|
"react-router-dom": "^7.5.0",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"vite": "^6.2.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/axios": "^0.14.4",
|
||||||
|
"@types/node": "^22.14.1",
|
||||||
|
"@types/react": "^19.1.2",
|
||||||
|
"@types/react-dom": "^19.1.2",
|
||||||
|
"@types/react-router-dom": "^5.3.3",
|
||||||
|
"@vitejs/plugin-react": "^4.4.0",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"postcss": "^8.4.32",
|
||||||
|
"prettier": "^3.5.3",
|
||||||
|
"tailwindcss": "^4.1.4",
|
||||||
|
"webpack": "^5.99.5",
|
||||||
|
"webpack-cli": "^6.0.1"
|
||||||
|
}
|
||||||
|
}
|
@ -5,7 +5,7 @@ import { NetworkProvider } from './context/NetworkContext';
|
|||||||
import Login from './components/auth/Login';
|
import Login from './components/auth/Login';
|
||||||
import Register from './components/auth/Register';
|
import Register from './components/auth/Register';
|
||||||
import NetworkList from './components/networks/NetworkList';
|
import NetworkList from './components/networks/NetworkList';
|
||||||
import FriendshipNetwork from './pages/FriendshipNetwork';
|
import FriendshipNetwork from './components/FriendshipNetwork';
|
||||||
import Header from './components/layout/Header';
|
import Header from './components/layout/Header';
|
||||||
|
|
||||||
// Protected route component
|
// Protected route component
|
@ -1,7 +1,10 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { getApiUrl } from './api';
|
|
||||||
|
|
||||||
const API_URL = getApiUrl();
|
const protocol = window.location.protocol;
|
||||||
|
const hostname = window.location.hostname;
|
||||||
|
const port = window.location.port;
|
||||||
|
|
||||||
|
const API_URL = protocol + '//' + hostname + (port ? ':' + port : '') + '/api';
|
||||||
|
|
||||||
// Configure axios
|
// Configure axios
|
||||||
axios.defaults.withCredentials = true;
|
axios.defaults.withCredentials = true;
|
@ -1,7 +1,10 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { getApiUrl } from './api';
|
|
||||||
|
|
||||||
const API_URL = getApiUrl();
|
const protocol = window.location.protocol;
|
||||||
|
const hostname = window.location.hostname;
|
||||||
|
const port = window.location.port;
|
||||||
|
|
||||||
|
const API_URL = protocol + '//' + hostname + (port ? ':' + port : '') + '/api';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export interface NetworkOwner {
|
export interface NetworkOwner {
|
||||||
@ -41,7 +44,7 @@ export const getUserNetworks = async (): Promise<Network[]> => {
|
|||||||
export const createNetwork = async (data: CreateNetworkData): Promise<Network> => {
|
export const createNetwork = async (data: CreateNetworkData): Promise<Network> => {
|
||||||
const response = await axios.post<{ success: boolean; data: Network }>(
|
const response = await axios.post<{ success: boolean; data: Network }>(
|
||||||
`${API_URL}/networks`,
|
`${API_URL}/networks`,
|
||||||
data,
|
data
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
};
|
};
|
||||||
@ -49,7 +52,7 @@ export const createNetwork = async (data: CreateNetworkData): Promise<Network> =
|
|||||||
// Get a specific network
|
// Get a specific network
|
||||||
export const getNetwork = async (id: string): Promise<Network> => {
|
export const getNetwork = async (id: string): Promise<Network> => {
|
||||||
const response = await axios.get<{ success: boolean; data: Network }>(
|
const response = await axios.get<{ success: boolean; data: Network }>(
|
||||||
`${API_URL}/networks/${id}`,
|
`${API_URL}/networks/${id}`
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
};
|
};
|
||||||
@ -58,7 +61,7 @@ export const getNetwork = async (id: string): Promise<Network> => {
|
|||||||
export const updateNetwork = async (id: string, data: UpdateNetworkData): Promise<Network> => {
|
export const updateNetwork = async (id: string, data: UpdateNetworkData): Promise<Network> => {
|
||||||
const response = await axios.put<{ success: boolean; data: Network }>(
|
const response = await axios.put<{ success: boolean; data: Network }>(
|
||||||
`${API_URL}/networks/${id}`,
|
`${API_URL}/networks/${id}`,
|
||||||
data,
|
data
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
};
|
};
|
@ -1,7 +1,10 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { getApiUrl } from './api';
|
|
||||||
|
|
||||||
const API_URL = getApiUrl();
|
const protocol = window.location.protocol;
|
||||||
|
const hostname = window.location.hostname;
|
||||||
|
const port = window.location.port;
|
||||||
|
|
||||||
|
const API_URL = protocol + '//' + hostname + (port ? ':' + port : '') + '/api';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export interface Person {
|
export interface Person {
|
||||||
@ -41,7 +44,7 @@ export interface UpdatePersonData {
|
|||||||
// Get all people in a network
|
// Get all people in a network
|
||||||
export const getPeople = async (networkId: string): Promise<Person[]> => {
|
export const getPeople = async (networkId: string): Promise<Person[]> => {
|
||||||
const response = await axios.get<{ success: boolean; data: Person[] }>(
|
const response = await axios.get<{ success: boolean; data: Person[] }>(
|
||||||
`${API_URL}/networks/${networkId}/people`,
|
`${API_URL}/networks/${networkId}/people`
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
};
|
};
|
||||||
@ -50,7 +53,7 @@ export const getPeople = async (networkId: string): Promise<Person[]> => {
|
|||||||
export const addPerson = async (networkId: string, data: CreatePersonData): Promise<Person> => {
|
export const addPerson = async (networkId: string, data: CreatePersonData): Promise<Person> => {
|
||||||
const response = await axios.post<{ success: boolean; data: Person }>(
|
const response = await axios.post<{ success: boolean; data: Person }>(
|
||||||
`${API_URL}/networks/${networkId}/people`,
|
`${API_URL}/networks/${networkId}/people`,
|
||||||
data,
|
data
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
};
|
};
|
||||||
@ -59,11 +62,11 @@ export const addPerson = async (networkId: string, data: CreatePersonData): Prom
|
|||||||
export const updatePerson = async (
|
export const updatePerson = async (
|
||||||
networkId: string,
|
networkId: string,
|
||||||
personId: string,
|
personId: string,
|
||||||
data: UpdatePersonData,
|
data: UpdatePersonData
|
||||||
): Promise<Person> => {
|
): Promise<Person> => {
|
||||||
const response = await axios.put<{ success: boolean; data: Person }>(
|
const response = await axios.put<{ success: boolean; data: Person }>(
|
||||||
`${API_URL}/networks/${networkId}/people/${personId}`,
|
`${API_URL}/networks/${networkId}/people/${personId}`,
|
||||||
data,
|
data
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
};
|
};
|
@ -1,36 +1,29 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { getApiUrl } from './api';
|
import { RELATIONSHIP_TYPES } from '../types/RelationShipTypes';
|
||||||
|
import { Relationship } from '../interfaces/IRelationship';
|
||||||
|
|
||||||
const API_URL = getApiUrl();
|
const protocol = window.location.protocol;
|
||||||
|
const hostname = window.location.hostname;
|
||||||
|
const port = window.location.port;
|
||||||
|
|
||||||
// Types
|
const API_URL = protocol + '//' + hostname + (port ? ':' + port : '') + '/api';
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all relationships in a network
|
// Get all relationships in a network
|
||||||
export const getRelationships = async (networkId: string): Promise<Relationship[]> => {
|
export const getRelationships = async (networkId: string): Promise<Relationship[]> => {
|
||||||
const response = await axios.get<{ success: boolean; data: Relationship[] }>(
|
const response = await axios.get<{ success: boolean; data: Relationship[] }>(
|
||||||
`${API_URL}/networks/${networkId}/relationships`,
|
`${API_URL}/networks/${networkId}/relationships`
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
};
|
};
|
||||||
@ -38,11 +31,11 @@ export const getRelationships = async (networkId: string): Promise<Relationship[
|
|||||||
// Add a relationship to the network
|
// Add a relationship to the network
|
||||||
export const addRelationship = async (
|
export const addRelationship = async (
|
||||||
networkId: string,
|
networkId: string,
|
||||||
data: CreateRelationshipData,
|
data: CreateRelationshipData
|
||||||
): Promise<Relationship> => {
|
): Promise<Relationship> => {
|
||||||
const response = await axios.post<{ success: boolean; data: Relationship }>(
|
const response = await axios.post<{ success: boolean; data: Relationship }>(
|
||||||
`${API_URL}/networks/${networkId}/relationships`,
|
`${API_URL}/networks/${networkId}/relationships`,
|
||||||
data,
|
data
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
};
|
};
|
||||||
@ -51,11 +44,11 @@ export const addRelationship = async (
|
|||||||
export const updateRelationship = async (
|
export const updateRelationship = async (
|
||||||
networkId: string,
|
networkId: string,
|
||||||
relationshipId: string,
|
relationshipId: string,
|
||||||
data: UpdateRelationshipData,
|
data: UpdateRelationshipData
|
||||||
): Promise<Relationship> => {
|
): Promise<Relationship> => {
|
||||||
const response = await axios.put<{ success: boolean; data: Relationship }>(
|
const response = await axios.put<{ success: boolean; data: Relationship }>(
|
||||||
`${API_URL}/networks/${networkId}/relationships/${relationshipId}`,
|
`${API_URL}/networks/${networkId}/relationships/${relationshipId}`,
|
||||||
data,
|
data
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
};
|
};
|
||||||
@ -63,7 +56,7 @@ export const updateRelationship = async (
|
|||||||
// Remove a relationship
|
// Remove a relationship
|
||||||
export const removeRelationship = async (
|
export const removeRelationship = async (
|
||||||
networkId: string,
|
networkId: string,
|
||||||
relationshipId: string,
|
relationshipId: string
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
await axios.delete(`${API_URL}/networks/${networkId}/relationships/${relationshipId}`);
|
await axios.delete(`${API_URL}/networks/${networkId}/relationships/${relationshipId}`);
|
||||||
};
|
};
|
@ -1,7 +1,10 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { GraphData } from 'react-force-graph-2d';
|
|
||||||
|
|
||||||
// Define types for graph elements
|
interface GraphData {
|
||||||
|
nodes: any[];
|
||||||
|
links?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
interface NodeData {
|
interface NodeData {
|
||||||
id: string;
|
id: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
@ -26,30 +29,26 @@ interface CustomGraphData extends GraphData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface CanvasGraphProps {
|
interface CanvasGraphProps {
|
||||||
data: CustomGraphData,
|
data: CustomGraphData;
|
||||||
width: number,
|
width: number;
|
||||||
height: number,
|
height: number;
|
||||||
zoomLevel: number,
|
|
||||||
onNodeClick: (nodeId: string) => void,
|
|
||||||
onNodeDrag: (nodeId, x, y) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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, zoomLevel, onNodeClick, onNodeDrag }) => {
|
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);
|
||||||
@ -60,7 +59,6 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
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,
|
||||||
@ -73,19 +71,16 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
>
|
>
|
||||||
>({});
|
>({});
|
||||||
|
|
||||||
// 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)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (allNodesHavePositions) {
|
if (allNodesHavePositions) {
|
||||||
@ -93,29 +88,21 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
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 &&
|
||||||
@ -129,15 +116,12 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
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;
|
||||||
|
|
||||||
@ -147,22 +131,14 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
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) {
|
||||||
@ -177,34 +153,28 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
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;
|
||||||
|
|
||||||
@ -213,7 +183,6 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Find connected nodes (neighbors) for the current node
|
|
||||||
const connectedNodeIds = new Set<string>();
|
const connectedNodeIds = new Set<string>();
|
||||||
data.edges.forEach(edge => {
|
data.edges.forEach(edge => {
|
||||||
if (edge.source === node.id) {
|
if (edge.source === node.id) {
|
||||||
@ -223,7 +192,6 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Attraction forces (only to connected nodes)
|
|
||||||
connectedNodeIds.forEach(targetId => {
|
connectedNodeIds.forEach(targetId => {
|
||||||
if (!newPositions[targetId]) return;
|
if (!newPositions[targetId]) return;
|
||||||
|
|
||||||
@ -231,18 +199,13 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
const dy = newPositions[targetId].y - newPositions[node.id].y;
|
const 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;
|
||||||
|
|
||||||
@ -251,33 +214,28 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update velocity with applied forces and damping
|
|
||||||
newPositions[node.id].vx = newPositions[node.id].vx * DAMPING + forceX;
|
newPositions[node.id].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
|
||||||
);
|
);
|
||||||
if (speed > MAX_VELOCITY) {
|
if (speed > MAX_VELOCITY) {
|
||||||
newPositions[node.id].vx = (newPositions[node.id].vx / speed) * MAX_VELOCITY;
|
newPositions[node.id].vx = (newPositions[node.id].vx / speed) * MAX_VELOCITY;
|
||||||
newPositions[node.id].vy = (newPositions[node.id].vy / speed) * MAX_VELOCITY;
|
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;
|
||||||
@ -307,28 +265,23 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
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;
|
||||||
}
|
}
|
||||||
@ -336,32 +289,27 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
|
|
||||||
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]: {
|
||||||
@ -371,35 +319,29 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
} 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]: {
|
||||||
@ -410,9 +352,7 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
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 => ({
|
||||||
@ -422,11 +362,10 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
setPanStart({ x, y });
|
setPanStart({ x, y });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[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);
|
||||||
}, []);
|
}, []);
|
||||||
@ -435,37 +374,30 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
(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 });
|
||||||
},
|
},
|
||||||
[scale, panOffset],
|
[scale, panOffset]
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleAutoLayout = useCallback(() => {
|
const toggleAutoLayout = useCallback(() => {
|
||||||
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);
|
||||||
|
|
||||||
@ -475,10 +407,9 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
ctx.fillText(autoLayout ? 'Physics: ON' : 'Physics: OFF', width - 70, 40);
|
ctx.fillText(autoLayout ? 'Physics: ON' : 'Physics: OFF', width - 70, 40);
|
||||||
},
|
},
|
||||||
[autoLayout, width],
|
[autoLayout, width]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Draw function - FIX: Properly memoized with all dependencies
|
|
||||||
const drawGraph = useCallback(() => {
|
const drawGraph = useCallback(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
@ -486,26 +417,20 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
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);
|
||||||
@ -519,15 +444,12 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
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;
|
||||||
|
|
||||||
@ -536,36 +458,30 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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';
|
||||||
@ -573,19 +489,17 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
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;
|
||||||
@ -596,7 +510,7 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
pos.x - textWidth / 2 - padding,
|
pos.x - textWidth / 2 - padding,
|
||||||
pos.y + NODE_RADIUS + 5,
|
pos.y + NODE_RADIUS + 5,
|
||||||
textWidth + padding * 2,
|
textWidth + padding * 2,
|
||||||
textHeight + padding * 2,
|
textHeight + padding * 2
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.fillStyle = 'white';
|
ctx.fillStyle = 'white';
|
||||||
@ -604,10 +518,8 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore canvas transformation
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
// Draw UI controls
|
|
||||||
drawControls(ctx);
|
drawControls(ctx);
|
||||||
}, [
|
}, [
|
||||||
data,
|
data,
|
||||||
@ -619,25 +531,20 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
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 = () => {
|
||||||
@ -645,10 +552,8 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
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);
|
||||||
@ -656,7 +561,6 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
};
|
};
|
||||||
}, [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';
|
||||||
@ -664,19 +568,16 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
|
|||||||
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';
|
@ -1,13 +1,9 @@
|
|||||||
// FriendshipNetwork.tsx - Main component for the friendship network visualization
|
import React, { RefObject, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import React, { useCallback, useRef, useState } from 'react';
|
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useFriendshipNetwork } from '../hooks/useFriendshipNetwork';
|
import { useFriendshipNetwork } from '../hooks/useFriendshipNetwork';
|
||||||
import { useNetworks } from '../context/NetworkContext';
|
import { useNetworks } from '../context/NetworkContext';
|
||||||
import {
|
import {
|
||||||
FaArrowLeft,
|
FaArrowLeft,
|
||||||
FaChevronLeft,
|
|
||||||
FaChevronRight,
|
|
||||||
FaExclamationTriangle,
|
FaExclamationTriangle,
|
||||||
FaInfo,
|
FaInfo,
|
||||||
FaTimes,
|
FaTimes,
|
||||||
@ -15,36 +11,30 @@ import {
|
|||||||
FaUserPlus,
|
FaUserPlus,
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
|
|
||||||
// Import components
|
import { Button, ConfirmDialog, Toast } from './FriendshipNetworkComponents';
|
||||||
import { Button, ConfirmDialog, Toast } from '../components/FriendshipNetworkComponents';
|
import NetworkSidebar from './NetworkSidebar';
|
||||||
import NetworkSidebar from '../components/NetworkSidebar';
|
import CanvasGraph from './CanvasGraph';
|
||||||
import CanvasGraph from '../components/CanvasGraph';
|
|
||||||
|
import { getRelationshipColor } from '../types/RelationShipTypes';
|
||||||
|
|
||||||
// Import types and constants
|
|
||||||
import {
|
import {
|
||||||
PersonNode,
|
PersonNode,
|
||||||
RelationshipEdge,
|
RelationshipEdge,
|
||||||
FormErrors,
|
FormErrors,
|
||||||
NewPersonForm,
|
NewPersonForm,
|
||||||
NewRelationshipForm,
|
NewRelationshipForm,
|
||||||
RelationshipType,
|
ToastItem,
|
||||||
} from '../types/types';
|
} from '../types/network';
|
||||||
|
|
||||||
const RELATIONSHIP_COLORS: Record<RelationshipType, string> = {
|
import {
|
||||||
freund: '#60A5FA', // Light blue
|
PersonFormModal,
|
||||||
partner: '#F472B6', // Pink
|
RelationshipFormModal,
|
||||||
familie: '#34D399', // Green
|
PersonDetailModal,
|
||||||
arbeitskolleg: '#FBBF24', // Yellow
|
SettingsModal,
|
||||||
custom: '#9CA3AF', // Gray
|
HelpModal,
|
||||||
};
|
} from './Modals';
|
||||||
|
|
||||||
const RELATIONSHIP_LABELS: Record<RelationshipType, string> = {
|
import { LoadingSpinner } from '../components/UIComponents';
|
||||||
freund: 'Friend',
|
|
||||||
partner: 'Partner',
|
|
||||||
familie: 'Family',
|
|
||||||
arbeitskolleg: 'Colleague',
|
|
||||||
custom: 'Custom',
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_SETTINGS = {
|
const DEFAULT_SETTINGS = {
|
||||||
darkMode: true,
|
darkMode: true,
|
||||||
@ -55,22 +45,227 @@ const DEFAULT_SETTINGS = {
|
|||||||
nodeSize: 'medium',
|
nodeSize: 'medium',
|
||||||
};
|
};
|
||||||
|
|
||||||
import { useToastNotifications } from '../hooks/useToastNotifications';
|
export const useToastNotifications = () => {
|
||||||
import { useGraphDimensions } from '../hooks/useGraphDimensions';
|
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||||
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
|
|
||||||
import { useSmartNodePositioning } from '../hooks/useSmartNodePositioning';
|
|
||||||
|
|
||||||
// Import modals
|
const addToast = useCallback(
|
||||||
import {
|
(message: string, type: 'error' | 'success' | 'warning' | 'info' = 'success') => {
|
||||||
PersonFormModal,
|
const id = Date.now();
|
||||||
RelationshipFormModal,
|
const newToast = {
|
||||||
PersonDetailModal,
|
id,
|
||||||
SettingsModal,
|
message,
|
||||||
HelpModal,
|
type,
|
||||||
} from '../components/Modals';
|
onClose: () => removeToast(id),
|
||||||
|
};
|
||||||
|
|
||||||
// Import UI components
|
setToasts(prevToasts => [...prevToasts, newToast]);
|
||||||
import { LoadingSpinner } from '../components/UIComponents';
|
|
||||||
|
setTimeout(() => removeToast(id), 3000);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeToast = useCallback((id: number) => {
|
||||||
|
setToasts(prevToasts => prevToasts.filter(toast => toast.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { toasts, addToast, removeToast };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing graph container dimensions and handling resize events
|
||||||
|
*/
|
||||||
|
export const useGraphDimensions = (
|
||||||
|
graphContainerRef: RefObject<HTMLDivElement>,
|
||||||
|
sidebarOpen: boolean
|
||||||
|
) => {
|
||||||
|
const [graphDimensions, setGraphDimensions] = useState({ width: 0, height: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!graphContainerRef.current) return;
|
||||||
|
|
||||||
|
const updateDimensions = () => {
|
||||||
|
if (graphContainerRef.current) {
|
||||||
|
const { width, height } = graphContainerRef.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
setGraphDimensions(prev => {
|
||||||
|
if (prev.width !== width || prev.height !== height) {
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial measurement
|
||||||
|
updateDimensions();
|
||||||
|
|
||||||
|
// Set up resize observer
|
||||||
|
const resizeObserver = new ResizeObserver(updateDimensions);
|
||||||
|
if (graphContainerRef.current) {
|
||||||
|
resizeObserver.observe(graphContainerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up window resize listener
|
||||||
|
window.addEventListener('resize', updateDimensions);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
return () => {
|
||||||
|
if (graphContainerRef.current) {
|
||||||
|
resizeObserver.unobserve(graphContainerRef.current);
|
||||||
|
}
|
||||||
|
window.removeEventListener('resize', updateDimensions);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update dimensions when sidebar is toggled
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (graphContainerRef.current) {
|
||||||
|
const { width, height } = graphContainerRef.current.getBoundingClientRect();
|
||||||
|
setGraphDimensions({ width, height });
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [sidebarOpen]);
|
||||||
|
|
||||||
|
return graphDimensions;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for setting up keyboard shortcuts
|
||||||
|
*/
|
||||||
|
export const useKeyboardShortcuts = (handlers: {
|
||||||
|
handleZoomIn: () => void;
|
||||||
|
handleZoomOut: () => void;
|
||||||
|
handleResetZoom: () => void;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
setPersonModalOpen: (open: boolean) => void;
|
||||||
|
setRelationshipModalOpen: (open: boolean) => void;
|
||||||
|
setHelpModalOpen: (open: boolean) => void;
|
||||||
|
}) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const {
|
||||||
|
handleZoomIn,
|
||||||
|
handleZoomOut,
|
||||||
|
handleResetZoom,
|
||||||
|
toggleSidebar,
|
||||||
|
setPersonModalOpen,
|
||||||
|
setRelationshipModalOpen,
|
||||||
|
setHelpModalOpen,
|
||||||
|
} = handlers;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Only apply shortcuts when not in an input field
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
|
||||||
|
|
||||||
|
// Ctrl/Cmd + / to open help modal
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
|
||||||
|
e.preventDefault();
|
||||||
|
setHelpModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// + for zoom in
|
||||||
|
if (e.key === '+' || e.key === '=') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleZoomIn();
|
||||||
|
}
|
||||||
|
|
||||||
|
// - for zoom out
|
||||||
|
if (e.key === '-' || e.key === '_') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleZoomOut();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0 for reset zoom
|
||||||
|
if (e.key === '0') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleResetZoom();
|
||||||
|
}
|
||||||
|
|
||||||
|
// n for new person
|
||||||
|
if (e.key === 'n' && !e.ctrlKey && !e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPersonModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// r for new relationship
|
||||||
|
if (e.key === 'r' && !e.ctrlKey && !e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
setRelationshipModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// s for toggle sidebar
|
||||||
|
if (e.key === 's' && !e.ctrlKey && !e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleSidebar();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [handlers]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage node positions in the graph
|
||||||
|
*/
|
||||||
|
export const useSmartNodePositioning = (
|
||||||
|
graphWidth: number,
|
||||||
|
graphHeight: number,
|
||||||
|
peopleCount: number
|
||||||
|
) => {
|
||||||
|
return useCallback(() => {
|
||||||
|
const centerX = graphWidth / 2;
|
||||||
|
const centerY = graphHeight / 2;
|
||||||
|
const maxRadius = Math.min(graphWidth, graphHeight) * 0.4;
|
||||||
|
const totalNodes = peopleCount;
|
||||||
|
const index = totalNodes;
|
||||||
|
|
||||||
|
if (totalNodes <= 0) {
|
||||||
|
return { x: centerX, y: centerY };
|
||||||
|
} else if (totalNodes <= 4) {
|
||||||
|
const theta = index * 2.399;
|
||||||
|
const radius = maxRadius * 0.5 * Math.sqrt(index / (totalNodes + 1));
|
||||||
|
return {
|
||||||
|
x: centerX + radius * Math.cos(theta),
|
||||||
|
y: centerY + radius * Math.sin(theta),
|
||||||
|
};
|
||||||
|
} else if (totalNodes <= 11) {
|
||||||
|
const isOuterRing = index >= Math.floor(totalNodes / 2);
|
||||||
|
const ringIndex = isOuterRing ? index - Math.floor(totalNodes / 2) : index;
|
||||||
|
const ringTotal = isOuterRing
|
||||||
|
? totalNodes - Math.floor(totalNodes / 2) + 1
|
||||||
|
: Math.floor(totalNodes / 2);
|
||||||
|
const ringRadius = isOuterRing ? maxRadius * 0.8 : maxRadius * 0.4;
|
||||||
|
|
||||||
|
const angle = (ringIndex / ringTotal) * 2 * Math.PI + (isOuterRing ? 0 : Math.PI / ringTotal);
|
||||||
|
return {
|
||||||
|
x: centerX + ringRadius * Math.cos(angle),
|
||||||
|
y: centerY + ringRadius * Math.sin(angle),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const clusterCount = Math.max(3, Math.floor(Math.sqrt(totalNodes)));
|
||||||
|
const clusterIndex = index % clusterCount;
|
||||||
|
|
||||||
|
const clusterAngle = (clusterIndex / clusterCount) * 2 * Math.PI;
|
||||||
|
const clusterDistance = maxRadius * 0.6;
|
||||||
|
const clusterX = centerX + clusterDistance * Math.cos(clusterAngle);
|
||||||
|
const clusterY = centerY + clusterDistance * Math.sin(clusterAngle);
|
||||||
|
|
||||||
|
const clusterRadius = maxRadius * 0.3;
|
||||||
|
const randomAngle = Math.random() * 2 * Math.PI;
|
||||||
|
const randomDistance = Math.random() * clusterRadius;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: clusterX + randomDistance * Math.cos(randomAngle),
|
||||||
|
y: clusterY + randomDistance * Math.sin(randomAngle),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [graphWidth, graphHeight, peopleCount]);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main FriendshipNetwork component
|
* Main FriendshipNetwork component
|
||||||
@ -102,11 +297,6 @@ const FriendshipNetwork: React.FC = () => {
|
|||||||
},
|
},
|
||||||
} = useFriendshipNetwork(id || null) as any;
|
} = useFriendshipNetwork(id || null) as any;
|
||||||
|
|
||||||
// Create a type-safe wrapper for updatePersonPosition
|
|
||||||
const updatePersonPosition = (id: string, position: { x: number; y: number }) => {
|
|
||||||
return updatePersonPositionImpl(id, position);
|
|
||||||
};
|
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [sidebarTab, setSidebarTab] = useState('people');
|
const [sidebarTab, setSidebarTab] = useState('people');
|
||||||
@ -116,7 +306,10 @@ const FriendshipNetwork: React.FC = () => {
|
|||||||
|
|
||||||
// Custom hooks
|
// Custom hooks
|
||||||
const { toasts, addToast, removeToast } = useToastNotifications();
|
const { toasts, addToast, removeToast } = useToastNotifications();
|
||||||
const graphDimensions = useGraphDimensions(graphContainerRef, sidebarOpen);
|
const graphDimensions = useGraphDimensions(
|
||||||
|
graphContainerRef as React.RefObject<HTMLDivElement>,
|
||||||
|
sidebarOpen
|
||||||
|
);
|
||||||
const getSmartNodePosition = useSmartNodePositioning(
|
const getSmartNodePosition = useSmartNodePositioning(
|
||||||
graphDimensions.width,
|
graphDimensions.width,
|
||||||
graphDimensions.height,
|
graphDimensions.height,
|
||||||
@ -152,7 +345,7 @@ const FriendshipNetwork: React.FC = () => {
|
|||||||
const [newRelationship, setNewRelationship] = useState<NewRelationshipForm>({
|
const [newRelationship, setNewRelationship] = useState<NewRelationshipForm>({
|
||||||
source: '',
|
source: '',
|
||||||
target: '',
|
target: '',
|
||||||
type: 'freund',
|
type: 'friend',
|
||||||
customType: '',
|
customType: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
bidirectional: true,
|
bidirectional: true,
|
||||||
@ -249,8 +442,8 @@ const FriendshipNetwork: React.FC = () => {
|
|||||||
|
|
||||||
// Create edges
|
// Create edges
|
||||||
const graphEdges = relationships.map((rel: RelationshipEdge) => {
|
const graphEdges = relationships.map((rel: RelationshipEdge) => {
|
||||||
const color = RELATIONSHIP_COLORS[rel.type] || RELATIONSHIP_COLORS.custom;
|
const color = getRelationshipColor(rel.type);
|
||||||
const width = rel.type === 'partner' ? 4 : rel.type === 'familie' ? 3 : 2;
|
const width = rel.type === 'partner' ? 4 : rel.type === 'family' ? 3 : 2;
|
||||||
|
|
||||||
// Highlight edges connected to selected node
|
// Highlight edges connected to selected node
|
||||||
const isHighlighted =
|
const isHighlighted =
|
||||||
@ -417,7 +610,7 @@ const FriendshipNetwork: React.FC = () => {
|
|||||||
setNewRelationship({
|
setNewRelationship({
|
||||||
source: '',
|
source: '',
|
||||||
target: '',
|
target: '',
|
||||||
type: 'freund',
|
type: 'friend',
|
||||||
customType: '',
|
customType: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
bidirectional: true,
|
bidirectional: true,
|
||||||
@ -552,11 +745,6 @@ const FriendshipNetwork: React.FC = () => {
|
|||||||
data={graphData}
|
data={graphData}
|
||||||
width={graphDimensions.width}
|
width={graphDimensions.width}
|
||||||
height={graphDimensions.height}
|
height={graphDimensions.height}
|
||||||
zoomLevel={zoomLevel}
|
|
||||||
onNodeClick={handleNodeClick}
|
|
||||||
onNodeDrag={(nodeId, x, y) => {
|
|
||||||
updatePersonPosition(nodeId, { x, y }).then();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -644,7 +832,6 @@ const FriendshipNetwork: React.FC = () => {
|
|||||||
errors={relationshipFormErrors}
|
errors={relationshipFormErrors}
|
||||||
onSubmit={handleRelationshipSubmit}
|
onSubmit={handleRelationshipSubmit}
|
||||||
people={sortedPeople}
|
people={sortedPeople}
|
||||||
relationshipLabels={RELATIONSHIP_LABELS}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{editPerson && (
|
{editPerson && (
|
||||||
@ -665,8 +852,6 @@ const FriendshipNetwork: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
relationships={relationships}
|
relationships={relationships}
|
||||||
people={people}
|
people={people}
|
||||||
relationshipColors={RELATIONSHIP_COLORS}
|
|
||||||
relationshipLabels={RELATIONSHIP_LABELS}
|
|
||||||
onDeleteRelationship={deleteRelationship}
|
onDeleteRelationship={deleteRelationship}
|
||||||
onAddNewConnection={() => {
|
onAddNewConnection={() => {
|
||||||
setNewRelationship({
|
setNewRelationship({
|
@ -52,7 +52,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
text,
|
text,
|
||||||
position = 'top',
|
position = 'top',
|
||||||
delay = 300,
|
delay = 300,
|
||||||
}) => {
|
}) => {
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | null>(null);
|
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
@ -226,7 +226,7 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
|||||||
confirmText = 'Confirm',
|
confirmText = 'Confirm',
|
||||||
cancelText = 'Cancel',
|
cancelText = 'Cancel',
|
||||||
variant = 'danger',
|
variant = 'danger',
|
||||||
}) => {
|
}) => {
|
||||||
const variantClasses = {
|
const variantClasses = {
|
||||||
danger: 'bg-red-600 hover:bg-red-700 focus:ring-red-500',
|
danger: 'bg-red-600 hover:bg-red-700 focus:ring-red-500',
|
||||||
warning: 'bg-amber-600 hover:bg-amber-700 focus:ring-amber-500',
|
warning: 'bg-amber-600 hover:bg-amber-700 focus:ring-amber-500',
|
||||||
@ -274,7 +274,7 @@ export const NetworkStats: React.FC<NetworkStatsProps> = ({ people, relationship
|
|||||||
people.length > 0 ? (relationships.length / people.length).toFixed(1) : '0.0';
|
people.length > 0 ? (relationships.length / people.length).toFixed(1) : '0.0';
|
||||||
|
|
||||||
const isolatedPeople = people.filter(
|
const isolatedPeople = people.filter(
|
||||||
person => !relationships.some(r => r.source === person._id || r.target === person._id),
|
person => !relationships.some(r => r.source === person._id || r.target === person._id)
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
// Find most connected person
|
// Find most connected person
|
||||||
@ -286,7 +286,7 @@ export const NetworkStats: React.FC<NetworkStatsProps> = ({ people, relationship
|
|||||||
const mostConnected =
|
const mostConnected =
|
||||||
personConnectionCounts.length > 0
|
personConnectionCounts.length > 0
|
||||||
? personConnectionCounts.reduce((prev, current) =>
|
? personConnectionCounts.reduce((prev, current) =>
|
||||||
prev.count > current.count ? prev : current,
|
prev.count > current.count ? prev : current
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@ -356,7 +356,7 @@ export const Toast: React.FC<ToastProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
autoClose = true,
|
autoClose = true,
|
||||||
duration = 3000,
|
duration = 3000,
|
||||||
}) => {
|
}) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoClose) {
|
if (autoClose) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@ -432,7 +432,7 @@ export const Button: React.FC<ButtonProps> = ({
|
|||||||
className = '',
|
className = '',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
}) => {
|
}) => {
|
||||||
const variantClasses = {
|
const variantClasses = {
|
||||||
primary: 'bg-indigo-600 hover:bg-indigo-700 text-white focus:ring-indigo-500',
|
primary: 'bg-indigo-600 hover:bg-indigo-700 text-white focus:ring-indigo-500',
|
||||||
secondary: 'bg-slate-700 hover:bg-slate-600 text-white focus:ring-slate-500',
|
secondary: 'bg-slate-700 hover:bg-slate-600 text-white focus:ring-slate-500',
|
||||||
@ -488,7 +488,7 @@ export const FormField: React.FC<FormFieldProps> = ({
|
|||||||
className = '',
|
className = '',
|
||||||
children,
|
children,
|
||||||
labelClassName = '',
|
labelClassName = '',
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className={`mb-4 ${className}`}>
|
<div className={`mb-4 ${className}`}>
|
||||||
<label
|
<label
|
@ -1,35 +1,18 @@
|
|||||||
// Modals.tsx - Modal components for the FriendshipNetwork
|
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import DatePicker from 'react-datepicker';
|
import DatePicker from 'react-datepicker';
|
||||||
import {
|
import { FaPlus, FaRegCalendarAlt, FaSave, FaStar, FaTrash, FaUserFriends, FaUserPlus } from 'react-icons/fa';
|
||||||
FaPlus,
|
|
||||||
FaRegCalendarAlt,
|
|
||||||
FaSave,
|
|
||||||
FaStar,
|
|
||||||
FaTrash,
|
|
||||||
FaUserFriends,
|
|
||||||
FaUserPlus,
|
|
||||||
} from 'react-icons/fa';
|
|
||||||
|
|
||||||
import { Button, FormField, Modal } from '../components/FriendshipNetworkComponents';
|
import { Button, FormField, Modal } from '../components/FriendshipNetworkComponents';
|
||||||
|
|
||||||
import {
|
import { FormErrors, NewPersonForm, NewRelationshipForm, PersonNode, RelationshipEdge } from '../types/network';
|
||||||
PersonNode,
|
|
||||||
RelationshipEdge,
|
|
||||||
RelationshipType,
|
|
||||||
FormErrors,
|
|
||||||
NewPersonForm,
|
|
||||||
NewRelationshipForm,
|
|
||||||
} from '../types/types';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
KeyboardShortcut,
|
getRelationshipColor,
|
||||||
TipItem,
|
getRelationshipLabel,
|
||||||
ToggleSetting,
|
RELATIONSHIP_TYPES,
|
||||||
OptionGroup,
|
RELATIONSHIPS,
|
||||||
ErrorMessage,
|
} from '../types/RelationShipTypes';
|
||||||
} from './UIComponents';
|
import { ErrorMessage, KeyboardShortcut, OptionGroup, TipItem, ToggleSetting } from './UIComponents';
|
||||||
|
|
||||||
// ==============================
|
// ==============================
|
||||||
// Person Form Modal
|
// Person Form Modal
|
||||||
@ -52,7 +35,7 @@ export const PersonFormModal: React.FC<PersonFormModalProps> = ({
|
|||||||
errors,
|
errors,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isEdit = false,
|
isEdit = false,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={isEdit ? 'Edit Person' : 'Add New Person'}>
|
<Modal isOpen={isOpen} onClose={onClose} title={isEdit ? 'Edit Person' : 'Add New Person'}>
|
||||||
<form onSubmit={onSubmit} className="space-y-4">
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
@ -88,7 +71,7 @@ export const PersonFormModal: React.FC<PersonFormModalProps> = ({
|
|||||||
id="birthday"
|
id="birthday"
|
||||||
selected={formData.birthday}
|
selected={formData.birthday}
|
||||||
onChange={(date: Date | null) => setFormData({ ...formData, birthday: date })}
|
onChange={(date: Date | null) => setFormData({ ...formData, birthday: date })}
|
||||||
dateFormat="MMMM d, yyyy"
|
dateFormat="dd.MM.yyyy"
|
||||||
placeholderText="Select birthday"
|
placeholderText="Select birthday"
|
||||||
className="w-full bg-slate-700 border border-slate-600 rounded-md p-2
|
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"
|
focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
|
||||||
@ -135,7 +118,6 @@ interface RelationshipFormModalProps {
|
|||||||
errors: FormErrors;
|
errors: FormErrors;
|
||||||
onSubmit: (e: React.FormEvent) => void;
|
onSubmit: (e: React.FormEvent) => void;
|
||||||
people: PersonNode[];
|
people: PersonNode[];
|
||||||
relationshipLabels: Record<RelationshipType, string>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RelationshipFormModal: React.FC<RelationshipFormModalProps> = ({
|
export const RelationshipFormModal: React.FC<RelationshipFormModalProps> = ({
|
||||||
@ -146,8 +128,7 @@ export const RelationshipFormModal: React.FC<RelationshipFormModalProps> = ({
|
|||||||
errors,
|
errors,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
people,
|
people,
|
||||||
relationshipLabels,
|
}) => {
|
||||||
}) => {
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title="Add New Relationship">
|
<Modal isOpen={isOpen} onClose={onClose} title="Add New Relationship">
|
||||||
<form onSubmit={onSubmit} className="space-y-4">
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
@ -196,13 +177,13 @@ export const RelationshipFormModal: React.FC<RelationshipFormModalProps> = ({
|
|||||||
onChange={e =>
|
onChange={e =>
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
type: e.target.value as RelationshipType,
|
type: e.target.value as RELATIONSHIP_TYPES,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{Object.entries(relationshipLabels).map(([value, label]) => (
|
{Object.entries(RELATIONSHIPS).map(([value, label]) => (
|
||||||
<option key={value} value={value}>
|
<option key={value} value={value}>
|
||||||
{label}
|
{label.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@ -282,8 +263,6 @@ interface PersonDetailModalProps {
|
|||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
relationships: RelationshipEdge[];
|
relationships: RelationshipEdge[];
|
||||||
people: PersonNode[];
|
people: PersonNode[];
|
||||||
relationshipColors: Record<RelationshipType, string>;
|
|
||||||
relationshipLabels: Record<RelationshipType, string>;
|
|
||||||
onDeleteRelationship: (id: string) => void;
|
onDeleteRelationship: (id: string) => void;
|
||||||
onAddNewConnection: () => void;
|
onAddNewConnection: () => void;
|
||||||
onNavigateToPerson: (id: string) => void;
|
onNavigateToPerson: (id: string) => void;
|
||||||
@ -299,12 +278,10 @@ export const PersonDetailModal: React.FC<PersonDetailModalProps> = ({
|
|||||||
onDelete,
|
onDelete,
|
||||||
relationships,
|
relationships,
|
||||||
people,
|
people,
|
||||||
relationshipColors,
|
|
||||||
relationshipLabels,
|
|
||||||
onDeleteRelationship,
|
onDeleteRelationship,
|
||||||
onAddNewConnection,
|
onAddNewConnection,
|
||||||
onNavigateToPerson,
|
onNavigateToPerson,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={`${person.firstName} ${person.lastName}`}>
|
<Modal isOpen={isOpen} onClose={onClose} title={`${person.firstName} ${person.lastName}`}>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@ -340,7 +317,7 @@ export const PersonDetailModal: React.FC<PersonDetailModalProps> = ({
|
|||||||
id="editBirthday"
|
id="editBirthday"
|
||||||
selected={person.birthday ? new Date(person.birthday) : null}
|
selected={person.birthday ? new Date(person.birthday) : null}
|
||||||
onChange={(date: Date | null) => setPerson({ ...person, birthday: date })}
|
onChange={(date: Date | null) => setPerson({ ...person, birthday: date })}
|
||||||
dateFormat="MMMM d, yyyy"
|
dateFormat="dd.MM.yyyy"
|
||||||
placeholderText="Select birthday"
|
placeholderText="Select birthday"
|
||||||
className="w-full bg-slate-700 border border-slate-600 rounded-md p-2
|
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"
|
focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
|
||||||
@ -383,7 +360,7 @@ export const PersonDetailModal: React.FC<PersonDetailModalProps> = ({
|
|||||||
<h4 className="font-medium text-indigo-400 mb-2">Connections</h4>
|
<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">
|
<div className="max-h-40 overflow-y-auto space-y-1 bg-slate-900 rounded-lg p-2">
|
||||||
{relationships.filter(
|
{relationships.filter(
|
||||||
(r: RelationshipEdge) => r.source === person._id || r.target === person._id
|
(r: RelationshipEdge) => r.source === person._id || r.target === person._id,
|
||||||
).length > 0 ? (
|
).length > 0 ? (
|
||||||
relationships
|
relationships
|
||||||
.filter((r: RelationshipEdge) => r.source === person._id || r.target === person._id)
|
.filter((r: RelationshipEdge) => r.source === person._id || r.target === person._id)
|
||||||
@ -402,7 +379,7 @@ export const PersonDetailModal: React.FC<PersonDetailModalProps> = ({
|
|||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span
|
<span
|
||||||
className="inline-block w-2 h-2 rounded-full mr-2"
|
className="inline-block w-2 h-2 rounded-full mr-2"
|
||||||
style={{ backgroundColor: relationshipColors[rel.type] }}
|
style={{ backgroundColor: getRelationshipColor(rel.type) }}
|
||||||
></span>
|
></span>
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{isSource ? 'To: ' : 'From: '}
|
{isSource ? 'To: ' : 'From: '}
|
||||||
@ -414,7 +391,7 @@ export const PersonDetailModal: React.FC<PersonDetailModalProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
{rel.type === 'custom'
|
{rel.type === 'custom'
|
||||||
? ` (${rel.customType})`
|
? ` (${rel.customType})`
|
||||||
: ` (${relationshipLabels[rel.type]})`}
|
: ` (${getRelationshipLabel(rel.type)})`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -472,7 +449,7 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
settings,
|
settings,
|
||||||
setSettings,
|
setSettings,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title="Network Settings">
|
<Modal isOpen={isOpen} onClose={onClose} title="Network Settings">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
@ -9,51 +9,15 @@ import {
|
|||||||
FaUserPlus,
|
FaUserPlus,
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
|
|
||||||
// Import custom UI components
|
|
||||||
import { Button, EmptyState, Tooltip, NetworkStats } from './FriendshipNetworkComponents';
|
import { Button, EmptyState, Tooltip, NetworkStats } from './FriendshipNetworkComponents';
|
||||||
|
import { PersonNode, RelationshipEdge } from '../types/network';
|
||||||
|
import {
|
||||||
|
getRelationshipColor,
|
||||||
|
getRelationshipLabel,
|
||||||
|
RELATIONSHIP_TYPES,
|
||||||
|
RELATIONSHIPS,
|
||||||
|
} from '../types/RelationShipTypes';
|
||||||
|
|
||||||
// Types
|
|
||||||
type RelationshipType = 'freund' | 'partner' | 'familie' | 'arbeitskolleg' | 'custom';
|
|
||||||
|
|
||||||
interface PersonNode {
|
|
||||||
_id: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
birthday?: Date | string | null;
|
|
||||||
notes?: string;
|
|
||||||
position?: {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RelationshipEdge {
|
|
||||||
_id: string;
|
|
||||||
source: string;
|
|
||||||
target: string;
|
|
||||||
type: RelationshipType;
|
|
||||||
customType?: string;
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Graph appearance constants
|
|
||||||
const RELATIONSHIP_COLORS = {
|
|
||||||
freund: '#60A5FA', // Light blue
|
|
||||||
partner: '#F472B6', // Pink
|
|
||||||
familie: '#34D399', // Green
|
|
||||||
arbeitskolleg: '#FBBF24', // Yellow
|
|
||||||
custom: '#9CA3AF', // Gray
|
|
||||||
};
|
|
||||||
|
|
||||||
const RELATIONSHIP_LABELS = {
|
|
||||||
freund: 'Friend',
|
|
||||||
partner: 'Partner',
|
|
||||||
familie: 'Family',
|
|
||||||
arbeitskolleg: 'Colleague',
|
|
||||||
custom: 'Custom',
|
|
||||||
};
|
|
||||||
|
|
||||||
// NetworkSidebar component props
|
|
||||||
interface NetworkSidebarProps {
|
interface NetworkSidebarProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
currentNetwork: any;
|
currentNetwork: any;
|
||||||
@ -332,7 +296,7 @@ const NetworkSidebar: React.FC<NetworkSidebarProps> = ({
|
|||||||
>
|
>
|
||||||
All Types
|
All Types
|
||||||
</button>
|
</button>
|
||||||
{Object.entries(RELATIONSHIP_COLORS).map(([type, color]) => (
|
{Object.entries(RELATIONSHIPS).map(([type, relationship]) => (
|
||||||
<button
|
<button
|
||||||
key={type}
|
key={type}
|
||||||
className={`px-3 py-1 text-xs rounded-full whitespace-nowrap flex items-center ${
|
className={`px-3 py-1 text-xs rounded-full whitespace-nowrap flex items-center ${
|
||||||
@ -340,14 +304,14 @@ const NetworkSidebar: React.FC<NetworkSidebarProps> = ({
|
|||||||
? 'bg-indigo-600 text-white'
|
? 'bg-indigo-600 text-white'
|
||||||
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
|
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => onRelationshipTypeFilterChange(type as RelationshipType)}
|
onClick={() => onRelationshipTypeFilterChange(type as RELATIONSHIP_TYPES)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="w-2 h-2 rounded-full mr-1"
|
className="w-2 h-2 rounded-full mr-1"
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: relationship.color }}
|
||||||
></span>
|
></span>
|
||||||
<span className="capitalize">
|
<span className="capitalize">
|
||||||
{RELATIONSHIP_LABELS[type as RelationshipType]}
|
{getRelationshipLabel(type as RELATIONSHIP_TYPES)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@ -398,12 +362,12 @@ const NetworkSidebar: React.FC<NetworkSidebarProps> = ({
|
|||||||
<div className="flex items-center text-xs text-slate-400 mt-1">
|
<div className="flex items-center text-xs text-slate-400 mt-1">
|
||||||
<span
|
<span
|
||||||
className="inline-block w-2 h-2 rounded-full mr-1"
|
className="inline-block w-2 h-2 rounded-full mr-1"
|
||||||
style={{ backgroundColor: RELATIONSHIP_COLORS[rel.type] }}
|
style={{ backgroundColor: getRelationshipColor(rel.type) }}
|
||||||
></span>
|
></span>
|
||||||
<span className="capitalize">
|
<span className="capitalize">
|
||||||
{rel.type === 'custom'
|
{rel.type === 'custom'
|
||||||
? rel.customType
|
? rel.customType
|
||||||
: RELATIONSHIP_LABELS[rel.type]}
|
: getRelationshipLabel(rel.type)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
@ -1,7 +1,5 @@
|
|||||||
// UIComponents.tsx - Small UI components used in the FriendshipNetwork
|
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormErrors } from '../types/types';
|
import { FormErrors } from '../types/network';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle setting component with a switch-style toggle
|
* Toggle setting component with a switch-style toggle
|
@ -17,9 +17,11 @@ const Header: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if we're on the login or register page
|
||||||
const isAuthPage = location.pathname === '/login' || location.pathname === '/register';
|
const isAuthPage = location.pathname === '/login' || location.pathname === '/register';
|
||||||
|
|
||||||
if (isAuthPage) {
|
if (isAuthPage) {
|
||||||
return null;
|
return null; // Don't show header on auth pages
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -27,10 +29,11 @@ const Header: React.FC = () => {
|
|||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex justify-between h-16">
|
<div className="flex justify-between h-16">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Link to="/" className="flex-shrink-0 flex items-center -ml-2">
|
<Link to="/" className="flex-shrink-0 flex items-center">
|
||||||
<FaNetworkWired className="h-6 w-6 text-indigo-400" />
|
<FaNetworkWired className="h-6 w-6 text-indigo-400" />
|
||||||
<span className="ml-2 text-white font-bold text-xl">RelNet</span>
|
<span className="ml-2 text-white font-bold text-xl">RelNet</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<nav className="ml-8 flex space-x-4">
|
<nav className="ml-8 flex space-x-4">
|
||||||
<Link
|
<Link
|
||||||
@ -46,6 +49,7 @@ const Header: React.FC = () => {
|
|||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{user ? (
|
{user ? (
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
@ -58,6 +62,7 @@ const Header: React.FC = () => {
|
|||||||
<FaUser />
|
<FaUser />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="absolute right-0 mt-2 w-48 bg-slate-800 rounded-md shadow-lg py-1 z-10 border border-slate-700 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
|
<div className="absolute right-0 mt-2 w-48 bg-slate-800 rounded-md shadow-lg py-1 z-10 border border-slate-700 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
@ -3,10 +3,11 @@ import { addPerson, getPeople, Person, removePerson, updatePerson } from '../api
|
|||||||
import {
|
import {
|
||||||
addRelationship,
|
addRelationship,
|
||||||
getRelationships,
|
getRelationships,
|
||||||
Relationship,
|
|
||||||
removeRelationship,
|
removeRelationship,
|
||||||
updateRelationship,
|
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
|
||||||
@ -24,7 +25,7 @@ const DEFAULT_POLL_INTERVAL = 5000;
|
|||||||
// Custom hook to manage friendship network data
|
// Custom hook to manage friendship network data
|
||||||
export const useFriendshipNetwork = (
|
export const useFriendshipNetwork = (
|
||||||
networkId: string | null,
|
networkId: string | null,
|
||||||
pollInterval = DEFAULT_POLL_INTERVAL,
|
pollInterval = DEFAULT_POLL_INTERVAL
|
||||||
) => {
|
) => {
|
||||||
const [people, setPeople] = useState<PersonNode[]>([]);
|
const [people, setPeople] = useState<PersonNode[]>([]);
|
||||||
const [relationships, setRelationships] = useState<RelationshipEdge[]>([]);
|
const [relationships, setRelationships] = useState<RelationshipEdge[]>([]);
|
||||||
@ -70,7 +71,7 @@ export const useFriendshipNetwork = (
|
|||||||
// Generate hashes to detect changes
|
// Generate hashes to detect changes
|
||||||
const positionsHash = JSON.stringify(peopleNodes.map(p => ({ id: p.id, pos: p.position })));
|
const positionsHash = JSON.stringify(peopleNodes.map(p => ({ id: p.id, pos: p.position })));
|
||||||
const relationshipsHash = JSON.stringify(
|
const relationshipsHash = JSON.stringify(
|
||||||
relationshipEdges.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type })),
|
relationshipEdges.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type }))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle people updates
|
// Handle people updates
|
||||||
@ -187,7 +188,7 @@ export const useFriendshipNetwork = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[networkId],
|
[networkId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set up polling for network data
|
// Set up polling for network data
|
||||||
@ -234,7 +235,7 @@ export const useFriendshipNetwork = (
|
|||||||
|
|
||||||
// Update the reference hash to avoid unnecessary state updates on next poll
|
// Update the reference hash to avoid unnecessary state updates on next poll
|
||||||
lastPeopleUpdateRef.current = JSON.stringify(
|
lastPeopleUpdateRef.current = JSON.stringify(
|
||||||
updatedPeople.map(p => ({ id: p.id, pos: p.position })),
|
updatedPeople.map(p => ({ id: p.id, pos: p.position }))
|
||||||
);
|
);
|
||||||
|
|
||||||
return newPersonNode;
|
return newPersonNode;
|
||||||
@ -252,7 +253,7 @@ export const useFriendshipNetwork = (
|
|||||||
lastName?: string;
|
lastName?: string;
|
||||||
birthday?: string | null;
|
birthday?: string | null;
|
||||||
position?: { x: number; y: number };
|
position?: { x: number; y: number };
|
||||||
},
|
}
|
||||||
): Promise<PersonNode> => {
|
): Promise<PersonNode> => {
|
||||||
if (!networkId) throw new Error('No network selected');
|
if (!networkId) throw new Error('No network selected');
|
||||||
|
|
||||||
@ -262,14 +263,14 @@ export const useFriendshipNetwork = (
|
|||||||
|
|
||||||
// Update the local state
|
// Update the local state
|
||||||
const updatedPeople = people.map(person =>
|
const updatedPeople = people.map(person =>
|
||||||
person._id === personId ? updatedPersonNode : person,
|
person._id === personId ? updatedPersonNode : person
|
||||||
);
|
);
|
||||||
setPeople(updatedPeople);
|
setPeople(updatedPeople);
|
||||||
|
|
||||||
// Update the reference hash if position changed to avoid unnecessary state updates on next poll
|
// Update the reference hash if position changed to avoid unnecessary state updates on next poll
|
||||||
if (personData.position) {
|
if (personData.position) {
|
||||||
lastPeopleUpdateRef.current = JSON.stringify(
|
lastPeopleUpdateRef.current = JSON.stringify(
|
||||||
updatedPeople.map(p => ({ id: p.id, pos: p.position })),
|
updatedPeople.map(p => ({ id: p.id, pos: p.position }))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -293,16 +294,16 @@ export const useFriendshipNetwork = (
|
|||||||
|
|
||||||
// Remove all relationships involving this person
|
// Remove all relationships involving this person
|
||||||
const updatedRelationships = relationships.filter(
|
const updatedRelationships = relationships.filter(
|
||||||
rel => rel.source !== personId && rel.target !== personId,
|
rel => rel.source !== personId && rel.target !== personId
|
||||||
);
|
);
|
||||||
setRelationships(updatedRelationships);
|
setRelationships(updatedRelationships);
|
||||||
|
|
||||||
// Update both reference hashes to avoid unnecessary state updates on next poll
|
// Update both reference hashes to avoid unnecessary state updates on next poll
|
||||||
lastPeopleUpdateRef.current = JSON.stringify(
|
lastPeopleUpdateRef.current = JSON.stringify(
|
||||||
updatedPeople.map(p => ({ id: p.id, pos: p.position })),
|
updatedPeople.map(p => ({ id: p.id, pos: p.position }))
|
||||||
);
|
);
|
||||||
lastRelationshipsUpdateRef.current = JSON.stringify(
|
lastRelationshipsUpdateRef.current = JSON.stringify(
|
||||||
updatedRelationships.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type })),
|
updatedRelationships.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type }))
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to delete person');
|
setError(err.message || 'Failed to delete person');
|
||||||
@ -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');
|
||||||
@ -328,7 +329,7 @@ export const useFriendshipNetwork = (
|
|||||||
|
|
||||||
// Update the relationship hash to avoid unnecessary state updates on next poll
|
// Update the relationship hash to avoid unnecessary state updates on next poll
|
||||||
lastRelationshipsUpdateRef.current = JSON.stringify(
|
lastRelationshipsUpdateRef.current = JSON.stringify(
|
||||||
updatedRelationships.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type })),
|
updatedRelationships.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type }))
|
||||||
);
|
);
|
||||||
|
|
||||||
return newRelationshipEdge;
|
return newRelationshipEdge;
|
||||||
@ -342,9 +343,9 @@ 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> => {
|
||||||
if (!networkId) throw new Error('No network selected');
|
if (!networkId) throw new Error('No network selected');
|
||||||
|
|
||||||
@ -352,7 +353,7 @@ export const useFriendshipNetwork = (
|
|||||||
const updatedRelationship = await updateRelationship(
|
const updatedRelationship = await updateRelationship(
|
||||||
networkId,
|
networkId,
|
||||||
relationshipId,
|
relationshipId,
|
||||||
relationshipData,
|
relationshipData
|
||||||
);
|
);
|
||||||
const updatedRelationshipEdge: RelationshipEdge = {
|
const updatedRelationshipEdge: RelationshipEdge = {
|
||||||
...updatedRelationship,
|
...updatedRelationship,
|
||||||
@ -360,13 +361,13 @@ export const useFriendshipNetwork = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updatedRelationships = relationships.map(rel =>
|
const updatedRelationships = relationships.map(rel =>
|
||||||
rel._id === relationshipId ? updatedRelationshipEdge : rel,
|
rel._id === relationshipId ? updatedRelationshipEdge : rel
|
||||||
);
|
);
|
||||||
setRelationships(updatedRelationships);
|
setRelationships(updatedRelationships);
|
||||||
|
|
||||||
// Update the relationship hash to avoid unnecessary state updates on next poll
|
// Update the relationship hash to avoid unnecessary state updates on next poll
|
||||||
lastRelationshipsUpdateRef.current = JSON.stringify(
|
lastRelationshipsUpdateRef.current = JSON.stringify(
|
||||||
updatedRelationships.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type })),
|
updatedRelationships.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type }))
|
||||||
);
|
);
|
||||||
|
|
||||||
return updatedRelationshipEdge;
|
return updatedRelationshipEdge;
|
||||||
@ -387,7 +388,7 @@ export const useFriendshipNetwork = (
|
|||||||
|
|
||||||
// Update the relationship hash to avoid unnecessary state updates on next poll
|
// Update the relationship hash to avoid unnecessary state updates on next poll
|
||||||
lastRelationshipsUpdateRef.current = JSON.stringify(
|
lastRelationshipsUpdateRef.current = JSON.stringify(
|
||||||
updatedRelationships.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type })),
|
updatedRelationships.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type }))
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to delete relationship');
|
setError(err.message || 'Failed to delete relationship');
|
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;
|
||||||
|
}
|
@ -11,7 +11,7 @@ if (rootElement) {
|
|||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.error('Root element not found');
|
console.error('Root element not found');
|
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;
|
@ -1,4 +1,4 @@
|
|||||||
export type RelationshipType = 'freund' | 'partner' | 'familie' | 'arbeitskolleg' | 'custom';
|
import { RELATIONSHIP_TYPES } from './RelationShipTypes';
|
||||||
|
|
||||||
export interface PersonNode {
|
export interface PersonNode {
|
||||||
_id: string;
|
_id: string;
|
||||||
@ -16,7 +16,7 @@ export interface RelationshipEdge {
|
|||||||
_id: string;
|
_id: string;
|
||||||
source: string;
|
source: string;
|
||||||
target: string;
|
target: string;
|
||||||
type: RelationshipType;
|
type: RELATIONSHIP_TYPES;
|
||||||
customType?: string;
|
customType?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
@ -38,14 +38,14 @@ export interface GraphEdge {
|
|||||||
target: string;
|
target: string;
|
||||||
color: string;
|
color: string;
|
||||||
width: number;
|
width: number;
|
||||||
type: RelationshipType;
|
type: RELATIONSHIP_TYPES;
|
||||||
customType?: string;
|
customType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CanvasGraphData {
|
export interface CanvasGraphData {
|
||||||
nodes: GraphNode[];
|
nodes: GraphNode[];
|
||||||
edges: GraphEdge[];
|
edges: GraphEdge[];
|
||||||
links: GraphEdge[]; // Added for compatibility with CustomGraphData
|
links: GraphEdge[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FormErrors {
|
export interface FormErrors {
|
||||||
@ -71,7 +71,7 @@ export interface NewPersonForm {
|
|||||||
export interface NewRelationshipForm {
|
export interface NewRelationshipForm {
|
||||||
source: string;
|
source: string;
|
||||||
target: string;
|
target: string;
|
||||||
type: RelationshipType;
|
type: RELATIONSHIP_TYPES;
|
||||||
customType: string;
|
customType: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
bidirectional: boolean;
|
bidirectional: boolean;
|
113
frontend/tsconfig.json
Normal file
113
frontend/tsconfig.json
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||||
|
|
||||||
|
/* Projects */
|
||||||
|
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||||
|
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||||
|
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||||
|
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||||
|
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||||
|
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||||
|
|
||||||
|
/* Language and Environment */
|
||||||
|
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||||
|
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||||
|
"jsx": "preserve", /* Specify what JSX code is generated. */
|
||||||
|
// "libReplacement": true, /* Enable lib replacement. */
|
||||||
|
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||||
|
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||||
|
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||||
|
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||||
|
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||||
|
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||||
|
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||||
|
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||||
|
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||||
|
|
||||||
|
/* Modules */
|
||||||
|
"module": "commonjs", /* Specify what module code is generated. */
|
||||||
|
"rootDir": "./src", /* Specify the root folder within your source files. */
|
||||||
|
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||||
|
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||||
|
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||||
|
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||||
|
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||||
|
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||||
|
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||||
|
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||||
|
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
|
||||||
|
// "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
|
||||||
|
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
|
||||||
|
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
||||||
|
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
||||||
|
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
|
||||||
|
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||||
|
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
||||||
|
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||||
|
|
||||||
|
/* JavaScript Support */
|
||||||
|
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||||
|
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||||
|
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||||
|
|
||||||
|
/* Emit */
|
||||||
|
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||||
|
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||||
|
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||||
|
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||||
|
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||||
|
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||||
|
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||||
|
"outDir": "./dist", /* Specify an output folder for all emitted files. */
|
||||||
|
// "removeComments": true, /* Disable emitting comments. */
|
||||||
|
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||||
|
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||||
|
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||||
|
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||||
|
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||||
|
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||||
|
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||||
|
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||||
|
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||||
|
"noEmitOnError": false, /* Disable emitting files if any type checking errors are reported. */
|
||||||
|
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||||
|
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||||
|
|
||||||
|
/* Interop Constraints */
|
||||||
|
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||||
|
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
||||||
|
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
|
||||||
|
// "erasableSyntaxOnly": true, /* Do not allow runtime constructs that are not part of ECMAScript. */
|
||||||
|
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||||
|
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||||
|
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||||
|
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||||
|
|
||||||
|
/* Type Checking */
|
||||||
|
"strict": true, /* Enable all strict type-checking options. */
|
||||||
|
"noImplicitAny": false, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||||
|
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||||
|
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||||
|
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||||
|
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||||
|
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
|
||||||
|
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||||
|
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||||
|
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||||
|
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||||
|
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||||
|
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||||
|
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||||
|
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||||
|
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||||
|
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||||
|
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||||
|
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||||
|
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||||
|
|
||||||
|
/* Completeness */
|
||||||
|
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||||
|
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||||
|
}
|
||||||
|
}
|
13
frontend/vite.config.mjs
Normal file
13
frontend/vite.config.mjs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
],
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
},
|
||||||
|
});
|
76
package.json
76
package.json
@ -1,53 +1,51 @@
|
|||||||
{
|
{
|
||||||
"name": "relnet",
|
"name": "relnet",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"main": "dist/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"start": "node dist/server.js",
|
||||||
"server": "bun run server/dev.ts",
|
"dev": "nodemon --exec ts-node src/server.ts",
|
||||||
"dev:all": "concurrently \"bun run dev\" \"bun run server\"",
|
"build": "tsc",
|
||||||
"build": "tsc && vite build",
|
"build:all": "npm run build && cd frontend && npm run build",
|
||||||
"preview": "vite preview",
|
"format": "prettier --write \"src/**/*.{ts,js,json}\"",
|
||||||
"start": "NODE_ENV=production bun run dist/server/index.js",
|
"format:check": "prettier --check \"src/**/*.{ts,js,json}\"",
|
||||||
"format": "prettier --write \"src/**/*.{tsx,ts,js,jsx,json,css,html}\"",
|
"format:all": "npm run format && cd frontend && npm run format"
|
||||||
"format:check": "prettier --check \"src/**/*.{tsx,ts,js,jsx,json,css,html}\""
|
|
||||||
},
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/philipredstone/relnet.git"
|
||||||
|
},
|
||||||
|
"author": "Tobias Hopp <tobstr02> & Philip Rothstein <philipredstone>",
|
||||||
|
"license": "ISC",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/philipredstone/relnet/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/philipredstone/relnet#readme",
|
||||||
|
"description": "Visualize your network among you and your friends",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.2.1",
|
"bcryptjs": "^3.0.2",
|
||||||
"axios": "^1.8.4",
|
"cookie-parser": "^1.4.7",
|
||||||
"cookie-parser": "^1.4.6",
|
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.5.0",
|
||||||
"express": "^4.18.2",
|
"express": "^5.1.0",
|
||||||
"framer-motion": "^12.7.3",
|
"express-validator": "^7.2.1",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^8.1.0",
|
||||||
"react": "^19.1.0",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"react-datepicker": "^8.3.0",
|
"mongoose": "^8.13.2"
|
||||||
"react-dom": "^19.1.0",
|
|
||||||
"react-force-graph-2d": "^1.27.1",
|
|
||||||
"react-icons": "^5.5.0",
|
|
||||||
"react-router-dom": "^7.5.0",
|
|
||||||
"ts-node": "^10.9.2",
|
|
||||||
"typescript": "^5.8.3",
|
|
||||||
"vite": "^6.3.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.4",
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/axios": "^0.14.4",
|
"@types/cookie-parser": "^1.4.8",
|
||||||
"@types/cookie-parser": "^1.4.6",
|
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/dotenv": "^8.2.3",
|
||||||
|
"@types/express": "^5.0.1",
|
||||||
|
"@types/helmet": "^4.0.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.9",
|
||||||
|
"@types/mongoose": "^5.11.97",
|
||||||
"@types/node": "^22.14.1",
|
"@types/node": "^22.14.1",
|
||||||
"@types/react": "^19.1.2",
|
"nodemon": "^3.1.9",
|
||||||
"@types/react-datepicker": "^4.19.6",
|
|
||||||
"@types/react-dom": "^19.1.2",
|
|
||||||
"@types/react-router-dom": "^5.3.3",
|
|
||||||
"@vitejs/plugin-react": "^4.4.0",
|
|
||||||
"autoprefixer": "^10.4.21",
|
|
||||||
"concurrently": "^8.2.2",
|
|
||||||
"postcss": "^8.4.32",
|
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"tailwindcss": "^4.1.4",
|
"ts-node": "^10.9.2",
|
||||||
"vite-plugin-node": "^5.0.1"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
|
||||||
theme: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
@ -1,7 +0,0 @@
|
|||||||
// This file is used for starting the server in development mode
|
|
||||||
import app from './index';
|
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3001;
|
|
||||||
app.listen(PORT, () => {
|
|
||||||
console.log(`Server running on port ${PORT}`);
|
|
||||||
});
|
|
@ -1,14 +0,0 @@
|
|||||||
export const getApiUrl = (): string => {
|
|
||||||
// const protocol = window.location.protocol;
|
|
||||||
// const hostname = window.location.hostname;
|
|
||||||
// const port = window.location.port;
|
|
||||||
|
|
||||||
// // @ts-ignore
|
|
||||||
// if (import.meta.env.DEV) {
|
|
||||||
// return protocol + '//' + hostname + ':5000' + '/api';
|
|
||||||
// } else {
|
|
||||||
// return protocol + '//' + hostname + (port ? ':' + port : '') + '/api';
|
|
||||||
// }
|
|
||||||
|
|
||||||
return '/api';
|
|
||||||
};
|
|
@ -8,12 +8,9 @@ import peopleRoutes from './routes/people.routes';
|
|||||||
import relationshipRoutes from './routes/relationship.routes';
|
import relationshipRoutes from './routes/relationship.routes';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import connectDB from './config/db';
|
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
connectDB();
|
|
||||||
|
|
||||||
const app: Application = express();
|
const app: Application = express();
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
@ -43,7 +40,7 @@ app.use(express.json());
|
|||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin: 'http://0.0.0.0:3000',
|
origin: process.env.APP_URL || 'http://localhost:3000',
|
||||||
credentials: true,
|
credentials: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -59,25 +56,10 @@ app.get('/api/health', (req, res) => {
|
|||||||
res.send('OK');
|
res.send('OK');
|
||||||
});
|
});
|
||||||
|
|
||||||
// In development, Vite handles static files
|
app.use(express.static(path.join(__dirname, '../frontend/dist/')));
|
||||||
// In production, we serve static files from the dist directory
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
app.use(express.static(path.resolve(__dirname, '../dist')));
|
|
||||||
|
|
||||||
// Always return the main index.html for any route that doesn't match an API endpoint
|
app.use((req, res, next) => {
|
||||||
app.get('*', (req, res) => {
|
res.sendFile(path.join(__dirname, '..', 'frontend/dist/index.html'));
|
||||||
res.sendFile(path.resolve(__dirname, '../dist/index.html'));
|
});
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// This will be handled by the Vite dev server
|
|
||||||
}
|
|
||||||
|
|
||||||
// This setup allows the server to be used both standalone and with Vite
|
|
||||||
if (import.meta.env?.PROD) {
|
|
||||||
const PORT = process.env.PORT || 3001;
|
|
||||||
app.listen(PORT, () => {
|
|
||||||
console.log(`Server running on port ${PORT}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default app;
|
export default app;
|
@ -4,7 +4,7 @@ import User, { IUser } from '../models/user.model';
|
|||||||
import Network from '../models/network.model';
|
import Network from '../models/network.model';
|
||||||
import Person from '../models/person.model';
|
import Person from '../models/person.model';
|
||||||
import Relationship from '../models/relationship.model';
|
import Relationship from '../models/relationship.model';
|
||||||
import { UserRequest } from '../../frontend/types/express';
|
import { UserRequest } from '../types/express';
|
||||||
import { validationResult } from 'express-validator';
|
import { validationResult } from 'express-validator';
|
||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
|
|
||||||
@ -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
|
@ -1,6 +1,6 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import Network from '../models/network.model';
|
import Network from '../models/network.model';
|
||||||
import { UserRequest } from '../../frontend/types/express';
|
import { UserRequest } from '../types/express';
|
||||||
import { validationResult } from 'express-validator';
|
import { validationResult } from 'express-validator';
|
||||||
|
|
||||||
// Get all networks for current user and all public networks
|
// Get all networks for current user and all public networks
|
@ -1,7 +1,7 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import Person from '../models/person.model';
|
import Person from '../models/person.model';
|
||||||
import Relationship from '../models/relationship.model';
|
import Relationship from '../models/relationship.model';
|
||||||
import { UserRequest } from '../../frontend/types/express';
|
import { UserRequest } from '../types/express';
|
||||||
import { validationResult } from 'express-validator';
|
import { validationResult } from 'express-validator';
|
||||||
|
|
||||||
// Get all people in a network
|
// Get all people in a network
|
@ -1,7 +1,7 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import Relationship from '../models/relationship.model';
|
import Relationship from '../models/relationship.model';
|
||||||
import Person from '../models/person.model';
|
import Person from '../models/person.model';
|
||||||
import { UserRequest } from '../../frontend/types/express';
|
import { UserRequest } from '../types/express';
|
||||||
import { validationResult } from 'express-validator';
|
import { validationResult } from 'express-validator';
|
||||||
|
|
||||||
// Get all relationships in a network
|
// Get all relationships in a network
|
@ -1,63 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, RefObject } from 'react';
|
|
||||||
import { ToastItem } from '../types/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for managing graph container dimensions and handling resize events
|
|
||||||
*/
|
|
||||||
export const useGraphDimensions = (
|
|
||||||
graphContainerRef: RefObject<HTMLDivElement>,
|
|
||||||
sidebarOpen: boolean
|
|
||||||
) => {
|
|
||||||
const [graphDimensions, setGraphDimensions] = useState({ width: 0, height: 0 });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!graphContainerRef.current) return;
|
|
||||||
|
|
||||||
const updateDimensions = () => {
|
|
||||||
if (graphContainerRef.current) {
|
|
||||||
const { width, height } = graphContainerRef.current.getBoundingClientRect();
|
|
||||||
|
|
||||||
setGraphDimensions(prev => {
|
|
||||||
if (prev.width !== width || prev.height !== height) {
|
|
||||||
return { width, height };
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initial measurement
|
|
||||||
updateDimensions();
|
|
||||||
|
|
||||||
// Set up resize observer
|
|
||||||
const resizeObserver = new ResizeObserver(updateDimensions);
|
|
||||||
if (graphContainerRef.current) {
|
|
||||||
resizeObserver.observe(graphContainerRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up window resize listener
|
|
||||||
window.addEventListener('resize', updateDimensions);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
return () => {
|
|
||||||
if (graphContainerRef.current) {
|
|
||||||
resizeObserver.unobserve(graphContainerRef.current);
|
|
||||||
}
|
|
||||||
window.removeEventListener('resize', updateDimensions);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Update dimensions when sidebar is toggled
|
|
||||||
useEffect(() => {
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
if (graphContainerRef.current) {
|
|
||||||
const { width, height } = graphContainerRef.current.getBoundingClientRect();
|
|
||||||
setGraphDimensions({ width, height });
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
|
||||||
}, [sidebarOpen]);
|
|
||||||
|
|
||||||
return graphDimensions;
|
|
||||||
};
|
|
@ -1,78 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, RefObject } from 'react';
|
|
||||||
import { ToastItem } from '../types/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for setting up keyboard shortcuts
|
|
||||||
*/
|
|
||||||
export const useKeyboardShortcuts = (handlers: {
|
|
||||||
handleZoomIn: () => void;
|
|
||||||
handleZoomOut: () => void;
|
|
||||||
handleResetZoom: () => void;
|
|
||||||
toggleSidebar: () => void;
|
|
||||||
setPersonModalOpen: (open: boolean) => void;
|
|
||||||
setRelationshipModalOpen: (open: boolean) => void;
|
|
||||||
setHelpModalOpen: (open: boolean) => void;
|
|
||||||
}) => {
|
|
||||||
useEffect(() => {
|
|
||||||
const {
|
|
||||||
handleZoomIn,
|
|
||||||
handleZoomOut,
|
|
||||||
handleResetZoom,
|
|
||||||
toggleSidebar,
|
|
||||||
setPersonModalOpen,
|
|
||||||
setRelationshipModalOpen,
|
|
||||||
setHelpModalOpen,
|
|
||||||
} = handlers;
|
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
// Only apply shortcuts when not in an input field
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
|
|
||||||
|
|
||||||
// Ctrl/Cmd + / to open help modal
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
|
|
||||||
e.preventDefault();
|
|
||||||
setHelpModalOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// + for zoom in
|
|
||||||
if (e.key === '+' || e.key === '=') {
|
|
||||||
e.preventDefault();
|
|
||||||
handleZoomIn();
|
|
||||||
}
|
|
||||||
|
|
||||||
// - for zoom out
|
|
||||||
if (e.key === '-' || e.key === '_') {
|
|
||||||
e.preventDefault();
|
|
||||||
handleZoomOut();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 0 for reset zoom
|
|
||||||
if (e.key === '0') {
|
|
||||||
e.preventDefault();
|
|
||||||
handleResetZoom();
|
|
||||||
}
|
|
||||||
|
|
||||||
// n for new person
|
|
||||||
if (e.key === 'n' && !e.ctrlKey && !e.metaKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
setPersonModalOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// r for new relationship
|
|
||||||
if (e.key === 'r' && !e.ctrlKey && !e.metaKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
setRelationshipModalOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// s for toggle sidebar
|
|
||||||
if (e.key === 's' && !e.ctrlKey && !e.metaKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
toggleSidebar();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [handlers]);
|
|
||||||
};
|
|
@ -1,60 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, RefObject } from 'react';
|
|
||||||
import { ToastItem } from '../types/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to manage node positions in the graph
|
|
||||||
*/
|
|
||||||
export const useSmartNodePositioning = (
|
|
||||||
graphWidth: number,
|
|
||||||
graphHeight: number,
|
|
||||||
peopleCount: number
|
|
||||||
) => {
|
|
||||||
return useCallback(() => {
|
|
||||||
const centerX = graphWidth / 2;
|
|
||||||
const centerY = graphHeight / 2;
|
|
||||||
const maxRadius = Math.min(graphWidth, graphHeight) * 0.4;
|
|
||||||
const totalNodes = peopleCount;
|
|
||||||
const index = totalNodes;
|
|
||||||
|
|
||||||
if (totalNodes <= 0) {
|
|
||||||
return { x: centerX, y: centerY };
|
|
||||||
} else if (totalNodes <= 4) {
|
|
||||||
const theta = index * 2.399;
|
|
||||||
const radius = maxRadius * 0.5 * Math.sqrt(index / (totalNodes + 1));
|
|
||||||
return {
|
|
||||||
x: centerX + radius * Math.cos(theta),
|
|
||||||
y: centerY + radius * Math.sin(theta),
|
|
||||||
};
|
|
||||||
} else if (totalNodes <= 11) {
|
|
||||||
const isOuterRing = index >= Math.floor(totalNodes / 2);
|
|
||||||
const ringIndex = isOuterRing ? index - Math.floor(totalNodes / 2) : index;
|
|
||||||
const ringTotal = isOuterRing
|
|
||||||
? totalNodes - Math.floor(totalNodes / 2) + 1
|
|
||||||
: Math.floor(totalNodes / 2);
|
|
||||||
const ringRadius = isOuterRing ? maxRadius * 0.8 : maxRadius * 0.4;
|
|
||||||
|
|
||||||
const angle = (ringIndex / ringTotal) * 2 * Math.PI + (isOuterRing ? 0 : Math.PI / ringTotal);
|
|
||||||
return {
|
|
||||||
x: centerX + ringRadius * Math.cos(angle),
|
|
||||||
y: centerY + ringRadius * Math.sin(angle),
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const clusterCount = Math.max(3, Math.floor(Math.sqrt(totalNodes)));
|
|
||||||
const clusterIndex = index % clusterCount;
|
|
||||||
|
|
||||||
const clusterAngle = (clusterIndex / clusterCount) * 2 * Math.PI;
|
|
||||||
const clusterDistance = maxRadius * 0.6;
|
|
||||||
const clusterX = centerX + clusterDistance * Math.cos(clusterAngle);
|
|
||||||
const clusterY = centerY + clusterDistance * Math.sin(clusterAngle);
|
|
||||||
|
|
||||||
const clusterRadius = maxRadius * 0.3;
|
|
||||||
const randomAngle = Math.random() * 2 * Math.PI;
|
|
||||||
const randomDistance = Math.random() * clusterRadius;
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: clusterX + randomDistance * Math.cos(randomAngle),
|
|
||||||
y: clusterY + randomDistance * Math.sin(randomAngle),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [graphWidth, graphHeight, peopleCount]);
|
|
||||||
};
|
|
@ -1,30 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, RefObject } from 'react';
|
|
||||||
import { ToastItem } from '../types/types';
|
|
||||||
|
|
||||||
export const useToastNotifications = () => {
|
|
||||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
|
||||||
|
|
||||||
const addToast = useCallback(
|
|
||||||
(message: string, type: 'error' | 'success' | 'warning' | 'info' = 'success') => {
|
|
||||||
const id = Date.now();
|
|
||||||
const newToast = {
|
|
||||||
id,
|
|
||||||
message,
|
|
||||||
type,
|
|
||||||
onClose: () => removeToast(id),
|
|
||||||
};
|
|
||||||
|
|
||||||
setToasts(prevToasts => [...prevToasts, newToast]);
|
|
||||||
|
|
||||||
// Auto-remove after 3 seconds
|
|
||||||
setTimeout(() => removeToast(id), 3000);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeToast = useCallback((id: number) => {
|
|
||||||
setToasts(prevToasts => prevToasts.filter(toast => toast.id !== id));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { toasts, addToast, removeToast };
|
|
||||||
};
|
|
@ -1,7 +1,7 @@
|
|||||||
import { 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 '../../frontend/types/express';
|
import { UserRequest } from '../types/express';
|
||||||
|
|
||||||
// JWT secret from environment variables
|
// JWT secret from environment variables
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'your_jwt_secret_key_change_this';
|
const JWT_SECRET = process.env.JWT_SECRET || 'your_jwt_secret_key_change_this';
|
@ -1,6 +1,6 @@
|
|||||||
import { Response, NextFunction } from 'express';
|
import { Response, NextFunction } from 'express';
|
||||||
import Network from '../models/network.model';
|
import Network from '../models/network.model';
|
||||||
import { UserRequest } from '../../frontend/types/express';
|
import { UserRequest } from '../types/express';
|
||||||
|
|
||||||
export const checkNetworkAccess = async (
|
export const checkNetworkAccess = async (
|
||||||
req: UserRequest,
|
req: UserRequest,
|
@ -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()
|
15
src/server.ts
Normal file
15
src/server.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import app from './app';
|
||||||
|
import connectDB from './config/db';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 5000;
|
||||||
|
|
||||||
|
// Connect to MongoDB
|
||||||
|
connectDB();
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server running on port ${PORT}`);
|
||||||
|
});
|
8
src/types/express.d.ts
vendored
Normal file
8
src/types/express.d.ts
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { Request } from 'express';
|
||||||
|
import { IUser } from '../models/user.model';
|
||||||
|
import { INetwork } from '../models/network.model';
|
||||||
|
|
||||||
|
export interface UserRequest extends Request {
|
||||||
|
user?: IUser;
|
||||||
|
network?: INetwork;
|
||||||
|
}
|
@ -1,8 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
|
||||||
theme: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
@ -1,36 +1,15 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "es2018",
|
||||||
"useDefineForClassFields": true,
|
"module": "commonjs",
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"outDir": "./dist",
|
||||||
"module": "ESNext",
|
"rootDir": "./src",
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
|
|
||||||
/* Paths */
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["frontend/*"],
|
|
||||||
"@server/*": ["server/*"]
|
|
||||||
},
|
|
||||||
|
|
||||||
/* For Node.js compatibility */
|
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"allowSyntheticDefaultImports": true
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
},
|
},
|
||||||
"include": ["frontend", "server", "src"],
|
"include": ["src/**/*"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"exclude": ["node_modules", "**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"composite": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowSyntheticDefaultImports": true
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
@ -1,39 +0,0 @@
|
|||||||
import { defineConfig } from 'vite';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
import { resolve } from 'path';
|
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react(), tailwindcss()],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': resolve(__dirname, 'src'),
|
|
||||||
'@server': resolve(__dirname, 'server'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
optimizeDeps: {
|
|
||||||
include: [
|
|
||||||
'react',
|
|
||||||
'react-dom',
|
|
||||||
'react-router-dom',
|
|
||||||
'framer-motion',
|
|
||||||
'react-icons',
|
|
||||||
'react-force-graph-2d',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
port: 3000,
|
|
||||||
proxy: {
|
|
||||||
'/api': {
|
|
||||||
target: 'http://localhost:5000',
|
|
||||||
changeOrigin: true,
|
|
||||||
secure: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
outDir: 'dist',
|
|
||||||
manifest: true,
|
|
||||||
},
|
|
||||||
});
|
|
Reference in New Issue
Block a user