session start works
This commit is contained in:
0
frontend/.dockerignore
Normal file
0
frontend/.dockerignore
Normal file
31
frontend/Dockerfile
Normal file
31
frontend/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
||||
# Stage 1: Build the React application
|
||||
FROM node:18-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and package-lock.json
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy the rest of the application source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Serve the application with Nginx
|
||||
FROM nginx:1.21-alpine
|
||||
|
||||
# Copy the built application from the build stage
|
||||
COPY --from=build /app/build /usr/share/nginx/html
|
||||
|
||||
# Copy a custom Nginx configuration if needed (optional)
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
# Start Nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
16
frontend/nginx.conf
Normal file
16
frontend/nginx.conf
Normal file
@@ -0,0 +1,16 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
21022
frontend/package-lock.json
generated
Normal file
21022
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
frontend/package.json
Normal file
52
frontend/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "unisono",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/material": "^5.14.18",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/node": "^16.18.61",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"axios": "^1.6.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.19.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"typescript": "^4.9.5",
|
||||
"uuid": "^9.0.1",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/uuid": "^9.0.7"
|
||||
}
|
||||
}
|
||||
20
frontend/public/index.html
Normal file
20
frontend/public/index.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="A real-time app for collaborative decision-making."
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<title>Unisono</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
24
frontend/src/App.tsx
Normal file
24
frontend/src/App.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { ThemeProvider, CssBaseline } from '@mui/material';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import theme from './theme';
|
||||
import CreateSession from './pages/CreateSession';
|
||||
|
||||
import SessionPage from './pages/SessionPage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<CreateSession />} />
|
||||
{/* Other routes will be added here */}
|
||||
<Route path="/session/:sessionId" element={<SessionPage />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
16
frontend/src/components/DesireForm.test.tsx
Normal file
16
frontend/src/components/DesireForm.test.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import DesireForm from './DesireForm';
|
||||
|
||||
test('renders desire submission form with input fields and submit button', () => {
|
||||
render(<DesireForm onSubmit={() => {}} />);
|
||||
|
||||
// Check for headings/labels for each category
|
||||
expect(screen.getByLabelText(/What you WANT/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/What you ACCEPT/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/What you DO NOT WANT/i)).toBeInTheDocument();
|
||||
|
||||
// Check for the submit button
|
||||
const submitButton = screen.getByRole('button', { name: /Submit Desires/i });
|
||||
expect(submitButton).toBeInTheDocument();
|
||||
});
|
||||
87
frontend/src/components/DesireForm.tsx
Normal file
87
frontend/src/components/DesireForm.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React, { useState } from 'react';
|
||||
import { TextField, Button, Box, Typography } from '@mui/material';
|
||||
|
||||
interface DesireFormProps {
|
||||
onSubmit: (desires: { wants: string[], accepts: string[], noGoes: string[] }) => void;
|
||||
}
|
||||
|
||||
const DesireForm: React.FC<DesireFormProps> = ({ onSubmit }) => {
|
||||
const [wants, setWants] = useState('');
|
||||
const [accepts, setAccepts] = useState('');
|
||||
const [noGoes, setNoGoes] = useState('');
|
||||
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const parsedWants = wants.split('\n').map(s => s.trim()).filter(s => s);
|
||||
const parsedAccepts = accepts.split('\n').map(s => s.trim()).filter(s => s);
|
||||
const parsedNoGoes = noGoes.split('\n').map(s => s.trim()).filter(s => s);
|
||||
|
||||
// FR-020: The system MUST require a user to enter at least one desire in at least one of the three categories
|
||||
if (parsedWants.length === 0 && parsedAccepts.length === 0 && parsedNoGoes.length === 0) {
|
||||
alert('Please enter at least one desire in any category.');
|
||||
return;
|
||||
}
|
||||
|
||||
// FR-016: System MUST validate a user's submission to prevent the same item from appearing in conflicting categories
|
||||
const allItems = [...parsedWants, ...parsedAccepts, ...parsedNoGoes];
|
||||
const uniqueItems = new Set(allItems);
|
||||
if (allItems.length !== uniqueItems.size) {
|
||||
alert('You have conflicting desires (same item in different categories). Please resolve.');
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit({
|
||||
wants: parsedWants,
|
||||
accepts: parsedAccepts,
|
||||
noGoes: parsedNoGoes,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>What you WANT</Typography>
|
||||
<TextField
|
||||
label="List items you want (one per line)"
|
||||
multiline
|
||||
rows={4}
|
||||
fullWidth
|
||||
value={wants}
|
||||
onChange={(e) => setWants(e.target.value)}
|
||||
margin="normal"
|
||||
/>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 4 }}>What you ACCEPT</Typography>
|
||||
<TextField
|
||||
label="List items you accept (one per line)"
|
||||
multiline
|
||||
rows={4}
|
||||
fullWidth
|
||||
value={accepts}
|
||||
onChange={(e) => setAccepts(e.target.value)}
|
||||
margin="normal"
|
||||
/>
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 4 }}>What you DO NOT WANT</Typography>
|
||||
<TextField
|
||||
label="List items you absolutely do not want (one per line)"
|
||||
multiline
|
||||
rows={4}
|
||||
fullWidth
|
||||
value={noGoes}
|
||||
onChange={(e) => setNoGoes(e.target.value)}
|
||||
margin="normal"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
>
|
||||
Submit Desires
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DesireForm;
|
||||
29
frontend/src/components/ResultsDisplay.test.tsx
Normal file
29
frontend/src/components/ResultsDisplay.test.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import ResultsDisplay from './ResultsDisplay';
|
||||
import { Decision } from '../hooks/useSession';
|
||||
|
||||
const mockDecision: Decision = {
|
||||
goTos: [{ title: 'Go to the beach', rawInputs: ['beach'] }],
|
||||
alsoGoods: [{ title: 'Eat pizza', rawInputs: ['pizza'] }],
|
||||
considerables: [{ title: 'Watch a movie', rawInputs: ['movie'] }],
|
||||
noGoes: [{ title: 'Stay home', rawInputs: ['home'] }],
|
||||
};
|
||||
|
||||
describe('ResultsDisplay', () => {
|
||||
it('renders all categories correctly', () => {
|
||||
render(<ResultsDisplay decision={mockDecision} />);
|
||||
|
||||
expect(screen.getByText('Go-to')).toBeInTheDocument();
|
||||
expect(screen.getByText('Go to the beach')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Also good')).toBeInTheDocument();
|
||||
expect(screen.getByText('Eat pizza')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Considerable')).toBeInTheDocument();
|
||||
expect(screen.getByText('Watch a movie')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('No-goes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Stay home')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
61
frontend/src/components/ResultsDisplay.tsx
Normal file
61
frontend/src/components/ResultsDisplay.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, List, ListItem, ListItemText, Collapse, IconButton } from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import { Decision, SemanticDesire } from '../hooks/useSession';
|
||||
|
||||
interface ResultsDisplayProps {
|
||||
decision: Decision;
|
||||
}
|
||||
|
||||
const CategorySection: React.FC<{ title: string; desires: SemanticDesire[]; defaultExpanded?: boolean }>
|
||||
= ({ title, desires, defaultExpanded = true }) => {
|
||||
const [expanded, setExpanded] = React.useState(defaultExpanded);
|
||||
|
||||
if (!desires || desires.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 3, border: '1px solid #e0e0e0', borderRadius: '4px', p: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="h6" component="h2" gutterBottom>
|
||||
{title}
|
||||
</Typography>
|
||||
<IconButton onClick={() => setExpanded(!expanded)} size="small">
|
||||
{expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
||||
<List dense>
|
||||
{desires.map((desire, index) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemText primary={desire.title} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const ResultsDisplay: React.FC<ResultsDisplayProps> = ({ decision }) => {
|
||||
if (!decision) {
|
||||
return <Typography>No decision available yet.</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="h5" component="h1" gutterBottom>
|
||||
Cooperative Decision
|
||||
</Typography>
|
||||
|
||||
<CategorySection title="Go-to" desires={decision.goTos} />
|
||||
<CategorySection title="Also good" desires={decision.alsoGoods} />
|
||||
<CategorySection title="Considerable" desires={decision.considerables} defaultExpanded={false} />
|
||||
<CategorySection title="No-goes" desires={decision.noGoes} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResultsDisplay;
|
||||
115
frontend/src/hooks/useSession.ts
Normal file
115
frontend/src/hooks/useSession.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useState, useEffect, useCallback, Dispatch, SetStateAction } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { webSocketService } from '../services/websocket'; // Import the websocket service
|
||||
|
||||
// Define the types for the session state based on data-model.md
|
||||
// In a real app, these would be in a separate types file.
|
||||
export interface Participant {
|
||||
participantId: string; // This will now be the clientId
|
||||
isCreator: boolean;
|
||||
hasSubmitted: boolean;
|
||||
}
|
||||
|
||||
export interface DesireSet {
|
||||
participantId: string;
|
||||
wants: string[];
|
||||
accepts: string[];
|
||||
noGoes: string[];
|
||||
}
|
||||
|
||||
export interface SemanticDesire {
|
||||
title: string;
|
||||
rawInputs: string[];
|
||||
}
|
||||
|
||||
export interface Decision {
|
||||
goTos: SemanticDesire[];
|
||||
alsoGoods: SemanticDesire[];
|
||||
considerables: SemanticDesire[];
|
||||
noGoes: SemanticDesire[];
|
||||
}
|
||||
|
||||
// Define the SessionState enum (mirroring backend)
|
||||
export enum SessionState {
|
||||
SETUP = 'SETUP',
|
||||
GATHERING = 'GATHERING',
|
||||
HARMONIZING = 'HARMONIZING',
|
||||
FINAL = 'FINAL',
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
sessionId: string;
|
||||
state: SessionState;
|
||||
expectedResponses: number;
|
||||
submittedCount: number;
|
||||
responses: { [clientId: string]: boolean }; // Map of clientId to a boolean indicating if they submitted
|
||||
finalResult: Decision | null;
|
||||
topic?: string; // This might be part of the initial setup payload
|
||||
}
|
||||
|
||||
// Utility to generate a persistent client ID
|
||||
const getOrCreateClientId = (): string => {
|
||||
let clientId = localStorage.getItem('clientId');
|
||||
if (!clientId) {
|
||||
clientId = uuidv4();
|
||||
localStorage.setItem('clientId', clientId);
|
||||
}
|
||||
return clientId;
|
||||
};
|
||||
|
||||
export const useSession = (sessionId: string): [Session | null, Dispatch<SetStateAction<Session | null>>, (message: any) => void, string] => {
|
||||
const clientId = getOrCreateClientId(); // Get or create clientId
|
||||
|
||||
const getInitialState = useCallback((): Session | null => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(`session-${sessionId}`);
|
||||
return item ? JSON.parse(item) : null;
|
||||
} catch (error) {
|
||||
console.error('Error reading from localStorage', error);
|
||||
return null;
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
const [session, setSession] = useState<Session | null>(getInitialState);
|
||||
|
||||
useEffect(() => {
|
||||
// Connect to WebSocket
|
||||
webSocketService.connect(sessionId, clientId);
|
||||
|
||||
// Handle incoming messages
|
||||
const handleMessage = (message: any) => {
|
||||
console.log('useSession: Processing incoming message:', message);
|
||||
if (message.type === 'STATE_UPDATE') {
|
||||
setSession(message.payload.session);
|
||||
} else if (message.type === 'ERROR') {
|
||||
console.error('WebSocket Error:', message.payload.message);
|
||||
// Optionally, handle error display to the user
|
||||
}
|
||||
// Add other message types as needed
|
||||
};
|
||||
|
||||
webSocketService.onMessage(handleMessage);
|
||||
|
||||
// Clean up on unmount
|
||||
return () => {
|
||||
webSocketService.removeMessageHandler(handleMessage);
|
||||
webSocketService.disconnect();
|
||||
};
|
||||
}, [sessionId, clientId]); // Re-run effect if sessionId or clientId changes
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (session) {
|
||||
window.localStorage.setItem(`session-${sessionId}`, JSON.stringify(session));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error writing to localStorage', error);
|
||||
}
|
||||
}, [session, sessionId]);
|
||||
|
||||
const sendMessage = useCallback((message: any) => {
|
||||
webSocketService.sendMessage(message);
|
||||
}, []);
|
||||
|
||||
return [session, setSession, sendMessage, clientId];
|
||||
};
|
||||
12
frontend/src/index.tsx
Normal file
12
frontend/src/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
22
frontend/src/pages/CreateSession.test.tsx
Normal file
22
frontend/src/pages/CreateSession.test.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import CreateSession from './CreateSession';
|
||||
|
||||
test('renders create session page with a form', () => {
|
||||
render(<CreateSession />);
|
||||
|
||||
// Check for a heading
|
||||
const headingElement = screen.getByText(/Create a New Session/i);
|
||||
expect(headingElement).toBeInTheDocument();
|
||||
|
||||
// Check for form fields
|
||||
const topicInput = screen.getByLabelText(/Topic/i);
|
||||
expect(topicInput).toBeInTheDocument();
|
||||
|
||||
const participantsInput = screen.getByLabelText(/Number of Participants/i);
|
||||
expect(participantsInput).toBeInTheDocument();
|
||||
|
||||
// Check for the create button
|
||||
const createButton = screen.getByRole('button', { name: /Create Session/i });
|
||||
expect(createButton).toBeInTheDocument();
|
||||
});
|
||||
48
frontend/src/pages/CreateSession.tsx
Normal file
48
frontend/src/pages/CreateSession.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { Box, Button, Typography, Container } from '@mui/material';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const CreateSession = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const response = await axios.post('http://localhost:8000/sessions');
|
||||
const { sessionId } = response.data;
|
||||
navigate(`/session/${sessionId}`);
|
||||
} catch (error) {
|
||||
console.error('Error creating session:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm">
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography component="h1" variant="h5">
|
||||
Unisono
|
||||
</Typography>
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Button
|
||||
type="button"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
Create a New Session
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateSession;
|
||||
132
frontend/src/pages/SessionPage.tsx
Normal file
132
frontend/src/pages/SessionPage.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Container, Typography, Box, CircularProgress, Alert, TextField, Button } from '@mui/material';
|
||||
import { useSession, Session, Participant, DesireSet, Decision, SessionState } from '../hooks/useSession';
|
||||
import { webSocketService } from '../services/websocket';
|
||||
import DesireForm from '../components/DesireForm';
|
||||
import ResultsDisplay from '../components/ResultsDisplay';
|
||||
|
||||
const SessionPage = () => {
|
||||
const { sessionId } = useParams<{ sessionId: string }>();
|
||||
const [session, setSession, sendMessage, clientId] = useSession(sessionId || '');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expectedResponses, setExpectedResponses] = useState(2);
|
||||
const [topic, setTopic] = useState('');
|
||||
|
||||
const handleSetupSession = () => {
|
||||
sendMessage({ type: 'SETUP_SESSION', payload: { expectedResponses, topic } });
|
||||
};
|
||||
|
||||
const handleSubmitDesires = (desires: { wants: string[], accepts: string[], noGoes: string[] }) => {
|
||||
if (!session || !clientId) return;
|
||||
|
||||
const desireSet: DesireSet = {
|
||||
participantId: clientId, // Use the clientId from the hook
|
||||
wants: desires.wants,
|
||||
accepts: desires.accepts,
|
||||
noGoes: desires.noGoes,
|
||||
};
|
||||
|
||||
sendMessage({
|
||||
type: 'SUBMIT_RESPONSE',
|
||||
payload: { response: desireSet },
|
||||
});
|
||||
};
|
||||
|
||||
if (!session) {
|
||||
return <Typography>Loading session...</Typography>;
|
||||
}
|
||||
|
||||
const remainingResponses = session.expectedResponses - session.submittedCount;
|
||||
const hasSubmittedCurrentParticipant = session.responses && session.responses[clientId];
|
||||
|
||||
return (
|
||||
<Container maxWidth="md">
|
||||
<Box sx={{ mt: 4 }}>
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Session: {session.topic || session.sessionId}
|
||||
</Typography>
|
||||
|
||||
{session.state === SessionState.SETUP && (
|
||||
<Box sx={{ mt: 4, p: 3, border: '1px dashed grey', borderRadius: '4px', textAlign: 'center' }}>
|
||||
<Typography variant="h6" component="p" gutterBottom>
|
||||
Set Up the Session
|
||||
</Typography>
|
||||
<TextField
|
||||
margin="normal"
|
||||
fullWidth
|
||||
id="topic"
|
||||
label="Session Topic"
|
||||
name="topic"
|
||||
autoFocus
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
name="expectedResponses"
|
||||
label="Number of Expected Responses"
|
||||
type="number"
|
||||
id="expectedResponses"
|
||||
value={expectedResponses}
|
||||
onChange={(e) => setExpectedResponses(parseInt(e.target.value, 10))}
|
||||
InputProps={{
|
||||
inputProps: { min: 1 }
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="contained"
|
||||
sx={{ mt: 2 }}
|
||||
onClick={handleSetupSession}
|
||||
>
|
||||
Start Session
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{session.state !== SessionState.SETUP && (
|
||||
<>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Expected Responses: {session.expectedResponses}
|
||||
</Typography>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
Status: {session.state}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
|
||||
{session.state === SessionState.GATHERING && !hasSubmittedCurrentParticipant && (
|
||||
<DesireForm onSubmit={handleSubmitDesires} />
|
||||
)}
|
||||
|
||||
{session.state === SessionState.GATHERING && hasSubmittedCurrentParticipant && (
|
||||
<Box sx={{ mt: 4, p: 3, border: '1px dashed grey', borderRadius: '4px', textAlign: 'center' }}>
|
||||
<Typography variant="h6" component="p">
|
||||
Waiting for {remainingResponses} more responses...
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Your desires have been submitted. The results will be calculated once all participants have responded.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{session.state === SessionState.HARMONIZING && (
|
||||
<Box sx={{ mt: 4, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<CircularProgress />
|
||||
<Typography variant="h6" sx={{ mt: 2 }}>Harmonizing Desires...</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{session.state === SessionState.FINAL && session.finalResult && (
|
||||
<ResultsDisplay decision={session.finalResult} />
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default SessionPage;
|
||||
85
frontend/src/services/websocket.ts
Normal file
85
frontend/src/services/websocket.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
class WebSocketService {
|
||||
private ws: WebSocket | null = null;
|
||||
private messageHandlers: ((message: any) => void)[] = [];
|
||||
private errorHandlers: ((error: Event) => void)[] = [];
|
||||
private currentSessionId: string | null = null;
|
||||
private currentClientId: string | null = null;
|
||||
|
||||
connect(sessionId: string, clientId: string) {
|
||||
// Prevent multiple connections
|
||||
if (this.ws) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentSessionId = sessionId;
|
||||
this.currentClientId = clientId;
|
||||
const wsUrl = `ws://localhost:8000/sessions/${sessionId}`;
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
// Directly send registration message on open
|
||||
this.sendMessage({ type: 'REGISTER_CLIENT' });
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
console.log('WebSocketService: Received and parsed message:', message);
|
||||
this.messageHandlers.forEach(handler => handler(message));
|
||||
} catch (error) {
|
||||
console.error('Error parsing incoming message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
this.ws = null;
|
||||
this.currentSessionId = null;
|
||||
this.currentClientId = null;
|
||||
};
|
||||
|
||||
this.ws.onerror = (event) => {
|
||||
console.error('WebSocket error:', event);
|
||||
this.errorHandlers.forEach(handler => handler(event));
|
||||
};
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(message: any) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.currentClientId && this.currentSessionId) {
|
||||
const messageToSend = {
|
||||
...message,
|
||||
clientId: this.currentClientId,
|
||||
sessionId: this.currentSessionId,
|
||||
};
|
||||
this.ws.send(JSON.stringify(messageToSend));
|
||||
} else {
|
||||
// This error can be ignored if it happens during initial connection in StrictMode
|
||||
// console.error('WebSocket is not connected or clientId/sessionId is missing.');
|
||||
}
|
||||
}
|
||||
|
||||
onMessage(handler: (message: any) => void) {
|
||||
this.messageHandlers.push(handler);
|
||||
}
|
||||
|
||||
removeMessageHandler(handler: (message: any) => void) {
|
||||
this.messageHandlers = this.messageHandlers.filter(h => h !== handler);
|
||||
}
|
||||
|
||||
onError(handler: (error: Event) => void) {
|
||||
this.errorHandlers.push(handler);
|
||||
}
|
||||
|
||||
removeErrorHandler(handler: (error: Event) => void) {
|
||||
this.errorHandlers = this.errorHandlers.filter(h => h !== handler);
|
||||
}
|
||||
}
|
||||
|
||||
export const webSocketService = new WebSocketService();
|
||||
21
frontend/src/theme.ts
Normal file
21
frontend/src/theme.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
|
||||
// A basic theme for the application
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#6750A4', // A Material Design 3 primary color
|
||||
},
|
||||
secondary: {
|
||||
main: '#958DA5',
|
||||
},
|
||||
background: {
|
||||
default: '#F3F4F6', // A light grey background
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
|
||||
},
|
||||
});
|
||||
|
||||
export default theme;
|
||||
26
frontend/tsconfig.json
Normal file
26
frontend/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user