commit 845bfb856ec1d33652c99be50b64fd49eeec12d5 Author: Tobias Hopp Date: Tue Apr 15 13:53:32 2025 +0200 initial commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..56491ec --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM node:18 as builder + +WORKDIR /app +COPY package.json /app + +RUN yarn + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0e19512 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + Relnet + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..42bfb74 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,31 @@ +{ + "name": "frontend", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "axios": "^1.8.4", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.5.0" + }, + "devDependencies": { + "@types/axios": "^0.14.4", + "@types/node": "^22.14.1", + "@types/react": "^19.1.2", + "@types/react-router-dom": "^5.3.3", + "@vitejs/plugin-react": "^4.4.0", + "ts-node": "^10.9.2", + "typescript": "^5.8.3", + "vite": "^6.2.6", + "webpack": "^5.99.5", + "webpack-cli": "^6.0.1" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..2ebcf3e --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import { AuthProvider, useAuth } from './context/AuthContext'; +import { NetworkProvider } from './context/NetworkContext'; +import Login from './components/auth/Login'; +import Register from './components/auth/Register'; +import NetworkList from './components/networks/NetworkList'; +import FriendshipNetwork from './components/FriendshipNetwork'; // Your existing component +import Header from './components/layout/Header'; + +// Protected route component +const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { user, loading } = useAuth(); + + if (loading) { + return
Loading...
; + } + + if (!user) { + return ; + } + + return <>{children}; +}; + +const App: React.FC = () => { + return ( + + + +
+
+
+ + } /> + } /> + + + + + } + /> + + + + + } + /> + + } /> + } /> + +
+
+
+
+
+ ); +}; + +export default App; diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..a30e508 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,56 @@ +import axios from 'axios'; + +const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api'; + +// Configure axios +axios.defaults.withCredentials = true; + +// Types +export interface RegisterData { + username: string; + email: string; + password: string; +} + +export interface LoginData { + email: string; + password: string; +} + +export interface User { + id: string; + username: string; + email: string; +} + +export interface AuthResponse { + success: boolean; + user: User; +} + +// Register user +export const register = async (data: RegisterData): Promise => { + const response = await axios.post(`${API_URL}/auth/register`, data); + return response.data.user; +}; + +// Login user +export const login = async (data: LoginData): Promise => { + const response = await axios.post(`${API_URL}/auth/login`, data); + return response.data.user; +}; + +// Logout user +export const logout = async (): Promise => { + await axios.post(`${API_URL}/auth/logout`); +}; + +// Get current user +export const getCurrentUser = async (): Promise => { + try { + const response = await axios.get(`${API_URL}/auth/me`); + return response.data.user; + } catch (error) { + return null; + } +}; \ No newline at end of file diff --git a/frontend/src/api/network.ts b/frontend/src/api/network.ts new file mode 100644 index 0000000..dd68129 --- /dev/null +++ b/frontend/src/api/network.ts @@ -0,0 +1,55 @@ +import axios from 'axios'; + +const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api'; + +// Types +export interface Network { + _id: string; + name: string; + description?: string; + owner: string; + isPublic: boolean; + createdAt: string; + updatedAt: string; +} + +export interface CreateNetworkData { + name: string; + description?: string; + isPublic?: boolean; +} + +export interface UpdateNetworkData { + name?: string; + description?: string; + isPublic?: boolean; +} + +// Get all networks for current user +export const getUserNetworks = async (): Promise => { + const response = await axios.get<{ success: boolean; data: Network[] }>(`${API_URL}/networks`); + return response.data.data; +}; + +// Create a new network +export const createNetwork = async (data: CreateNetworkData): Promise => { + const response = await axios.post<{ success: boolean; data: Network }>(`${API_URL}/networks`, data); + return response.data.data; +}; + +// Get a specific network +export const getNetwork = async (id: string): Promise => { + const response = await axios.get<{ success: boolean; data: Network }>(`${API_URL}/networks/${id}`); + return response.data.data; +}; + +// Update a network +export const updateNetwork = async (id: string, data: UpdateNetworkData): Promise => { + const response = await axios.put<{ success: boolean; data: Network }>(`${API_URL}/networks/${id}`, data); + return response.data.data; +}; + +// Delete a network +export const deleteNetwork = async (id: string): Promise => { + await axios.delete(`${API_URL}/networks/${id}`); +}; \ No newline at end of file diff --git a/frontend/src/api/people.ts b/frontend/src/api/people.ts new file mode 100644 index 0000000..c4fd419 --- /dev/null +++ b/frontend/src/api/people.ts @@ -0,0 +1,73 @@ +import axios from 'axios'; + +const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api'; + +// Types +export interface Person { + _id: string; + firstName: string; + lastName: string; + birthday?: string; + network: string; + position: { + x: number; + y: number; + }; + createdAt: string; + updatedAt: string; +} + +export interface CreatePersonData { + firstName: string; + lastName: string; + birthday?: string; + position?: { + x: number; + y: number; + }; +} + +export interface UpdatePersonData { + firstName?: string; + lastName?: string; + birthday?: string | null; + position?: { + x: number; + y: number; + }; +} + +// Get all people in a network +export const getPeople = async (networkId: string): Promise => { + const response = await axios.get<{ success: boolean; data: Person[] }>( + `${API_URL}/networks/${networkId}/people` + ); + return response.data.data; +}; + +// Add a person to the network +export const addPerson = async (networkId: string, data: CreatePersonData): Promise => { + const response = await axios.post<{ success: boolean; data: Person }>( + `${API_URL}/networks/${networkId}/people`, + data + ); + return response.data.data; +}; + +// Update a person +export const updatePerson = async ( + networkId: string, + personId: string, + data: UpdatePersonData +): Promise => { + const response = await axios.put<{ success: boolean; data: Person }>( + `${API_URL}/networks/${networkId}/people/${personId}`, + data + ); + return response.data.data; +}; + +// Remove a person from the network +export const removePerson = async (networkId: string, personId: string): Promise => { + await axios.delete(`${API_URL}/networks/${networkId}/people/${personId}`); +}; \ No newline at end of file diff --git a/frontend/src/api/relationships.ts b/frontend/src/api/relationships.ts new file mode 100644 index 0000000..a4fc6ba --- /dev/null +++ b/frontend/src/api/relationships.ts @@ -0,0 +1,65 @@ +import axios from 'axios'; + +const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/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'; + customType?: string; +} + +export interface UpdateRelationshipData { + type?: 'freund' | 'partner' | 'familie' | 'arbeitskolleg' | 'custom'; + customType?: string; +} + +// Get all relationships in a network +export const getRelationships = async (networkId: string): Promise => { + const response = await axios.get<{ success: boolean; data: Relationship[] }>( + `${API_URL}/networks/${networkId}/relationships` + ); + return response.data.data; +}; + +// Add a relationship to the network +export const addRelationship = async ( + networkId: string, + data: CreateRelationshipData +): Promise => { + const response = await axios.post<{ success: boolean; data: Relationship }>( + `${API_URL}/networks/${networkId}/relationships`, + data + ); + return response.data.data; +}; + +// Update a relationship +export const updateRelationship = async ( + networkId: string, + relationshipId: string, + data: UpdateRelationshipData +): Promise => { + const response = await axios.put<{ success: boolean; data: Relationship }>( + `${API_URL}/networks/${networkId}/relationships/${relationshipId}`, + data + ); + return response.data.data; +}; + +// Remove a relationship +export const removeRelationship = async (networkId: string, relationshipId: string): Promise => { + await axios.delete(`${API_URL}/networks/${networkId}/relationships/${relationshipId}`); +}; \ No newline at end of file diff --git a/frontend/src/components/FriendshipNetwork.tsx b/frontend/src/components/FriendshipNetwork.tsx new file mode 100644 index 0000000..865fbcc --- /dev/null +++ b/frontend/src/components/FriendshipNetwork.tsx @@ -0,0 +1,771 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useFriendshipNetwork } from '../hooks/useFriendshipNetwork'; +import { useNetworks } from '../context/NetworkContext'; + +const FriendshipNetwork: React.FC = () => { + const { id } = useParams<{ id: string }>(); + const { networks } = useNetworks(); + const navigate = useNavigate(); + + const { + people, + relationships, + loading, + error, + createPerson, + updatePerson, + deletePerson, + createRelationship, + updateRelationship, + deleteRelationship + } = useFriendshipNetwork(id || null); + + // Local state for the UI + const [selectedNode, setSelectedNode] = useState(null); + const [popupInfo, setPopupInfo] = useState(null); + const [newPerson, setNewPerson] = useState({ + firstName: '', + lastName: '', + birthday: '' + }); + const [newRelationship, setNewRelationship] = useState({ + source: '', + targets: [] as string[], + type: 'freund', + customType: '' + }); + const svgRef = useRef(null); + const nodeRefs = useRef<{ [key: string]: SVGGElement | null }>({}); + const [dragging, setDragging] = useState(null); + const [dragStartPos, setDragStartPos] = useState({ x: 0, y: 0 }); + const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); + const [showOverrideModal, setShowOverrideModal] = useState(false); + const [overrideRelationship, setOverrideRelationship] = useState(null); + + // Get current network info + const currentNetwork = networks.find(network => network._id === id); + + // Redirect if network not found + useEffect(() => { + if (!loading && !currentNetwork && networks.length > 0) { + navigate('/networks'); + } + }, [currentNetwork, networks, loading, navigate]); + + // Add a new person to the network + const handleAddPerson = async () => { + if (newPerson.firstName.trim() === '' || newPerson.lastName.trim() === '') { + alert('Please enter both first and last name'); + return; + } + + try { + await createPerson({ + firstName: newPerson.firstName.trim(), + lastName: newPerson.lastName.trim(), + birthday: newPerson.birthday || undefined, + position: { + x: 100 + Math.random() * 400, + y: 100 + Math.random() * 300 + } + }); + + setNewPerson({ + firstName: '', + lastName: '', + birthday: '' + }); + } catch (error) { + console.error('Error adding person:', error); + alert('Failed to add person.'); + } + }; + + // Add new relationships between source person and multiple target people + const handleAddRelationship = async () => { + const { source, targets, type, customType } = newRelationship; + + if (source === '' || targets.length === 0) { + alert('Please select source and at least one target person'); + return; + } + + const actualType = type === 'custom' ? customType.trim() : type; + + if (type === 'custom' && customType.trim() === '') { + alert('Please enter a custom relationship type'); + return; + } + + // Check if any relationships already exist + const existingRelationships: any[] = []; + targets.forEach(target => { + if (source !== target) { + const existingEdge = relationships.find(edge => + (edge.source === source && edge.target === target) || + (edge.source === target && edge.target === source) + ); + + if (existingEdge) { + existingRelationships.push({ + source, + target, + existingType: existingEdge.type, + newType: actualType, + edgeId: existingEdge.id + }); + } + } + }); + + if (existingRelationships.length > 0) { + // Show override modal + setOverrideRelationship({ + existingRelationships, + newRelationships: targets.filter(target => + source !== target && !existingRelationships.some(rel => rel.target === target) + ).map(target => ({ source, target, type: actualType })) + }); + setShowOverrideModal(true); + return; + } + + // Process each target for new relationships + const addPromises = targets.map(target => { + if (source !== target) { + return createRelationship({ + source, + target, + type: type as any, + customType: type === 'custom' ? customType : undefined + }); + } + return Promise.resolve(); + }).filter(Boolean); + + if (addPromises.length === 0) { + alert('No valid relationships to add.'); + return; + } + + try { + await Promise.all(addPromises); + setNewRelationship({ source: '', targets: [], type: 'freund', customType: '' }); + } catch (error) { + console.error('Error adding relationships:', error); + alert('Failed to add one or more relationships.'); + } + }; + + // Handle confirming relationship overrides + const handleConfirmOverride = async () => { + if (!overrideRelationship) return; + + const { existingRelationships, newRelationships } = overrideRelationship; + + try { + // Remove existing relationships that will be overridden + await Promise.all(existingRelationships.map(rel => deleteRelationship(rel.edgeId))); + + // Add new overridden relationships + await Promise.all(existingRelationships.map(rel => + createRelationship({ + source: rel.source, + target: rel.target, + type: rel.newType as any, + customType: rel.newType === 'custom' ? rel.customType : undefined + }) + )); + + // Add completely new relationships + await Promise.all(newRelationships.map(rel => + createRelationship({ + source: rel.source, + target: rel.target, + type: rel.type as any, + customType: rel.type === 'custom' ? rel.customType : undefined + }) + )); + + setShowOverrideModal(false); + setOverrideRelationship(null); + setNewRelationship({ source: '', targets: [], type: 'freund', customType: '' }); + } catch (error) { + console.error('Error overriding relationships:', error); + alert('Failed to override relationships.'); + } + }; + + // Handle canceling relationship overrides + const handleCancelOverride = async () => { + // If there are new relationships that don't need overrides, add those + if (overrideRelationship && overrideRelationship.newRelationships.length > 0) { + try { + await Promise.all(overrideRelationship.newRelationships.map(rel => + createRelationship({ + source: rel.source, + target: rel.target, + type: rel.type as any, + customType: rel.type === 'custom' ? rel.customType : undefined + }) + )); + } catch (error) { + console.error('Error adding new relationships:', error); + } + } + + setShowOverrideModal(false); + setOverrideRelationship(null); + }; + + // Handle multiple selections in the targets dropdown + const handleTargetChange = (e: React.ChangeEvent) => { + const selectedOptions = Array.from(e.target.selectedOptions, option => option.value); + setNewRelationship({...newRelationship, targets: selectedOptions}); + }; + + // Handle node drag start + const handleMouseDown = (e: React.MouseEvent, id: string) => { + if (svgRef.current) { + const node = people.find(n => n.id === id); + if (!node) return; + + setDragging(id); + setDragStartPos({ ...node.position }); + setMousePos({ x: e.clientX, y: e.clientY }); + + e.stopPropagation(); + e.preventDefault(); + } + }; + + // Handle node dragging + const handleMouseMove = (e: React.MouseEvent) => { + if (dragging && svgRef.current) { + const dx = e.clientX - mousePos.x; + const dy = e.clientY - mousePos.y; + + const newX = dragStartPos.x + dx; + const newY = dragStartPos.y + dy; + + // Update node position in the UI immediately + const updatedPeople = people.map(node => + node.id === dragging + ? { + ...node, + position: { x: newX, y: newY } + } + : node + ); + + // We don't actually update the state here for performance reasons + // Instead, we update the DOM directly + const draggedNode = nodeRefs.current[dragging]; + if (draggedNode) { + draggedNode.setAttribute('transform', `translate(${newX}, ${newY})`); + } + } + }; + + // Handle node drag end + const handleMouseUp = async () => { + if (dragging) { + const node = people.find(n => n.id === dragging); + if (node) { + // Get the final position from the DOM + const draggedNode = nodeRefs.current[dragging]; + if (draggedNode) { + const transform = draggedNode.getAttribute('transform'); + if (transform) { + const match = transform.match(/translate\(([^,]+),\s*([^)]+)\)/); + if (match) { + const x = parseFloat(match[1]); + const y = parseFloat(match[2]); + + // Save the new position to the server + try { + await updatePerson(dragging, { position: { x, y } }); + } catch (error) { + console.error('Error updating position:', error); + } + } + } + } + } + setDragging(null); + } + }; + + // Delete a node and its associated edges + const handleDeleteNode = async (id: string) => { + if (window.confirm('Are you sure you want to delete this person? All their relationships will also be deleted.')) { + try { + await deletePerson(id); + setSelectedNode(null); + setPopupInfo(null); + } catch (error) { + console.error('Error deleting person:', error); + alert('Failed to delete person.'); + } + } + }; + + // Get relationship type label + const getRelationshipLabel = (type: string) => { + switch(type) { + case 'freund': return 'Freund/in'; + case 'partner': return 'Partner/in'; + case 'familie': return 'Familie/Verwandschaft'; + case 'arbeitskolleg': return 'Arbeitskolleg/innen'; + default: return type; + } + }; + + // Remove a relationship between two people + const handleRemoveRelationship = async (edgeId: string) => { + try { + await deleteRelationship(edgeId); + + // Update popup info if it's open + if (popupInfo) { + const nodeId = popupInfo.node.id; + const nodeRelationships = relationships + .filter(edge => edge.id !== edgeId) + .filter(edge => edge.source === nodeId || edge.target === nodeId) + .map(edge => { + const otherId = edge.source === nodeId ? edge.target : edge.source; + const other = people.find(n => n.id === otherId); + return { + person: other ? `${other.firstName} ${other.lastName}` : otherId, + type: edge.type, + edgeId: edge.id + }; + }); + + setPopupInfo({ + ...popupInfo, + relationships: nodeRelationships + }); + } + } catch (error) { + console.error('Error removing relationship:', error); + alert('Failed to remove relationship.'); + } + }; + + // Show popup with person details and relationships + const showPersonDetails = (nodeId: string) => { + const node = people.find(n => n.id === nodeId); + if (!node) return; + + // Find all relationships + const nodeRelationships = relationships.filter( + edge => edge.source === nodeId || edge.target === nodeId + ).map(edge => { + const otherId = edge.source === nodeId ? edge.target : edge.source; + const other = people.find(n => n.id === otherId); + return { + person: other ? `${other.firstName} ${other.lastName}` : otherId, + type: edge.type, + edgeId: edge.id + }; + }); + + setPopupInfo({ + node, + relationships: nodeRelationships, + position: { ...node.position } + }); + }; + + // Close popup + const closePopup = () => { + setPopupInfo(null); + }; + + // Get abbreviated name for display in graph (first name + first letter of last name) + const getDisplayName = (node: any) => { + return `${node.firstName} ${node.lastName.charAt(0)}.`; + }; + + // Close popup when clicking outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (popupInfo && + !(e.target as Element).closest('.popup') && + !dragging) { + closePopup(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [popupInfo, dragging]); + + if (loading) { + return
Loading network data...
; + } + + if (error) { + return
{error}
; + } + + return ( +
+ {/* Sidebar menu */} +
+

+ {currentNetwork?.name || 'Friend Network'} +

+ + {/* Add Person Form */} +
+

Add Person

+
+ setNewPerson({...newPerson, firstName: e.target.value})} + /> + setNewPerson({...newPerson, lastName: e.target.value})} + /> + setNewPerson({...newPerson, birthday: e.target.value})} + /> + +
+
+ + {/* Add Relationship Form */} +
+

