This commit is contained in:
Gilles Soulier
2026-01-05 13:13:08 +01:00
parent 8e14adafc6
commit 1d177e96a6
149 changed files with 29541 additions and 1 deletions

10
client/.env.example Normal file
View File

@@ -0,0 +1,10 @@
# Created by: Claude
# Date: 2026-01-03
# Purpose: Variables d'environnement pour le client Mesh
# Refs: client/CLAUDE.md
# URL de l'API Mesh Server
VITE_API_URL=http://localhost:8000
# URL WebSocket (sera déduite de l'API URL si non spécifiée)
# VITE_WS_URL=ws://localhost:8000/ws

246
client/CLAUDE.md Normal file
View File

@@ -0,0 +1,246 @@
# CLAUDE.md — Mesh Client
This file provides client-specific guidance for the Mesh web application.
## Client Role
The Mesh Client is a web application (React/TypeScript) that provides:
- User interface for chat, audio/video calls, screen sharing
- **WebRTC media plane**: Direct P2P audio/video/screen connections
- WebSocket connection to server for control plane
- Integration with desktop agent for advanced features
**Critical**: The client handles WebRTC media directly (P2P). File/folder/terminal sharing is delegated to the desktop agent via QUIC.
## Technology Stack
- **React 18** with TypeScript
- **Vite** for build tooling
- **React Router** for navigation
- **TanStack Query** for server state management
- **Zustand** for client state management
- **simple-peer** for WebRTC abstraction
- **Monokai-inspired dark theme**
## Project Structure
```
client/
├── src/
│ ├── main.tsx # App entry point
│ ├── App.tsx # Main app component
│ ├── pages/
│ │ ├── Login.tsx # Login page
│ │ └── Room.tsx # Main room interface
│ ├── components/
│ │ ├── Chat/ # Chat components
│ │ ├── Video/ # Video call components
│ │ ├── Participants/ # Participant list
│ │ └── Controls/ # Call controls
│ ├── lib/
│ │ ├── websocket.ts # WebSocket client
│ │ ├── webrtc.ts # WebRTC manager
│ │ └── events.ts # Event handlers
│ ├── stores/
│ │ ├── authStore.ts # Auth state
│ │ ├── roomStore.ts # Room state
│ │ └── callStore.ts # Call state
│ ├── hooks/
│ │ ├── useWebSocket.ts # WebSocket hook
│ │ └── useWebRTC.ts # WebRTC hook
│ ├── types/
│ │ └── events.ts # Event type definitions
│ └── styles/
│ ├── global.css # Global styles
│ └── theme.css # Monokai theme
├── public/
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts
└── CLAUDE.md
```
## Development Commands
### Setup
```bash
cd client
npm install
# or
pnpm install
```
### Run Development Server
```bash
npm run dev
# Opens at http://localhost:3000
```
### Build for Production
```bash
npm run build
# Output in dist/
```
### Type Checking
```bash
npm run type-check
```
### Linting
```bash
npm run lint
```
## Design System - Monokai Dark Theme
The UI uses a Monokai-inspired color palette defined in [src/styles/theme.css](src/styles/theme.css):
**Colors**:
- Background Primary: `#272822`
- Background Secondary: `#1e1f1c`
- Text Primary: `#f8f8f2`
- Accent Primary (cyan): `#66d9ef`
- Accent Success (green): `#a6e22e`
- Accent Warning (orange): `#fd971f`
- Accent Error (pink): `#f92672`
**Typography**:
- System font stack with fallbacks
- `Fira Code` for code/monospace elements
## WebSocket Integration
The client maintains a persistent WebSocket connection to the server for control plane events.
**Connection flow**:
1. Authenticate with JWT (obtained from login)
2. Send `system.hello` with peer type and version
3. Receive `system.welcome` with assigned `peer_id`
4. Join room with `room.join`
5. Listen for events and send messages
See [protocol_events_v_2.md](../protocol_events_v_2.md) for complete event protocol.
**Key events to handle**:
- `system.welcome` - Store peer_id
- `room.joined` - Update participant list
- `chat.message.created` - Display message
- `rtc.offer/answer/ice` - WebRTC signaling
- `presence.update` - Update participant status
## WebRTC Implementation
**Call flow**:
1. Request capability token from server (via REST API)
2. Create local media stream (`getUserMedia`)
3. Create peer connection with ICE servers
4. Send `rtc.offer` with capability token
5. Receive `rtc.answer`
6. Exchange ICE candidates via `rtc.ice`
7. Connection established, media flows P2P
**Screen sharing**:
- Use `getDisplayMedia` instead of `getUserMedia`
- Same signaling flow with screen capability token
**Important**:
- Always include capability token in WebRTC signaling messages
- Handle ICE connection failures gracefully
- Implement reconnection logic
- Clean up media streams on disconnect
## State Management
**Zustand stores**:
```typescript
// authStore.ts
{
user: User | null,
token: string | null,
peerId: string | null,
login: (username, password) => Promise<void>,
logout: () => void
}
// roomStore.ts
{
currentRoom: Room | null,
participants: Participant[],
messages: Message[],
joinRoom: (roomId) => void,
sendMessage: (content) => void
}
// callStore.ts
{
activeCall: Call | null,
localStream: MediaStream | null,
remoteStreams: Map<peerId, MediaStream>,
startCall: (peerId, type) => Promise<void>,
endCall: () => void
}
```
## Component Guidelines
1. **Functional components with TypeScript**
2. **Use hooks for side effects and state**
3. **CSS Modules for component-scoped styles**
4. **Semantic HTML**
5. **Accessibility**: ARIA labels, keyboard navigation
6. **Error boundaries** for graceful error handling
## Security Considerations
- **Never store JWT in localStorage** (use httpOnly cookies or memory)
- **Validate all incoming WebSocket messages**
- **Sanitize user-generated content** (messages, usernames)
- **Verify WebRTC fingerprints** (optional, V1+)
- **No sensitive data in console logs**
## Performance Optimization
- **Lazy load routes** with React.lazy
- **Virtualize long lists** (messages, participants)
- **Debounce input handlers**
- **Memoize expensive computations** (useMemo)
- **Avoid unnecessary re-renders** (React.memo)
## Testing Strategy
1. **Unit tests**: Components, hooks, utilities
2. **Integration tests**: WebSocket flows, WebRTC signaling
3. **E2E tests**: Complete user journeys (login, join room, send message, call)
## Browser Support
- **Chrome/Edge**: 90+
- **Firefox**: 88+
- **Safari**: 15+
WebRTC requires modern browsers. Provide warning for unsupported browsers.
## Environment Variables
Create `.env.local` for development:
```
VITE_API_URL=http://localhost:8000
VITE_WS_URL=ws://localhost:8000/ws
```
## Build & Deployment
The client builds to static files that can be served via:
- Nginx/Caddy
- CDN (CloudFront, Cloudflare)
- Docker container with nginx
**Important**: Configure CORS on the server to allow client origin.
---
**Remember**: The client handles only WebRTC media (audio/video/screen) in P2P mode. File/folder/terminal sharing requires the desktop agent.

13
client/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mesh - P2P Communication</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4012
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
client/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "mesh-client",
"version": "0.1.0",
"private": true,
"description": "Mesh Web Client - P2P communication platform",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@tanstack/react-query": "^5.17.9",
"axios": "^1.13.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.1",
"simple-peer": "^9.11.1",
"zustand": "^4.4.7"
},
"devDependencies": {
"@types/react": "^18.2.47",
"@types/react-dom": "^18.2.18",
"@types/simple-peer": "^9.11.8",
"@typescript-eslint/eslint-plugin": "^6.18.1",
"@typescript-eslint/parser": "^6.18.1",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.3.3",
"vite": "^5.0.11"
}
}

57
client/src/App.tsx Normal file
View File

@@ -0,0 +1,57 @@
// Created by: Claude
// Date: 2026-01-01
// Purpose: Main App component for Mesh client
// Refs: CLAUDE.md
import React from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useAuthStore } from './stores/authStore'
import Login from './pages/Login'
import Home from './pages/Home'
import Room from './pages/Room'
import ToastContainer from './components/ToastContainer'
import './styles/theme.css'
const queryClient = new QueryClient()
/**
* Composant pour protéger les routes authentifiées.
*/
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated } = useAuthStore()
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />
}
const App: React.FC = () => {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<div className="app">
<ToastContainer />
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<ProtectedRoute>
<Home />
</ProtectedRoute>
}
/>
<Route
path="/room/:roomId"
element={
<ProtectedRoute>
<Room />
</ProtectedRoute>
}
/>
</Routes>
</div>
</BrowserRouter>
</QueryClientProvider>
)
}
export default App

View File

