mirror of
https://github.com/xtr-dev/rondevu-demo.git
synced 2025-12-10 02:43:23 +00:00
Compare commits
58 Commits
v1.0.1
...
d575022412
| Author | SHA1 | Date | |
|---|---|---|---|
| d575022412 | |||
| 84ceae9a05 | |||
| c5f640bc62 | |||
| 9163e5166c | |||
| 7d3b19a2b0 | |||
| 7d19557966 | |||
| 70fd6bd16a | |||
| 6dece31f2d | |||
| b741e8f40c | |||
| 2c20af83c9 | |||
| 78c16c95f5 | |||
| 953f62ce81 | |||
| c46bfb40a9 | |||
| 50eeec5164 | |||
| 217b84701f | |||
| 273b6349c6 | |||
| 37c84b7553 | |||
| 2d1b2e8ff4 | |||
| c849f6a109 | |||
| 0348ef5d8e | |||
| 1ac1121793 | |||
| caedff590f | |||
| d922329437 | |||
| 4c58dee371 | |||
| e4868c085b | |||
| 9a424b6015 | |||
| 79030adb09 | |||
| 1f1502940b | |||
| 348a732178 | |||
| 9e761546e7 | |||
| f9fb74de53 | |||
| e5e28c8264 | |||
| 4021a02f6d | |||
| 73f04bc078 | |||
| 257d8f264a | |||
| 678f692b64 | |||
| 83df6aeee3 | |||
| c3045778eb | |||
| dc856b7abf | |||
| ed8709c6f6 | |||
| b27ab02552 | |||
| b321a01d5e | |||
| 2dc4c711e3 | |||
| b8637ed8ad | |||
| 94b2849971 | |||
| 65f4aaffe0 | |||
| e1c8c25ea8 | |||
| 600d6308b9 | |||
| adc363fed0 | |||
| d677f36eeb | |||
| eaf474a984 | |||
| 1efe7346f4 | |||
| 1bbce9295d | |||
| 6538b5a18f | |||
| ee440b083d | |||
| 60f1068bd1 | |||
| 55a3d0ba51 | |||
| 2b574526d1 |
66
CLAUDE.md
Normal file
66
CLAUDE.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Rondevu Demo Development Guidelines
|
||||||
|
|
||||||
|
## WebRTC Configuration
|
||||||
|
|
||||||
|
### TURN Server Setup
|
||||||
|
|
||||||
|
When configuring TURN servers:
|
||||||
|
|
||||||
|
- ✅ **DO** include the port number in TURN URLs: `turn:server.com:3478`
|
||||||
|
- ✅ **DO** test TURN connectivity before deploying: `turnutils_uclient -u user -w pass server.com 3478 -y`
|
||||||
|
- ✅ **DO** provide both TCP and UDP transports for maximum compatibility
|
||||||
|
- ❌ **DON'T** omit the port number (even if it's the default 3478)
|
||||||
|
- ❌ **DON'T** assume TURN works without testing
|
||||||
|
|
||||||
|
### ICE Configuration
|
||||||
|
|
||||||
|
**Force Relay Mode for Testing:**
|
||||||
|
```javascript
|
||||||
|
const RTC_CONFIG = {
|
||||||
|
iceServers: [...],
|
||||||
|
iceTransportPolicy: 'relay' // Forces TURN relay, bypasses NAT issues
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `iceTransportPolicy: 'relay'` to:
|
||||||
|
- Test if TURN server is working correctly
|
||||||
|
- Bypass NAT hairpinning issues (when both peers are on same network)
|
||||||
|
- Ensure maximum compatibility
|
||||||
|
|
||||||
|
**Remove or comment out** `iceTransportPolicy: 'relay'` for production to allow direct connections when possible.
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
### Enable Detailed ICE Logging
|
||||||
|
|
||||||
|
The demo includes detailed ICE candidate logging. Check browser console for:
|
||||||
|
- 🧊 ICE candidate gathering
|
||||||
|
- 🧊 ICE connection state changes
|
||||||
|
- 📤 Candidates sent to server
|
||||||
|
- 📥 Candidates received from server
|
||||||
|
- ✅ Successful candidate pairs
|
||||||
|
- ❌ Failed candidate pairs
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Connection stuck in "connecting":**
|
||||||
|
- Enable relay-only mode to test TURN
|
||||||
|
- Check if both peers are behind same NAT (hairpinning issue)
|
||||||
|
- Verify TURN credentials are correct
|
||||||
|
|
||||||
|
2. **No candidates gathered:**
|
||||||
|
- Check STUN/TURN server URLs
|
||||||
|
- Verify firewall isn't blocking UDP ports
|
||||||
|
- Check TURN server is running
|
||||||
|
|
||||||
|
3. **Candidates gathered but connection fails:**
|
||||||
|
- Check if TURN relay is actually working (use `turnutils_uclient`)
|
||||||
|
- Verify server is filtering candidates by role correctly
|
||||||
|
- Enable detailed logging to see which candidate pairs are failing
|
||||||
|
|
||||||
|
## UI Guidelines
|
||||||
|
|
||||||
|
- Show clear connection status (waiting, connecting, connected, failed)
|
||||||
|
- Display peer role (offerer vs answerer) for debugging
|
||||||
|
- Provide visual feedback for all user actions
|
||||||
|
- Use toast notifications for errors and success messages
|
||||||
332
README.md
332
README.md
@@ -1,51 +1,54 @@
|
|||||||
# Rondevu
|
# Rondevu Demo
|
||||||
|
|
||||||
🎯 **Simple WebRTC peer signaling and discovery**
|
🎯 **Interactive WebRTC peer discovery and connection demo**
|
||||||
|
|
||||||
Meet peers by topic, by peer ID, or by connection ID.
|
Experience topic-based peer discovery and WebRTC connections using the Rondevu signaling platform.
|
||||||
|
|
||||||
**Related repositories:**
|
**Related repositories:**
|
||||||
- [rondevu-server](https://github.com/xtr-dev/rondevu-server) - HTTP signaling server
|
- [@xtr-dev/rondevu-client](https://github.com/xtr-dev/rondevu-client) - TypeScript client library ([npm](https://www.npmjs.com/package/@xtr-dev/rondevu-client))
|
||||||
- [rondevu-client](https://github.com/xtr-dev/rondevu-client) - TypeScript client library
|
- [@xtr-dev/rondevu-server](https://github.com/xtr-dev/rondevu-server) - HTTP signaling server ([npm](https://www.npmjs.com/package/@xtr-dev/rondevu-server), [live](https://api.ronde.vu))
|
||||||
|
- [@xtr-dev/rondevu-demo](https://github.com/xtr-dev/rondevu-demo) - Interactive demo ([live](https://ronde.vu))
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Rondevu Demo
|
## Overview
|
||||||
|
|
||||||
**Interactive demo showcasing three ways to connect WebRTC peers.**
|
This demo showcases the complete Rondevu workflow:
|
||||||
|
|
||||||
Experience how easy WebRTC peer discovery can be with Rondevu's three connection methods:
|
1. **Register** - Get peer credentials (automatically saved)
|
||||||
|
2. **Create Offers** - Advertise your WebRTC connection on topics
|
||||||
|
3. **Discover Peers** - Find other peers by topic
|
||||||
|
4. **Connect** - Establish direct P2P WebRTC connections via `RondevuPeer`
|
||||||
|
5. **Chat** - Send messages over WebRTC data channels
|
||||||
|
|
||||||
🎯 **Connect by Topic** - Auto-discover and join any available peer
|
### Key Features
|
||||||
👤 **Connect by Peer ID** - Filter and connect to specific peers
|
|
||||||
🔗 **Connect by Connection ID** - Share a code and connect directly
|
|
||||||
|
|
||||||
### Features
|
- **Topic-Based Discovery** - Find peers by shared topics (like torrent infohashes)
|
||||||
|
- **Real P2P Connections** - Actual WebRTC data channels (not simulated)
|
||||||
|
- **State-Based Peer Management** - Uses `RondevuPeer` with clean state machine (idle → creating-offer → waiting-for-answer → exchanging-ice → connected)
|
||||||
|
- **Trickle ICE** - Fast connection establishment by sending ICE candidates as they're discovered
|
||||||
|
- **Persistent Credentials** - Saves authentication to localStorage
|
||||||
|
- **Topics Browser** - Browse all active topics and peer counts
|
||||||
|
- **Multiple Connections** - Support multiple simultaneous peer connections
|
||||||
|
- **Real-time Chat** - Direct peer-to-peer messaging
|
||||||
|
|
||||||
- **Three Connection Methods** - Experience topic discovery, peer filtering, and direct connection
|
## Quick Start
|
||||||
- **Real WebRTC** - Actual P2P connections using RTCPeerConnection (not simulated!)
|
|
||||||
- **P2P Data Channel** - Direct peer-to-peer chat without server relay
|
|
||||||
- **Peer Discovery** - Browse topics and discover available peers
|
|
||||||
- **Real-time Chat** - Send and receive messages over WebRTC data channel
|
|
||||||
- **Activity Log** - Monitor all API and WebRTC events
|
|
||||||
|
|
||||||
### Quick Start
|
### Installation
|
||||||
|
|
||||||
#### Installation
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Development
|
### Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
This will start the Vite dev server at `http://localhost:5173`
|
This starts the Vite dev server at `http://localhost:5173`
|
||||||
|
|
||||||
#### Build for Production
|
### Build for Production
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
@@ -53,150 +56,190 @@ npm run build
|
|||||||
|
|
||||||
The built files will be in the `dist/` directory.
|
The built files will be in the `dist/` directory.
|
||||||
|
|
||||||
#### Preview Production Build
|
### Preview Production Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run preview
|
npm run preview
|
||||||
```
|
```
|
||||||
|
|
||||||
### Three Ways to Connect
|
## How to Use
|
||||||
|
|
||||||
This demo demonstrates all three Rondevu connection methods:
|
### Step 1: Register (One-time)
|
||||||
|
|
||||||
#### 1️⃣ Join Topic (Auto-Discovery)
|
The demo automatically registers you when you first visit. Your credentials are saved in localStorage for future visits.
|
||||||
|
|
||||||
**Easiest method** - Just enter a topic and auto-connect to first available peer:
|
### Step 2: Create an Offer
|
||||||
|
|
||||||
1. Enter a topic name in the "Join Topic" section (e.g., "demo-room")
|
1. Go to the "Create Offer" tab
|
||||||
2. Click "Join Topic"
|
2. Enter one or more topics (comma-separated), e.g., `demo-room, testing`
|
||||||
3. Rondevu finds the first available peer and connects automatically
|
3. Click "Create Offer"
|
||||||
4. Start chatting!
|
4. Your offer is now advertised on those topics
|
||||||
|
|
||||||
**Best for:** Quick matching, joining any available game/chat
|
**Share the topic name with peers you want to connect with!**
|
||||||
|
|
||||||
---
|
### Step 3: Discover and Connect (Other Peer)
|
||||||
|
|
||||||
#### 2️⃣ Discover Peers (Filter by Peer ID)
|
1. Go to the "Discover Offers" tab
|
||||||
|
2. Enter the same topic (e.g., `demo-room`)
|
||||||
|
3. Click "Discover Offers"
|
||||||
|
4. See available peers and their offers
|
||||||
|
5. Click "Answer Offer" to connect
|
||||||
|
|
||||||
**Connect to specific peers** - Browse and select which peer to connect to:
|
### Step 4: Chat
|
||||||
|
|
||||||
1. Enter a topic name (e.g., "demo-room")
|
1. Once connected, go to the "Chat" tab
|
||||||
2. Click "Discover in [topic]" to list all available peers
|
2. Select a connection from the dropdown
|
||||||
3. See each peer's ID in the list
|
3. Type messages and hit Enter or click Send
|
||||||
4. Click "Connect" on the specific peer you want to talk to
|
4. Messages are sent **directly peer-to-peer** via WebRTC
|
||||||
5. Start chatting!
|
|
||||||
|
|
||||||
**Best for:** Connecting to friends, teammates, or specific users
|
### Browse Topics
|
||||||
|
|
||||||
---
|
Click the "Topics" tab to:
|
||||||
|
- See all active topics
|
||||||
|
- View peer counts for each topic
|
||||||
|
- Quick-discover by clicking a topic
|
||||||
|
|
||||||
#### 3️⃣ Create/Connect by ID (Direct Connection)
|
## Testing Locally
|
||||||
|
|
||||||
**Share a connection code** - Like sharing a meeting link:
|
|
||||||
|
|
||||||
**To create:**
|
|
||||||
1. Enter a topic name (e.g., "meetings")
|
|
||||||
2. Enter a custom Connection ID (e.g., "my-meeting-123") or leave blank for auto-generation
|
|
||||||
3. Click "Create Connection"
|
|
||||||
4. **Share the Connection ID** with the person you want to connect with
|
|
||||||
|
|
||||||
**To join:**
|
|
||||||
1. Get the Connection ID from your friend (e.g., "my-meeting-123")
|
|
||||||
2. Enter it in the "Connect by ID" section
|
|
||||||
3. Click "Connect to ID"
|
|
||||||
4. Start chatting!
|
|
||||||
|
|
||||||
**Best for:** Meeting rooms, QR code connections, invitation-based sessions
|
|
||||||
|
|
||||||
#### Testing Locally
|
|
||||||
|
|
||||||
The easiest way to test:
|
The easiest way to test:
|
||||||
1. Open the demo in **two different browser windows** (or tabs)
|
|
||||||
2. In window 1: Create an offer with topic "test-room"
|
|
||||||
3. In window 2: Discover peers in "test-room" and click Connect
|
|
||||||
4. Watch the connection establish and start chatting!
|
|
||||||
|
|
||||||
#### Browse Topics
|
1. Open the demo in **two browser windows** (or tabs)
|
||||||
|
2. **Window 1**: Create an offer with topic `test-room`
|
||||||
|
3. **Window 2**: Discover offers in `test-room` and answer
|
||||||
|
4. Switch to Chat tab in both windows
|
||||||
|
5. Start chatting peer-to-peer!
|
||||||
|
|
||||||
- Click "Refresh Topics" to see all active topics
|
## Technical Implementation
|
||||||
- Click on any topic to auto-fill the discovery form
|
|
||||||
|
|
||||||
### Server Configuration
|
### RondevuPeer State Machine
|
||||||
|
|
||||||
This demo connects to: `https://rondevu.xtrdev.workers.dev`
|
This demo uses the `RondevuPeer` class which implements a clean state-based connection lifecycle:
|
||||||
|
|
||||||
To use a different server, modify the `baseUrl` in `src/main.js`:
|
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const client = new RondevuClient({
|
import { Rondevu } from '@xtr-dev/rondevu-client';
|
||||||
baseUrl: 'https://your-server.com'
|
|
||||||
|
// Create peer
|
||||||
|
const peer = client.createPeer();
|
||||||
|
|
||||||
|
// Set up event listeners
|
||||||
|
peer.on('state', (state) => {
|
||||||
|
console.log('Peer state:', state);
|
||||||
|
// Offerer: idle → creating-offer → waiting-for-answer → exchanging-ice → connected
|
||||||
|
// Answerer: idle → answering → exchanging-ice → connected
|
||||||
|
});
|
||||||
|
|
||||||
|
peer.on('connected', () => {
|
||||||
|
console.log('✅ P2P connection established!');
|
||||||
|
});
|
||||||
|
|
||||||
|
peer.on('datachannel', (channel) => {
|
||||||
|
channel.addEventListener('message', (event) => {
|
||||||
|
console.log('📥 Message:', event.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
channel.addEventListener('open', () => {
|
||||||
|
// Channel is ready, can send messages
|
||||||
|
channel.send('Hello!');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
peer.on('failed', (error) => {
|
||||||
|
console.error('❌ Connection failed:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create offer (offerer)
|
||||||
|
await peer.createOffer({
|
||||||
|
topics: ['demo-room'],
|
||||||
|
ttl: 300000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Or answer an offer (answerer)
|
||||||
|
await peer.answer(offerId, offerSdp, {
|
||||||
|
topics: ['demo-room']
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Technologies
|
### Connection States
|
||||||
|
|
||||||
- **Vite** - Fast development and build tool
|
**Offerer Flow:**
|
||||||
- **@xtr-dev/rondevu-client** - TypeScript client for Rondevu API
|
1. **idle** - Initial state
|
||||||
- **Vanilla JavaScript** - No framework dependencies
|
2. **creating-offer** - Creating WebRTC offer and sending to server
|
||||||
|
3. **waiting-for-answer** - Polling for answer from peer (every 2 seconds)
|
||||||
|
4. **exchanging-ice** - Exchanging ICE candidates (polling every 1 second)
|
||||||
|
5. **connected** - Successfully connected!
|
||||||
|
6. **failed/closed** - Connection failed or was closed
|
||||||
|
|
||||||
### API Examples
|
**Answerer Flow:**
|
||||||
|
1. **idle** - Initial state
|
||||||
|
2. **answering** - Creating WebRTC answer and sending to server
|
||||||
|
3. **exchanging-ice** - Exchanging ICE candidates (polling every 1 second)
|
||||||
|
4. **connected** - Successfully connected!
|
||||||
|
5. **failed/closed** - Connection failed or was closed
|
||||||
|
|
||||||
The demo showcases all major Rondevu API endpoints:
|
### What Happens Under the Hood
|
||||||
|
|
||||||
- `GET /` - List all topics
|
1. **Offerer** calls `peer.createOffer()`:
|
||||||
- `GET /:topic/sessions` - Discover peers in a topic
|
- State → `creating-offer`
|
||||||
- `POST /:topic/offer` - Create a new offer
|
- Creates RTCPeerConnection and data channel
|
||||||
- `POST /answer` - Send answer to a peer
|
- Generates SDP offer
|
||||||
- `POST /poll` - Poll for peer data
|
- Sets up ICE candidate handler (before gathering starts)
|
||||||
- `GET /health` - Check server health
|
- Sets local description → ICE gathering begins
|
||||||
|
- Posts offer to Rondevu server
|
||||||
|
- State → `waiting-for-answer`
|
||||||
|
- Polls for answers every 2 seconds
|
||||||
|
- When answer received → State → `exchanging-ice`
|
||||||
|
|
||||||
### WebRTC Implementation Details
|
2. **Answerer** calls `peer.answer()`:
|
||||||
|
- State → `answering`
|
||||||
|
- Creates RTCPeerConnection
|
||||||
|
- Sets remote description (offer SDP)
|
||||||
|
- Generates SDP answer
|
||||||
|
- Sends answer to server (registers as answerer)
|
||||||
|
- Sets up ICE candidate handler (before gathering starts)
|
||||||
|
- Sets local description → ICE gathering begins
|
||||||
|
- State → `exchanging-ice`
|
||||||
|
|
||||||
This demo implements a **complete WebRTC peer-to-peer connection** with:
|
3. **ICE Exchange** (Trickle ICE):
|
||||||
|
- Both peers generate ICE candidates as they're discovered
|
||||||
|
- Candidates are automatically sent to server immediately
|
||||||
|
- Peers poll and receive remote candidates (every 1 second)
|
||||||
|
- ICE establishes the direct P2P path
|
||||||
|
- State → `connected`
|
||||||
|
|
||||||
#### Connection Flow
|
4. **Connection Established**:
|
||||||
|
- Data channel opens
|
||||||
|
- Chat messages flow directly between peers
|
||||||
|
- No server relay (true P2P!)
|
||||||
|
|
||||||
1. **Offerer** creates an `RTCPeerConnection` and generates an SDP offer
|
### Key Features of Implementation
|
||||||
2. Offer is sent to the Rondevu signaling server via `POST /:topic/offer`
|
|
||||||
3. **Answerer** discovers the offer via `GET /:topic/sessions`
|
|
||||||
4. Answerer creates an `RTCPeerConnection`, sets the remote offer, and generates an SDP answer
|
|
||||||
5. Answer is sent via `POST /answer`
|
|
||||||
6. Both peers generate ICE candidates and send them via `POST /answer` with `candidate` field
|
|
||||||
7. Both peers poll via `POST /poll` to receive remote ICE candidates
|
|
||||||
8. Once candidates are exchanged, the **direct P2P connection** is established
|
|
||||||
9. Data channel opens and chat messages flow **directly between peers**
|
|
||||||
|
|
||||||
#### Key Features
|
- **Trickle ICE**: Candidates sent immediately as discovered (no waiting)
|
||||||
|
- **Proper Authorization**: Answer sent to server before ICE gathering to authorize candidate posting
|
||||||
|
- **Event Cleanup**: All event listeners properly removed with `removeEventListener`
|
||||||
|
- **State Management**: Clean state machine with well-defined transitions
|
||||||
|
- **Error Handling**: Graceful failure states with error events
|
||||||
|
|
||||||
- **Real RTCPeerConnection** - Not simulated, actual WebRTC
|
### Architecture
|
||||||
- **STUN servers** - Google's public STUN servers for NAT traversal
|
|
||||||
- **Data Channel** - Named "chat" channel for text messaging
|
|
||||||
- **ICE Trickle** - Candidates are sent as they're generated
|
|
||||||
- **Automatic Polling** - Polls every 1 second for remote data
|
|
||||||
- **Connection States** - Visual indicators for connecting/connected/failed states
|
|
||||||
- **Graceful Cleanup** - Properly closes connections and stops polling
|
|
||||||
|
|
||||||
#### Technologies
|
- **Frontend**: React + Vite
|
||||||
|
- **Signaling**: Rondevu server (Cloudflare Workers + D1)
|
||||||
|
- **Client**: @xtr-dev/rondevu-client (TypeScript library)
|
||||||
|
- **WebRTC**: RTCPeerConnection with STUN/TURN servers
|
||||||
|
- **Connection Management**: RondevuPeer class with state machine
|
||||||
|
|
||||||
- **RTCPeerConnection API** - Core WebRTC connection
|
## Server Configuration
|
||||||
- **RTCDataChannel API** - Unreliable but fast text messaging
|
|
||||||
- **Rondevu Signaling** - SDP and ICE candidate exchange
|
|
||||||
- **STUN Protocol** - NAT traversal (stun.l.google.com)
|
|
||||||
|
|
||||||
### Development Notes
|
This demo connects to: `https://api.ronde.vu`
|
||||||
|
|
||||||
- Peer IDs are auto-generated on page load
|
To use a different server, modify `API_URL` in `src/App.jsx`:
|
||||||
- WebRTC connections use **real** RTCPeerConnection (not simulated!)
|
|
||||||
- Sessions expire after the server's configured timeout (5 minutes default)
|
|
||||||
- The demo is completely client-side (no backend required)
|
|
||||||
- Messages are sent P2P - the server only facilitates discovery
|
|
||||||
- Works across different browsers and networks (with STUN support)
|
|
||||||
|
|
||||||
### Deployment
|
```javascript
|
||||||
|
const API_URL = 'https://your-server.com';
|
||||||
|
```
|
||||||
|
|
||||||
#### Deploy to Cloudflare Pages
|
## Deployment
|
||||||
|
|
||||||
The demo can be easily deployed to Cloudflare Pages (free tier):
|
### Deploy to Cloudflare Pages
|
||||||
|
|
||||||
**Quick Deploy via Wrangler:**
|
**Quick Deploy via Wrangler:**
|
||||||
|
|
||||||
@@ -213,7 +256,46 @@ npx wrangler pages deploy dist --project-name=rondevu-demo
|
|||||||
4. Set output directory: `dist`
|
4. Set output directory: `dist`
|
||||||
5. Deploy automatically on every push!
|
5. Deploy automatically on every push!
|
||||||
|
|
||||||
### License
|
## Development Notes
|
||||||
|
|
||||||
|
- Credentials are stored in localStorage and persist across sessions
|
||||||
|
- Offers expire after 5 minutes by default
|
||||||
|
- The peer automatically polls for answers and ICE candidates
|
||||||
|
- Multiple simultaneous connections are supported
|
||||||
|
- WebRTC uses Google's public STUN servers + custom TURN server for NAT traversal
|
||||||
|
- Data channel messages are unreliable but fast (perfect for chat)
|
||||||
|
- Connection cleanup is automatic when peers disconnect
|
||||||
|
|
||||||
|
## Connection Timeouts
|
||||||
|
|
||||||
|
The demo uses these default timeouts:
|
||||||
|
|
||||||
|
- **ICE Gathering**: 10 seconds (not used with trickle ICE)
|
||||||
|
- **Waiting for Answer**: 30 seconds
|
||||||
|
- **Creating Answer**: 10 seconds
|
||||||
|
- **ICE Connection**: 30 seconds
|
||||||
|
|
||||||
|
These can be customized in the `PeerOptions`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
await peer.createOffer({
|
||||||
|
topics: ['my-topic'],
|
||||||
|
timeouts: {
|
||||||
|
waitingForAnswer: 60000, // 1 minute
|
||||||
|
iceConnection: 45000 // 45 seconds
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technologies
|
||||||
|
|
||||||
|
- **React** - UI framework
|
||||||
|
- **Vite** - Build tool and dev server
|
||||||
|
- **@xtr-dev/rondevu-client** - Rondevu client library with `RondevuPeer`
|
||||||
|
- **RTCPeerConnection** - WebRTC connections
|
||||||
|
- **RTCDataChannel** - P2P messaging
|
||||||
|
- **QRCode** - QR code generation for easy topic sharing
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
||||||
|
|||||||
50
package-lock.json
generated
50
package-lock.json
generated
@@ -1,17 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "rondevu-demo",
|
"name": "rondevu-demo",
|
||||||
"version": "1.0.1",
|
"version": "0.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rondevu-demo",
|
"name": "rondevu-demo",
|
||||||
"version": "1.0.1",
|
"version": "0.5.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@xtr-dev/rondevu-client": "^0.7.4",
|
||||||
"@zxing/library": "^0.21.3",
|
"@zxing/library": "^0.21.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0"
|
"react-dom": "^18.2.0",
|
||||||
|
"react-hot-toast": "^2.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.0",
|
"@types/react": "^18.2.0",
|
||||||
@@ -1168,6 +1170,21 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xtr-dev/rondevu-client": {
|
||||||
|
"version": "0.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xtr-dev/rondevu-client/-/rondevu-client-0.7.4.tgz",
|
||||||
|
"integrity": "sha512-MPmw9iSc7LxLduu4TtVrcPvBl/Cuul5sqgOAKUWW7XYXYAObFYUtu9RcbWShR+a6Bwwx7oHadz5I2U8eWsebXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@xtr-dev/rondevu-client": "^0.5.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xtr-dev/rondevu-client/node_modules/@xtr-dev/rondevu-client": {
|
||||||
|
"version": "0.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xtr-dev/rondevu-client/-/rondevu-client-0.5.1.tgz",
|
||||||
|
"integrity": "sha512-110ejMCizPUPkHwwwNvcdCSZceLaHeFbf1LNkXvbG6pnLBqCf2uoGOOaRkArb7HNNFABFB+HXzm/AVzNdadosw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@zxing/library": {
|
"node_modules/@zxing/library": {
|
||||||
"version": "0.21.3",
|
"version": "0.21.3",
|
||||||
"resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.21.3.tgz",
|
"resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.21.3.tgz",
|
||||||
@@ -1328,7 +1345,6 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
@@ -1473,6 +1489,15 @@
|
|||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/goober": {
|
||||||
|
"version": "2.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
|
||||||
|
"integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"csstype": "^3.0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-fullwidth-code-point": {
|
"node_modules/is-fullwidth-code-point": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
@@ -1713,6 +1738,23 @@
|
|||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-hot-toast": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.1.3",
|
||||||
|
"goober": "^2.1.16"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16",
|
||||||
|
"react-dom": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rondevu-demo",
|
"name": "rondevu-demo",
|
||||||
"version": "1.0.1",
|
"version": "0.5.0",
|
||||||
"description": "Demo application for Rondevu peer signaling and discovery",
|
"description": "Demo application for Rondevu topic-based peer discovery and signaling",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -10,10 +10,12 @@
|
|||||||
"deploy": "npm run build && npx wrangler pages deploy dist --project-name=rondevu-demo"
|
"deploy": "npm run build && npx wrangler pages deploy dist --project-name=rondevu-demo"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@xtr-dev/rondevu-client": "^0.7.4",
|
||||||
"@zxing/library": "^0.21.3",
|
"@zxing/library": "^0.21.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0"
|
"react-dom": "^18.2.0",
|
||||||
|
"react-hot-toast": "^2.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.0",
|
"@types/react": "^18.2.0",
|
||||||
|
|||||||
1469
src/App.jsx
1469
src/App.jsx
File diff suppressed because it is too large
Load Diff
@@ -2,95 +2,31 @@ import QRCodeDisplay from './QRCodeDisplay';
|
|||||||
|
|
||||||
function ConnectionForm({
|
function ConnectionForm({
|
||||||
action,
|
action,
|
||||||
method,
|
|
||||||
topic,
|
|
||||||
setTopic,
|
|
||||||
connectionId,
|
connectionId,
|
||||||
setConnectionId,
|
setConnectionId,
|
||||||
peerId,
|
|
||||||
setPeerId,
|
|
||||||
topics,
|
|
||||||
sessions,
|
|
||||||
connectionStatus,
|
connectionStatus,
|
||||||
qrCodeUrl,
|
qrCodeUrl,
|
||||||
currentConnectionId,
|
currentConnectionId,
|
||||||
onConnect,
|
onConnect,
|
||||||
onBack,
|
onBack
|
||||||
onTopicSelect,
|
|
||||||
onDiscoverPeers
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="step-container">
|
<div className="step-container">
|
||||||
<h2>Enter Details</h2>
|
<h2>{action === 'create' ? 'Create Connection' : 'Join Connection'}</h2>
|
||||||
<div className="form-container">
|
<div className="form-container">
|
||||||
{(method === 'topic' || method === 'peer-id' || (method === 'connection-id' && action === 'create')) && (
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Topic</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={topic}
|
|
||||||
onChange={(e) => setTopic(e.target.value)}
|
|
||||||
placeholder="e.g., game-room"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
{topics.length > 0 && (
|
|
||||||
<div className="topic-list">
|
|
||||||
{topics.map((t) => (
|
|
||||||
<button
|
|
||||||
key={t.topic}
|
|
||||||
className="topic-item"
|
|
||||||
onClick={() => {
|
|
||||||
onTopicSelect(t.topic);
|
|
||||||
if (method === 'peer-id') {
|
|
||||||
onDiscoverPeers(t.topic);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t.topic} <span className="peer-count">({t.count})</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{method === 'peer-id' && (
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Peer ID</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={peerId}
|
|
||||||
onChange={(e) => setPeerId(e.target.value)}
|
|
||||||
placeholder="e.g., player-123"
|
|
||||||
/>
|
|
||||||
{sessions.length > 0 && (
|
|
||||||
<div className="topic-list">
|
|
||||||
{sessions.map((s) => (
|
|
||||||
<button
|
|
||||||
key={s.code}
|
|
||||||
className="topic-item"
|
|
||||||
onClick={() => setPeerId(s.peerId)}
|
|
||||||
>
|
|
||||||
{s.peerId}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{method === 'connection-id' && (
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Connection ID {action === 'create' && '(optional)'}</label>
|
<label>Connection ID {action === 'create' && '(optional)'}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={connectionId}
|
value={connectionId}
|
||||||
onChange={(e) => setConnectionId(e.target.value)}
|
onChange={(e) => setConnectionId(e.target.value)}
|
||||||
placeholder={action === 'create' ? 'Auto-generated if empty' : 'e.g., meeting-123'}
|
placeholder={action === 'create' ? 'Auto-generated if empty' : 'Enter connection ID'}
|
||||||
autoFocus={action === 'join'}
|
autoFocus={action === 'connect'}
|
||||||
/>
|
/>
|
||||||
</div>
|
{action === 'create' && !connectionId && (
|
||||||
|
<p className="help-text">Leave empty to auto-generate a random ID</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="button-row">
|
<div className="button-row">
|
||||||
<button className="back-button" onClick={onBack}>← Back</button>
|
<button className="back-button" onClick={onBack}>← Back</button>
|
||||||
@@ -99,12 +35,10 @@ function ConnectionForm({
|
|||||||
onClick={onConnect}
|
onClick={onConnect}
|
||||||
disabled={
|
disabled={
|
||||||
connectionStatus === 'connecting' ||
|
connectionStatus === 'connecting' ||
|
||||||
(method === 'topic' && !topic) ||
|
(action === 'connect' && !connectionId)
|
||||||
(method === 'peer-id' && (!topic || !peerId)) ||
|
|
||||||
(method === 'connection-id' && action === 'join' && !connectionId)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{connectionStatus === 'connecting' ? 'Connecting...' : 'Connect'}
|
{connectionStatus === 'connecting' ? 'Connecting...' : (action === 'create' ? 'Create' : 'Connect')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ function Header() {
|
|||||||
<header className="header">
|
<header className="header">
|
||||||
<div className="header-content">
|
<div className="header-content">
|
||||||
<h1>Rondevu</h1>
|
<h1>Rondevu</h1>
|
||||||
<p className="tagline">Meet WebRTC peers by topic, peer ID, or connection ID</p>
|
<p className="tagline">Simple WebRTC peer signaling and discovery. Meet peers by topic, peer ID, or connection ID.</p>
|
||||||
<div className="header-links">
|
<div className="header-links">
|
||||||
<a href="https://github.com/xtr-dev/rondevu-client" target="_blank" rel="noopener noreferrer">
|
<a href="https://github.com/xtr-dev/rondevu-client" target="_blank" rel="noopener noreferrer">
|
||||||
<svg className="github-icon" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
|
<svg className="github-icon" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
|
||||||
@@ -21,7 +21,7 @@ function Header() {
|
|||||||
<svg className="github-icon" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
|
<svg className="github-icon" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
|
||||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
|
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
|
||||||
</svg>
|
</svg>
|
||||||
View source
|
Demo
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
120
src/components/TopicsList.jsx
Normal file
120
src/components/TopicsList.jsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
function TopicsList({ rdv, onClose }) {
|
||||||
|
const [topics, setTopics] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pagination, setPagination] = useState(null);
|
||||||
|
const [limit] = useState(20);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTopics();
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
const loadTopics = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await rdv.api.listTopics(page, limit);
|
||||||
|
setTopics(response.topics);
|
||||||
|
setPagination(response.pagination);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
loadTopics();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevPage = () => {
|
||||||
|
if (page > 1) {
|
||||||
|
setPage(page - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextPage = () => {
|
||||||
|
if (pagination?.hasMore) {
|
||||||
|
setPage(page + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal-content topics-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>Active Topics</h2>
|
||||||
|
<button className="close-button" onClick={onClose}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-body">
|
||||||
|
{error && (
|
||||||
|
<div className="error-message" style={{ marginBottom: '1rem' }}>
|
||||||
|
Error: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="loading-message">Loading topics...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{topics.length === 0 ? (
|
||||||
|
<div className="empty-message">
|
||||||
|
No active topics found. Be the first to create one!
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="topics-list">
|
||||||
|
{topics.map((topic) => (
|
||||||
|
<div key={topic.topic} className="topic-item">
|
||||||
|
<div className="topic-name">{topic.topic}</div>
|
||||||
|
<div className="topic-count">
|
||||||
|
{topic.count} {topic.count === 1 ? 'peer' : 'peers'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pagination && (
|
||||||
|
<div className="pagination">
|
||||||
|
<button
|
||||||
|
onClick={handlePrevPage}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="pagination-button"
|
||||||
|
>
|
||||||
|
← Previous
|
||||||
|
</button>
|
||||||
|
<span className="pagination-info">
|
||||||
|
Page {pagination.page} of {Math.ceil(pagination.total / pagination.limit)}
|
||||||
|
{' '}({pagination.total} total)
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleNextPage}
|
||||||
|
disabled={!pagination.hasMore}
|
||||||
|
className="pagination-button"
|
||||||
|
>
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button onClick={handleRefresh} className="button button-secondary">
|
||||||
|
🔄 Refresh
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose} className="button button-primary">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TopicsList;
|
||||||
226
src/index.css
226
src/index.css
@@ -162,6 +162,12 @@ body {
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
input[type="text"] {
|
input[type="text"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
@@ -790,3 +796,223 @@ input[type="text"]:disabled {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Topics List Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
|
||||||
|
animation: slideUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 24px 32px;
|
||||||
|
border-bottom: 1px solid #e8eaf0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #6c757d;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button:hover {
|
||||||
|
background: #f8f9fc;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 24px 32px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 20px 32px;
|
||||||
|
border-top: 1px solid #e8eaf0;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topics-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: #f8f9fc;
|
||||||
|
border: 1px solid #e8eaf0;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-item:hover {
|
||||||
|
background: #f0f2f8;
|
||||||
|
border-color: #d0d5dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-size: 1rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-count {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #6c757d;
|
||||||
|
background: white;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #e8eaf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: 1px solid #e8eaf0;
|
||||||
|
background: white;
|
||||||
|
color: #1a1a1a;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-button:hover:not(:disabled) {
|
||||||
|
background: #f8f9fc;
|
||||||
|
border-color: #5568d3;
|
||||||
|
color: #5568d3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-message, .empty-message, .error-message {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: #dc3545;
|
||||||
|
background: #ffe8eb;
|
||||||
|
border: 1px solid #ffccd2;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-topics-button {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 80px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 14px 24px;
|
||||||
|
background: #5568d3;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 12px rgba(85, 104, 211, 0.3);
|
||||||
|
transition: all 0.2s;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-topics-button:hover {
|
||||||
|
background: #667eea;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(85, 104, 211, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Topic cards grid for discovery */
|
||||||
|
.topic-card-hover {
|
||||||
|
padding: 25px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-card-hover:hover {
|
||||||
|
border-color: #667eea;
|
||||||
|
background: #fafbfc;
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.2);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user