import React, { useState, useCallback, useRef, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useFriendshipNetwork } from '../hooks/useFriendshipNetwork'; import { useNetworks } from '../context/NetworkContext'; import DatePicker from 'react-datepicker'; import 'react-datepicker/dist/react-datepicker.css'; import { Transition } from '@headlessui/react'; import { FaUserPlus, FaUserFriends, FaTrash, FaEdit, FaRedo, FaCompress, FaSearchPlus, FaSearchMinus, FaTimes, FaChevronLeft, FaChevronRight, FaRegCalendarAlt, FaInfo, FaFilter, FaPalette, FaSave, FaUserCircle, FaSearch, FaCog, FaHome, FaArrowLeft, FaNetworkWired, FaPlus, FaStar, FaExclamationTriangle, } from 'react-icons/fa'; // Import custom UI components import { Tooltip, Modal, ConfirmDialog, NetworkStats, Toast, Button, FormField, Badge, EmptyState, Card, CardBody, ToastItem, } from './FriendshipNetworkComponents'; // Import visible canvas graph component import CanvasGraph from './CanvasGraph'; // 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 = () => { const { id } = useParams<{ id: string }>(); const { networks } = useNetworks(); const navigate = useNavigate(); const graphContainerRef = useRef(null); const [graphDimensions, setGraphDimensions] = useState({ width: 0, height: 0 }); // Network data state from custom hook const { people, relationships, loading, error, createPerson, updatePerson, deletePerson, createRelationship, deleteRelationship, refreshNetwork, updatePersonPosition: updatePersonPositionImpl = ( id: string, position: { x: number; y: number } ) => { console.warn('updatePersonPosition not implemented'); return Promise.resolve(); }, } = 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); }; // Local UI state const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarTab, setSidebarTab] = useState('overview'); const [zoomLevel, setZoomLevel] = useState(1); const [toasts, setToasts] = useState([]); const [interactionHint, setInteractionHint] = useState(true); // Modal states const [personModalOpen, setPersonModalOpen] = useState(false); const [relationshipModalOpen, setRelationshipModalOpen] = useState(false); const [personDetailModalOpen, setPersonDetailModalOpen] = useState(false); const [settingsModalOpen, setSettingsModalOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [helpModalOpen, setHelpModalOpen] = useState(false); const [itemToDelete, setItemToDelete] = useState<{ type: string; id: string }>({ type: '', id: '', }); // Form errors const [personFormErrors, setPersonFormErrors] = useState({}); const [relationshipFormErrors, setRelationshipFormErrors] = useState({}); // Form states const [newPerson, setNewPerson] = useState({ firstName: '', lastName: '', birthday: null as Date | null, notes: '', }); const [editPerson, setEditPerson] = useState(null); const [newRelationship, setNewRelationship] = useState({ source: '', target: '', type: 'freund' as RelationshipType, customType: '', notes: '', bidirectional: true, }); // Filter states const [peopleFilter, setPeopleFilter] = useState(''); const [relationshipFilter, setRelationshipFilter] = useState(''); const [relationshipTypeFilter, setRelationshipTypeFilter] = useState('all'); // Settings state const [settings, setSettings] = useState({ darkMode: true, autoLayout: true, showLabels: true, animationSpeed: 'medium', highlightConnections: true, nodeSize: 'medium', }); // Selected person state for highlighting const [selectedPersonId, setSelectedPersonId] = useState(null); // Get current network info const currentNetwork = networks.find(network => network._id === id); // Effect for graph container dimensions 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]); // Dismiss interaction hint after 10 seconds useEffect(() => { if (interactionHint) { const timer = setTimeout(() => { setInteractionHint(false); }, 10000); return () => clearTimeout(timer); } }, [interactionHint]); // Keyboard shortcuts useEffect(() => { 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); }, []); // Filtered people and relationships const filteredPeople = people.filter(person => `${person.firstName} ${person.lastName}`.toLowerCase().includes(peopleFilter.toLowerCase()) ); const filteredRelationships = relationships.filter(rel => { const source = people.find(p => p._id === rel.source); const target = people.find(p => p._id === rel.target); if (!source || !target) return false; const matchesFilter = `${source.firstName} ${source.lastName} ${target.firstName} ${target.lastName}` .toLowerCase() .includes(relationshipFilter.toLowerCase()); const matchesType = relationshipTypeFilter === 'all' || rel.type === relationshipTypeFilter; return matchesFilter && matchesType; }); // Add toast notification const addToast = (message: string, type: 'error' | 'success' | 'warning' | 'info' = 'success') => { const id = Date.now(); setToasts(prevToasts => [...prevToasts, { id, message, type, onClose: () => removeToast(id) }]); // Auto-remove after 3 seconds setTimeout(() => { removeToast(id); }, 3000); }; // Remove toast notification const removeToast = (id: number) => { setToasts(prevToasts => prevToasts.filter(toast => toast.id !== id)); }; // Smart node placement for new people const getSmartNodePosition = useCallback(() => { const centerX = graphDimensions.width / 2; const centerY = graphDimensions.height / 2; const maxRadius = Math.min(graphDimensions.width, graphDimensions.height) * 0.4; const totalNodes = people.length; 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), }; } }, [graphDimensions.width, graphDimensions.height, people.length]); // Transform API data to graph format const getGraphData = useCallback(() => { if (!people || !relationships) { return { nodes: [], edges: [] }; } // Create nodes const graphNodes = people.map(person => { 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; // Determine background color based on connection count or highlight state let bgColor; if (isSelected) { bgColor = '#F472B6'; // Pink-400 for selected } else if (isConnected && settings.highlightConnections) { bgColor = '#A78BFA'; // Violet-400 for connected } else if (connectionCount === 0) { bgColor = '#94A3B8'; // Slate-400 } else if (connectionCount === 1) { bgColor = '#38BDF8'; // Sky-400 } else if (connectionCount <= 3) { bgColor = '#818CF8'; // Indigo-400 } else if (connectionCount <= 5) { bgColor = '#A78BFA'; // Violet-400 } else { bgColor = '#F472B6'; // Pink-400 } return { id: person._id, firstName: person.firstName, lastName: person.lastName, connectionCount, bgColor, x: person.position?.x || 0, y: person.position?.y || 0, showLabel: settings.showLabels, }; }); // Create edges const graphEdges = relationships.map(rel => { const color = RELATIONSHIP_COLORS[rel.type] || RELATIONSHIP_COLORS.custom; 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); return { 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, }; }); return { nodes: graphNodes, edges: graphEdges }; }, [people, relationships, settings.showLabels, settings.highlightConnections, selectedPersonId]); // Validate person form const validatePersonForm = (person: typeof newPerson): FormErrors => { const errors: FormErrors = {}; if (!person.firstName.trim()) { errors.firstName = 'First name is required'; } if (!person.lastName.trim()) { errors.lastName = 'Last name is required'; } return errors; }; // Validate relationship form const validateRelationshipForm = (relationship: typeof newRelationship): FormErrors => { const errors: FormErrors = {}; if (!relationship.source) { errors.source = 'Source person is required'; } if (!relationship.target) { errors.target = 'Target person is required'; } if (relationship.source === relationship.target) { errors.target = 'Source and target cannot be the same person'; } if (relationship.type === 'custom' && !relationship.customType.trim()) { errors.customType = 'Custom relationship type is required'; } // Check if relationship already exists if (relationship.source && relationship.target) { const existingRelationship = 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'; } } return errors; }; // Handle person form submission const handlePersonSubmit = (e: React.FormEvent) => { e.preventDefault(); const errors = validatePersonForm(newPerson); setPersonFormErrors(errors); if (Object.keys(errors).length > 0) return; // Create person with smart positioning const position = getSmartNodePosition(); createPerson({ firstName: newPerson.firstName.trim(), lastName: newPerson.lastName.trim(), birthday: newPerson.birthday?.toISOString() || undefined, notes: newPerson.notes, position, }); // Reset form and close modal setNewPerson({ firstName: '', lastName: '', birthday: null, notes: '', }); setPersonModalOpen(false); addToast('Person added successfully'); }; // Handle person update const handleUpdatePerson = (e: React.FormEvent) => { e.preventDefault(); if (!editPerson) return; const errors = validatePersonForm(editPerson as any); setPersonFormErrors(errors); if (Object.keys(errors).length > 0) return; updatePerson(editPerson._id, { firstName: editPerson.firstName, lastName: editPerson.lastName, birthday: editPerson.birthday ? new Date(editPerson.birthday).toISOString() : undefined, notes: editPerson.notes, }); setEditPerson(null); setPersonDetailModalOpen(false); addToast('Person updated successfully'); }; // Handle relationship form submission const handleRelationshipSubmit = (e: React.FormEvent) => { e.preventDefault(); const errors = validateRelationshipForm(newRelationship); setRelationshipFormErrors(errors); if (Object.keys(errors).length > 0) return; const { source, target, type, customType, notes, bidirectional } = newRelationship; // Create the relationship createRelationship({ 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, }); } // Reset form and close modal setNewRelationship({ source: '', target: '', type: 'freund', customType: '', notes: '', bidirectional: true, }); setRelationshipModalOpen(false); addToast(`Relationship${bidirectional ? 's' : ''} created successfully`); }; // Handle deletion confirmation const confirmDelete = (type: string, id: string) => { setItemToDelete({ type, id }); setDeleteConfirmOpen(true); }; // Execute deletion const executeDelete = () => { const { type, id } = itemToDelete; if (type === 'person') { deletePerson(id); addToast('Person deleted'); } else if (type === 'relationship') { deleteRelationship(id); addToast('Relationship deleted'); } }; // Open person detail modal const openPersonDetail = (person: PersonNode) => { setEditPerson({ ...person }); setPersonDetailModalOpen(true); }; // Handle zoom controls const handleZoomIn = () => { setZoomLevel(prev => Math.min(prev + 0.2, 2.5)); }; const handleZoomOut = () => { setZoomLevel(prev => Math.max(prev - 0.2, 0.5)); }; const handleResetZoom = () => { setZoomLevel(1); }; // Toggle sidebar const toggleSidebar = () => { setSidebarOpen(!sidebarOpen); }; // Handle refresh network const handleRefreshNetwork = () => { refreshNetwork(); addToast('Network refreshed'); }; // Handle node click to select and highlight const handleNodeClick = (nodeId: string) => { // Toggle selection if (selectedPersonId === nodeId) { setSelectedPersonId(null); } else { setSelectedPersonId(nodeId); } // Open person details const person = people.find(p => p._id === nodeId); if (person) { openPersonDetail(person); } }; // Sort people alphabetically const sortedPeople = [...filteredPeople].sort((a, b) => { const nameA = `${a.firstName} ${a.lastName}`.toLowerCase(); const nameB = `${b.firstName} ${b.lastName}`.toLowerCase(); return nameA.localeCompare(nameB); }); // Loading state if (loading) { return (