@@ -0,0 +1,50 @@
/* Created by: Claude
Date: 2026-01-03
Purpose: Styles pour ConnectionIndicator
Refs: CLAUDE.md
*/
.indicator {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
transition: all 0.3s ease;
}
.icon {
font-size: 14px;
}
.label {
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Qualités de connexion */
.excellent {
background: rgba(166, 226, 46, 0.1);
color: var(--accent-success);
border: 1px solid rgba(166, 226, 46, 0.3);
}
.good {
background: rgba(102, 217, 239, 0.1);
color: var(--accent-primary);
border: 1px solid rgba(102, 217, 239, 0.3);
}
.poor {
background: rgba(230, 219, 116, 0.1);
color: #e6db74;
border: 1px solid rgba(230, 219, 116, 0.3);
}
.disconnected {
background: rgba(249, 38, 114, 0.1);
color: var(--accent-error);
border: 1px solid rgba(249, 38, 114, 0.3);
}

View File

@@ -0,0 +1,151 @@
// Created by: Claude
// Date: 2026-01-03
// Purpose: Indicateur de qualité de connexion WebRTC
// Refs: client/CLAUDE.md
import React, { useEffect, useState } from 'react'
import styles from './ConnectionIndicator.module.css'
export interface ConnectionIndicatorProps {
peerConnection?: RTCPeerConnection
peerId: string
username: string
}
type ConnectionQuality = 'excellent' | 'good' | 'poor' | 'disconnected'
const ConnectionIndicator: React.FC<ConnectionIndicatorProps> = ({
peerConnection,
peerId,
username,
}) => {
const [quality, setQuality] = useState<ConnectionQuality>('disconnected')
const [stats, setStats] = useState<{
rtt?: number // Round-trip time en ms
packetsLost?: number
jitter?: number // En ms
}>({})
useEffect(() => {
if (!peerConnection) {
setQuality('disconnected')
return
}
// Surveiller l'état de connexion
const handleConnectionStateChange = () => {
const state = peerConnection.connectionState
console.log(`[${username}] Connection state:`, state)
if (state === 'connected') {
setQuality('good')
} else if (state === 'connecting') {
setQuality('poor')
} else if (state === 'disconnected' || state === 'failed' || state === 'closed') {
setQuality('disconnected')
}
}
peerConnection.addEventListener('connectionstatechange', handleConnectionStateChange)
handleConnectionStateChange() // État initial
// Récupérer les stats toutes les 2 secondes
const statsInterval = setInterval(async () => {
if (peerConnection.connectionState !== 'connected') {
return
}
try {
const stats = await peerConnection.getStats()
let rtt: number | undefined
let packetsLost = 0
let jitter: number | undefined
stats.forEach((report) => {
if (report.type === 'candidate-pair' && report.state === 'succeeded') {
rtt = report.currentRoundTripTime ? report.currentRoundTripTime * 1000 : undefined
}
if (report.type === 'inbound-rtp' && report.kind === 'video') {
packetsLost = report.packetsLost || 0
jitter = report.jitter ? report.jitter * 1000 : undefined
}
})
setStats({ rtt, packetsLost, jitter })
// Déterminer la qualité selon RTT
if (rtt !== undefined) {
if (rtt < 100) {
setQuality('excellent')
} else if (rtt < 200) {
setQuality('good')
} else {
setQuality('poor')
}
}
} catch (error) {
console.error('Error getting WebRTC stats:', error)
}
}, 2000)
return () => {
peerConnection.removeEventListener('connectionstatechange', handleConnectionStateChange)
clearInterval(statsInterval)
}
}, [peerConnection, username])
const getQualityIcon = () => {
switch (quality) {
case 'excellent':
return '📶'
case 'good':
return '📡'
case 'poor':
return '⚠️'
case 'disconnected':
return '❌'
}
}
const getQualityLabel = () => {
switch (quality) {
case 'excellent':
return 'Excellente'
case 'good':
return 'Bonne'
case 'poor':
return 'Faible'
case 'disconnected':
return 'Déconnecté'
}
}
return (
<div className={`${styles.indicator} ${styles[quality]}`} title={getTooltip()}>
<span className={styles.icon}>{getQualityIcon()}</span>
<span className={styles.label}>{getQualityLabel()}</span>
</div>
)
function getTooltip(): string {
if (quality === 'disconnected') {
return 'Pas de connexion'
}
const parts: string[] = []
if (stats.rtt !== undefined) {
parts.push(`RTT: ${stats.rtt.toFixed(0)}ms`)
}
if (stats.packetsLost !== undefined && stats.packetsLost > 0) {
parts.push(`Paquets perdus: ${stats.packetsLost}`)
}
if (stats.jitter !== undefined) {
parts.push(`Jitter: ${stats.jitter.toFixed(1)}ms`)
}
return parts.length > 0 ? parts.join(' | ') : getQualityLabel()
}
}
export default ConnectionIndicator

View File

@@ -0,0 +1,143 @@
/* Created by: Claude */
/* Date: 2026-01-05 */
/* Purpose: Styles pour le modal d'invitation de membres */
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: #2d2d2d;
border-radius: 8px;
padding: 0;
width: 90%;
max-width: 500px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #404040;
}
.header h2 {
margin: 0;
font-size: 1.25rem;
color: #f5f5f5;
}
.closeButton {
background: none;
border: none;
color: #999;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s;
}
.closeButton:hover {
background: #404040;
color: #f5f5f5;
}
.form {
padding: 24px;
}
.field {
margin-bottom: 20px;
}
.field label {
display: block;
margin-bottom: 8px;
color: #ccc;
font-size: 0.9rem;
font-weight: 500;
}
.input {
width: 100%;
padding: 12px;
background: #1a1a1a;
border: 1px solid #404040;
border-radius: 4px;
color: #f5f5f5;
font-size: 1rem;
transition: border-color 0.2s;
}
.input:focus {
outline: none;
border-color: #007acc;
}
.error {
padding: 12px;
background: rgba(220, 53, 69, 0.1);
border: 1px solid rgba(220, 53, 69, 0.3);
border-radius: 4px;
color: #ff6b6b;
margin-bottom: 16px;
font-size: 0.9rem;
}
.actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.cancelButton,
.submitButton {
padding: 10px 20px;
border: none;
border-radius: 4px;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.cancelButton {
background: #404040;
color: #f5f5f5;
}
.cancelButton:hover:not(:disabled) {
background: #4a4a4a;
}
.submitButton {
background: #007acc;
color: white;
}
.submitButton:hover:not(:disabled) {
background: #005a9e;
}
.cancelButton:disabled,
.submitButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View File

@@ -0,0 +1,100 @@
// Created by: Claude
// Date: 2026-01-05
// Purpose: Modal pour inviter un membre à une room
// Refs: client/CLAUDE.md
import React, { useState } from 'react'
import { roomsApi } from '../services/api'
import styles from './InviteMemberModal.module.css'
interface InviteMemberModalProps {
roomId: string
onClose: () => void
onMemberAdded: () => void
}
const InviteMemberModal: React.FC<InviteMemberModalProps> = ({
roomId,
onClose,
onMemberAdded,
}) => {
const [username, setUsername] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
try {
await roomsApi.addMember(roomId, username)
onMemberAdded()
onClose()
} catch (err: any) {
console.error('Error adding member:', err)
if (err.response?.status === 404) {
setError(`Utilisateur "${username}" introuvable`)
} else if (err.response?.status === 400) {
setError('Cet utilisateur est déjà membre de la room')
} else if (err.response?.status === 403) {
setError('Seul le propriétaire peut ajouter des membres')
} else {
setError('Erreur lors de l\'ajout du membre')
}
} finally {
setLoading(false)
}
}
return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.header}>
<h2>Inviter un membre</h2>
<button className={styles.closeButton} onClick={onClose}>
</button>
</div>
<form onSubmit={handleSubmit} className={styles.form}>
<div className={styles.field}>
<label htmlFor="username">Nom d'utilisateur</label>
<input
id="username"
type="text"
placeholder="Entrez le nom d'utilisateur"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
className={styles.input}
/>
</div>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.actions}>
<button
type="button"
onClick={onClose}
className={styles.cancelButton}
disabled={loading}
>
Annuler
</button>
<button
type="submit"
className={styles.submitButton}
disabled={loading || !username.trim()}
>
{loading ? 'Invitation...' : 'Inviter'}
</button>
</div>
</form>
</div>
</div>
)
}
export default InviteMemberModal

View File

@@ -0,0 +1,47 @@
/* Created by: Claude
Date: 2026-01-03
Purpose: Styles pour MediaControls
Refs: CLAUDE.md
*/
.controls {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
.controlButton {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
color: var(--text-secondary);
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 1.25rem;
transition: all 0.2s ease;
min-width: 48px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.controlButton:hover:not(:disabled) {
border-color: var(--accent-primary);
transform: translateY(-1px);
}
.controlButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.controlButton.active {
border-color: var(--accent-success);
background: rgba(102, 217, 239, 0.1);
}
.controlButton.inactive {
border-color: var(--accent-error);
opacity: 0.6;
}

View File

@@ -0,0 +1,66 @@
// Created by: Claude
// Date: 2026-01-03
// Purpose: Composant pour contrôles média (audio/vidéo/partage)
// Refs: client/CLAUDE.md
import React from 'react'
import styles from './MediaControls.module.css'
export interface MediaControlsProps {
isAudioEnabled: boolean
isVideoEnabled: boolean
isScreenSharing: boolean
onToggleAudio: () => void
onToggleVideo: () => void
onToggleScreenShare: () => void
disabled?: boolean
}
const MediaControls: React.FC<MediaControlsProps> = ({
isAudioEnabled,
isVideoEnabled,
isScreenSharing,
onToggleAudio,
onToggleVideo,
onToggleScreenShare,
disabled = false,
}) => {
return (
<div className={styles.controls}>
<button
className={`${styles.controlButton} ${
isAudioEnabled ? styles.active : styles.inactive
}`}
onClick={onToggleAudio}
disabled={disabled}
title={isAudioEnabled ? 'Désactiver le micro' : 'Activer le micro'}
>
{isAudioEnabled ? '🎤' : '🔇'}
</button>
<button
className={`${styles.controlButton} ${
isVideoEnabled ? styles.active : styles.inactive
}`}
onClick={onToggleVideo}
disabled={disabled}
title={isVideoEnabled ? 'Désactiver la caméra' : 'Activer la caméra'}
>
{isVideoEnabled ? '📹' : '📷'}
</button>
<button
className={`${styles.controlButton} ${
isScreenSharing ? styles.active : styles.inactive
}`}
onClick={onToggleScreenShare}
disabled={disabled}
title={isScreenSharing ? 'Arrêter le partage' : 'Partager l\'écran'}
>
🖥
</button>
</div>
)
}
export default MediaControls

View File

@@ -0,0 +1,96 @@
/* Created by: Claude
Date: 2026-01-03
Purpose: Styles pour ToastContainer
Refs: CLAUDE.md
*/
.container {
position: fixed;
top: var(--spacing-lg);
right: var(--spacing-lg);
z-index: 9999;
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
max-width: 400px;
}
.toast {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
padding: var(--spacing-md);
display: flex;
align-items: center;
gap: var(--spacing-sm);
cursor: pointer;
animation: slideIn 0.3s ease-out;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transition: all 0.2s ease;
}
.toast:hover {
transform: translateX(-4px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.icon {
font-size: 1.5rem;
flex-shrink: 0;
}
.message {
flex: 1;
color: var(--text-primary);
font-size: 14px;
line-height: 1.4;
}
.close {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s ease;
}
.close:hover {
background: var(--bg-primary);
color: var(--text-primary);
}
/* Types de notifications */
.info {
border-left: 4px solid var(--accent-primary);
}
.success {
border-left: 4px solid var(--accent-success);
}
.warning {
border-left: 4px solid #e6db74;
}
.error {
border-left: 4px solid var(--accent-error);
}

View File

@@ -0,0 +1,47 @@
// Created by: Claude
// Date: 2026-01-03
// Purpose: Conteneur pour afficher les notifications toast
// Refs: client/CLAUDE.md
import React from 'react'
import { useNotificationStore } from '../stores/notificationStore'
import styles from './ToastContainer.module.css'
const ToastContainer: React.FC = () => {
const { notifications, removeNotification } = useNotificationStore()
if (notifications.length === 0) {
return null
}
return (
<div className={styles.container}>
{notifications.map((notification) => (
<div
key={notification.id}
className={`${styles.toast} ${styles[notification.type]}`}
onClick={() => removeNotification(notification.id)}
>
<div className={styles.icon}>
{notification.type === 'info' && ''}
{notification.type === 'success' && '✅'}
{notification.type === 'warning' && '⚠️'}
{notification.type === 'error' && '❌'}
</div>
<div className={styles.message}>{notification.message}</div>
<button
className={styles.close}
onClick={(e) => {
e.stopPropagation()
removeNotification(notification.id)
}}
>
×
</button>
</div>
))}
</div>
)
}
export default ToastContainer

View File

@@ -0,0 +1,152 @@
/* Created by: Claude
Date: 2026-01-03
Purpose: Styles pour VideoGrid
Refs: CLAUDE.md
*/
.gridContainer {
position: relative;
width: 100%;
height: 100%;
}
.videoGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--spacing-md);
padding: var(--spacing-lg);
width: 100%;
height: 100%;
overflow-y: auto;
}
.localPreview {
position: absolute;
bottom: 20px;
right: 20px;
width: 240px;
height: 135px;
border-radius: 8px;
overflow: hidden;
border: 2px solid var(--accent-primary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 10;
transition: all 0.3s ease;
}
.localPreview:hover {
transform: scale(1.05);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.5);
}
.localVideo {
width: 100%;
height: 100%;
object-fit: cover;
transform: scaleX(-1); /* Effet miroir pour la caméra locale */
}
.localLabel {
position: absolute;
bottom: 8px;
left: 8px;
right: 8px;
color: white;
font-size: 12px;
font-weight: 600;
background: rgba(0, 0, 0, 0.6);
padding: 4px 8px;
border-radius: 4px;
text-align: center;
}
.videoContainer {
position: relative;
background: var(--bg-secondary);
border-radius: 8px;
overflow: hidden;
aspect-ratio: 16 / 9;
border: 1px solid var(--border-primary);
transition: all 0.3s ease;
}
.videoContainer.speaking {
border: 2px solid var(--accent-success);
box-shadow: 0 0 20px rgba(166, 226, 46, 0.3);
transform: scale(1.02);
}
.video {
width: 100%;
height: 100%;
object-fit: cover;
}
.videoOverlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);
padding: 0.5rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-sm);
}
.videoLabel {
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
flex: 1;
display: flex;
align-items: center;
gap: 6px;
}
.speakingIcon {
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.2);
}
}
.noVideo {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: var(--bg-primary);
}
.noVideoIcon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.emptyState {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: var(--spacing-lg);
}
.emptyMessage {
color: var(--text-secondary);
font-size: 16px;
text-align: center;
}

View File