Add Relationship

+
+ + + +

Hold Ctrl/Cmd to select multiple people

+ + + + {newRelationship.type === 'custom' && ( + setNewRelationship({...newRelationship, customType: e.target.value})} + /> + )} + + +
+
+ + {/* People List */} +
+

People ({people.length})

+
    + {people.map(node => ( +
  • + {node.firstName} {node.lastName} + +
  • + ))} +
+
+ + {/* Relationships List */} +
+

All Relationships ({relationships.length})

+ {relationships.length === 0 ? ( +

No relationships yet

+ ) : ( +
    + {relationships.map(edge => { + const source = people.find(n => n.id === edge.source); + const target = people.find(n => n.id === edge.target); + if (!source || !target) return null; + + return ( +
  • +
    + + {source.firstName} {source.lastName.charAt(0)}. ↔ {target.firstName} {target.lastName.charAt(0)}. + +
    + {getRelationshipLabel(edge.type)} + +
    +
    +
  • + ); + })} +
+ )} +
+
+ + {/* Visualization Area */} +
+ + {/* Edges (Relationships) */} + {relationships.map(edge => { + const source = people.find(n => n.id === edge.source); + const target = people.find(n => n.id === edge.target); + + if (!source || !target) return null; + + // Determine the line color based on relationship type + let strokeColor = '#9CA3AF'; // Default gray + if (edge.type === 'freund') strokeColor = '#3B82F6'; // Blue + if (edge.type === 'partner') strokeColor = '#EC4899'; // Pink + if (edge.type === 'familie') strokeColor = '#10B981'; // Green + if (edge.type === 'arbeitskolleg') strokeColor = '#F59E0B'; // Yellow + + return ( + + + + {getRelationshipLabel(edge.type)} + + + ); + })} + + {/* Nodes (People) */} + {people.map(node => ( + handleMouseDown(e, node.id)} + ref={el => { nodeRefs.current[node.id] = el; }} + className="cursor-grab" + > + showPersonDetails(node.id)} + /> + + {getDisplayName(node)} + + + ))} + + + {/* Person Details Popup */} + {popupInfo && ( +
(svgRef.current?.clientWidth || 0) / 2 + ? popupInfo.position.x - 260 : popupInfo.position.x + 40, + top: popupInfo.position.y > (svgRef.current?.clientHeight || 0) / 2 + ? popupInfo.position.y - 200 : popupInfo.position.y, + }} + > +
+

Person Details

+ +
+
+

Name: {popupInfo.node.firstName} {popupInfo.node.lastName}

+ {popupInfo.node.birthday && ( +

Birthday: {new Date(popupInfo.node.birthday).toLocaleDateString()}

+ )} +
+
+

Relationships:

+ {popupInfo.relationships.length === 0 ? ( +

No relationships yet

+ ) : ( +
    + {popupInfo.relationships.map((rel: any, index: number) => ( +
  • +
    + {rel.person} + {getRelationshipLabel(rel.type)} +
    +
    + +
    +
  • + ))} +
+ )} +
+
+ )} + + {/* Override Confirmation Modal */} + {showOverrideModal && overrideRelationship && ( +
+
+

Existing Relationship(s)

+

+ {overrideRelationship.existingRelationships.length === 1 + ? "There is already a relationship between these people:" + : "There are already relationships between these people:"} +

+ +
    + {overrideRelationship.existingRelationships.map((rel: any, index: number) => { + const source = people.find(n => n.id === rel.source); + const target = people.find(n => n.id === rel.target); + if (!source || !target) return null; + + return ( +
  • +
    + + {source.firstName} {source.lastName} ↔ {target.firstName} {target.lastName} + +
    +
    + + Current: {getRelationshipLabel(rel.existingType)} + + + New: {getRelationshipLabel(rel.newType)} + +
    +
  • + ); + })} +
+ +

Do you want to override the existing relationship(s)?

+ +
+ + +
+
+
+ )} + + {/* Instructions */} +
+

