Compare commits

...

20 Commits

Author SHA1 Message Date
d55e58d099 Reformat
Took 18 minutes
2025-04-17 13:08:27 +02:00
006b648dd0 Update width for relationship edges
Took 6 minutes
2025-04-16 16:18:24 +02:00
c31b5c5b14 Refactor relationship types to single class and add more
Took 38 minutes
2025-04-16 15:39:13 +02:00
0333d37aae Refactor to interfaces and type classes
Took 5 minutes
2025-04-16 13:59:15 +02:00
3da29516ec Refactor friendship types to one type
Took 10 minutes
2025-04-16 13:49:58 +02:00
00e7294f41 Set name to firstname and first letter of lastname and increase node size
Took 3 hours 34 minutes
2025-04-16 13:25:47 +02:00
b054d55018 add autofocus for modals 2025-04-16 11:27:39 +02:00
bbb3645d99 Fix birthdate input 2025-04-16 11:17:50 +02:00
9ce80b4c59 Fix Dockerfile healthcheck, add curl 2025-04-16 10:53:55 +02:00
56c0867a20 Fix Dockerfile healthcheck for $port var 2025-04-16 10:45:57 +02:00
faae6ec930 Fix Dockerfile cmd run 2025-04-16 10:35:51 +02:00
8cbb83715f Remove bun lockfile from gitignore for frozen lockfile usage in dockerfile
See 6ca54dfe9d
2025-04-16 10:25:46 +02:00
11b83eeffc Merge branch 'main' of https://github.com/philipredstone/relnet 2025-04-16 10:24:06 +02:00
ad3ced0650 Code cleanup 2025-04-16 10:19:34 +02:00
3fd311e312 Remove unused imports 2025-04-16 10:18:05 +02:00
88d0c58a35 fix health check 2025-04-16 10:18:00 +02:00
ca7b5fcf70 Fix interface reference 2025-04-16 10:17:02 +02:00
6ca54dfe9d add health check and use bun 2025-04-16 10:16:04 +02:00
0cc99458e0 Fix graph nodes and remove stub imports 2025-04-16 10:15:49 +02:00
1243cdc5ae Fix addToast func parameters 2025-04-16 10:13:14 +02:00
25 changed files with 410 additions and 619 deletions

1
.gitignore vendored
View File

@ -9,6 +9,5 @@ frontend/bun.lockb
node_modules
dist
.env
*bun.lockb
*yarn.lock
*package-lock.json

View File