@@ -0,0 +1,146 @@
// Created by: Claude
// Date: 2026-01-03
// Purpose: Composant grille vidéo pour afficher les streams
// Refs: client/CLAUDE.md
import React, { useEffect, useRef } from 'react'
import { PeerConnection } from '../stores/webrtcStore'
import ConnectionIndicator from './ConnectionIndicator'
import { useAudioLevel } from '../hooks/useAudioLevel'
import styles from './VideoGrid.module.css'
export interface VideoGridProps {
localStream?: MediaStream
localScreenStream?: MediaStream
peers: PeerConnection[]
localUsername: string
}
const VideoGrid: React.FC<VideoGridProps> = ({
localStream,
localScreenStream,
peers,
localUsername,
}) => {
const localVideoRef = useRef<HTMLVideoElement>(null)
const localScreenRef = useRef<HTMLVideoElement>(null)
// Attacher le stream local
useEffect(() => {
if (localVideoRef.current && localStream) {
localVideoRef.current.srcObject = localStream
}
}, [localStream])
// Attacher le stream de partage d'écran local
useEffect(() => {
if (localScreenRef.current && localScreenStream) {
localScreenRef.current.srcObject = localScreenStream
}
}, [localScreenStream])
// Si aucun stream actif, afficher un message
if (!localStream && !localScreenStream && peers.length === 0) {
return (
<div className={styles.emptyState}>
<p className={styles.emptyMessage}>
Activez votre caméra ou microphone pour démarrer un appel
</p>
</div>
)
}
return (
<div className={styles.gridContainer}>
{/* Grille principale pour les autres participants */}
<div className={styles.videoGrid}>
{/* Partage d'écran local (plein écran) */}
{localScreenStream && (
<div className={styles.videoContainer}>
<video
ref={localScreenRef}
autoPlay
muted
playsInline
className={styles.video}
/>
<div className={styles.videoLabel}>
{localUsername} - Partage d'écran
</div>
</div>
)}
{/* Streams des peers (plein écran) */}
{peers.map((peer) => (
<PeerVideo key={peer.peer_id} peer={peer} />
))}
</div>
{/* Miniature locale (picture-in-picture) */}
{localStream && (
<div className={styles.localPreview}>
<video
ref={localVideoRef}
autoPlay
muted
playsInline
className={styles.localVideo}
/>
<div className={styles.localLabel}>
{localUsername}
</div>
</div>
)}
</div>
)
}
/**
* Composant pour afficher le stream d'un peer.
*/
const PeerVideo: React.FC<{ peer: PeerConnection }> = ({ peer }) => {
const videoRef = useRef<HTMLVideoElement>(null)
const { isSpeaking } = useAudioLevel(peer.stream, 0.02)
useEffect(() => {
if (videoRef.current && peer.stream) {
videoRef.current.srcObject = peer.stream
}
}, [peer.stream])
if (!peer.stream) {
return (
<div className={styles.videoContainer}>
<div className={styles.noVideo}>
<span className={styles.noVideoIcon}>👤</span>
<div className={styles.videoLabel}>{peer.username}</div>
</div>
</div>
)
}
return (
<div className={`${styles.videoContainer} ${isSpeaking ? styles.speaking : ''}`}>
<video
ref={videoRef}
autoPlay
playsInline
className={styles.video}
/>
<div className={styles.videoOverlay}>
<div className={styles.videoLabel}>
{isSpeaking && <span className={styles.speakingIcon}>🎙</span>}
{peer.username}
{peer.isScreenSharing && ' - Partage d\'écran'}
</div>
<ConnectionIndicator
peerConnection={peer.connection}
peerId={peer.peer_id}
username={peer.username}
/>
</div>
</div>
)
}
export default VideoGrid

View File

@@ -0,0 +1,74 @@
// Created by: Claude
// Date: 2026-01-03
// Purpose: Hook pour détecter le niveau audio et la parole
// Refs: client/CLAUDE.md
import { useEffect, useState, useRef } from 'react'
/**
* Hook pour détecter si quelqu'un parle via l'analyse audio.
*/
export const useAudioLevel = (stream?: MediaStream, threshold: number = 0.01) => {
const [isSpeaking, setIsSpeaking] = useState(false)
const [audioLevel, setAudioLevel] = useState(0)
const audioContextRef = useRef<AudioContext | null>(null)
const analyserRef = useRef<AnalyserNode | null>(null)
const animationFrameRef = useRef<number>()
useEffect(() => {
if (!stream) {
setIsSpeaking(false)
setAudioLevel(0)
return
}
const audioTrack = stream.getAudioTracks()[0]
if (!audioTrack) {
return
}
// Créer le contexte audio
const audioContext = new AudioContext()
const analyser = audioContext.createAnalyser()
const source = audioContext.createMediaStreamSource(stream)
analyser.fftSize = 256
analyser.smoothingTimeConstant = 0.8
source.connect(analyser)
audioContextRef.current = audioContext
analyserRef.current = analyser
const dataArray = new Uint8Array(analyser.frequencyBinCount)
// Analyser le niveau audio en continu
const checkAudioLevel = () => {
if (!analyserRef.current) return
analyserRef.current.getByteFrequencyData(dataArray)
// Calculer le niveau moyen
const average = dataArray.reduce((a, b) => a + b) / dataArray.length
const normalized = average / 255 // Normaliser entre 0 et 1
setAudioLevel(normalized)
setIsSpeaking(normalized > threshold)
animationFrameRef.current = requestAnimationFrame(checkAudioLevel)
}
checkAudioLevel()
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
if (audioContextRef.current) {
audioContextRef.current.close()
}
}
}, [stream, threshold])
return { isSpeaking, audioLevel }
}

View File

@@ -0,0 +1,238 @@
// Created by: Claude
// Date: 2026-01-03
// Purpose: Hook pour gérer WebSocket avec intégration room/messages
// Refs: client/CLAUDE.md, docs/protocol_events_v_2.md
import { useCallback, useRef } from 'react'
import { useWebSocket, WebSocketEvent } from './useWebSocket'
import { useRoomStore, Message } from '../stores/roomStore'
import { WebRTCSignalEvent } from './useWebRTC'
/**
* Hook pour gérer WebSocket avec intégration automatique du store room.
*
* Gère automatiquement:
* - Réception de messages (chat.message.created)
* - Événements de room (room.joined, room.left)
* - Mise à jour de présence
*/
/**
* Gestionnaires WebRTC.
*/
interface WebRTCHandlers {
onOffer?: (fromPeerId: string, username: string, sdp: string) => void
onAnswer?: (fromPeerId: string, sdp: string) => void
onIceCandidate?: (fromPeerId: string, candidate: RTCIceCandidateInit) => void
}
export const useRoomWebSocket = (webrtcHandlers?: WebRTCHandlers) => {
const {
addMessage,
addMember,
removeMember,
updateMemberPresence,
} = useRoomStore()
const webrtcHandlersRef = useRef<WebRTCHandlers | undefined>(webrtcHandlers)
webrtcHandlersRef.current = webrtcHandlers
/**
* Gestionnaire d'événements WebSocket.
*/
const handleMessage = useCallback(
(event: WebSocketEvent) => {
console.log('WebSocket event:', event.type, event)
switch (event.type) {
case 'system.welcome':
console.log('Connected to Mesh server, peer_id:', event.payload.peer_id)
break
case 'chat.message.created': {
// Nouveau message de chat
const message: Message = {
message_id: event.payload.message_id,
room_id: event.payload.room_id,
user_id: event.payload.user_id,
from_username: event.payload.from_username,
content: event.payload.content,
created_at: event.payload.created_at,
}
addMessage(event.payload.room_id, message)
break
}
case 'room.joined': {
// Un membre a rejoint la room
const member = {
user_id: event.payload.user_id,
username: event.payload.username,
peer_id: event.payload.peer_id,
role: event.payload.role || 'member',
presence: 'online' as const,
}
addMember(event.payload.room_id, member)
break
}
case 'room.left': {
// Un membre a quitté la room
removeMember(event.payload.room_id, event.payload.user_id)
break
}
case 'presence.update': {
// Mise à jour de présence
updateMemberPresence(
event.payload.room_id,
event.payload.user_id,
event.payload.presence
)
break
}
case 'rtc.offer': {
// Offer WebRTC reçue
const { from_peer_id, from_username, sdp } = event.payload
webrtcHandlersRef.current?.onOffer?.(from_peer_id, from_username, sdp)
break
}
case 'rtc.answer': {
// Answer WebRTC reçue
const { from_peer_id, sdp } = event.payload
webrtcHandlersRef.current?.onAnswer?.(from_peer_id, sdp)
break
}
case 'rtc.ice_candidate': {
// Candidat ICE reçu
const { from_peer_id, candidate } = event.payload
webrtcHandlersRef.current?.onIceCandidate?.(from_peer_id, candidate)
break
}
case 'error': {
// Erreur du serveur
console.error('Server error:', event.payload.code, event.payload.message)
break
}
default:
// Autres événements (P2P, etc.)
console.log('Unhandled event type:', event.type)
}
},
[addMessage, addMember, removeMember, updateMemberPresence]
)
/**
* Callbacks WebSocket.
*/
const handleConnect = useCallback(() => {
console.log('WebSocket connected')
}, [])
const handleDisconnect = useCallback(() => {
console.log('WebSocket disconnected')
}, [])
const handleError = useCallback((error: Event) => {
console.error('WebSocket error:', error)
}, [])
/**
* Hook WebSocket avec gestionnaires d'événements.
*/
const ws = useWebSocket({
onMessage: handleMessage,
onConnect: handleConnect,
onDisconnect: handleDisconnect,
onError: handleError,
})
/**
* Rejoindre une room.
*/
const joinRoom = useCallback(
(roomId: string) => {
return ws.sendEvent({
type: 'room.join',
to: 'server',
payload: {
room_id: roomId,
},
})
},
[ws]
)
/**
* Quitter une room.
*/
const leaveRoom = useCallback(
(roomId: string) => {
return ws.sendEvent({
type: 'room.leave',
to: 'server',
payload: {
room_id: roomId,
},
})
},
[ws]
)
/**
* Envoyer un message dans une room.
*/
const sendMessage = useCallback(
(roomId: string, content: string) => {
return ws.sendEvent({
type: 'chat.message.send',
to: 'server',
payload: {
room_id: roomId,
content,
},
})
},
[ws]
)
/**
* Mettre à jour sa présence.
*/
const updatePresence = useCallback(
(roomId: string, presence: 'online' | 'busy' | 'offline') => {
return ws.sendEvent({
type: 'presence.update',
to: 'server',
payload: {
room_id: roomId,
presence,
},
})
},
[ws]
)
/**
* Envoyer un signal WebRTC.
*/
const sendRTCSignal = useCallback(
(event: WebRTCSignalEvent) => {
return ws.sendEvent(event)
},
[ws]
)
return {
...ws,
joinRoom,
leaveRoom,
sendMessage,
updatePresence,
sendRTCSignal,
}
}

View File