Tip: Drag people to arrange them. Click on a person to view their details and relationships.

+
+
+
+ ); +}; + +export default FriendshipNetwork; \ No newline at end of file diff --git a/frontend/src/components/auth/Login.tsx b/frontend/src/components/auth/Login.tsx new file mode 100644 index 0000000..2146b04 --- /dev/null +++ b/frontend/src/components/auth/Login.tsx @@ -0,0 +1,89 @@ +import React, { useState } from 'react'; +import { useAuth } from '../../context/AuthContext'; +import { useNavigate } from 'react-router-dom'; + +const Login: React.FC = () => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const { login } = useAuth(); + const navigate = useNavigate(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + + try { + await login({ email, password }); + navigate('/networks'); + } catch (err: any) { + setError(err.response?.data?.message || 'Login failed. Please check your credentials.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Login

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setEmail(e.target.value)} + required + /> +
+ +
+ + setPassword(e.target.value)} + required + /> +
+ +
+ + + Register + +
+
+
+
+ ); +}; + +export default Login; \ No newline at end of file diff --git a/frontend/src/components/auth/Register.tsx b/frontend/src/components/auth/Register.tsx new file mode 100644 index 0000000..e7301d0 --- /dev/null +++ b/frontend/src/components/auth/Register.tsx @@ -0,0 +1,131 @@ +import React, { useState } from 'react'; +import { useAuth } from '../../context/AuthContext'; +import { useNavigate } from 'react-router-dom'; + +const Register: React.FC = () => { + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const { register } = useAuth(); + const navigate = useNavigate(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + // Basic validation + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + + if (password.length < 6) { + setError('Password must be at least 6 characters'); + return; + } + + setLoading(true); + + try { + await register({ username, email, password }); + navigate('/networks'); + } catch (err: any) { + setError(err.response?.data?.message || 'Registration failed. Please try again.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Register

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setUsername(e.target.value)} + required + /> +
+ +
+ + setEmail(e.target.value)} + required + /> +
+ +
+ + setPassword(e.target.value)} + required + /> +
+ +
+ + setConfirmPassword(e.target.value)} + required + /> +
+ +
+ + + Login + +
+
+
+
+ ); +}; + +export default Register; \ No newline at end of file diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx new file mode 100644 index 0000000..078cffc --- /dev/null +++ b/frontend/src/components/layout/Header.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import {Link, useNavigate} from 'react-router-dom'; +import {useAuth} from '../../context/AuthContext'; + +const Header: React.FC = () => { + const {user, logout} = useAuth(); + const navigate = useNavigate(); + + const handleLogout = async () => { + try { + await logout(); + navigate('/login'); + } catch (error) { + console.error('Logout failed:', error); + } + }; + + return ( +
+
+ + Friendship Network + + + +
+
+ ); +}; + +export default Header; \ No newline at end of file diff --git a/frontend/src/components/networks/NetworkList.tsx b/frontend/src/components/networks/NetworkList.tsx new file mode 100644 index 0000000..8e18dd0 --- /dev/null +++ b/frontend/src/components/networks/NetworkList.tsx @@ -0,0 +1,195 @@ +import React, { useState } from 'react'; +import { useNetworks } from '../../context/NetworkContext'; +import { useNavigate } from 'react-router-dom'; + +const NetworkList: React.FC = () => { + const { networks, loading, error, createNetwork, deleteNetwork } = useNetworks(); + const [showCreateForm, setShowCreateForm] = useState(false); + const [newNetworkName, setNewNetworkName] = useState(''); + const [newNetworkDescription, setNewNetworkDescription] = useState(''); + const [isPublic, setIsPublic] = useState(false); + const [formError, setFormError] = useState(null); + const [createLoading, setCreateLoading] = useState(false); + const navigate = useNavigate(); + + const handleCreateNetwork = async (e: React.FormEvent) => { + e.preventDefault(); + setFormError(null); + + if (!newNetworkName.trim()) { + setFormError('Network name is required'); + return; + } + + setCreateLoading(true); + + try { + const network = await createNetwork({ + name: newNetworkName.trim(), + description: newNetworkDescription.trim() || undefined, + isPublic + }); + + // Reset form + setNewNetworkName(''); + setNewNetworkDescription(''); + setIsPublic(false); + setShowCreateForm(false); + + // Navigate to the new network + navigate(`/networks/${network._id}`); + } catch (err: any) { + setFormError(err.response?.data?.message || 'Failed to create network'); + } finally { + setCreateLoading(false); + } + }; + + const handleDeleteNetwork = async (id: string) => { + if (window.confirm('Are you sure you want to delete this network? This action cannot be undone.')) { + try { + await deleteNetwork(id); + } catch (err: any) { + alert(err.response?.data?.message || 'Failed to delete network'); + } + } + }; + + if (loading) { + return
Loading networks...
; + } + + return ( +
+
+

My Networks

+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* Create Network Form */} + {showCreateForm && ( +
+

Create New Network

+ + {formError && ( +
+ {formError} +
+ )} + +
+
+ + setNewNetworkName(e.target.value)} + required + /> +
+ +
+ +