Compare commits

7 Commits

Author SHA1 Message Date
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
11 changed files with 214 additions and 447 deletions

View File

@ -44,6 +44,12 @@ 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/*
WORKDIR /app

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,27 +8,15 @@ 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;
}

View File

@ -35,9 +35,9 @@ interface CanvasGraphProps {
}
// 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
@ -573,9 +573,9 @@ const CanvasGraph: React.FC<CanvasGraphProps> = ({ data, width, height, zoomLeve
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);

View File

@ -47,81 +47,9 @@ import {
// Import visible canvas graph component
import CanvasGraph from './CanvasGraph';
import { getRelationshipColor, RELATIONSHIP_TYPES, RELATIONSHIPS } from '../types/RelationShipTypes';
import { FormErrors, PersonNode } from '../interfaces/IPersonNode';
// Define 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;
}
interface CanvasGraphProps {
data: {
nodes: {
id: string;
firstName: string;
lastName: string;
connectionCount: number;
bgColor: string;
x: number;
y: number;
showLabel: boolean;
}[];
edges: {
id: string;
source: string;
target: string;
color: string;
width: number;
type: RelationshipType;
customType?: string;
}[];
};
width: number;
height: number;
zoomLevel: number;
onNodeClick: (nodeId: string) => void;
onNodeDrag: (nodeId: string, x: number, y: number) => void;
}
// Type for form errors
interface FormErrors {
[key: string]: 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',
};
// Main FriendshipNetwork component
const FriendshipNetwork: React.FC = () => {
@ -143,10 +71,7 @@ const FriendshipNetwork: React.FC = () => {
createRelationship,
deleteRelationship,
refreshNetwork,
updatePersonPosition: updatePersonPositionImpl = (
id: string,
position: { x: number; y: number },
) => {
updatePersonPosition: updatePersonPositionImpl = (id: string, position: { x: number; y: number }) => {
console.warn('updatePersonPosition not implemented');
return Promise.resolve();
},
@ -172,8 +97,7 @@ const FriendshipNetwork: React.FC = () => {
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [helpModalOpen, setHelpModalOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState<{ type: string; id: string }>({
type: '',
id: '',
type: '', id: '',
});
// Form errors
@ -182,21 +106,13 @@ const FriendshipNetwork: React.FC = () => {
// Form states
const [newPerson, setNewPerson] = useState({
firstName: '',
lastName: '',
birthday: null as Date | null,
notes: '',
firstName: '', lastName: '', birthday: null as Date | null, notes: '',
});
const [editPerson, setEditPerson] = useState<PersonNode | null>(null);
const [newRelationship, setNewRelationship] = useState({
source: '',
target: '',
type: 'freund' as RelationshipType,
customType: '',
notes: '',
bidirectional: true,
source: '', target: '', type: 'friend' as RELATIONSHIP_TYPES, customType: '', notes: '', bidirectional: true,
});
// Filter states
@ -335,9 +251,7 @@ const FriendshipNetwork: React.FC = () => {
}, []);
// Filtered people and relationships
const filteredPeople = people.filter(person =>
`${person.firstName} ${person.lastName}`.toLowerCase().includes(peopleFilter.toLowerCase()),
);
const filteredPeople = people.filter(person => `${person.firstName} ${person.lastName}`.toLowerCase().includes(peopleFilter.toLowerCase()));
const filteredRelationships = relationships.filter(rel => {
const source = people.find(p => p._id === rel.source);
@ -345,8 +259,7 @@ const FriendshipNetwork: React.FC = () => {
if (!source || !target) return false;
const matchesFilter =
`${source.firstName} ${source.lastName} ${target.firstName} ${target.lastName}`
const matchesFilter = `${source.firstName} ${source.lastName} ${target.firstName} ${target.lastName}`
.toLowerCase()
.includes(relationshipFilter.toLowerCase());
@ -385,21 +298,17 @@ const FriendshipNetwork: React.FC = () => {
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),
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 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),
x: centerX + ringRadius * Math.cos(angle), y: centerY + ringRadius * Math.sin(angle),
};
} else {
const clusterCount = Math.max(3, Math.floor(Math.sqrt(totalNodes)));
@ -415,8 +324,7 @@ const FriendshipNetwork: React.FC = () => {
const randomDistance = Math.random() * clusterRadius;
return {
x: clusterX + randomDistance * Math.cos(randomAngle),
y: clusterY + randomDistance * Math.sin(randomAngle),
x: clusterX + randomDistance * Math.cos(randomAngle), y: clusterY + randomDistance * Math.sin(randomAngle),
};
}
}, [graphDimensions.width, graphDimensions.height, people.length]);
@ -424,24 +332,16 @@ const FriendshipNetwork: React.FC = () => {
// Transform API data to graph format
const getGraphData = useCallback(() => {
if (!people || !relationships) {
return { nodes: [], edges: [] };
return { nodes: [], edges: [], links: [] };
}
// Create nodes
const graphNodes = people.map(person => {
const connectionCount = relationships.filter(
r => r.source === person._id || r.target === person._id,
).length;
const connectionCount = relationships.filter(r => r.source === person._id || r.target === person._id).length;
// Determine if node should be highlighted
const isSelected = person._id === selectedPersonId;
const isConnected = selectedPersonId
? relationships.some(
r =>
(r.source === selectedPersonId && r.target === person._id) ||
(r.target === selectedPersonId && r.source === person._id),
)
: false;
const isConnected = selectedPersonId ? relationships.some(r => (r.source === selectedPersonId && r.target === person._id) || (r.target === selectedPersonId && r.source === person._id)) : false;
// Determine background color based on connection count or highlight state
let bgColor;
@ -475,27 +375,20 @@ const FriendshipNetwork: React.FC = () => {
// Create edges
const graphEdges = relationships.map(rel => {
const color = RELATIONSHIP_COLORS[rel.type] || RELATIONSHIP_COLORS.custom;
const color = RELATIONSHIPS[rel.type as RELATIONSHIP_TYPES]?.color || RELATIONSHIPS.custom.color;
const width = rel.type === 'partner' ? 4 : rel.type === 'familie' ? 3 : 2;
// Highlight edges connected to selected node
const isHighlighted =
selectedPersonId &&
settings.highlightConnections &&
(rel.source === selectedPersonId || rel.target === selectedPersonId);
const isHighlighted = selectedPersonId && settings.highlightConnections && (rel.source === selectedPersonId || rel.target === selectedPersonId);
return {
id: rel._id,
source: rel.source,
target: rel.target,
color: isHighlighted ? '#F472B6' : color, // Pink color for highlighted edges
id: rel._id, source: rel.source, target: rel.target, color: isHighlighted ? '#F472B6' : color, // Pink color for highlighted edges
width: isHighlighted ? width + 1 : width, // Slightly thicker for highlighted
type: rel.type,
customType: rel.customType,
type: rel.type, customType: rel.customType,
};
});
return { nodes: graphNodes, edges: graphEdges };
return { nodes: graphNodes, edges: graphEdges, links: [] };
}, [people, relationships, settings.showLabels, settings.highlightConnections, selectedPersonId]);
// Validate person form
@ -535,13 +428,7 @@ const FriendshipNetwork: React.FC = () => {
// Check if relationship already exists
if (relationship.source && relationship.target) {
const existingRelationship = relationships.find(
r =>
(r.source === relationship.source && r.target === relationship.target) ||
(relationship.bidirectional &&
r.source === relationship.target &&
r.target === relationship.source),
);
const existingRelationship = relationships.find(r => (r.source === relationship.source && r.target === relationship.target) || (relationship.bidirectional && r.source === relationship.target && r.target === relationship.source));
if (existingRelationship) {
errors.general = 'This relationship already exists';
@ -573,10 +460,7 @@ const FriendshipNetwork: React.FC = () => {
// Reset form and close modal
setNewPerson({
firstName: '',
lastName: '',
birthday: null,
notes: '',
firstName: '', lastName: '', birthday: null, notes: '',
});
setPersonModalOpen(false);
@ -619,32 +503,19 @@ const FriendshipNetwork: React.FC = () => {
// Create the relationship
createRelationship({
source,
target,
type,
customType: type === 'custom' ? customType : undefined,
notes,
source, target, type, customType: type === 'custom' ? customType : undefined, notes,
});
// Create bidirectional relationship if selected
if (bidirectional && source !== target) {
createRelationship({
source: target,
target: source,
type,
customType: type === 'custom' ? customType : undefined,
notes,
source: target, target: source, type, customType: type === 'custom' ? customType : undefined, notes,
});
}
// Reset form and close modal
setNewRelationship({
source: '',
target: '',
type: 'freund',
customType: '',
notes: '',
bidirectional: true,
source: '', target: '', type: 'friend', customType: '', notes: '', bidirectional: true,
});
setRelationshipModalOpen(false);
@ -725,21 +596,18 @@ const FriendshipNetwork: React.FC = () => {
// Loading state
if (loading) {
return (
<div className="flex justify-center items-center h-screen bg-slate-900">
return (<div className="flex justify-center items-center h-screen bg-slate-900">
<div className="flex flex-col items-center space-y-4">
<div
className="w-16 h-16 border-t-4 border-b-4 border-indigo-500 border-solid rounded-full animate-spin"></div>
<p className="text-white text-lg">Loading your network...</p>
</div>
</div>
);
</div>);
}
// Error state
if (error) {
return (
<div className="flex justify-center items-center h-screen bg-slate-900">
return (<div className="flex justify-center items-center h-screen bg-slate-900">
<div className="bg-red-500/20 border border-red-500 text-white p-6 rounded-lg shadow-lg max-w-md">
<h3 className="text-lg font-bold mb-3 flex items-center">
<FaExclamationTriangle className="mr-2 text-red-500" /> Error
@ -754,15 +622,13 @@ const FriendshipNetwork: React.FC = () => {
Back to Networks
</Button>
</div>
</div>
);
</div>);
}
// Generate graph data
const graphData = getGraphData();
return (
<div className="flex h-screen bg-slate-900 text-white overflow-hidden">
return (<div className="flex h-screen bg-slate-900 text-white overflow-hidden">
{/* Sidebar Toggle Button */}
<button
onClick={toggleSidebar}
@ -832,31 +698,19 @@ const FriendshipNetwork: React.FC = () => {
{/* Sidebar Tabs */}
<div className="flex border-b border-slate-700 mb-4">
<button
className={`flex-1 py-2 font-medium flex items-center justify-center ${
sidebarTab === 'overview'
? 'text-indigo-400 border-b-2 border-indigo-400'
: 'text-slate-400 hover:text-slate-300'
}`}
className={`flex-1 py-2 font-medium flex items-center justify-center ${sidebarTab === 'overview' ? 'text-indigo-400 border-b-2 border-indigo-400' : 'text-slate-400 hover:text-slate-300'}`}
onClick={() => setSidebarTab('overview')}
>
<FaInfo className="mr-2" /> Overview
</button>
<button
className={`flex-1 py-2 font-medium flex items-center justify-center ${
sidebarTab === 'people'
? 'text-indigo-400 border-b-2 border-indigo-400'
: 'text-slate-400 hover:text-slate-300'
}`}
className={`flex-1 py-2 font-medium flex items-center justify-center ${sidebarTab === 'people' ? 'text-indigo-400 border-b-2 border-indigo-400' : 'text-slate-400 hover:text-slate-300'}`}
onClick={() => setSidebarTab('people')}
>
<FaUserCircle className="mr-2" /> People
</button>
<button
className={`flex-1 py-2 font-medium flex items-center justify-center ${
sidebarTab === 'relations'
? 'text-indigo-400 border-b-2 border-indigo-400'
: 'text-slate-400 hover:text-slate-300'
}`}
className={`flex-1 py-2 font-medium flex items-center justify-center ${sidebarTab === 'relations' ? 'text-indigo-400 border-b-2 border-indigo-400' : 'text-slate-400 hover:text-slate-300'}`}
onClick={() => setSidebarTab('relations')}
>
<FaUserFriends className="mr-2" /> Relations
@ -864,8 +718,7 @@ const FriendshipNetwork: React.FC = () => {
</div>
{/* Tab Content */}
{sidebarTab === 'overview' && (
<div className="space-y-4">
{sidebarTab === 'overview' && (<div className="space-y-4">
<Card>
<CardBody>
<h3 className="font-medium mb-2 text-indigo-400">About This Network</h3>
@ -898,17 +751,16 @@ const FriendshipNetwork: React.FC = () => {
<CardBody>
<h3 className="font-medium mb-2 text-indigo-400">Legend</h3>
<div className="space-y-2">
{Object.entries(RELATIONSHIP_COLORS).map(([type, color]) => (
{Object.entries(RELATIONSHIPS).map(([type, { label, color }]) => (
<div key={type} className="flex items-center text-sm">
<div
className="w-4 h-4 rounded-full mr-2"
style={{ backgroundColor: color }}
></div>
<span className="capitalize">
{RELATIONSHIP_LABELS[type as RelationshipType]}
{RELATIONSHIPS[type]?.label}
</span>
</div>
))}
</div>))}
</div>
</CardBody>
</Card>
@ -931,11 +783,9 @@ const FriendshipNetwork: React.FC = () => {
Help
</Button>
</div>
</div>
)}
</div>)}
{sidebarTab === 'people' && (
<div>
{sidebarTab === 'people' && (<div>
<div className="flex items-center mb-3">
<div className="relative flex-1">
<input
@ -951,23 +801,13 @@ const FriendshipNetwork: React.FC = () => {
</div>
<div className="space-y-2 max-h-[calc(100vh-350px)] overflow-y-auto pr-1">
{sortedPeople.length > 0 ? (
sortedPeople.map(person => {
const connectionCount = relationships.filter(
r => r.source === person._id || r.target === person._id,
).length;
{sortedPeople.length > 0 ? (sortedPeople.map(person => {
const connectionCount = relationships.filter(r => r.source === person._id || r.target === person._id).length;
return (
<div
return (<div
key={person._id}
className={`bg-slate-700 rounded-lg p-3 group hover:bg-slate-600 transition-colors
cursor-pointer border-l-4 ${
selectedPersonId === person._id
? 'border-l-pink-500'
: connectionCount > 0
? 'border-l-indigo-500'
: 'border-l-slate-700'
}`}
cursor-pointer border-l-4 ${selectedPersonId === person._id ? 'border-l-pink-500' : connectionCount > 0 ? 'border-l-indigo-500' : 'border-l-slate-700'}`}
onClick={() => {
openPersonDetail(person);
setSelectedPersonId(person._id);
@ -1013,38 +853,24 @@ const FriendshipNetwork: React.FC = () => {
</Tooltip>
</div>
</div>
</div>
);
})
) : (
<EmptyState
</div>);
})) : (<EmptyState
title={peopleFilter ? 'No matches found' : 'No people yet'}
description={
peopleFilter
? 'Try adjusting your search criteria'
: 'Add people to start building your network'
}
description={peopleFilter ? 'Try adjusting your search criteria' : 'Add people to start building your network'}
icon={<FaUserCircle className="text-2xl text-slate-400" />}
action={
!peopleFilter && (
<Button
action={!peopleFilter && (<Button
variant="primary"
size="sm"
onClick={() => setPersonModalOpen(true)}
icon={<FaUserPlus />}
>
Add Person
</Button>
)
}
/>
)}
</Button>)}
/>)}
</div>
</div>
)}
</div>)}
{sidebarTab === 'relations' && (
<div>
{sidebarTab === 'relations' && (<div>
<div className="flex items-center mb-3">
<div className="relative flex-1">
<input
@ -1061,52 +887,36 @@ const FriendshipNetwork: React.FC = () => {
<div className="flex mb-3 overflow-x-auto pb-2 space-x-1">
<button
className={`px-3 py-1 text-xs rounded-full whitespace-nowrap ${
relationshipTypeFilter === 'all'
? 'bg-indigo-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
className={`px-3 py-1 text-xs rounded-full whitespace-nowrap ${relationshipTypeFilter === 'all' ? 'bg-indigo-600 text-white' : 'bg-slate-700 text-slate-300 hover:bg-slate-600'}`}
onClick={() => setRelationshipTypeFilter('all')}
>
All Types
</button>
{Object.entries(RELATIONSHIP_COLORS).map(([type, color]) => (
<button
{Object.entries(RELATIONSHIPS).map(([type, { label, color }]) => (<button
key={type}
className={`px-3 py-1 text-xs rounded-full whitespace-nowrap flex items-center ${
relationshipTypeFilter === type
? 'bg-indigo-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
onClick={() => setRelationshipTypeFilter(type as RelationshipType)}
className={`px-3 py-1 text-xs rounded-full whitespace-nowrap flex items-center ${relationshipTypeFilter === type ? 'bg-indigo-600 text-white' : 'bg-slate-700 text-slate-300 hover:bg-slate-600'}`}
onClick={() => setRelationshipTypeFilter(type as RELATIONSHIP_TYPES)}
>
<span
className="w-2 h-2 rounded-full mr-1"
style={{ backgroundColor: color }}
></span>
<span className="capitalize">
{RELATIONSHIP_LABELS[type as RelationshipType]}
{RELATIONSHIPS[type as RELATIONSHIP_TYPES]?.label}
</span>
</button>
))}
</button>))}
</div>
<div className="space-y-2 max-h-[calc(100vh-390px)] overflow-y-auto pr-1">
{filteredRelationships.length > 0 ? (
filteredRelationships.map(rel => {
{filteredRelationships.length > 0 ? (filteredRelationships.map(rel => {
const source = people.find(p => p._id === rel.source);
const target = people.find(p => p._id === rel.target);
if (!source || !target) return null;
return (
<div
return (<div
key={rel._id}
className={`bg-slate-700 rounded-lg p-3 group hover:bg-slate-600 transition-colors
border-l-4 ${
selectedPersonId === rel.source || selectedPersonId === rel.target
? 'border-l-pink-500'
: 'border-l-slate-700'
}`}
border-l-4 ${selectedPersonId === rel.source || selectedPersonId === rel.target ? 'border-l-pink-500' : 'border-l-slate-700'}`}
>
<div className="flex justify-between items-center">
<div>
@ -1137,12 +947,10 @@ const FriendshipNetwork: React.FC = () => {
<div className="flex items-center text-xs text-slate-400 mt-1">
<span
className="inline-block w-2 h-2 rounded-full mr-1"
style={{ backgroundColor: RELATIONSHIP_COLORS[rel.type] }}
style={{ backgroundColor: RELATIONSHIPS[rel.type as RELATIONSHIP_TYPES]?.color }}
></span>
<span className="capitalize">
{rel.type === 'custom'
? rel.customType
: RELATIONSHIP_LABELS[rel.type]}
{rel.type === 'custom' ? rel.customType : RELATIONSHIPS[rel.type as RELATIONSHIP_TYPES]?.label}
</span>
</div>
</div>
@ -1157,40 +965,22 @@ const FriendshipNetwork: React.FC = () => {
</Tooltip>
</div>
</div>
</div>
);
})
) : (
<EmptyState
title={
relationshipFilter || relationshipTypeFilter !== 'all'
? 'No matches found'
: 'No relationships yet'
}
description={
relationshipFilter || relationshipTypeFilter !== 'all'
? 'Try adjusting your search criteria'
: 'Create relationships between people to visualize connections'
}
</div>);
})) : (<EmptyState
title={relationshipFilter || relationshipTypeFilter !== 'all' ? 'No matches found' : 'No relationships yet'}
description={relationshipFilter || relationshipTypeFilter !== 'all' ? 'Try adjusting your search criteria' : 'Create relationships between people to visualize connections'}
icon={<FaUserFriends className="text-2xl text-slate-400" />}
action={
!relationshipFilter &&
relationshipTypeFilter === 'all' && (
<Button
action={!relationshipFilter && relationshipTypeFilter === 'all' && (<Button
variant="primary"
size="sm"
onClick={() => setRelationshipModalOpen(true)}
icon={<FaUserFriends />}
>
Add Relationship
</Button>
)
}
/>
)}
</Button>)}
/>)}
</div>
</div>
)}
</div>)}
</div>
</Transition>
</div>
@ -1200,9 +990,7 @@ const FriendshipNetwork: React.FC = () => {
{graphDimensions.width <= 0 || graphDimensions.height <= 0 ? (
<div className="w-full h-full flex justify-center items-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-500"></div>
</div>
) : (
<CanvasGraph
</div>) : (<CanvasGraph
data={graphData}
width={graphDimensions.width}
height={graphDimensions.height}
@ -1211,8 +999,7 @@ const FriendshipNetwork: React.FC = () => {
onNodeDrag={(nodeId, x, y) => {
updatePersonPosition(nodeId, { x, y }).then();
}}
/>
)}
/>)}
{/* Empty state overlay */}
{people.length === 0 && (
@ -1234,12 +1021,10 @@ const FriendshipNetwork: React.FC = () => {
Add Your First Person
</Button>
</div>
</div>
)}
</div>)}
{/* Interaction hint */}
{people.length > 0 && interactionHint && (
<div
{people.length > 0 && interactionHint && (<div
className="absolute bottom-20 left-1/2 transform -translate-x-1/2 bg-indigo-900/90
text-white px-4 py-2 rounded-lg shadow-lg text-sm flex items-center space-x-2 animate-pulse"
>
@ -1251,8 +1036,7 @@ const FriendshipNetwork: React.FC = () => {
>
<FaTimes />
</button>
</div>
)}
</div>)}
{/* Graph controls */}
<div className="absolute bottom-6 right-6 flex flex-col space-y-3">
@ -1330,8 +1114,7 @@ const FriendshipNetwork: React.FC = () => {
{personFormErrors.general && (
<div className="bg-red-500/20 border border-red-500 text-white p-3 rounded-lg text-sm mb-4">
{personFormErrors.general}
</div>
)}
</div>)}
<FormField label="First Name" id="firstName" required error={personFormErrors.firstName}>
<input
@ -1340,6 +1123,7 @@ const FriendshipNetwork: React.FC = () => {
className={`w-full bg-slate-700 border ${personFormErrors.firstName ? 'border-red-500' : 'border-slate-600'}
rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white`}
placeholder="Enter first name"
autoFocus={true}
value={newPerson.firstName}
onChange={e => setNewPerson({ ...newPerson, firstName: e.target.value })}
/>
@ -1363,7 +1147,7 @@ const FriendshipNetwork: React.FC = () => {
id="birthday"
selected={newPerson.birthday}
onChange={date => setNewPerson({ ...newPerson, birthday: date })}
dateFormat="MMMM d, yyyy"
dateFormat="dd.MM.yyyy"
placeholderText="Select birthday"
className="w-full bg-slate-700 border border-slate-600 rounded-md p-2
focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
@ -1416,8 +1200,7 @@ const FriendshipNetwork: React.FC = () => {
{relationshipFormErrors.general && (
<div className="bg-red-500/20 border border-red-500 text-white p-3 rounded-lg text-sm mb-4">
{relationshipFormErrors.general}
</div>
)}
</div>)}
<FormField
label="Source Person"
@ -1427,17 +1210,16 @@ const FriendshipNetwork: React.FC = () => {
>
<select
id="source"
autoFocus={true}
className={`w-full bg-slate-700 border ${relationshipFormErrors.source ? 'border-red-500' : 'border-slate-600'}
rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white`}
value={newRelationship.source}
onChange={e => setNewRelationship({ ...newRelationship, source: e.target.value })}
>
<option value="">Select person</option>
{sortedPeople.map(person => (
<option key={`source-${person._id}`} value={person._id}>
{sortedPeople.map(person => (<option key={`source-${person._id}`} value={person._id}>
{person.firstName} {person.lastName}
</option>
))}
</option>))}
</select>
</FormField>
@ -1455,11 +1237,9 @@ const FriendshipNetwork: React.FC = () => {
onChange={e => setNewRelationship({ ...newRelationship, target: e.target.value })}
>
<option value="">Select person</option>
{sortedPeople.map(person => (
<option key={`target-${person._id}`} value={person._id}>
{sortedPeople.map(person => (<option key={`target-${person._id}`} value={person._id}>
{person.firstName} {person.lastName}
</option>
))}
</option>))}
</select>
</FormField>
@ -1469,23 +1249,17 @@ const FriendshipNetwork: React.FC = () => {
className="w-full bg-slate-700 border border-slate-600 rounded-md p-2
focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
value={newRelationship.type}
onChange={e =>
setNewRelationship({
...newRelationship,
type: e.target.value as RelationshipType,
})
}
onChange={e => setNewRelationship({
...newRelationship, type: e.target.value as RELATIONSHIP_TYPES,
})}
>
{Object.entries(RELATIONSHIP_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{Object.entries(RELATIONSHIPS).map(([value, { label }]) => (<option key={value} value={value}>
{label}
</option>
))}
</option>))}
</select>
</FormField>
{newRelationship.type === 'custom' && (
<FormField
{newRelationship.type === 'custom' && (<FormField
label="Custom Type"
id="customType"
required
@ -1498,15 +1272,11 @@ const FriendshipNetwork: React.FC = () => {
rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white`}
placeholder="Enter custom relationship type"
value={newRelationship.customType}
onChange={e =>
setNewRelationship({
...newRelationship,
customType: e.target.value,
})
}
onChange={e => setNewRelationship({
...newRelationship, customType: e.target.value,
})}
/>
</FormField>
)}
</FormField>)}
<FormField label="Notes (Optional)" id="relationNotes">
<textarea
@ -1525,12 +1295,9 @@ const FriendshipNetwork: React.FC = () => {
id="bidirectional"
className="h-4 w-4 rounded border-gray-500 text-indigo-600 focus:ring-indigo-500 bg-slate-700"
checked={newRelationship.bidirectional}
onChange={e =>
setNewRelationship({
...newRelationship,
bidirectional: e.target.checked,
})
}
onChange={e => setNewRelationship({
...newRelationship, bidirectional: e.target.checked,
})}
/>
<label htmlFor="bidirectional" className="ml-2 block text-sm text-gray-300">
Create bidirectional relationship (recommended)
@ -1555,8 +1322,7 @@ const FriendshipNetwork: React.FC = () => {
</Modal>
{/* Person Detail Modal */}
{editPerson && (
<Modal
{editPerson && (<Modal
isOpen={personDetailModalOpen}
onClose={() => {
setPersonDetailModalOpen(false);
@ -1571,8 +1337,7 @@ const FriendshipNetwork: React.FC = () => {
{personFormErrors.general && (
<div className="bg-red-500/20 border border-red-500 text-white p-3 rounded-lg text-sm mb-4">
{personFormErrors.general}
</div>
)}
</div>)}
<FormField
label="First Name"
@ -1612,7 +1377,7 @@ const FriendshipNetwork: React.FC = () => {
id="editBirthday"
selected={editPerson.birthday ? new Date(editPerson.birthday) : null}
onChange={date => setEditPerson({ ...editPerson, birthday: date })}
dateFormat="MMMM d, yyyy"
dateFormat="dd.MM.yyyy"
placeholderText="Select birthday"
className="w-full bg-slate-700 border border-slate-600 rounded-md p-2
focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white"
@ -1668,10 +1433,7 @@ const FriendshipNetwork: React.FC = () => {
<div>
<h4 className="font-medium text-indigo-400 mb-2">Connections</h4>
<div className="max-h-40 overflow-y-auto space-y-1 bg-slate-900 rounded-lg p-2">
{relationships.filter(
r => r.source === editPerson._id || r.target === editPerson._id,
).length > 0 ? (
relationships
{relationships.filter(r => r.source === editPerson._id || r.target === editPerson._id).length > 0 ? (relationships
.filter(r => r.source === editPerson._id || r.target === editPerson._id)
.map(rel => {
const isSource = rel.source === editPerson._id;
@ -1680,15 +1442,14 @@ const FriendshipNetwork: React.FC = () => {
if (!otherPerson) return null;
return (
<div
return (<div
key={rel._id}
className="flex justify-between items-center py-1 px-2 hover:bg-slate-800 rounded"
>
<div className="flex items-center">
<span
className="inline-block w-2 h-2 rounded-full mr-2"
style={{ backgroundColor: RELATIONSHIP_COLORS[rel.type] }}
style={{ backgroundColor: RELATIONSHIPS[rel.type as RELATIONSHIP_TYPES]?.color }}
></span>
<span className="text-sm">
{isSource ? 'To: ' : 'From: '}
@ -1707,7 +1468,7 @@ const FriendshipNetwork: React.FC = () => {
</span>
{rel.type === 'custom'
? ` (${rel.customType})`
: ` (${RELATIONSHIP_LABELS[rel.type]})`}
: ` (${RELATIONSHIPS[rel.type as RELATIONSHIP_TYPES]?.label})`}
</span>
</div>
<button
@ -1716,12 +1477,8 @@ const FriendshipNetwork: React.FC = () => {
>
<FaTrash size={12} />
</button>
</div>
);
})
) : (
<div className="text-center py-2 text-slate-400 text-sm">No connections yet</div>
)}
</div>);
})) : (<div className="text-center py-2 text-slate-400 text-sm">No connections yet</div>)}
</div>
<div className="mt-3 flex justify-center">
<Button
@ -1729,8 +1486,7 @@ const FriendshipNetwork: React.FC = () => {
size="sm"
onClick={() => {
setNewRelationship({
...newRelationship,
source: editPerson._id,
...newRelationship, source: editPerson._id,
});
setPersonDetailModalOpen(false);
setTimeout(() => setRelationshipModalOpen(true), 100);
@ -1742,8 +1498,7 @@ const FriendshipNetwork: React.FC = () => {
</div>
</div>
</div>
</Modal>
)}
</Modal>)}
{/* Settings Modal */}
<Modal
@ -1765,9 +1520,7 @@ const FriendshipNetwork: React.FC = () => {
/>
<div className="block h-6 bg-slate-700 rounded-full w-12"></div>
<div
className={`absolute left-1 top-1 w-4 h-4 rounded-full transition-transform ${
settings.showLabels ? 'transform translate-x-6 bg-indigo-500' : 'bg-gray-400'
}`}
className={`absolute left-1 top-1 w-4 h-4 rounded-full transition-transform ${settings.showLabels ? 'transform translate-x-6 bg-indigo-500' : 'bg-gray-400'}`}
></div>
</div>
</div>
@ -1785,9 +1538,7 @@ const FriendshipNetwork: React.FC = () => {
/>
<div className="block h-6 bg-slate-700 rounded-full w-12"></div>
<div
className={`absolute left-1 top-1 w-4 h-4 rounded-full transition-transform ${
settings.autoLayout ? 'transform translate-x-6 bg-indigo-500' : 'bg-gray-400'
}`}
className={`absolute left-1 top-1 w-4 h-4 rounded-full transition-transform ${settings.autoLayout ? 'transform translate-x-6 bg-indigo-500' : 'bg-gray-400'}`}
></div>
</div>
</div>
@ -1801,17 +1552,11 @@ const FriendshipNetwork: React.FC = () => {
name="highlightConnections"
className="sr-only"
checked={settings.highlightConnections}
onChange={() =>
setSettings({ ...settings, highlightConnections: !settings.highlightConnections })
}
onChange={() => setSettings({ ...settings, highlightConnections: !settings.highlightConnections })}
/>
<div className="block h-6 bg-slate-700 rounded-full w-12"></div>
<div
className={`absolute left-1 top-1 w-4 h-4 rounded-full transition-transform ${
settings.highlightConnections
? 'transform translate-x-6 bg-indigo-500'
: 'bg-gray-400'
}`}
className={`absolute left-1 top-1 w-4 h-4 rounded-full transition-transform ${settings.highlightConnections ? 'transform translate-x-6 bg-indigo-500' : 'bg-gray-400'}`}
></div>
</div>
</div>
@ -1819,38 +1564,26 @@ const FriendshipNetwork: React.FC = () => {
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Animation Speed</label>
<div className="flex space-x-2">
{['slow', 'medium', 'fast'].map(speed => (
<button
{['slow', 'medium', 'fast'].map(speed => (<button
key={speed}
className={`flex-1 py-2 px-3 rounded-md text-sm ${
settings.animationSpeed === speed
? 'bg-indigo-600 text-white'
: 'bg-slate-700 text-gray-300 hover:bg-slate-600'
}`}
className={`flex-1 py-2 px-3 rounded-md text-sm ${settings.animationSpeed === speed ? 'bg-indigo-600 text-white' : 'bg-slate-700 text-gray-300 hover:bg-slate-600'}`}
onClick={() => setSettings({ ...settings, animationSpeed: speed })}
>
{speed.charAt(0).toUpperCase() + speed.slice(1)}
</button>
))}
</button>))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Node Size</label>
<div className="flex space-x-2">
{['small', 'medium', 'large'].map(size => (
<button
{['small', 'medium', 'large'].map(size => (<button
key={size}
className={`flex-1 py-2 px-3 rounded-md text-sm ${
settings.nodeSize === size
? 'bg-indigo-600 text-white'
: 'bg-slate-700 text-gray-300 hover:bg-slate-600'
}`}
className={`flex-1 py-2 px-3 rounded-md text-sm ${settings.nodeSize === size ? 'bg-indigo-600 text-white' : 'bg-slate-700 text-gray-300 hover:bg-slate-600'}`}
onClick={() => setSettings({ ...settings, nodeSize: size })}
>
{size.charAt(0).toUpperCase() + size.slice(1)}
</button>
))}
</button>))}
</div>
</div>
@ -1964,28 +1697,21 @@ const FriendshipNetwork: React.FC = () => {
onClose={() => setDeleteConfirmOpen(false)}
onConfirm={executeDelete}
title="Confirm Deletion"
message={
itemToDelete.type === 'person'
? 'Are you sure you want to delete this person? This will also remove all their relationships.'
: 'Are you sure you want to delete this relationship?'
}
message={itemToDelete.type === 'person' ? 'Are you sure you want to delete this person? This will also remove all their relationships.' : 'Are you sure you want to delete this relationship?'}
confirmText="Delete"
variant="danger"
/>
{/* Toast Notifications */}
<div className="fixed bottom-4 right-4 z-[9900] space-y-2 pointer-events-none">
{toasts.map(toast => (
<Toast
{toasts.map(toast => (<Toast
key={toast.id}
message={toast.message}
type={toast.type as any}
onClose={() => removeToast(toast.id)}
/>
))}
/>))}
</div>
</div>
);
</div>);
};
export default FriendshipNetwork;

View File

@ -3,10 +3,11 @@ import { addPerson, getPeople, Person, removePerson, updatePerson } from '../api
import {
addRelationship,
getRelationships,
Relationship,
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
@ -314,7 +315,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');
@ -342,7 +343,7 @@ export const useFriendshipNetwork = (
const updateRelationshipData = async (
relationshipId: string,
relationshipData: {
type?: 'freund' | 'partner' | 'familie' | 'arbeitskolleg' | 'custom';
type?: RELATIONSHIP_TYPES;
customType?: string;
},
): Promise<RelationshipEdge> => {

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

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

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

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