@@ -0,0 +1,358 @@
// Created by: Claude
// Date: 2026-01-03
// Purpose: Hook pour gérer WebRTC (audio/vidéo)
// Refs: client/CLAUDE.md, docs/signaling_v_2.md
import { useCallback, useEffect } from 'react'
import { useWebRTCStore } from '../stores/webrtcStore'
import { notify } from '../stores/notificationStore'
/**
* Événement WebRTC à envoyer via WebSocket.
*/
export interface WebRTCSignalEvent {
type: 'rtc.offer' | 'rtc.answer' | 'rtc.ice_candidate'
to: string
payload: {
room_id: string
target_peer_id: string
sdp?: string
candidate?: RTCIceCandidateInit
}
}
/**
* Options pour le hook useWebRTC.
*/
export interface UseWebRTCOptions {
roomId: string
peerId: string
onSignal?: (event: WebRTCSignalEvent) => void
}
/**
* Hook pour gérer WebRTC.
*/
export const useWebRTC = ({ roomId, peerId, onSignal }: UseWebRTCOptions) => {
const {
localMedia,
peers,
iceServers,
setLocalStream,
setLocalAudio,
setLocalVideo,
setScreenStream,
stopLocalMedia,
addPeer,
removePeer,
setPeerStream,
updatePeerMedia,
getPeer,
clearAll,
} = useWebRTCStore()
/**
* Démarrer le média local (audio/vidéo).
*/
const startMedia = useCallback(
async (audio: boolean = true, video: boolean = false) => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio, video })
setLocalStream(stream)
setLocalAudio(audio)
setLocalVideo(video)
if (audio && video) {
notify.success('Caméra et micro activés')
} else if (video) {
notify.success('Caméra activée')
} else if (audio) {
notify.success('Micro activé')
}
return stream
} catch (error: any) {
console.error('Error accessing media devices:', error)
// Messages d'erreur personnalisés
if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') {
notify.error('Permission refusée. Veuillez autoriser l\'accès à votre caméra/micro.')
} else if (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError') {
notify.error('Aucune caméra ou micro détecté.')
} else if (error.name === 'NotReadableError' || error.name === 'TrackStartError') {
notify.error('Impossible d\'accéder à la caméra/micro (déjà utilisé par une autre application).')
} else {
notify.error('Erreur lors de l\'accès aux périphériques média.')
}
throw error
}
},
[setLocalStream, setLocalAudio, setLocalVideo]
)
/**
* Démarrer le partage d'écran.
*/
const startScreenShare = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false,
})
setScreenStream(stream)
notify.success('Partage d\'écran démarré')
// Arrêter le partage quand l'utilisateur clique sur "Arrêter le partage"
stream.getVideoTracks()[0].onended = () => {
setScreenStream(undefined)
notify.info('Partage d\'écran arrêté')
}
return stream
} catch (error: any) {
console.error('Error starting screen share:', error)
if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') {
notify.warning('Partage d\'écran annulé')
} else {
notify.error('Erreur lors du partage d\'écran')
}
throw error
}
}, [setScreenStream])
/**
* Arrêter le partage d'écran.
*/
const stopScreenShare = useCallback(() => {
if (localMedia.screenStream) {
localMedia.screenStream.getTracks().forEach((track) => track.stop())
setScreenStream(undefined)
}
}, [localMedia.screenStream, setScreenStream])
/**
* Créer une connexion RTCPeerConnection.
*/
const createPeerConnection = useCallback(
(targetPeerId: string, username: string) => {
const pc = new RTCPeerConnection({ iceServers })
// Ajouter le stream local à la connexion
if (localMedia.stream) {
localMedia.stream.getTracks().forEach((track) => {
if (localMedia.stream) {
pc.addTrack(track, localMedia.stream)
}
})
}
// Ajouter le stream de partage d'écran si actif
if (localMedia.screenStream) {
localMedia.screenStream.getTracks().forEach((track) => {
if (localMedia.screenStream) {
pc.addTrack(track, localMedia.screenStream)
}
})
}
// Gérer les candidats ICE
pc.onicecandidate = (event) => {
if (event.candidate && onSignal) {
onSignal({
type: 'rtc.ice_candidate',
to: 'server',
payload: {
room_id: roomId,
target_peer_id: targetPeerId,
candidate: event.candidate.toJSON(),
},
})
}
}
// Gérer la réception de stream distant
pc.ontrack = (event) => {
console.log('Received remote track from', targetPeerId, event.track.kind)
const [remoteStream] = event.streams
if (remoteStream) {
setPeerStream(targetPeerId, remoteStream)
}
}
// Gérer la déconnexion
pc.onconnectionstatechange = () => {
console.log('Connection state with', targetPeerId, ':', pc.connectionState)
if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
removePeer(targetPeerId)
}
}
// Ajouter le peer au store
addPeer(targetPeerId, username, roomId, pc)
return pc
},
[
iceServers,
localMedia.stream,
localMedia.screenStream,
roomId,
onSignal,
addPeer,
setPeerStream,
removePeer,
]
)
/**
* Initier un appel (créer une offer).
*/
const createOffer = useCallback(
async (targetPeerId: string, username: string) => {
const pc = createPeerConnection(targetPeerId, username)
try {
const offer = await pc.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true,
})
await pc.setLocalDescription(offer)
if (onSignal && offer.sdp) {
onSignal({
type: 'rtc.offer',
to: 'server',
payload: {
room_id: roomId,
target_peer_id: targetPeerId,
sdp: offer.sdp,
},
})
}
} catch (error) {
console.error('Error creating offer:', error)
removePeer(targetPeerId)
throw error
}
},
[createPeerConnection, roomId, onSignal, removePeer]
)
/**
* Gérer une offer reçue (créer une answer).
*/
const handleOffer = useCallback(
async (fromPeerId: string, username: string, sdp: string) => {
const pc = createPeerConnection(fromPeerId, username)
try {
await pc.setRemoteDescription({
type: 'offer',
sdp,
})
const answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
if (onSignal && answer.sdp) {
onSignal({
type: 'rtc.answer',
to: 'server',
payload: {
room_id: roomId,
target_peer_id: fromPeerId,
sdp: answer.sdp,
},
})
}
} catch (error) {
console.error('Error handling offer:', error)
removePeer(fromPeerId)
throw error
}
},
[createPeerConnection, roomId, onSignal, removePeer]
)
/**
* Gérer une answer reçue.
*/
const handleAnswer = useCallback(
async (fromPeerId: string, sdp: string) => {
const peer = getPeer(fromPeerId)
if (!peer) {
console.error('Peer not found:', fromPeerId)
return
}
try {
await peer.connection.setRemoteDescription({
type: 'answer',
sdp,
})
} catch (error) {
console.error('Error handling answer:', error)
removePeer(fromPeerId)
throw error
}
},
[getPeer, removePeer]
)
/**
* Gérer un candidat ICE reçu.
*/
const handleIceCandidate = useCallback(
async (fromPeerId: string, candidate: RTCIceCandidateInit) => {
const peer = getPeer(fromPeerId)
if (!peer) {
console.error('Peer not found:', fromPeerId)
return
}
try {
await peer.connection.addIceCandidate(new RTCIceCandidate(candidate))
} catch (error) {
console.error('Error adding ICE candidate:', error)
}
},
[getPeer]
)
/**
* Nettoyer toutes les connexions lors du démontage.
*/
useEffect(() => {
return () => {
clearAll()
}
}, [clearAll])
return {
// État
localMedia,
peers: Array.from(peers.values()),
// Média local
startMedia,
stopMedia: stopLocalMedia,
toggleAudio: () => setLocalAudio(!localMedia.isAudioEnabled),
toggleVideo: () => setLocalVideo(!localMedia.isVideoEnabled),
startScreenShare,
stopScreenShare,
// WebRTC signaling
createOffer,
handleOffer,
handleAnswer,
handleIceCandidate,
// Cleanup
cleanup: clearAll,
}
}

View File

@@ -0,0 +1,259 @@
// Created by: Claude
// Date: 2026-01-03
// Purpose: Hook personnalisé pour la gestion WebSocket avec reconnexion
// Refs: client/CLAUDE.md, docs/protocol_events_v_2.md
import { useEffect, useRef, useState, useCallback } from 'react'
import { useAuthStore } from '../stores/authStore'
/**
* Événement WebSocket structuré selon le protocole Mesh.
*/
export interface WebSocketEvent {
type: string
id: string
timestamp: string
from: string
to: string
payload: any
}
/**
* Options pour le hook useWebSocket.
*/
interface UseWebSocketOptions {
url?: string
autoConnect?: boolean
reconnectDelay?: number
maxReconnectAttempts?: number
onMessage?: (event: WebSocketEvent) => void
onConnect?: () => void
onDisconnect?: () => void
onError?: (error: Event) => void
}
/**
* État de connexion WebSocket.
*/
export enum ConnectionStatus {
DISCONNECTED = 'disconnected',
CONNECTING = 'connecting',
CONNECTED = 'connected',
RECONNECTING = 'reconnecting',
ERROR = 'error',
}
/**
* Hook personnalisé pour gérer la connexion WebSocket.
*
* Fonctionnalités:
* - Connexion automatique avec le token JWT
* - Reconnexion automatique en cas de déconnexion
* - Gestion des événements structurés
* - Envoi d'événements typés
*/
export const useWebSocket = (options: UseWebSocketOptions = {}) => {
const {
url = import.meta.env.VITE_WS_URL || 'ws://localhost:8000/ws',
autoConnect = true,
reconnectDelay = 3000,
maxReconnectAttempts = 5,
onMessage,
onConnect,
onDisconnect,
onError,
} = options
const { token, logout } = useAuthStore()
const wsRef = useRef<WebSocket | null>(null)
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const reconnectAttemptsRef = useRef(0)
const peerId = useRef<string | null>(null)
const [status, setStatus] = useState<ConnectionStatus>(ConnectionStatus.DISCONNECTED)
const [lastError, setLastError] = useState<string | null>(null)
/**
* Nettoyer les timeouts de reconnexion.
*/
const clearReconnectTimeout = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
reconnectTimeoutRef.current = null
}
}, [])
/**
* Connecter au serveur WebSocket.
*/
const connect = useCallback(() => {
if (!token) {
console.warn('Cannot connect to WebSocket: no token available')
return
}
if (wsRef.current?.readyState === WebSocket.OPEN) {
console.warn('WebSocket already connected')
return
}
setStatus(
reconnectAttemptsRef.current > 0
? ConnectionStatus.RECONNECTING
: ConnectionStatus.CONNECTING
)
try {
// Construire l'URL avec le token en query parameter
const wsUrl = `${url}?token=${token}`
const ws = new WebSocket(wsUrl)
ws.onopen = () => {
console.log('WebSocket connected')
setStatus(ConnectionStatus.CONNECTED)
setLastError(null)
reconnectAttemptsRef.current = 0
// Envoyer system.hello pour s'identifier
const helloEvent: Partial<WebSocketEvent> = {
type: 'system.hello',
payload: {
client_type: 'web',
version: '1.0.0',
},
}
ws.send(JSON.stringify(helloEvent))
onConnect?.()
}
ws.onmessage = (event) => {
try {
const data: WebSocketEvent = JSON.parse(event.data)
// Stocker le peer_id depuis system.welcome
if (data.type === 'system.welcome') {
peerId.current = data.payload.peer_id
console.log('Received peer_id:', peerId.current)
}
onMessage?.(data)
} catch (err) {
console.error('Error parsing WebSocket message:', err)
}
}
ws.onerror = (error) => {
console.error('WebSocket error:', error)
setLastError('WebSocket connection error')
setStatus(ConnectionStatus.ERROR)
onError?.(error)
}
ws.onclose = (event) => {
console.log('WebSocket closed:', event.code, event.reason)
wsRef.current = null
peerId.current = null
if (event.code === 1008) {
// Invalid token - déconnecter l'utilisateur
console.error('Invalid token, logging out')
logout()
setStatus(ConnectionStatus.DISCONNECTED)
} else if (reconnectAttemptsRef.current < maxReconnectAttempts) {
// Tenter une reconnexion
setStatus(ConnectionStatus.RECONNECTING)
reconnectAttemptsRef.current++
console.log(
`Reconnecting... (attempt ${reconnectAttemptsRef.current}/${maxReconnectAttempts})`
)
reconnectTimeoutRef.current = setTimeout(() => {
connect()
}, reconnectDelay)
} else {
setStatus(ConnectionStatus.DISCONNECTED)
setLastError('Max reconnection attempts reached')
}
onDisconnect?.()
}
wsRef.current = ws
} catch (err) {
console.error('Error creating WebSocket:', err)
setStatus(ConnectionStatus.ERROR)
setLastError('Failed to create WebSocket connection')
}
}, [token, url, reconnectDelay, maxReconnectAttempts, onConnect, onMessage, onDisconnect, onError, logout])
/**
* Déconnecter du serveur WebSocket.
*/
const disconnect = useCallback(() => {
clearReconnectTimeout()
reconnectAttemptsRef.current = maxReconnectAttempts // Empêcher la reconnexion auto
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
peerId.current = null
}
setStatus(ConnectionStatus.DISCONNECTED)
}, [clearReconnectTimeout, maxReconnectAttempts])
/**
* Envoyer un événement WebSocket.
*/
const sendEvent = useCallback((event: Partial<WebSocketEvent>) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
console.warn('WebSocket not connected, cannot send event')
return false
}
try {
// Ajouter les champs par défaut
const fullEvent: WebSocketEvent = {
id: crypto.randomUUID(),
timestamp: new Date().toISOString(),
from: peerId.current || 'unknown',
to: event.to || 'server',
type: event.type || 'unknown',
payload: event.payload || {},
}
wsRef.current.send(JSON.stringify(fullEvent))
return true
} catch (err) {
console.error('Error sending WebSocket event:', err)
return false
}
}, [])
/**
* Connexion automatique au montage.
*/
useEffect(() => {
if (autoConnect && token) {
connect()
}
return () => {
clearReconnectTimeout()
if (wsRef.current) {
wsRef.current.close()
}
}
}, [autoConnect, token, connect, clearReconnectTimeout])
return {
status,
lastError,
peerId: peerId.current,
isConnected: status === ConnectionStatus.CONNECTED,
connect,
disconnect,
sendEvent,
}
}

15
client/src/main.tsx Normal file
View File

