first
This commit is contained in:
10
client/.env.example
Normal file
10
client/.env.example
Normal 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
246
client/CLAUDE.md
Normal 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
13
client/index.html
Normal 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
4012
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
client/package.json
Normal file
35
client/package.json
Normal 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
57
client/src/App.tsx
Normal 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
|
||||
50
client/src/components/ConnectionIndicator.module.css
Normal file
50
client/src/components/ConnectionIndicator.module.css
Normal 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);
|
||||
}
|
||||
151
client/src/components/ConnectionIndicator.tsx
Normal file
151
client/src/components/ConnectionIndicator.tsx
Normal 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
|
||||
143
client/src/components/InviteMemberModal.module.css
Normal file
143
client/src/components/InviteMemberModal.module.css
Normal 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;
|
||||
}
|
||||
100
client/src/components/InviteMemberModal.tsx
Normal file
100
client/src/components/InviteMemberModal.tsx
Normal 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
|
||||
47
client/src/components/MediaControls.module.css
Normal file
47
client/src/components/MediaControls.module.css
Normal 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;
|
||||
}
|
||||
66
client/src/components/MediaControls.tsx
Normal file
66
client/src/components/MediaControls.tsx
Normal 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
|
||||
96
client/src/components/ToastContainer.module.css
Normal file
96
client/src/components/ToastContainer.module.css
Normal 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);
|
||||
}
|
||||
47
client/src/components/ToastContainer.tsx
Normal file
47
client/src/components/ToastContainer.tsx
Normal 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
|
||||
152
client/src/components/VideoGrid.module.css
Normal file
152
client/src/components/VideoGrid.module.css
Normal 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;
|
||||
}
|
||||
146
client/src/components/VideoGrid.tsx
Normal file
146
client/src/components/VideoGrid.tsx
Normal 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
|
||||
74
client/src/hooks/useAudioLevel.ts
Normal file
74
client/src/hooks/useAudioLevel.ts
Normal 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 }
|
||||
}
|
||||
238
client/src/hooks/useRoomWebSocket.ts
Normal file
238
client/src/hooks/useRoomWebSocket.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
358
client/src/hooks/useWebRTC.ts
Normal file
358
client/src/hooks/useWebRTC.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
259
client/src/hooks/useWebSocket.ts
Normal file
259
client/src/hooks/useWebSocket.ts
Normal 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
15
client/src/main.tsx
Normal 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>,
|
||||
)
|
||||
246
client/src/pages/Home.module.css
Normal file
246
client/src/pages/Home.module.css
Normal 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
181
client/src/pages/Home.tsx
Normal 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
|
||||
128
client/src/pages/Login.module.css
Normal file
128
client/src/pages/Login.module.css
Normal 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
171
client/src/pages/Login.tsx
Normal 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
|
||||
360
client/src/pages/Room.module.css
Normal file
360
client/src/pages/Room.module.css
Normal 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
442
client/src/pages/Room.tsx
Normal 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
225
client/src/services/api.ts
Normal 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
|
||||
70
client/src/stores/authStore.ts
Normal file
70
client/src/stores/authStore.ts
Normal 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
|
||||
}
|
||||
)
|
||||
)
|
||||
107
client/src/stores/notificationStore.ts
Normal file
107
client/src/stores/notificationStore.ts
Normal 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
|
||||
})
|
||||
},
|
||||
}
|
||||
283
client/src/stores/roomStore.ts
Normal file
283
client/src/stores/roomStore.ts
Normal 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(),
|
||||
}),
|
||||
}))
|
||||
274
client/src/stores/webrtcStore.ts
Normal file
274
client/src/stores/webrtcStore.ts
Normal 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(),
|
||||
})
|
||||
},
|
||||
}))
|
||||
59
client/src/styles/global.css
Normal file
59
client/src/styles/global.css
Normal 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
128
client/src/styles/theme.css
Normal 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
31
client/tsconfig.json
Normal 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
10
client/tsconfig.node.json
Normal 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
30
client/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user