Loading your network...

); } // Error state if (error) { return (

Error

{error}

); } // Generate graph data const graphData = getGraphData(); return (
{/* Sidebar Toggle Button */} {/* Sidebar */}
{/* Network Header */}

{currentNetwork?.name || 'Relationship Network'}

Visualize your connections

{/* Network Stats */} {/* Action Buttons */}
{/* Sidebar Tabs */}
{/* Tab Content */} {sidebarTab === 'overview' && (

About This Network

This interactive visualization shows relationships between people in your network.

  • Drag nodes to rearrange the network
  • Click on people for more details
  • Hover over connections to see relationship types
  • Use the controls to zoom in/out and center the view

Legend

{Object.entries(RELATIONSHIP_COLORS).map(([type, color]) => (
{RELATIONSHIP_LABELS[type as RelationshipType]}
))}
)} {sidebarTab === 'people' && (
setPeopleFilter(e.target.value)} />
{sortedPeople.length > 0 ? ( sortedPeople.map(person => { const connectionCount = relationships.filter( r => r.source === person._id || r.target === person._id ).length; return (
0 ? 'border-l-indigo-500' : 'border-l-slate-700' }`} onClick={() => { openPersonDetail(person); setSelectedPersonId(person._id); }} >

{person.firstName} {person.lastName}

0 ? '#60A5FA' : '#94A3B8', }} > {connectionCount} connection{connectionCount !== 1 ? 's' : ''}
); }) ) : ( } action={ !peopleFilter && ( ) } /> )}
)} {sidebarTab === 'relations' && (
setRelationshipFilter(e.target.value)} />
{Object.entries(RELATIONSHIP_COLORS).map(([type, color]) => ( ))}
{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 (
{ e.stopPropagation(); setSelectedPersonId(rel.source); openPersonDetail(source); }} > {source.firstName} {source.lastName} { e.stopPropagation(); setSelectedPersonId(rel.target); const targetPerson = people.find(p => p._id === rel.target); if (targetPerson) openPersonDetail(targetPerson); }} > {target.firstName} {target.lastName}
{rel.type === 'custom' ? rel.customType : RELATIONSHIP_LABELS[rel.type]}
); }) ) : ( } action={ !relationshipFilter && relationshipTypeFilter === 'all' && ( ) } /> )}
)}
{/* Main Graph Area */}
{graphDimensions.width <= 0 || graphDimensions.height <= 0 ? (
) : ( { updatePersonPosition(nodeId, { x, y }); }} /> )} {/* Empty state overlay */} {people.length === 0 && (

Start Building Your Network

Add people and create relationships between them to visualize your network

)} {/* Interaction hint */} {people.length > 0 && interactionHint && (
Click on a person to see details, drag to reposition
)} {/* Graph controls */}
{/* Quick action buttons */}
{/* Add Person Modal */} { setPersonModalOpen(false); setPersonFormErrors({}); }} title="Add New Person" >
{personFormErrors.general && (
{personFormErrors.general}
)} setNewPerson({ ...newPerson, firstName: e.target.value })} /> setNewPerson({ ...newPerson, lastName: e.target.value })} />
setNewPerson({ ...newPerson, birthday: date })} dateFormat="MMMM d, yyyy" placeholderText="Select birthday" className="w-full bg-slate-700 border border-slate-600 rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-white" showYearDropdown dropdownMode="select" wrapperClassName="w-full" />