@@ -0,0 +1,15 @@
// Created by: Claude
// Date: 2026-01-01
// Purpose: Main entry point for Mesh client application
// Refs: CLAUDE.md
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/global.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,246 @@
/* Created by: Claude */
/* Date: 2026-01-03 */
/* Purpose: Styles pour la page d'accueil */
/* Refs: CLAUDE.md */
.container {
min-height: 100vh;
background: var(--bg-primary);
display: flex;
flex-direction: column;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
color: var(--text-secondary);
font-size: 1.2rem;
}
.header {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-primary);
padding: 1rem 2rem;
}
.headerContent {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.title {
font-size: 1.8rem;
font-weight: 700;
color: var(--accent-primary);
margin: 0;
}
.userInfo {
display: flex;
align-items: center;
gap: 1rem;
}
.username {
color: var(--text-primary);
font-weight: 600;
}
.logoutButton {
background: transparent;
border: 1px solid var(--border-primary);
color: var(--text-secondary);
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s ease;
}
.logoutButton:hover {
border-color: var(--accent-error);
color: var(--accent-error);
}
.main {
flex: 1;
max-width: 1200px;
width: 100%;
margin: 0 auto;
padding: 2rem;
}
.roomsHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.roomsTitle {
font-size: 1.5rem;
color: var(--text-primary);
margin: 0;
}
.createButton {
background: var(--accent-primary);
color: var(--bg-primary);
border: none;
padding: 0.75rem 1.5rem;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s ease;
}
.createButton:hover {
background: var(--accent-success);
transform: translateY(-1px);
}
.createForm {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.input {
width: 100%;
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: 4px;
padding: 0.875rem 1rem;
font-size: 1rem;
color: var(--text-primary);
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
margin-bottom: 1rem;
}
.input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(102, 217, 239, 0.1);
}
.formButtons {
display: flex;
gap: 0.75rem;
}
.submitButton {
background: var(--accent-primary);
color: var(--bg-primary);
border: none;
padding: 0.75rem 1.5rem;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s ease;
}
.submitButton:hover:not(:disabled) {
background: var(--accent-success);
}
.submitButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.cancelButton {
background: transparent;
border: 1px solid var(--border-primary);
color: var(--text-secondary);
padding: 0.75rem 1.5rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
.cancelButton:hover {
border-color: var(--accent-error);
color: var(--accent-error);
}
.error {
background: rgba(249, 38, 114, 0.1);
border: 1px solid var(--accent-error);
border-radius: 4px;
padding: 0.75rem 1rem;
color: var(--accent-error);
margin-bottom: 1.5rem;
}
.empty {
text-align: center;
padding: 3rem 1rem;
color: var(--text-secondary);
}
.empty p {
margin: 0 0 0.5rem 0;
}
.emptyHint {
font-size: 0.9rem;
color: var(--text-tertiary);
}
.roomsList {
display: flex;
flex-direction: column;
gap: 1rem;
}
.roomCard {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
padding: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
transition: all 0.2s ease;
}
.roomCard:hover {
border-color: var(--accent-primary);
transform: translateX(4px);
box-shadow: 0 2px 8px rgba(102, 217, 239, 0.1);
}
.roomInfo {
flex: 1;
}
.roomName {
font-size: 1.2rem;
color: var(--text-primary);
margin: 0 0 0.5rem 0;
font-weight: 600;
}
.roomMeta {
font-size: 0.9rem;
color: var(--text-tertiary);
margin: 0;
}
.roomArrow {
font-size: 1.5rem;
color: var(--accent-primary);
transition: transform 0.2s ease;
}
.roomCard:hover .roomArrow {
transform: translateX(4px);
}

181
client/src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,181 @@
// Created by: Claude
// Date: 2026-01-03
// Purpose: Page d'accueil avec liste des rooms
// Refs: CLAUDE.md
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/authStore'
import { roomsApi, Room } from '../services/api'
import styles from './Home.module.css'
const Home: React.FC = () => {
const navigate = useNavigate()
const { user, logout, isAuthenticated } = useAuthStore()
const [rooms, setRooms] = useState<Room[]>([])
const [loading, setLoading] = useState(true)
const [creating, setCreating] = useState(false)
const [showCreateForm, setShowCreateForm] = useState(false)
const [newRoomName, setNewRoomName] = useState('')
const [error, setError] = useState<string | null>(null)
// Rediriger vers login si non authentifié
useEffect(() => {
if (!isAuthenticated) {
navigate('/login')
}
}, [isAuthenticated, navigate])
// Charger les rooms
useEffect(() => {
loadRooms()
}, [])
const loadRooms = async () => {
try {
setLoading(true)
setError(null)
const data = await roomsApi.list()
setRooms(data)
} catch (err: any) {
console.error('Error loading rooms:', err)
setError('Erreur de chargement des rooms')
} finally {
setLoading(false)
}
}
const handleCreateRoom = async (e: React.FormEvent) => {
e.preventDefault()
if (!newRoomName.trim()) {
return
}
try {
setCreating(true)
setError(null)
const newRoom = await roomsApi.create(newRoomName.trim())
// Ajouter à la liste
setRooms([newRoom, ...rooms])
// Réinitialiser le formulaire
setNewRoomName('')
setShowCreateForm(false)
// Naviguer vers la nouvelle room
navigate(`/room/${newRoom.room_id}`)
} catch (err: any) {
console.error('Error creating room:', err)
setError('Erreur lors de la création de la room')
} finally {
setCreating(false)
}
}
const handleLogout = () => {
logout()
navigate('/login')
}
if (loading) {
return (
<div className={styles.container}>
<div className={styles.loading}>Chargement...</div>
</div>
)
}
return (
<div className={styles.container}>
<header className={styles.header}>
<div className={styles.headerContent}>
<h1 className={styles.title}>Mesh</h1>
<div className={styles.userInfo}>
<span className={styles.username}>{user?.username}</span>
<button onClick={handleLogout} className={styles.logoutButton}>
Déconnexion
</button>
</div>
</div>
</header>
<main className={styles.main}>
<div className={styles.roomsHeader}>
<h2 className={styles.roomsTitle}>Rooms</h2>
{!showCreateForm && (
<button
onClick={() => setShowCreateForm(true)}
className={styles.createButton}
>
+ Nouvelle Room
</button>
)}
</div>
{showCreateForm && (
<form onSubmit={handleCreateRoom} className={styles.createForm}>
<input
type="text"
placeholder="Nom de la room"
value={newRoomName}
onChange={(e) => setNewRoomName(e.target.value)}
required
className={styles.input}
autoFocus
/>
<div className={styles.formButtons}>
<button type="submit" disabled={creating} className={styles.submitButton}>
{creating ? 'Création...' : 'Créer'}
</button>
<button
type="button"
onClick={() => {
setShowCreateForm(false)
setNewRoomName('')
}}
className={styles.cancelButton}
>
Annuler
</button>
</div>
</form>
)}
{error && <div className={styles.error}>{error}</div>}
{rooms.length === 0 ? (
<div className={styles.empty}>
<p>Aucune room disponible</p>
<p className={styles.emptyHint}>
Créez votre première room pour commencer à communiquer
</p>
</div>
) : (
<div className={styles.roomsList}>
{rooms.map((room) => (
<div
key={room.room_id}
className={styles.roomCard}
onClick={() => navigate(`/room/${room.room_id}`)}
>
<div className={styles.roomInfo}>
<h3 className={styles.roomName}>{room.name}</h3>
<p className={styles.roomMeta}>
Créée le {new Date(room.created_at).toLocaleDateString('fr-FR')}
</p>
</div>
<div className={styles.roomArrow}></div>
</div>
))}
</div>
)}
</main>
</div>
)
}
export default Home

View File

@@ -0,0 +1,128 @@
/* Created by: Claude
Date: 2026-01-01
Purpose: Login page styles
Refs: CLAUDE.md
*/
.container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--bg-primary);
}
.loginBox {
width: 100%;
max-width: 400px;
padding: var(--spacing-xl);
background-color: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
box-shadow: 0 4px 16px var(--shadow);
}
.title {
font-size: 32px;
font-weight: 700;
color: var(--accent-primary);
margin-bottom: var(--spacing-sm);
text-align: center;
}
.subtitle {
color: var(--text-secondary);
text-align: center;
margin-bottom: var(--spacing-xl);
}
.form {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.input {
width: 100%;
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: 4px;
padding: 0.875rem 1rem;
font-size: 1rem;
color: var(--text-primary);
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
transition: all 0.2s ease;
}
.input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(102, 217, 239, 0.1);
}
.input::placeholder {
color: var(--text-tertiary);
}
.error {
background: rgba(249, 38, 114, 0.1);
border: 1px solid var(--accent-error);
border-radius: 4px;
padding: 0.75rem 1rem;
color: var(--accent-error);
font-size: 0.9rem;
text-align: center;
}
.button {
width: 100%;
padding: var(--spacing-md);
font-size: 16px;
margin-top: var(--spacing-sm);
background: var(--accent-primary);
color: var(--bg-primary);
border: none;
border-radius: 4px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.button:hover:not(:disabled) {
background: var(--accent-success);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(166, 226, 46, 0.2);
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.toggleMode {
margin-top: 1.5rem;
text-align: center;
}
.toggleMode p {
color: var(--text-secondary);
font-size: 0.9rem;
margin: 0;
}
.link {
background: none;
border: none;
color: var(--accent-primary);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
text-decoration: underline;
padding: 0;
transition: color 0.2s ease;
}
.link:hover {
color: var(--accent-success);
}

171
client/src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,171 @@
// Created by: Claude
// Date: 2026-01-01
// Purpose: Page de connexion avec authentification fonctionnelle
// Refs: CLAUDE.md
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/authStore'
import { authApi } from '../services/api'
import styles from './Login.module.css'
const Login: React.FC = () => {
const navigate = useNavigate()
const { setAuth, isAuthenticated } = useAuthStore()
const [mode, setMode] = useState<'login' | 'register'>('login')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Rediriger si déjà authentifié
useEffect(() => {
if (isAuthenticated) {
navigate('/')
}
}, [isAuthenticated, navigate])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
try {
let authResponse
if (mode === 'login') {
// Connexion
authResponse = await authApi.login({ username, password })
} else {
// Enregistrement
authResponse = await authApi.register({
username,
password,
email: email || undefined,
})
}
// Sauvegarder le token d'abord pour les prochaines requêtes
setAuth(authResponse.access_token, {
user_id: authResponse.user_id,
username: authResponse.username,
})
// Rediriger vers la page d'accueil
navigate('/')
} catch (err: any) {
console.error('Authentication error:', err)
if (err.response?.status === 400) {
const detail = err.response.data.detail
if (typeof detail === 'string' && detail.includes('already registered')) {
setError('Ce nom d\'utilisateur ou email est déjà utilisé')
} else {
setError(detail || 'Données invalides')
}
} else if (err.response?.status === 401) {
setError('Nom d\'utilisateur ou mot de passe incorrect')
} else if (err.response?.status === 422) {
// Erreur de validation Pydantic
const detail = err.response.data.detail
if (Array.isArray(detail) && detail.length > 0) {
const errors = detail.map((d: any) => {
if (d.loc && d.loc.includes('password')) {
return 'Le mot de passe doit contenir au moins 8 caractères'
}
if (d.loc && d.loc.includes('email')) {
return 'Email invalide'
}
return d.msg
})
setError(errors.join(', '))
} else {
setError('Données invalides')
}
} else {
setError('Erreur de connexion au serveur')
}
} finally {
setLoading(false)
}
}
const toggleMode = () => {
setMode(mode === 'login' ? 'register' : 'login')
setError(null)
}
return (
<div className={styles.container}>
<div className={styles.loginBox}>
<h1 className={styles.title}>Mesh</h1>
<p className={styles.subtitle}>P2P Communication Platform</p>
<form onSubmit={handleSubmit} className={styles.form}>
<input
type="text"
placeholder="Nom d'utilisateur"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className={styles.input}
autoComplete="username"
/>
{mode === 'register' && (
<input
type="email"
placeholder="Email (optionnel)"
value={email}
onChange={(e) => setEmail(e.target.value)}
className={styles.input}
autoComplete="email"
/>
)}
<input
type="password"
placeholder="Mot de passe"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className={styles.input}
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
/>
{error && <div className={styles.error}>{error}</div>}
<button type="submit" disabled={loading} className={styles.button}>
{loading
? 'Chargement...'
: mode === 'login'
? 'Se connecter'
: 'S\'inscrire'}
</button>
</form>
<div className={styles.toggleMode}>
{mode === 'login' ? (
<p>
Pas encore de compte ?{' '}
<button onClick={toggleMode} className={styles.link}>
S'inscrire
</button>
</p>
) : (
<p>
Déjà un compte ?{' '}
<button onClick={toggleMode} className={styles.link}>
Se connecter
</button>
</p>
)}
</div>
</div>
</div>
)
}
export default Login

View File

@@ -0,0 +1,360 @@
/* Created by: Claude
Date: 2026-01-01
Purpose: Room page styles
Refs: CLAUDE.md
*/
.container {
display: flex;
width: 100%;
height: 100vh;
background: var(--bg-primary);
}
.loading,
.error {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
min-height: 100vh;
color: var(--text-secondary);
gap: 1rem;
}
.sidebar {
width: 280px;
background-color: var(--bg-secondary);
border-right: 1px solid var(--border-primary);
display: flex;
flex-direction: column;
}
.sidebarHeader {
padding: var(--spacing-lg);
border-bottom: 1px solid var(--border-primary);
}
.logo {
color: var(--accent-primary);
font-size: 24px;
margin: 0 0 0.5rem 0;
}
.roomInfo {
margin-top: var(--spacing-md);
}
.roomName {
color: var(--text-secondary);
font-size: 14px;
font-weight: 600;
}
.participants {
padding: var(--spacing-lg);
flex: 1;
overflow-y: auto;
}
.participantsHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-md);
}
.participantsTitle {
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
margin: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.inviteButton {
width: 28px;
height: 28px;
border-radius: 4px;
border: none;
background: var(--accent-primary);
color: white;
font-size: 20px;
font-weight: 300;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
padding: 0;
}
.inviteButton:hover {
background: #005a9e;
transform: scale(1.05);
}
.inviteButton:active {
transform: scale(0.95);
}
.participantList {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.participant {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm);
border-radius: 4px;
transition: background-color 0.2s ease;
}
.participant:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.status {
font-size: 10px;
}
.status-online {
color: var(--accent-success);
}
.status-busy {
color: var(--accent-warning);
}
.status-offline {
color: var(--text-tertiary);
}
.participantName {
flex: 1;
color: var(--text-primary);
font-size: 14px;
}
.participantRole {
color: var(--text-tertiary);
font-size: 11px;
text-transform: uppercase;
}
.sidebarFooter {
padding: var(--spacing-lg);
border-top: 1px solid var(--border-primary);
}
.connectionStatus {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.statusIndicator {
font-size: 10px;
}
.statusIndicator.connected {
color: var(--accent-success);
}
.statusIndicator.disconnected {
color: var(--accent-error);
}
.statusText {
color: var(--text-secondary);
font-size: 13px;
}
.leaveButton {
width: 100%;
background: transparent;
border: 1px solid var(--border-primary);
color: var(--text-secondary);
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
.leaveButton:hover {
border-color: var(--accent-error);
color: var(--accent-error);
}
.main {
flex: 1;
display: flex;
flex-direction: column;
background-color: var(--bg-primary);
}
.header {
padding: var(--spacing-md) var(--spacing-lg);
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-primary);
display: flex;
justify-content: space-between;
align-items: center;
}
.headerTitle {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.headerRight {
display: flex;
gap: var(--spacing-sm);
}
.actionButton {
background: transparent;
border: 1px solid var(--border-primary);
color: var(--text-secondary);
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s ease;
}
.actionButton:hover:not(:disabled) {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.actionButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.chatArea {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.videoArea {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
background: var(--bg-primary);
}
.messages {
flex: 1;
padding: var(--spacing-lg);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
.systemMessage {
color: var(--text-secondary);
font-size: 14px;
text-align: center;
font-style: italic;
padding: 2rem;
}
.message {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border-radius: 8px;
border-left: 3px solid var(--border-primary);
}
.message.ownMessage {
background: rgba(102, 217, 239, 0.1);
border-left-color: var(--accent-primary);
}
.messageHeader {
display: flex;
justify-content: space-between;
align-items: center;
}
.messageAuthor {
color: var(--accent-primary);
font-weight: 600;
font-size: 14px;
}
.messageTime {
color: var(--text-tertiary);
font-size: 12px;
}
.messageContent {
color: var(--text-primary);
line-height: 1.5;
word-wrap: break-word;
}
.inputArea {
padding: var(--spacing-md) var(--spacing-lg);
background-color: var(--bg-secondary);
border-top: 1px solid var(--border-primary);
display: flex;
gap: var(--spacing-sm);
}
.messageInput {
flex: 1;
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: 4px;
padding: 0.875rem 1rem;
font-size: 1rem;
color: var(--text-primary);
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
}
.messageInput:focus {
outline: none;
border-color: var(--accent-primary);
}
.messageInput:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sendButton {
background: var(--accent-primary);
color: var(--bg-primary);
border: none;
padding: 0.875rem 1.5rem;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s ease;
}
.sendButton:hover:not(:disabled) {
background: var(--accent-success);
}
.sendButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}

442
client/src/pages/Room.tsx Normal file
View File

@@ -0,0 +1,442 @@
// Created by: Claude
// Date: 2026-01-01
// Purpose: Page Room avec chat fonctionnel
// Refs: CLAUDE.md, protocol_events_v_2.md
import React, { useEffect, useState, useRef, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useRoomStore } from '../stores/roomStore'
import { useRoomWebSocket } from '../hooks/useRoomWebSocket'
import { useWebRTC } from '../hooks/useWebRTC'
import { roomsApi } from '../services/api'
import { useAuthStore } from '../stores/authStore'
import MediaControls from '../components/MediaControls'
import VideoGrid from '../components/VideoGrid'
import InviteMemberModal from '../components/InviteMemberModal'
import styles from './Room.module.css'
const Room: React.FC = () => {
const { roomId } = useParams<{ roomId: string }>()
const navigate = useNavigate()
const { user } = useAuthStore()
const {
currentRoom,
setCurrentRoom,
clearCurrentRoom,
} = useRoomStore()
// WebRTC - utiliser useRef pour éviter les re-renders en boucle
const webrtcRef = useRef<{
handleOffer: (fromPeerId: string, username: string, sdp: string) => void
handleAnswer: (fromPeerId: string, sdp: string) => void
handleIceCandidate: (fromPeerId: string, candidate: RTCIceCandidateInit) => void
} | null>(null)
// Callbacks stables pour useRoomWebSocket
const onOffer = useCallback((fromPeerId: string, username: string, sdp: string) => {
webrtcRef.current?.handleOffer(fromPeerId, username, sdp)
}, [])
const onAnswer = useCallback((fromPeerId: string, sdp: string) => {
webrtcRef.current?.handleAnswer(fromPeerId, sdp)
}, [])
const onIceCandidate = useCallback((fromPeerId: string, candidate: RTCIceCandidateInit) => {
webrtcRef.current?.handleIceCandidate(fromPeerId, candidate)
}, [])
const {
isConnected,
status,
peerId,
joinRoom,
leaveRoom,
sendMessage: wsSendMessage,
sendRTCSignal,
} = useRoomWebSocket({
onOffer,
onAnswer,
onIceCandidate,
})
// WebRTC
const webrtc = useWebRTC({
roomId: roomId || '',
peerId: peerId || '',
onSignal: sendRTCSignal,
})
// Mettre à jour la référence WebRTC (useRef ne déclenche pas de re-render)
useEffect(() => {
webrtcRef.current = {
handleOffer: webrtc.handleOffer,
handleAnswer: webrtc.handleAnswer,
handleIceCandidate: webrtc.handleIceCandidate,
}
}, [webrtc.handleOffer, webrtc.handleAnswer, webrtc.handleIceCandidate])
const [messageInput, setMessageInput] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showVideo, setShowVideo] = useState(false)
const [showInviteModal, setShowInviteModal] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
/**
* Recharger les membres de la room.
*/
const reloadMembers = useCallback(async () => {
if (!roomId) return
try {
const members = await roomsApi.getMembers(roomId)
if (currentRoom) {
setCurrentRoom(roomId, {
...currentRoom,
members,
})
}
} catch (err) {
console.error('Error reloading members:', err)
}
}, [roomId, currentRoom, setCurrentRoom])
/**
* Charger les informations de la room.
*/
useEffect(() => {
if (!roomId) {
navigate('/')
return
}
const loadRoom = async () => {
try {
setLoading(true)
setError(null)
// Charger les détails de la room
const roomData = await roomsApi.get(roomId)
const members = await roomsApi.getMembers(roomId)
// Mettre à jour le store
setCurrentRoom(roomId, {
...roomData,
members,
messages: [],
})
} catch (err: any) {
console.error('Error loading room:', err)
setError('Impossible de charger la room')
} finally {
setLoading(false)
}
}
loadRoom()
return () => {
// Quitter la room lors du démontage
if (isConnected && roomId) {
leaveRoom(roomId)
}
clearCurrentRoom()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [roomId])
/**
* Rejoindre la room via WebSocket une fois connecté.
*/
useEffect(() => {
if (isConnected && roomId && !loading) {
console.log('Joining room:', roomId)
joinRoom(roomId)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isConnected, roomId, loading])
/**
* Créer des offers WebRTC pour les membres déjà présents quand on active la vidéo.
*/
useEffect(() => {
if (
webrtc.localMedia.stream &&
currentRoom?.members &&
peerId
) {
const otherMembers = currentRoom.members.filter(
(m) => m.peer_id && m.peer_id !== peerId && m.user_id !== user?.user_id
)
// Créer une offer pour chaque membre
otherMembers.forEach((member) => {
if (member.peer_id) {
console.log('Creating WebRTC offer for', member.username)
webrtc.createOffer(member.peer_id, member.username)
}
})
}
}, [webrtc.localMedia.stream, currentRoom?.members, peerId, user?.user_id])
/**
* Scroller vers le bas quand de nouveaux messages arrivent.
*/
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [currentRoom?.messages])
/**
* Envoyer un message.
*/
const handleSendMessage = (e: React.FormEvent) => {
e.preventDefault()
if (!messageInput.trim() || !roomId || !isConnected) {
return
}
// Envoyer le message via WebSocket
const success = wsSendMessage(roomId, messageInput.trim())
if (success) {
setMessageInput('')
}
}
/**
* Quitter la room.
*/
const handleLeaveRoom = () => {
if (roomId && isConnected) {
leaveRoom(roomId)
}
webrtc.cleanup()
navigate('/')
}
/**
* Gérer l'activation de l'audio.
*/
const handleToggleAudio = async () => {
if (!webrtc.localMedia.stream) {
// Démarrer le média pour la première fois
await webrtc.startMedia(true, false)
setShowVideo(true)
} else {
webrtc.toggleAudio()
}
}
/**
* Gérer l'activation de la vidéo.
*/
const handleToggleVideo = async () => {
if (!webrtc.localMedia.stream) {
// Démarrer le média pour la première fois
await webrtc.startMedia(true, true)
setShowVideo(true)
} else {
webrtc.toggleVideo()
}
}
/**
* Gérer le partage d'écran.
*/
const handleToggleScreenShare = async () => {
if (webrtc.localMedia.isScreenSharing) {
webrtc.stopScreenShare()
} else {
await webrtc.startScreenShare()
setShowVideo(true)
}
}
if (loading) {
return (
<div className={styles.container}>
<div className={styles.loading}>Chargement de la room...</div>
</div>
)
}
if (error) {
return (
<div className={styles.container}>
<div className={styles.error}>
<p>{error}</p>
<button onClick={() => navigate('/')}>Retour à l'accueil</button>
</div>
</div>
)
}
const messages = currentRoom?.messages || []
const members = currentRoom?.members || []
return (
<div className={styles.container}>
{/* Sidebar - Participants */}
<div className={styles.sidebar}>
<div className={styles.sidebarHeader}>
<h2 className={styles.logo}>Mesh</h2>
<div className={styles.roomInfo}>
<span className={styles.roomName}>{currentRoom?.name || 'Room'}</span>
</div>
</div>
<div className={styles.participants}>
<div className={styles.participantsHeader}>
<h3 className={styles.participantsTitle}>
Participants ({members.length})
</h3>
<button
className={styles.inviteButton}
onClick={() => setShowInviteModal(true)}
title="Inviter un membre"
>
+
</button>
</div>
<div className={styles.participantList}>
{members.map((member) => (
<div key={member.user_id} className={styles.participant}>
<span
className={`${styles.status} ${
styles[`status-${member.presence}`]
}`}
>
</span>
<span className={styles.participantName}>
{member.username}
{member.user_id === user?.user_id && ' (vous)'}
</span>
<span className={styles.participantRole}>{member.role}</span>
</div>
))}
</div>
</div>
<div className={styles.sidebarFooter}>
<div className={styles.connectionStatus}>
<span className={`${styles.statusIndicator} ${isConnected ? styles.connected : styles.disconnected}`}>
</span>
<span className={styles.statusText}>
{isConnected ? 'Connecté' : status}
</span>
</div>
<button onClick={handleLeaveRoom} className={styles.leaveButton}>
Quitter la room
</button>
</div>
</div>
{/* Main - Chat et Vidéo */}
<div className={styles.main}>
<div className={styles.header}>
<div className={styles.headerLeft}>
<h2 className={styles.headerTitle}>
{showVideo ? 'Appel vidéo' : 'Chat'}
</h2>
</div>
<div className={styles.headerRight}>
<MediaControls
isAudioEnabled={webrtc.localMedia.isAudioEnabled}
isVideoEnabled={webrtc.localMedia.isVideoEnabled}
isScreenSharing={webrtc.localMedia.isScreenSharing}
onToggleAudio={handleToggleAudio}
onToggleVideo={handleToggleVideo}
onToggleScreenShare={handleToggleScreenShare}
disabled={!isConnected}
/>
<button
className={styles.actionButton}
onClick={() => setShowVideo(!showVideo)}
disabled={!isConnected}
>
{showVideo ? '💬 Chat' : '📹 Vidéo'}
</button>
</div>
</div>
{/* Zone vidéo ou chat selon le mode */}
{showVideo ? (
<div className={styles.videoArea}>
<VideoGrid
localStream={webrtc.localMedia.stream}
localScreenStream={webrtc.localMedia.screenStream}
peers={webrtc.peers}
localUsername={user?.username || 'Vous'}
/>
</div>
) : (
<div className={styles.chatArea}>
<div className={styles.messages}>
{messages.length === 0 ? (
<div className={styles.systemMessage}>
Bienvenue dans Mesh. C'est le début de votre conversation.
</div>
) : (
messages.map((message) => (
<div
key={message.message_id}
className={`${styles.message} ${
message.user_id === user?.user_id ? styles.ownMessage : ''
}`}
>
<div className={styles.messageHeader}>
<span className={styles.messageAuthor}>
{message.from_username}
</span>
<span className={styles.messageTime}>
{new Date(message.created_at).toLocaleTimeString('fr-FR', {
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
<div className={styles.messageContent}>{message.content}</div>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
</div>
)}
<form onSubmit={handleSendMessage} className={styles.inputArea}>
<input
type="text"
placeholder="Tapez un message..."
className={styles.messageInput}
value={messageInput}
onChange={(e) => setMessageInput(e.target.value)}
disabled={!isConnected}
/>
<button
type="submit"
className={styles.sendButton}
disabled={!isConnected || !messageInput.trim()}
>
Envoyer
</button>
</form>
</div>
{/* Modal d'invitation */}
{showInviteModal && roomId && (
<InviteMemberModal
roomId={roomId}
onClose={() => setShowInviteModal(false)}
onMemberAdded={reloadMembers}
/>
)}
</div>
)
}
export default Room

225
client/src/services/api.ts Normal file
View File

@@ -0,0 +1,225 @@
// Created by: Claude
// Date: 2026-01-03
// Purpose: Service API pour les appels au serveur Mesh
// Refs: client/CLAUDE.md, docs/protocol_events_v_2.md
import axios, { AxiosInstance } from 'axios'
import { useAuthStore } from '../stores/authStore'
// URL du serveur (à configurer via variable d'environnement)
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
/**
* Instance Axios configurée pour l'API Mesh.
*/
const apiClient: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
})
/**
* Intercepteur pour ajouter le token d'authentification à chaque requête.
*/
apiClient.interceptors.request.use(
(config) => {
const token = useAuthStore.getState().token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
/**
* Intercepteur pour gérer les erreurs d'authentification.
*/
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Token expiré ou invalide, déconnecter l'utilisateur
useAuthStore.getState().logout()
window.location.href = '/login'
}
return Promise.reject(error)
}
)
// ==================== Types ====================
export interface RegisterRequest {
username: string
password: string
email?: string
}
export interface LoginRequest {
username: string
password: string
}
export interface AuthResponse {
access_token: string
token_type: string
user_id: string
username: string
}
export interface User {
user_id: string
username: string
email?: string
}
export interface Room {
room_id: string
name: string
owner_user_id: string
created_at: string
}
export interface RoomMember {
user_id: string
username: string
role: 'owner' | 'member' | 'guest'
presence: 'online' | 'busy' | 'offline'
}
export interface CapabilityTokenRequest {
room_id: string
capabilities: string[]
target_peer_id?: string
}
export interface CapabilityTokenResponse {
capability_token: string
expires_in: number
}
// ==================== API Functions ====================
/**
* Authentification et gestion utilisateur.
*/
export const authApi = {
/**
* Enregistrer un nouvel utilisateur.
*/
register: async (data: RegisterRequest): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>('/api/auth/register', data)
return response.data
},
/**
* Se connecter.
*/
login: async (data: LoginRequest): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>('/api/auth/login', data)
return response.data
},
/**
* Récupérer les informations de l'utilisateur courant.
*/
getMe: async (): Promise<User> => {
const response = await apiClient.get<User>('/api/auth/me')
return response.data
},
/**
* Demander un capability token.
*/
requestCapability: async (data: CapabilityTokenRequest): Promise<CapabilityTokenResponse> => {
const response = await apiClient.post<CapabilityTokenResponse>(
'/api/auth/capability',
data
)
return response.data
},
}
/**
* Gestion des rooms.
*/
export const roomsApi = {
/**
* Créer une nouvelle room.
*/
create: async (name: string): Promise<Room> => {
const response = await apiClient.post<Room>('/api/rooms/', { name })
return response.data
},
/**
* Lister toutes les rooms accessibles.
*/
list: async (): Promise<Room[]> => {
const response = await apiClient.get<Room[]>('/api/rooms/')
return response.data
},
/**
* Récupérer les détails d'une room.
*/
get: async (roomId: string): Promise<Room> => {
const response = await apiClient.get<Room>(`/api/rooms/${roomId}`)
return response.data
},
/**
* Récupérer les membres d'une room.
*/
getMembers: async (roomId: string): Promise<RoomMember[]> => {
const response = await apiClient.get<RoomMember[]>(`/api/rooms/${roomId}/members`)
return response.data
},
/**
* Ajouter un membre à une room.
*/
addMember: async (roomId: string, username: string): Promise<RoomMember> => {
const response = await apiClient.post<RoomMember>(`/api/rooms/${roomId}/members`, {
username,
})
return response.data
},
}
/**
* Gestion des sessions P2P.
*/
export const p2pApi = {
/**
* Créer une session P2P.
*/
createSession: async (data: {
room_id: string
target_peer_id: string
kind: 'file' | 'folder' | 'terminal'
capabilities: string[]
}) => {
const response = await apiClient.post('/api/p2p/session', data)
return response.data
},
/**
* Lister les sessions P2P actives.
*/
listSessions: async () => {
const response = await apiClient.get('/api/p2p/sessions')
return response.data
},
/**
* Fermer une session P2P.
*/
closeSession: async (sessionId: string) => {
const response = await apiClient.delete(`/api/p2p/session/${sessionId}`)
return response.data
},
}
export default apiClient

View File

@@ -0,0 +1,70 @@
// Created by: Claude
// Date: 2026-01-03
// Purpose: Store Zustand pour la gestion de l'authentification
// Refs: client/CLAUDE.md
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
/**
* Informations sur l'utilisateur connecté.
*/
interface User {
user_id: string
username: string
email?: string
}
/**
* État de l'authentification.
*/
interface AuthState {
// État
token: string | null
user: User | null
isAuthenticated: boolean
// Actions
setAuth: (token: string, user: User) => void
logout: () => void
updateUser: (user: Partial<User>) => void
}
/**
* Store pour la gestion de l'authentification.
*
* Utilise zustand avec persistance dans localStorage pour maintenir
* la session entre les rafraîchissements de page.
*/
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
// État initial
token: null,
user: null,
isAuthenticated: false,
// Définir l'authentification (login/register)
setAuth: (token, user) => set({
token,
user,
isAuthenticated: true,
}),
// Déconnexion
logout: () => set({
token: null,
user: null,
isAuthenticated: false,
}),
// Mettre à jour les informations utilisateur
updateUser: (userData) => set((state) => ({
user: state.user ? { ...state.user, ...userData } : null,
})),
}),
{
name: 'mesh-auth-storage', // Clé dans localStorage
}
)
)

View File

@@ -0,0 +1,107 @@
// Created by: Claude
// Date: 2026-01-03
// Purpose: Store pour les notifications toast
// Refs: client/CLAUDE.md
import { create } from 'zustand'
/**
* Type de notification.
*/
export type NotificationType = 'info' | 'success' | 'warning' | 'error'
/**
* Notification toast.
*/
export interface Notification {
id: string
type: NotificationType
message: string
duration?: number // ms, undefined = ne se ferme pas auto
}
/**
* État du store de notifications.
*/
interface NotificationState {
notifications: Notification[]
addNotification: (notification: Omit<Notification, 'id'>) => void
removeNotification: (id: string) => void
clearAll: () => void
}
/**
* Store pour gérer les notifications toast.
*/
export const useNotificationStore = create<NotificationState>((set) => ({
notifications: [],
addNotification: (notification) => {
const id = `notif-${Date.now()}-${Math.random()}`
const newNotification: Notification = {
id,
...notification,
duration: notification.duration ?? 5000, // 5s par défaut
}
set((state) => ({
notifications: [...state.notifications, newNotification],
}))
// Auto-fermeture si duration définie
if (newNotification.duration) {
setTimeout(() => {
set((state) => ({
notifications: state.notifications.filter((n) => n.id !== id),
}))
}, newNotification.duration)
}
},
removeNotification: (id) => {
set((state) => ({
notifications: state.notifications.filter((n) => n.id !== id),
}))
},
clearAll: () => {
set({ notifications: [] })
},
}))
/**
* Helpers pour ajouter des notifications.
*/
export const notify = {
info: (message: string, duration?: number) => {
useNotificationStore.getState().addNotification({
type: 'info',
message,
duration,
})
},
success: (message: string, duration?: number) => {
useNotificationStore.getState().addNotification({
type: 'success',
message,
duration,
})
},
warning: (message: string, duration?: number) => {
useNotificationStore.getState().addNotification({
type: 'warning',
message,
duration,
})
},
error: (message: string, duration?: number) => {
useNotificationStore.getState().addNotification({
type: 'error',
message,
duration: duration ?? 7000, // Erreurs restent plus longtemps
})
},
}

View File

@@ -0,0 +1,283 @@
// Created by: Claude
// Date: 2026-01-03
// Purpose: Store Zustand pour la gestion des rooms et messages
// Refs: client/CLAUDE.md
import { create } from 'zustand'
/**
* Message dans une room.
*/
export interface Message {
message_id: string
room_id: string
user_id: string
from_username: string
content: string
created_at: string
}
/**
* Membre d'une room.
*/
export interface RoomMember {
user_id: string
username: string
peer_id?: string
role: 'owner' | 'member' | 'guest'
presence: 'online' | 'busy' | 'offline'
}
/**
* Informations sur une room.
*/
export interface RoomInfo {
room_id: string
name: string
owner_user_id: string
created_at: string
members: RoomMember[]
messages: Message[]
}
/**
* État de la room courante.
*/
interface RoomState {
// Room courante
currentRoomId: string | null
currentRoom: RoomInfo | null
// Cache des rooms
rooms: Map<string, RoomInfo>
// Actions - Room courante
setCurrentRoom: (roomId: string, roomInfo: Partial<RoomInfo>) => void
clearCurrentRoom: () => void
// Actions - Messages
addMessage: (roomId: string, message: Message) => void
setMessages: (roomId: string, messages: Message[]) => void
// Actions - Membres
addMember: (roomId: string, member: RoomMember) => void
removeMember: (roomId: string, userId: string) => void
updateMemberPresence: (roomId: string, userId: string, presence: 'online' | 'busy' | 'offline') => void
setMembers: (roomId: string, members: RoomMember[]) => void
// Actions - Cache
updateRoomCache: (roomId: string, updates: Partial<RoomInfo>) => void
clearCache: () => void
}
/**
* Store pour la gestion des rooms et messages.
*/
export const useRoomStore = create<RoomState>((set, get) => ({
// État initial
currentRoomId: null,
currentRoom: null,
rooms: new Map(),
// Définir la room courante
setCurrentRoom: (roomId, roomInfo) => {
const existingRoom = get().rooms.get(roomId)
const room: RoomInfo = {
room_id: roomId,
name: roomInfo.name || existingRoom?.name || 'Unknown Room',
owner_user_id: roomInfo.owner_user_id || existingRoom?.owner_user_id || '',
created_at: roomInfo.created_at || existingRoom?.created_at || new Date().toISOString(),
members: roomInfo.members || existingRoom?.members || [],
messages: roomInfo.messages || existingRoom?.messages || [],
}
set((state) => {
const newRooms = new Map(state.rooms)
newRooms.set(roomId, room)
return {
currentRoomId: roomId,
currentRoom: room,
rooms: newRooms,
}
})
},
// Effacer la room courante
clearCurrentRoom: () => set({
currentRoomId: null,
currentRoom: null,
}),
// Ajouter un message
addMessage: (roomId, message) => {
set((state) => {
const room = state.rooms.get(roomId)
if (!room) return state
const updatedRoom = {
...room,
messages: [...room.messages, message],
}
const newRooms = new Map(state.rooms)
newRooms.set(roomId, updatedRoom)
return {
rooms: newRooms,
currentRoom: state.currentRoomId === roomId ? updatedRoom : state.currentRoom,
}
})
},
// Définir tous les messages d'une room
setMessages: (roomId, messages) => {
set((state) => {
const room = state.rooms.get(roomId)
if (!room) return state
const updatedRoom = {
...room,
messages,
}
const newRooms = new Map(state.rooms)
newRooms.set(roomId, updatedRoom)
return {
rooms: newRooms,
currentRoom: state.currentRoomId === roomId ? updatedRoom : state.currentRoom,
}
})
},
// Ajouter un membre
addMember: (roomId, member) => {
set((state) => {
const room = state.rooms.get(roomId)
if (!room) return state
// Vérifier si le membre existe déjà
const existingIndex = room.members.findIndex((m) => m.user_id === member.user_id)
let updatedMembers
if (existingIndex >= 0) {
// Mettre à jour le membre existant
updatedMembers = [...room.members]
updatedMembers[existingIndex] = { ...updatedMembers[existingIndex], ...member }
} else {
// Ajouter un nouveau membre
updatedMembers = [...room.members, member]
}
const updatedRoom = {
...room,
members: updatedMembers,
}
const newRooms = new Map(state.rooms)
newRooms.set(roomId, updatedRoom)
return {
rooms: newRooms,
currentRoom: state.currentRoomId === roomId ? updatedRoom : state.currentRoom,
}
})
},
// Retirer un membre
removeMember: (roomId, userId) => {
set((state) => {
const room = state.rooms.get(roomId)
if (!room) return state
const updatedRoom = {
...room,
members: room.members.filter((m) => m.user_id !== userId),
}
const newRooms = new Map(state.rooms)
newRooms.set(roomId, updatedRoom)
return {
rooms: newRooms,
currentRoom: state.currentRoomId === roomId ? updatedRoom : state.currentRoom,
}
})
},
// Mettre à jour la présence d'un membre
updateMemberPresence: (roomId, userId, presence) => {
set((state) => {
const room = state.rooms.get(roomId)
if (!room) return state
const updatedMembers = room.members.map((m) =>
m.user_id === userId ? { ...m, presence } : m
)
const updatedRoom = {
...room,
members: updatedMembers,
}
const newRooms = new Map(state.rooms)
newRooms.set(roomId, updatedRoom)
return {
rooms: newRooms,
currentRoom: state.currentRoomId === roomId ? updatedRoom : state.currentRoom,
}
})
},
// Définir tous les membres
setMembers: (roomId, members) => {
set((state) => {
const room = state.rooms.get(roomId)
if (!room) return state
const updatedRoom = {
...room,
members,
}
const newRooms = new Map(state.rooms)
newRooms.set(roomId, updatedRoom)
return {
rooms: newRooms,
currentRoom: state.currentRoomId === roomId ? updatedRoom : state.currentRoom,
}
})
},
// Mettre à jour le cache d'une room
updateRoomCache: (roomId, updates) => {
set((state) => {
const room = state.rooms.get(roomId)
if (!room) return state
const updatedRoom = {
...room,
...updates,
}
const newRooms = new Map(state.rooms)
newRooms.set(roomId, updatedRoom)
return {
rooms: newRooms,
currentRoom: state.currentRoomId === roomId ? updatedRoom : state.currentRoom,
}
})
},
// Vider le cache
clearCache: () => set({
currentRoomId: null,
currentRoom: null,
rooms: new Map(),
}),
}))

View File

@@ -0,0 +1,274 @@
// Created by: Claude
// Date: 2026-01-03
// Purpose: Store Zustand pour la gestion des connexions WebRTC
// Refs: client/CLAUDE.md, docs/signaling_v_2.md
import { create } from 'zustand'
/**
* Types de média.
*/
export type MediaType = 'audio' | 'video' | 'screen'
/**
* État d'un peer WebRTC.
*/
export interface PeerConnection {
peer_id: string
username: string
room_id: string
connection: RTCPeerConnection
stream?: MediaStream
isAudioEnabled: boolean
isVideoEnabled: boolean
isScreenSharing: boolean
}
/**
* État local des médias.
*/
export interface LocalMedia {
stream?: MediaStream
isAudioEnabled: boolean
isVideoEnabled: boolean
isScreenSharing: boolean
screenStream?: MediaStream
}
/**
* État du store WebRTC.
*/
interface WebRTCState {
// Média local
localMedia: LocalMedia
// Connexions avec les peers
peers: Map<string, PeerConnection>
// Configuration ICE (STUN/TURN)
iceServers: RTCIceServer[]
// Actions - Média local
setLocalStream: (stream: MediaStream) => void
setLocalAudio: (enabled: boolean) => void
setLocalVideo: (enabled: boolean) => void
setScreenStream: (stream?: MediaStream) => void
stopLocalMedia: () => void
// Actions - Peers
addPeer: (peerId: string, username: string, roomId: string, connection: RTCPeerConnection) => void
removePeer: (peerId: string) => void
setPeerStream: (peerId: string, stream: MediaStream) => void
updatePeerMedia: (peerId: string, updates: Partial<Pick<PeerConnection, 'isAudioEnabled' | 'isVideoEnabled' | 'isScreenSharing'>>) => void
getPeer: (peerId: string) => PeerConnection | undefined
// Actions - Configuration
setIceServers: (servers: RTCIceServer[]) => void
// Actions - Cleanup
clearAll: () => void
}
/**
* Store pour la gestion des connexions WebRTC.
*/
export const useWebRTCStore = create<WebRTCState>((set, get) => ({
// État initial
localMedia: {
isAudioEnabled: false,
isVideoEnabled: false,
isScreenSharing: false,
},
peers: new Map(),
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
],
// Définir le stream local
setLocalStream: (stream) => {
set((state) => ({
localMedia: {
...state.localMedia,
stream,
},
}))
},
// Activer/désactiver l'audio local
setLocalAudio: (enabled) => {
set((state) => {
if (state.localMedia.stream) {
state.localMedia.stream.getAudioTracks().forEach((track) => {
track.enabled = enabled
})
}
return {
localMedia: {
...state.localMedia,
isAudioEnabled: enabled,
},
}
})
},
// Activer/désactiver la vidéo locale
setLocalVideo: (enabled) => {
set((state) => {
if (state.localMedia.stream) {
state.localMedia.stream.getVideoTracks().forEach((track) => {
track.enabled = enabled
})
}
return {
localMedia: {
...state.localMedia,
isVideoEnabled: enabled,
},
}
})
},
// Définir le stream de partage d'écran
setScreenStream: (stream) => {
set((state) => ({
localMedia: {
...state.localMedia,
screenStream: stream,
isScreenSharing: !!stream,
},
}))
},
// Arrêter tous les médias locaux
stopLocalMedia: () => {
set((state) => {
// Arrêter le stream principal
if (state.localMedia.stream) {
state.localMedia.stream.getTracks().forEach((track) => track.stop())
}
// Arrêter le partage d'écran
if (state.localMedia.screenStream) {
state.localMedia.screenStream.getTracks().forEach((track) => track.stop())
}
return {
localMedia: {
isAudioEnabled: false,
isVideoEnabled: false,
isScreenSharing: false,
},
}
})
},
// Ajouter un peer
addPeer: (peerId, username, roomId, connection) => {
set((state) => {
const newPeers = new Map(state.peers)
newPeers.set(peerId, {
peer_id: peerId,
username,
room_id: roomId,
connection,
isAudioEnabled: false,
isVideoEnabled: false,
isScreenSharing: false,
})
return { peers: newPeers }
})
},
// Retirer un peer
removePeer: (peerId) => {
set((state) => {
const peer = state.peers.get(peerId)
if (peer) {
// Fermer la connexion
peer.connection.close()
// Arrêter le stream
if (peer.stream) {
peer.stream.getTracks().forEach((track) => track.stop())
}
}
const newPeers = new Map(state.peers)
newPeers.delete(peerId)
return { peers: newPeers }
})
},
// Définir le stream d'un peer
setPeerStream: (peerId, stream) => {
set((state) => {
const peer = state.peers.get(peerId)
if (!peer) return state
const newPeers = new Map(state.peers)
newPeers.set(peerId, {
...peer,
stream,
})
return { peers: newPeers }
})
},
// Mettre à jour l'état des médias d'un peer
updatePeerMedia: (peerId, updates) => {
set((state) => {
const peer = state.peers.get(peerId)
if (!peer) return state
const newPeers = new Map(state.peers)
newPeers.set(peerId, {
...peer,
...updates,
})
return { peers: newPeers }
})
},
// Obtenir un peer
getPeer: (peerId) => {
return get().peers.get(peerId)
},
// Définir les serveurs ICE
setIceServers: (servers) => {
set({ iceServers: servers })
},
// Tout nettoyer
clearAll: () => {
const state = get()
// Arrêter les médias locaux
state.stopLocalMedia()
// Fermer toutes les connexions peers
state.peers.forEach((peer) => {
peer.connection.close()
if (peer.stream) {
peer.stream.getTracks().forEach((track) => track.stop())
}
})
set({
localMedia: {
isAudioEnabled: false,
isVideoEnabled: false,
isScreenSharing: false,
},
peers: new Map(),
})
},
}))

View File

@@ -0,0 +1,59 @@
/* Created by: Claude
Date: 2026-01-01
Purpose: Global styles for Mesh client
Refs: CLAUDE.md - Dark theme requirement
*/
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body,
#root {
width: 100%;
height: 100%;
overflow: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--bg-primary);
color: var(--text-primary);
}
code {
font-family: 'Fira Code', 'Courier New', monospace;
}
.app {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
/* Scrollbar styling for dark theme */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--accent-primary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--accent-hover);
}