@ -1,47 +1,78 @@
# syntax=docker/dockerfile:1
FROM node:22 AS frontend-builder
# Workingdir
# Frontend builder stage
FROM oven/bun:1 AS frontend-builder
WORKDIR /frontend
# Copy files
# Install dependencies
COPY frontend/package.json frontend/bun.lockb* ./
RUN bun install --frozen-lockfile
# Copy source files
COPY frontend/src/ src/
COPY frontend/package.json .
COPY frontend/index.html .
COPY frontend/tsconfig.json .
COPY frontend/vite.config.mjs .
# Install libs
RUN yarn install
# Build to dist/
RUN yarn build
# Build the frontend
RUN bun run build
FROM node:22 AS backend-builder
# Workingdir
# Backend builder stage
FROM oven/bun:1 AS backend-builder
WORKDIR /app
COPY package.json .
RUN yarn install
# Copy package files first for better caching
COPY package.json bun.lockb* ./
# Install dependencies
RUN bun install --frozen-lockfile
# Copy source files
COPY tsconfig.json .
COPY src/ src/
# Build to dist/
RUN yarn run build
# Build the backend
RUN bun run build
# Final production stage
FROM oven/bun:1-slim
LABEL "org.opencontainers.image.title"="Relnet"
LABEL "org.opencontainers.image.source"="https://github.com/philipredstone/relnet"
LABEL "org.opencontainers.image.url"="https://github.com/philipredstone/relnet"
LABEL "org.opencontainers.image.description"="A dynamic web application for visualizing and managing a friendship network. This project uses graphs to display interactive connection graphs between friends, and includes a backend server built with Node.js, TypeScript, Express, and Mongoose for data persistence."
LABEL "org.opencontainers.image.version"="1.0.0"
LABEL "VERSION"="1.0.0"
LABEL maintainer="Tobias Hopp and Philip Rothstein"
# Install curl for healthcheck
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
apt-get -qq -y install curl && \
rm -rf /var/cache/apt/archives /var/lib/apt/lists/*
# Final stage
FROM node:22-slim
COPY --from=frontend-builder /frontend/dist/ frontend/dist
COPY --from=backend-builder /app/dist/ dist/
COPY --from=backend-builder /app/node_modules node_modules
COPY package.json .
WORKDIR /app
# Copy built artifacts from previous stages
COPY --from=frontend-builder /frontend/dist/ ./frontend/dist
COPY --from=backend-builder /app/dist/ ./dist/
# Only copy production dependencies
COPY package.json ./
RUN bun install --production --frozen-lockfile
# Set environment variables
ENV PORT=80
ENV MONGODB_URI=mongodb://db:27017/friendship-network
ENV APP_URL=http://localhost:80
ENV ENABLE_REGISTRATION=true
CMD ["yarn", "run", "start"]
# Expose the port
EXPOSE 80
# Health check to verify the application is running
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD /bin/sh -c 'curl -f http://localhost:$PORT/api/health || exit 1'
# Start the application
CMD ["bun", "dist/server.js"]

View File

@ -1,5 +1,5 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { BrowserRouter as Router, Navigate, Route, Routes } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext';
import { NetworkProvider } from './context/NetworkContext';
import Login from './components/auth/Login';

View File

@ -44,7 +44,7 @@ export const getUserNetworks = async (): Promise<Network[]> => {
export const createNetwork = async (data: CreateNetworkData): Promise<Network> => {
const response = await axios.post<{ success: boolean; data: Network }>(
`${API_URL}/networks`,
data
data,
);
return response.data.data;
};
@ -52,7 +52,7 @@ export const createNetwork = async (data: CreateNetworkData): Promise<Network> =
// Get a specific network
export const getNetwork = async (id: string): Promise<Network> => {
const response = await axios.get<{ success: boolean; data: Network }>(
`${API_URL}/networks/${id}`
`${API_URL}/networks/${id}`,
);
return response.data.data;
};
@ -61,7 +61,7 @@ export const getNetwork = async (id: string): Promise<Network> => {
export const updateNetwork = async (id: string, data: UpdateNetworkData): Promise<Network> => {
const response = await axios.put<{ success: boolean; data: Network }>(
`${API_URL}/networks/${id}`,
data
data,
);
return response.data.data;
};

View File

@ -44,7 +44,7 @@ export interface UpdatePersonData {
// Get all people in a network
export const getPeople = async (networkId: string): Promise<Person[]> => {
const response = await axios.get<{ success: boolean; data: Person[] }>(
`${API_URL}/networks/${networkId}/people`
`${API_URL}/networks/${networkId}/people`,
);
return response.data.data;
};
@ -53,7 +53,7 @@ export const getPeople = async (networkId: string): Promise<Person[]> => {
export const addPerson = async (networkId: string, data: CreatePersonData): Promise<Person> => {
const response = await axios.post<{ success: boolean; data: Person }>(
`${API_URL}/networks/${networkId}/people`,
data
data,
);
return response.data.data;
};
@ -62,11 +62,11 @@ export const addPerson = async (networkId: string, data: CreatePersonData): Prom
export const updatePerson = async (
networkId: string,
personId: string,
data: UpdatePersonData
data: UpdatePersonData,
): Promise<Person> => {
const response = await axios.put<{ success: boolean; data: Person }>(
`${API_URL}/networks/${networkId}/people/${personId}`,
data
data,
);
return response.data.data;
};

View File

@ -1,4 +1,6 @@
import axios from 'axios';
import { RELATIONSHIP_TYPES } from '../types/RelationShipTypes';
import { Relationship } from '../interfaces/IRelationship';
const protocol = window.location.protocol;
const hostname = window.location.hostname;
@ -6,34 +8,22 @@ const port = window.location.port;
const API_URL = protocol + '//' + hostname + (port ? ':' + port : '') + '/api';
// Types
export interface Relationship {
_id: string;
source: string;
target: string;
type: 'freund' | 'partner' | 'familie' | 'arbeitskolleg' | 'custom';
customType?: string;
network: string;
createdAt: string;
updatedAt: string;
}
export interface CreateRelationshipData {
source: string;
target: string;
type: 'freund' | 'partner' | 'familie' | 'arbeitskolleg' | 'custom';
type: RELATIONSHIP_TYPES;
customType?: string;
}
export interface UpdateRelationshipData {
type?: 'freund' | 'partner' | 'familie' | 'arbeitskolleg' | 'custom';
type?: RELATIONSHIP_TYPES;
customType?: string;
}
// Get all relationships in a network
export const getRelationships = async (networkId: string): Promise<Relationship[]> => {
const response = await axios.get<{ success: boolean; data: Relationship[] }>(
`${API_URL}/networks/${networkId}/relationships`
`${API_URL}/networks/${networkId}/relationships`,
);
return response.data.data;
};
@ -41,11 +31,11 @@ export const getRelationships = async (networkId: string): Promise<Relationship[
// Add a relationship to the network
export const addRelationship = async (
networkId: string,
data: CreateRelationshipData
data: CreateRelationshipData,
): Promise<Relationship> => {
const response = await axios.post<{ success: boolean; data: Relationship }>(
`${API_URL}/networks/${networkId}/relationships`,
data
data,
);
return response.data.data;
};
@ -54,11 +44,11 @@ export const addRelationship = async (
export const updateRelationship = async (
networkId: string,
relationshipId: string,
data: UpdateRelationshipData
data: UpdateRelationshipData,
): Promise<Relationship> => {
const response = await axios.put<{ success: boolean; data: Relationship }>(
`${API_URL}/networks/${networkId}/relationships/${relationshipId}`,
data
data,
);
return response.data.data;
};
@ -66,7 +56,7 @@ export const updateRelationship = async (
// Remove a relationship
export const removeRelationship = async (
networkId: string,
relationshipId: string
relationshipId: string,
): Promise<void> => {
await axios.delete(`${API_URL}/networks/${networkId}/relationships/${relationshipId}`);
};

View File

@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { GraphData } from 'react-force-graph-2d';
// Define types for graph elements
interface NodeData {
@ -19,21 +20,24 @@ interface EdgeData {
width: number;
}
interface GraphData {
interface CustomGraphData extends GraphData {
nodes: NodeData[];
edges: EdgeData[];
}
interface CanvasGraphProps {
data: GraphData;
width: number;
height: number;
data: CustomGraphData,
width: number,
height: number,
zoomLevel: number,
onNodeClick: (nodeId: string) => void,
onNodeDrag: (nodeId, x, y) => void
}
// Physics constants
const NODE_RADIUS = 30; // Node radius in pixels
const MIN_DISTANCE = 100; // Minimum distance between any two nodes
const MAX_DISTANCE = 300; // Maximum distance between connected nodes
const NODE_RADIUS = 45; // Node radius in pixels
const MIN_DISTANCE = 110; // Minimum distance between any two nodes
const MAX_DISTANCE = 500; // Maximum distance between connected nodes
const REPULSION_STRENGTH = 500; // How strongly nodes repel each other when too close
const ATTRACTION_STRENGTH = 0.1; // Default attraction between connected nodes
const CONSTRAINT_STRENGTH = 0.2; // Strength of distance constraints
@ -42,7 +46,7 @@ const CENTER_GRAVITY = 0.01; // Force pulling nodes to the center
const MAX_VELOCITY = 5; // Maximum velocity to prevent wild movement
const COOLING_FACTOR = 0.99; // System gradually cools down
const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLevel, onNodeClick, onNodeDrag }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
// State for interactions
@ -81,7 +85,7 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
// Skip if we already have positions for all nodes
const allNodesHavePositions = data.nodes.every(
node =>
nodePositions[node.id] && (nodePositions[node.id].x !== 0 || nodePositions[node.id].y !== 0)
nodePositions[node.id] && (nodePositions[node.id].x !== 0 || nodePositions[node.id].y !== 0),
);
if (allNodesHavePositions) {
@ -254,7 +258,7 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
// Limit maximum velocity to prevent wild movement
const speed = Math.sqrt(
newPositions[node.id].vx * newPositions[node.id].vx +
newPositions[node.id].vy * newPositions[node.id].vy
newPositions[node.id].vy * newPositions[node.id].vy,
);
if (speed > MAX_VELOCITY) {
newPositions[node.id].vx = (newPositions[node.id].vx / speed) * MAX_VELOCITY;
@ -332,7 +336,7 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
return null;
},
[data.nodes, nodePositions, panOffset, scale]
[data.nodes, nodePositions, panOffset, scale],
); // FIX: Added proper dependencies
// Mouse event handlers
@ -372,7 +376,7 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
setPanStart({ x, y });
}
},
[findNodeAtPosition, nodePositions, panOffset, scale]
[findNodeAtPosition, nodePositions, panOffset, scale],
); // FIX: Added proper dependencies
const handleMouseMove = useCallback(
@ -418,7 +422,7 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
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(() => {
@ -451,7 +455,7 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
setScale(newScale);
setPanOffset({ x: newPanOffsetX, y: newPanOffsetY });
},
[scale, panOffset]
[scale, panOffset],
);
const toggleAutoLayout = useCallback(() => {
@ -471,7 +475,7 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
ctx.textBaseline = 'middle';
ctx.fillText(autoLayout ? 'Physics: ON' : 'Physics: OFF', width - 70, 40);
},
[autoLayout, width]
[autoLayout, width],
);
// Draw function - FIX: Properly memoized with all dependencies
@ -569,9 +573,9 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
ctx.stroke();
// Draw initials
const initials = `${node.firstName.charAt(0)}${node.lastName.charAt(0)}`;
const initials = `${node.firstName} ${node.lastName.charAt(0)}.`;
ctx.fillStyle = 'white';
ctx.font = 'bold 16px sans-serif';
ctx.font = 'bold 13px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(initials, pos.x, pos.y);
@ -592,7 +596,7 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
pos.x - textWidth / 2 - padding,
pos.y + NODE_RADIUS + 5,
textWidth + padding * 2,
textHeight + padding * 2
textHeight + padding * 2,
);
ctx.fillStyle = 'white';
@ -628,7 +632,7 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height }) => {
toggleAutoLayout();
}
},
[width, toggleAutoLayout]
[width, toggleAutoLayout],
); // FIX: Added proper dependencies
// FIX: Ensure continuous rendering with requestAnimationFrame

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { Transition } from '@headlessui/react';
import { FaTimes } from 'react-icons/fa';
@ -274,7 +274,7 @@ export const NetworkStats: React.FC<NetworkStatsProps> = ({ people, relationship
people.length > 0 ? (relationships.length / people.length).toFixed(1) : '0.0';
const isolatedPeople = people.filter(
person => !relationships.some(r => r.source === person._id || r.target === person._id)
person => !relationships.some(r => r.source === person._id || r.target === person._id),
).length;
// Find most connected person
@ -286,7 +286,7 @@ export const NetworkStats: React.FC<NetworkStatsProps> = ({ people, relationship
const mostConnected =
personConnectionCounts.length > 0
? personConnectionCounts.reduce((prev, current) =>
prev.count > current.count ? prev : current
prev.count > current.count ? prev : current,
)
: null;

View File

@ -1,7 +1,7 @@
import React from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import { FaUser, FaSignOutAlt, FaNetworkWired } from 'react-icons/fa';
import { FaNetworkWired, FaSignOutAlt, FaUser } from 'react-icons/fa';
const Header: React.FC = () => {
const { user, logout } = useAuth();
@ -63,7 +63,8 @@ const Header: React.FC = () => {
</div>
</button>
<div className="absolute right-0 mt-2 w-48 bg-slate-800 rounded-md shadow-lg py-1 z-10 border border-slate-700 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
<div
className="absolute right-0 mt-2 w-48 bg-slate-800 rounded-md shadow-lg py-1 z-10 border border-slate-700 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
<button
onClick={handleLogout}
className="w-full text-left px-4 py-2 text-sm text-slate-300 hover:bg-slate-700 flex items-center"

View File

@ -2,8 +2,8 @@ import React, { useState } from 'react';
import { useNetworks } from '../../context/NetworkContext';
import { useAuth } from '../../context/AuthContext';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { FaPlus, FaNetworkWired, FaTrash, FaEye, FaGlobe, FaLock, FaTimes } from 'react-icons/fa';
import { AnimatePresence, motion } from 'framer-motion';
import { FaEye, FaGlobe, FaLock, FaNetworkWired, FaPlus, FaTimes, FaTrash } from 'react-icons/fa';
const NetworkList: React.FC = () => {
const { networks, loading, error, createNetwork, deleteNetwork } = useNetworks();

View File

@ -1,12 +1,12 @@
import React, { createContext, useState, useEffect, useContext, ReactNode } from 'react';
import React, { createContext, ReactNode, useContext, useEffect, useState } from 'react';
import {
User,
getCurrentUser,
login as apiLogin,
register as apiRegister,
logout as apiLogout,
LoginData,
logout as apiLogout,
register as apiRegister,
RegisterData,
User,
} from '../api/auth';
interface AuthContextProps {

View File

@ -1,11 +1,11 @@
import React, { createContext, useState, useEffect, useContext, ReactNode } from 'react';
import React, { createContext, ReactNode, useContext, useEffect, useState } from 'react';
import {
Network,
getUserNetworks,
createNetwork as apiCreateNetwork,
updateNetwork as apiUpdateNetwork,
deleteNetwork as apiDeleteNetwork,
CreateNetworkData,
deleteNetwork as apiDeleteNetwork,
getUserNetworks,
Network,
updateNetwork as apiUpdateNetwork,
UpdateNetworkData,
} from '../api/network';
import { useAuth } from './AuthContext';

View File

@ -1,12 +1,8 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Person, getPeople, addPerson, updatePerson, removePerson } from '../api/people';
import {
Relationship,
getRelationships,
addRelationship,
updateRelationship,
removeRelationship,
} from '../api/relationships';
import { useCallback, useEffect, useRef, useState } from 'react';
import { addPerson, getPeople, Person, removePerson, updatePerson } from '../api/people';
import { addRelationship, getRelationships, removeRelationship, updateRelationship } from '../api/relationships';
import { Relationship } from '../interfaces/IRelationship';
import { RELATIONSHIP_TYPES } from '../types/RelationShipTypes';
interface PersonNode extends Person {
// Additional properties needed for the visualization
@ -24,7 +20,7 @@ const DEFAULT_POLL_INTERVAL = 5000;
// Custom hook to manage friendship network data
export const useFriendshipNetwork = (
networkId: string | null,
pollInterval = DEFAULT_POLL_INTERVAL
pollInterval = DEFAULT_POLL_INTERVAL,
) => {
const [people, setPeople] = useState<PersonNode[]>([]);
const [relationships, setRelationships] = useState<RelationshipEdge[]>([]);
@ -70,7 +66,7 @@ export const useFriendshipNetwork = (
// Generate hashes to detect changes
const positionsHash = JSON.stringify(peopleNodes.map(p => ({ id: p.id, pos: p.position })));
const relationshipsHash = JSON.stringify(
relationshipEdges.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type }))
relationshipEdges.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type })),
);
// Handle people updates
@ -187,7 +183,7 @@ export const useFriendshipNetwork = (
}
}
},
[networkId]
[networkId],
);
// Set up polling for network data
@ -234,7 +230,7 @@ export const useFriendshipNetwork = (
// Update the reference hash to avoid unnecessary state updates on next poll
lastPeopleUpdateRef.current = JSON.stringify(
updatedPeople.map(p => ({ id: p.id, pos: p.position }))
updatedPeople.map(p => ({ id: p.id, pos: p.position })),
);
return newPersonNode;
@ -252,7 +248,7 @@ export const useFriendshipNetwork = (
lastName?: string;
birthday?: string | null;
position?: { x: number; y: number };
}
},
): Promise<PersonNode> => {
if (!networkId) throw new Error('No network selected');
@ -262,14 +258,14 @@ export const useFriendshipNetwork = (
// Update the local state
const updatedPeople = people.map(person =>
person._id === personId ? updatedPersonNode : person
person._id === personId ? updatedPersonNode : person,
);
setPeople(updatedPeople);
// Update the reference hash if position changed to avoid unnecessary state updates on next poll
if (personData.position) {
lastPeopleUpdateRef.current = JSON.stringify(
updatedPeople.map(p => ({ id: p.id, pos: p.position }))
updatedPeople.map(p => ({ id: p.id, pos: p.position })),
);
}
@ -293,16 +289,16 @@ export const useFriendshipNetwork = (
// Remove all relationships involving this person
const updatedRelationships = relationships.filter(
rel => rel.source !== personId && rel.target !== personId
rel => rel.source !== personId && rel.target !== personId,
);
setRelationships(updatedRelationships);
// Update both reference hashes to avoid unnecessary state updates on next poll
lastPeopleUpdateRef.current = JSON.stringify(
updatedPeople.map(p => ({ id: p.id, pos: p.position }))
updatedPeople.map(p => ({ id: p.id, pos: p.position })),
);
lastRelationshipsUpdateRef.current = JSON.stringify(
updatedRelationships.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type }))
updatedRelationships.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type })),
);
} catch (err: any) {
setError(err.message || 'Failed to delete person');
@ -314,7 +310,7 @@ export const useFriendshipNetwork = (
const createRelationship = async (relationshipData: {
source: string;
target: string;
type: 'freund' | 'partner' | 'familie' | 'arbeitskolleg' | 'custom';
type: RELATIONSHIP_TYPES;
customType?: string;
}): Promise<RelationshipEdge> => {
if (!networkId) throw new Error('No network selected');
@ -328,7 +324,7 @@ export const useFriendshipNetwork = (
// Update the relationship hash to avoid unnecessary state updates on next poll
lastRelationshipsUpdateRef.current = JSON.stringify(
updatedRelationships.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type }))
updatedRelationships.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type })),
);
return newRelationshipEdge;
@ -342,9 +338,9 @@ export const useFriendshipNetwork = (
const updateRelationshipData = async (
relationshipId: string,
relationshipData: {
type?: 'freund' | 'partner' | 'familie' | 'arbeitskolleg' | 'custom';
type?: RELATIONSHIP_TYPES;
customType?: string;
}
},
): Promise<RelationshipEdge> => {
if (!networkId) throw new Error('No network selected');
@ -352,7 +348,7 @@ export const useFriendshipNetwork = (
const updatedRelationship = await updateRelationship(
networkId,
relationshipId,
relationshipData
relationshipData,
);
const updatedRelationshipEdge: RelationshipEdge = {
...updatedRelationship,
@ -360,13 +356,13 @@ export const useFriendshipNetwork = (
};
const updatedRelationships = relationships.map(rel =>
rel._id === relationshipId ? updatedRelationshipEdge : rel
rel._id === relationshipId ? updatedRelationshipEdge : rel,
);
setRelationships(updatedRelationships);
// Update the relationship hash to avoid unnecessary state updates on next poll
lastRelationshipsUpdateRef.current = JSON.stringify(
updatedRelationships.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type }))
updatedRelationships.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type })),
);
return updatedRelationshipEdge;
@ -387,7 +383,7 @@ export const useFriendshipNetwork = (
// Update the relationship hash to avoid unnecessary state updates on next poll
lastRelationshipsUpdateRef.current = JSON.stringify(
updatedRelationships.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type }))
updatedRelationships.map(r => ({ id: r.id, src: r.source, tgt: r.target, type: r.type })),
);
} catch (err: any) {
setError(err.message || 'Failed to delete relationship');

View File

@ -0,0 +1,15 @@
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;
}

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

View File

@ -11,7 +11,7 @@ if (rootElement) {
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
</React.StrictMode>,
);
} else {
console.error('Root element not found');

View File

@ -0,0 +1,15 @@
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;

View File

@ -51,6 +51,11 @@ app.use('/api/networks', networkRoutes);
app.use('/api/networks', peopleRoutes);
app.use('/api/networks', relationshipRoutes);
// Health check
app.get('/api/health', (req, res) => {
res.send('OK');
});
app.use(express.static(path.join(__dirname, '../frontend/dist/')));
app.use((req, res, next) => {

View File

@ -217,12 +217,12 @@ const createSampleDemoNetwork = async (userId: mongoose.Types.ObjectId | string)
// Create relationships between people
const relationships = [
{ source: 'JohnSmith', target: 'EmmaJohnson', type: 'freund' },
{ source: 'EmmaJohnson', target: 'MichaelWilliams', type: 'familie' },
{ source: 'MichaelWilliams', target: 'SarahBrown', type: 'arbeitskolleg' },
{ source: 'SarahBrown', target: 'DavidJones', type: 'freund' },
{ source: 'JohnSmith', target: 'EmmaJohnson', type: 'friend' },
{ source: 'EmmaJohnson', target: 'MichaelWilliams', type: 'family' },
{ source: 'MichaelWilliams', target: 'SarahBrown', type: 'colleague' },
{ source: 'SarahBrown', target: 'DavidJones', type: 'friend' },
{ source: 'DavidJones', target: 'LisaGarcia', type: 'partner' },
{ source: 'JohnSmith', target: 'DavidJones', type: 'arbeitskolleg' },
{ source: 'JohnSmith', target: 'DavidJones', type: 'colleague' },
];
// Create each relationship

View File

@ -3,7 +3,6 @@ import Person from '../models/person.model';
import Relationship from '../models/relationship.model';
import { UserRequest } from '../types/express';
import { validationResult } from 'express-validator';
import mongoose from 'mongoose';
// Get all people in a network
export const getPeople = async (req: UserRequest, res: Response): Promise<void> => {

View File

@ -1,4 +1,4 @@
import { Request, Response, NextFunction } from 'express';
import { Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import User from '../models/user.model';
import { UserRequest } from '../types/express';

View File

@ -1,5 +1,10 @@
import mongoose, { Document, Schema } from 'mongoose';
export const RELATIONSHIP_TYPES = [
'acquaintance', 'friend', 'partner', 'family', 'secondDegree', 'colleague', 'teacher', 'exPartner', 'custom',
];
export interface IRelationship extends Document {
_id: string;
source: mongoose.Types.ObjectId;
@ -24,7 +29,7 @@ const RelationshipSchema = new Schema(
type: {
type: String,
required: [true, 'Relationship type is required'],
enum: ['freund', 'partner', 'familie', 'arbeitskolleg', 'custom'],
enum: RELATIONSHIP_TYPES,
},
customType: {
type: String,
@ -36,7 +41,7 @@ const RelationshipSchema = new Schema(
required: true,
},
},
{ timestamps: true }
{ timestamps: true },
);
// Create compound index to ensure unique relationships in a network

View File

@ -3,6 +3,8 @@ import { check } from 'express-validator';
import * as relationshipController from '../controllers/relationship.controller';
import { auth } from '../middleware/auth.middleware';
import { checkNetworkAccess } from '../middleware/network-access.middleware';
import { RELATIONSHIP_TYPES } from '../models/relationship.model';
const router = express.Router();
@ -22,13 +24,7 @@ router.post(
[
check('source', 'Source person ID is required').not().isEmpty().isMongoId(),
check('target', 'Target person ID is required').not().isEmpty().isMongoId(),
check('type', 'Relationship type is required').isIn([
'freund',
'partner',
'familie',
'arbeitskolleg',
'custom',
]),
check('type', 'Relationship type is required').isIn(RELATIONSHIP_TYPES),
check('customType', 'Custom type is required when type is custom')
.if(check('type').equals('custom'))
.not()
@ -45,7 +41,7 @@ router.put(
[
check('type', 'Relationship type must be valid if provided')
.optional()
.isIn(['freund', 'partner', 'familie', 'arbeitskolleg', 'custom']),
.isIn(RELATIONSHIP_TYPES),
check('customType', 'Custom type is required when type is custom')
.if(check('type').equals('custom'))
.not()

View File

@ -1,7 +1,6 @@
import { Request } from 'express';
import { IUser } from '../models/user.model';
import { INetwork } from '../models/network.model';
import { Document } from 'mongoose';
export interface UserRequest extends Request {
user?: IUser;