Initial commit: Modern step-based UI for Rondevu demo

- Step-based flow: Choose action → Choose method → Enter details → Chat
- Modern design with clean header and footer
- GitHub links with icons to client and server repos
- Footer link to ronde.vu
- Three connection methods: Topic, Peer ID, Connection ID
- Real WebRTC peer-to-peer chat
- Mobile-responsive design
- Collapsible activity log

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-07 21:40:19 +01:00
commit ae16c58ee2
12 changed files with 3282 additions and 0 deletions

452
src/App.jsx Normal file
View File

@@ -0,0 +1,452 @@
import { useState, useEffect, useRef } from 'react';
import { Rondevu, RondevuClient } from '@xtr-dev/rondevu-client';
const rdv = new Rondevu({
baseUrl: 'https://rondevu.xtrdev.workers.dev',
rtcConfig: {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{
urls: 'turn:relay1.expressturn.com:3480',
username: 'ef13B1E5PH265HK1N2',
credential: 'TTcTPEy3ndxsS0Gp'
}
]
}
});
const client = new RondevuClient({
baseUrl: 'https://rondevu.xtrdev.workers.dev'
});
function App() {
// Step-based state
const [step, setStep] = useState(1); // 1: action, 2: method, 3: details, 4: connected
const [action, setAction] = useState(null); // 'create' or 'join'
const [method, setMethod] = useState(null); // 'topic', 'peer-id', 'connection-id'
// Connection state
const [topic, setTopic] = useState('');
const [connectionId, setConnectionId] = useState('');
const [peerId, setPeerId] = useState('');
const [topics, setTopics] = useState([]);
const [sessions, setSessions] = useState([]);
const [connectionStatus, setConnectionStatus] = useState('disconnected');
const [connectedPeer, setConnectedPeer] = useState(null);
const [currentConnectionId, setCurrentConnectionId] = useState(null);
// Chat state
const [messages, setMessages] = useState([]);
const [messageInput, setMessageInput] = useState('');
const [logs, setLogs] = useState([]);
const connectionRef = useRef(null);
const dataChannelRef = useRef(null);
useEffect(() => {
log('Demo initialized', 'info');
loadTopics();
}, []);
const log = (message, type = 'info') => {
const timestamp = new Date().toLocaleTimeString();
setLogs(prev => [...prev, { message, type, timestamp }]);
};
const loadTopics = async () => {
try {
const { topics } = await client.listTopics();
setTopics(topics);
} catch (error) {
log(`Error loading topics: ${error.message}`, 'error');
}
};
const discoverPeers = async (topicName) => {
try {
const { sessions: foundSessions } = await client.listSessions(topicName);
const otherSessions = foundSessions.filter(s => s.peerId !== rdv.peerId);
setSessions(otherSessions);
} catch (error) {
log(`Error discovering peers: ${error.message}`, 'error');
}
};
const setupConnection = (connection) => {
connectionRef.current = connection;
connection.on('connect', () => {
log('✅ Connected!', 'success');
setConnectionStatus('connected');
setStep(4);
const channel = connection.dataChannel('chat');
setupDataChannel(channel);
});
connection.on('disconnect', () => {
log('Disconnected', 'info');
reset();
});
connection.on('error', (error) => {
log(`Error: ${error.message}`, 'error');
if (error.message.includes('timeout')) {
reset();
}
});
connection.on('datachannel', (channel) => {
if (channel.label === 'chat') {
setupDataChannel(channel);
}
});
};
const setupDataChannel = (channel) => {
dataChannelRef.current = channel;
channel.onmessage = (event) => {
setMessages(prev => [...prev, {
text: event.data,
type: 'received',
timestamp: new Date()
}]);
};
};
const handleConnect = async () => {
try {
setConnectionStatus('connecting');
log('Connecting...', 'info');
let connection;
if (action === 'create') {
if (method === 'connection-id') {
const id = connectionId || `conn-${Date.now()}`;
connection = await rdv.create(id, topic || 'default');
setCurrentConnectionId(id);
log(`Created connection: ${id}`, 'success');
} else {
const id = `conn-${Date.now()}`;
connection = await rdv.create(id, topic);
setCurrentConnectionId(id);
log(`Created connection: ${id}`, 'success');
}
} else {
if (method === 'topic') {
connection = await rdv.join(topic);
setCurrentConnectionId(connection.id);
} else if (method === 'peer-id') {
connection = await rdv.join(topic, {
filter: (s) => s.peerId === peerId
});
setCurrentConnectionId(connection.id);
} else if (method === 'connection-id') {
connection = await rdv.connect(connectionId);
setCurrentConnectionId(connectionId);
}
}
setConnectedPeer(connection.remotePeerId || 'Waiting...');
setupConnection(connection);
} catch (error) {
log(`Error: ${error.message}`, 'error');
setConnectionStatus('disconnected');
}
};
const sendMessage = () => {
if (!messageInput || !dataChannelRef.current || dataChannelRef.current.readyState !== 'open') {
return;
}
dataChannelRef.current.send(messageInput);
setMessages(prev => [...prev, {
text: messageInput,
type: 'sent',
timestamp: new Date()
}]);
setMessageInput('');
};
const reset = () => {
if (connectionRef.current) {
connectionRef.current.close();
}
setStep(1);
setAction(null);
setMethod(null);
setTopic('');
setConnectionId('');
setPeerId('');
setSessions([]);
setConnectionStatus('disconnected');
setConnectedPeer(null);
setCurrentConnectionId(null);
setMessages([]);
connectionRef.current = null;
dataChannelRef.current = null;
};
return (
<div className="app">
<header className="header">
<div className="header-content">
<h1>Rondevu</h1>
<p className="tagline">Meet WebRTC peers by topic, peer ID, or connection ID</p>
<div className="header-links">
<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">
<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>
Client
</a>
<a href="https://github.com/xtr-dev/rondevu-server" target="_blank" rel="noopener noreferrer">
<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"/>
</svg>
Server
</a>
</div>
</div>
</header>
<main className="main">
{step === 1 && (
<div className="step-container">
<h2>Choose Action</h2>
<div className="button-grid">
<button
className="action-button"
onClick={() => {
setAction('create');
setStep(2);
}}
>
<div className="button-title">Create</div>
<div className="button-description">Start a new connection</div>
</button>
<button
className="action-button"
onClick={() => {
setAction('join');
setStep(2);
}}
>
<div className="button-title">Join</div>
<div className="button-description">Connect to existing peers</div>
</button>
</div>
</div>
)}
{step === 2 && (
<div className="step-container">
<h2>{action === 'create' ? 'Create' : 'Join'} by...</h2>
<div className="button-grid">
<button
className="action-button"
onClick={() => {
setMethod('topic');
setStep(3);
}}
>
<div className="button-title">Topic</div>
<div className="button-description">
{action === 'create' ? 'Create in a topic' : 'Auto-connect to first peer'}
</div>
</button>
{action === 'join' && (
<button
className="action-button"
onClick={() => {
setMethod('peer-id');
setStep(3);
}}
>
<div className="button-title">Peer ID</div>
<div className="button-description">Connect to specific peer</div>
</button>
)}
<button
className="action-button"
onClick={() => {
setMethod('connection-id');
setStep(3);
}}
>
<div className="button-title">Connection ID</div>
<div className="button-description">
{action === 'create' ? 'Custom connection code' : 'Direct connection'}
</div>
</button>
</div>
<button className="back-button" onClick={() => setStep(1)}> Back</button>
</div>
)}
{step === 3 && (
<div className="step-container">
<h2>Enter Details</h2>
<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={() => {
setTopic(t.topic);
if (method === 'peer-id') {
discoverPeers(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">
<label>Connection ID {action === 'create' && '(optional)'}</label>
<input
type="text"
value={connectionId}
onChange={(e) => setConnectionId(e.target.value)}
placeholder={action === 'create' ? 'Auto-generated if empty' : 'e.g., meeting-123'}
autoFocus={action === 'join'}
/>
</div>
)}
<div className="button-row">
<button className="back-button" onClick={() => setStep(2)}> Back</button>
<button
className="primary-button"
onClick={handleConnect}
disabled={
connectionStatus === 'connecting' ||
(method === 'topic' && !topic) ||
(method === 'peer-id' && (!topic || !peerId)) ||
(method === 'connection-id' && action === 'join' && !connectionId)
}
>
{connectionStatus === 'connecting' ? 'Connecting...' : 'Connect'}
</button>
</div>
</div>
</div>
)}
{step === 4 && (
<div className="chat-container">
<div className="chat-header">
<div>
<h2>Connected</h2>
<p className="connection-details">
Peer: {connectedPeer || 'Unknown'} ID: {currentConnectionId}
</p>
</div>
<button className="disconnect-button" onClick={reset}>Disconnect</button>
</div>
<div className="messages">
{messages.length === 0 ? (
<p className="empty">No messages yet. Start chatting!</p>
) : (
messages.map((msg, idx) => (
<div key={idx} className={`message ${msg.type}`}>
<div className="message-text">{msg.text}</div>
<div className="message-time">{msg.timestamp.toLocaleTimeString()}</div>
</div>
))
)}
</div>
<div className="message-input">
<input
type="text"
value={messageInput}
onChange={(e) => setMessageInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
placeholder="Type a message..."
disabled={!dataChannelRef.current || dataChannelRef.current.readyState !== 'open'}
/>
<button
onClick={sendMessage}
disabled={!dataChannelRef.current || dataChannelRef.current.readyState !== 'open'}
>
Send
</button>
</div>
{logs.length > 0 && (
<details className="logs">
<summary>Activity Log ({logs.length})</summary>
<div className="log-entries">
{logs.map((log, idx) => (
<div key={idx} className={`log-entry ${log.type}`}>
[{log.timestamp}] {log.message}
</div>
))}
</div>
</details>
)}
</div>
)}
<div className="peer-id-badge">Your Peer ID: {rdv.peerId}</div>
</main>
<footer className="footer">
<a href="https://ronde.vu" target="_blank" rel="noopener noreferrer">
ronde.vu
</a>
</footer>
</div>
);
}
export default App;

566
src/index.css Normal file
View File

@@ -0,0 +1,566 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
color: #333;
}
.app {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.header {
text-align: center;
color: white;
margin-bottom: 40px;
padding: 40px 20px 20px;
}
.header-content h1 {
font-size: 3rem;
margin-bottom: 8px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
font-weight: 700;
}
.tagline {
font-size: 1.1rem;
opacity: 0.95;
margin-bottom: 20px;
}
.header-links {
display: flex;
gap: 16px;
justify-content: center;
margin-top: 20px;
}
.header-links a {
color: white;
text-decoration: none;
font-size: 0.95rem;
opacity: 0.9;
transition: opacity 0.2s;
display: inline-flex;
align-items: center;
gap: 6px;
}
.header-links a:hover {
opacity: 1;
}
.github-icon {
display: inline-block;
vertical-align: middle;
}
.main {
position: relative;
min-height: 500px;
}
.step-container {
background: white;
border-radius: 16px;
padding: 48px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.step-container h2 {
color: #667eea;
margin-bottom: 32px;
font-size: 1.8rem;
text-align: center;
font-weight: 600;
}
.button-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
.action-button {
background: white;
border: 3px solid #e0e0e0;
padding: 32px 24px;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
text-align: left;
display: flex;
flex-direction: column;
gap: 8px;
}
.action-button:hover {
border-color: #667eea;
background: #f8f9ff;
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.2);
}
.button-title {
font-size: 1.4rem;
font-weight: 700;
color: #333;
}
.button-description {
font-size: 0.95rem;
color: #6c757d;
line-height: 1.4;
}
.form-container {
max-width: 500px;
margin: 0 auto;
}
.form-group {
margin-bottom: 24px;
}
.form-group label {
display: block;
font-weight: 600;
color: #333;
margin-bottom: 8px;
font-size: 0.95rem;
}
input[type="text"] {
width: 100%;
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
transition: all 0.2s;
}
input[type="text"]:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
input[type="text"]:disabled {
background: #f5f5f5;
cursor: not-allowed;
}
.topic-list {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.topic-item {
background: #f8f9fa;
color: #333;
border: 2px solid #e0e0e0;
padding: 8px 12px;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
margin: 0;
}
.topic-item:hover {
border-color: #667eea;
background: #f0f2ff;
transform: none;
box-shadow: none;
}
.topic-item .peer-count {
background: transparent;
color: #667eea;
padding: 0;
font-weight: 600;
font-size: 0.85rem;
}
.button-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 32px;
}
.back-button {
background: transparent;
color: #6c757d;
border: none;
padding: 8px 16px;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.2s;
margin: 0;
}
.back-button:hover {
color: #667eea;
background: transparent;
transform: none;
box-shadow: none;
}
.primary-button {
background: #667eea;
color: white;
border: none;
padding: 12px 32px;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin: 0;
}
.primary-button:hover:not(:disabled) {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.primary-button:disabled {
background: #ccc;
cursor: not-allowed;
transform: none;
}
.chat-container {
background: white;
border-radius: 16px;
padding: 32px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
animation: fadeIn 0.3s ease-out;
max-width: 700px;
margin: 0 auto;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 2px solid #f0f0f0;
}
.chat-header h2 {
color: #667eea;
font-size: 1.6rem;
margin-bottom: 8px;
font-weight: 600;
}
.connection-details {
color: #6c757d;
font-size: 0.9rem;
}
.disconnect-button {
background: #dc3545;
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin: 0;
}
.disconnect-button:hover {
background: #c82333;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.3);
}
.messages {
background: #f8f9fa;
border-radius: 12px;
padding: 20px;
min-height: 400px;
max-height: 400px;
overflow-y: auto;
margin-bottom: 20px;
}
.messages::-webkit-scrollbar {
width: 8px;
}
.messages::-webkit-scrollbar-track {
background: #e0e0e0;
border-radius: 4px;
}
.messages::-webkit-scrollbar-thumb {
background: #667eea;
border-radius: 4px;
}
.empty {
text-align: center;
color: #6c757d;
padding: 40px 20px;
font-size: 0.95rem;
}
.message {
margin-bottom: 16px;
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message-text {
padding: 12px 16px;
border-radius: 12px;
max-width: 70%;
word-wrap: break-word;
line-height: 1.4;
}
.message.sent .message-text {
background: #667eea;
color: white;
margin-left: auto;
border-bottom-right-radius: 4px;
}
.message.received .message-text {
background: white;
color: #333;
border: 2px solid #e0e0e0;
border-bottom-left-radius: 4px;
}
.message-time {
font-size: 0.75rem;
color: #6c757d;
margin-top: 4px;
padding: 0 16px;
}
.message.sent .message-time {
text-align: right;
}
.message-input {
display: flex;
gap: 12px;
}
.message-input input {
flex: 1;
margin-bottom: 0;
}
.message-input button {
margin: 0;
padding: 12px 24px;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.message-input button:hover:not(:disabled) {
background: #5568d3;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.message-input button:disabled {
background: #ccc;
cursor: not-allowed;
transform: none;
}
.logs {
margin-top: 24px;
border-top: 2px solid #f0f0f0;
padding-top: 20px;
}
.logs summary {
cursor: pointer;
font-weight: 600;
color: #667eea;
font-size: 0.95rem;
margin-bottom: 12px;
}
.log-entries {
background: #1e1e1e;
color: #d4d4d4;
padding: 16px;
border-radius: 8px;
font-family: 'Courier New', monospace;
font-size: 0.85rem;
max-height: 200px;
overflow-y: auto;
margin-top: 12px;
}
.log-entry {
margin-bottom: 4px;
line-height: 1.5;
}
.log-entry.info {
color: #4ec9b0;
}
.log-entry.success {
color: #6a9955;
}
.log-entry.error {
color: #f48771;
}
.peer-id-badge {
position: fixed;
bottom: 20px;
right: 20px;
background: rgba(255, 255, 255, 0.95);
color: #667eea;
padding: 10px 16px;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
}
.footer {
text-align: center;
padding: 40px 20px 30px;
margin-top: 40px;
}
.footer a {
color: white;
text-decoration: none;
font-size: 0.9rem;
opacity: 0.8;
transition: opacity 0.2s;
}
.footer a:hover {
opacity: 1;
}
@media (max-width: 768px) {
.app {
padding: 10px;
}
.header {
padding: 20px 10px 10px;
}
.header-content h1 {
font-size: 2rem;
}
.tagline {
font-size: 1rem;
}
.step-container {
padding: 32px 24px;
}
.button-grid {
grid-template-columns: 1fr;
}
.chat-container {
padding: 24px 16px;
}
.messages {
min-height: 300px;
max-height: 300px;
}
.message-text {
max-width: 85%;
}
.peer-id-badge {
bottom: 10px;
right: 10px;
font-size: 0.75rem;
padding: 8px 12px;
}
}
@media (max-width: 480px) {
.header-content h1 {
font-size: 1.6rem;
}
.step-container h2 {
font-size: 1.4rem;
}
.button-title {
font-size: 1.2rem;
}
.chat-header {
flex-direction: column;
gap: 16px;
}
.disconnect-button {
width: 100%;
}
}

10
src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);