128
client/src/styles/theme.css Normal file
View File

@@ -0,0 +1,128 @@
/* Created by: Claude
Date: 2026-01-01
Purpose: Monokai-inspired dark theme for Mesh client
Refs: CLAUDE.md - Dark theme like Monokai
*/
:root {
/* Monokai-inspired color palette */
--bg-primary: #272822;
--bg-secondary: #1e1f1c;
--bg-tertiary: #3e3d32;
--bg-hover: #49483e;
--text-primary: #f8f8f2;
--text-secondary: #75715e;
--text-muted: #49483e;
--accent-primary: #66d9ef;
--accent-secondary: #a6e22e;
--accent-warning: #fd971f;
--accent-error: #f92672;
--accent-success: #a6e22e;
--accent-purple: #ae81ff;
--accent-yellow: #e6db74;
--accent-hover: #89e1f5;
--border-primary: #49483e;
--border-focus: #66d9ef;
--shadow: rgba(0, 0, 0, 0.3);
/* Spacing */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
/* Border radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
/* Transitions */
--transition-fast: 150ms ease-in-out;
--transition-normal: 250ms ease-in-out;
}
/* Button styles */
button {
background-color: var(--accent-primary);
color: var(--bg-primary);
border: none;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: 600;
transition: background-color var(--transition-fast);
}
button:hover {
background-color: var(--accent-hover);
}
button:disabled {
background-color: var(--text-muted);
cursor: not-allowed;
}
button.secondary {
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
button.secondary:hover {
background-color: var(--bg-hover);
}
button.danger {
background-color: var(--accent-error);
color: var(--text-primary);
}
/* Input styles */
input,
textarea {
background-color: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-primary);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-sm);
font-size: 14px;
transition: border-color var(--transition-fast);
}
input:focus,
textarea:focus {
outline: none;
border-color: var(--border-focus);
}
input::placeholder,
textarea::placeholder {
color: var(--text-secondary);
}
/* Card styles */
.card {
background-color: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
padding: var(--spacing-lg);
box-shadow: 0 2px 8px var(--shadow);
}
/* Status indicators */
.status-online {
color: var(--accent-success);
}
.status-busy {
color: var(--accent-warning);
}
.status-offline {
color: var(--text-secondary);
}

31
client/tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Paths */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
client/tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

30
client/vite.config.ts Normal file
View File

@@ -0,0 +1,30 @@
// Created by: Claude
// Date: 2026-01-01
// Purpose: Vite configuration for Mesh client
// Refs: CLAUDE.md
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:8000',
ws: true,
},
},
},
})