diff --git a/src/App.jsx b/src/App.jsx index e58d7dc..f9614c9 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,7 +1,11 @@ import { useState, useEffect, useRef } from 'react'; import { Rondevu, RondevuClient } from '@xtr-dev/rondevu-client'; import QRCode from 'qrcode'; -import { BrowserQRCodeReader } from '@zxing/library'; +import Header from './components/Header'; +import ActionSelector from './components/ActionSelector'; +import MethodSelector from './components/MethodSelector'; +import ConnectionForm from './components/ConnectionForm'; +import ChatView from './components/ChatView'; const rdv = new Rondevu({ baseUrl: 'https://rondevu.xtrdev.workers.dev', @@ -44,13 +48,12 @@ function App() { const [messageInput, setMessageInput] = useState(''); const [logs, setLogs] = useState([]); const [channelReady, setChannelReady] = useState(false); + const [fileUploadProgress, setFileUploadProgress] = useState(null); const connectionRef = useRef(null); const dataChannelRef = useRef(null); - const fileInputRef = useRef(null); const fileTransfersRef = useRef(new Map()); // Track ongoing file transfers - const videoRef = useRef(null); - const scannerRef = useRef(null); + const uploadCancelRef = useRef(false); useEffect(() => { log('Demo initialized', 'info'); @@ -195,68 +198,6 @@ function App() { } }; - const startScanning = async () => { - try { - scannerRef.current = new BrowserQRCodeReader(); - log('Starting QR scanner...', 'info'); - - const videoInputDevices = await scannerRef.current.listVideoInputDevices(); - - if (videoInputDevices.length === 0) { - log('No camera found', 'error'); - return; - } - - // Prefer back camera (environment-facing) - let selectedDeviceId = videoInputDevices[0].deviceId; - const backCamera = videoInputDevices.find(device => - device.label.toLowerCase().includes('back') || - device.label.toLowerCase().includes('rear') || - device.label.toLowerCase().includes('environment') - ); - - if (backCamera) { - selectedDeviceId = backCamera.deviceId; - log('Using back camera', 'info'); - } else { - log('Back camera not found, using default', 'info'); - } - - scannerRef.current.decodeFromVideoDevice( - selectedDeviceId, - videoRef.current, - (result, err) => { - if (result) { - const scannedId = result.getText(); - log(`Scanned: ${scannedId}`, 'success'); - setConnectionId(scannedId); - stopScanning(); - setMethod('connection-id'); - setStep(3); - } - } - ); - } catch (error) { - log(`Scanner error: ${error.message}`, 'error'); - } - }; - - const stopScanning = () => { - if (scannerRef.current) { - scannerRef.current.reset(); - log('Scanner stopped', 'info'); - } - }; - - useEffect(() => { - if (action === 'scan') { - startScanning(); - } - return () => { - stopScanning(); - }; - }, [action]); - const sendMessage = () => { if (!messageInput || !channelReady || !dataChannelRef.current) { return; @@ -281,6 +222,10 @@ function App() { const CHUNK_SIZE = 16384; // 16KB chunks const fileId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const totalChunks = Math.ceil(file.size / CHUNK_SIZE); + + uploadCancelRef.current = false; + setFileUploadProgress({ fileName: file.name, progress: 0 }); log(`Sending file: ${file.name} (${(file.size / 1024).toFixed(2)} KB)`, 'info'); @@ -292,7 +237,7 @@ function App() { name: file.name, size: file.size, mimeType: file.type, - chunks: Math.ceil(file.size / CHUNK_SIZE) + chunks: totalChunks }; dataChannelRef.current.send(JSON.stringify(metadata)); @@ -302,11 +247,22 @@ function App() { let chunkIndex = 0; const readChunk = () => { + if (uploadCancelRef.current) { + setFileUploadProgress(null); + log('File upload cancelled', 'info'); + return; + } + const slice = file.slice(offset, offset + CHUNK_SIZE); reader.readAsArrayBuffer(slice); }; reader.onload = (e) => { + if (uploadCancelRef.current) { + setFileUploadProgress(null); + return; + } + const chunk = { type: 'file-chunk', fileId, @@ -318,6 +274,10 @@ function App() { offset += CHUNK_SIZE; chunkIndex++; + // Update progress + const progress = Math.round((chunkIndex / totalChunks) * 100); + setFileUploadProgress({ fileName: file.name, progress }); + if (offset < file.size) { readChunk(); } else { @@ -338,16 +298,19 @@ function App() { timestamp: new Date() }]); + setFileUploadProgress(null); log(`File sent: ${file.name}`, 'success'); } }; reader.onerror = () => { + setFileUploadProgress(null); log(`Error reading file: ${file.name}`, 'error'); }; readChunk(); } catch (error) { + setFileUploadProgress(null); log(`Error sending file: ${error.message}`, 'error'); } @@ -355,6 +318,11 @@ function App() { event.target.value = ''; }; + const cancelFileUpload = () => { + uploadCancelRef.current = true; + setFileUploadProgress(null); + }; + const handleReceivedMessage = (data) => { try { const message = JSON.parse(data); @@ -437,7 +405,6 @@ function App() { if (connectionRef.current) { connectionRef.current.close(); } - stopScanning(); setStep(1); setAction(null); setMethod(null); @@ -455,317 +422,81 @@ function App() { dataChannelRef.current = null; }; + const handleScanComplete = (scannedId) => { + setConnectionId(scannedId); + setAction('join'); + setMethod('connection-id'); + setStep(3); + }; + + const handleScanCancel = () => { + setAction(null); + }; + return (
-
-
-

Rondevu

-

Meet WebRTC peers by topic, peer ID, or connection ID

-
- - - - - Client - - - - - - Server - - - - - - View source - -
-
-
+
{step === 1 && ( -
-

Choose Action

-
- - - -
- {action === 'scan' && ( -
-
- )} -
+ )} {step === 2 && ( -
-

{action === 'create' ? 'Create' : 'Join'} by...

-
- - {action === 'join' && ( - - )} - -
- -
+ { + setMethod(m); + setStep(3); + }} + onBack={() => setStep(1)} + /> )} {step === 3 && ( -
-

Enter Details

-
- {(method === 'topic' || (method === 'peer-id') || (method === 'connection-id' && action === 'create')) && ( -
- - setTopic(e.target.value)} - placeholder="e.g., game-room" - autoFocus - /> - {topics.length > 0 && ( -
- {topics.map((t) => ( - - ))} -
- )} -
- )} - - {method === 'peer-id' && ( -
- - setPeerId(e.target.value)} - placeholder="e.g., player-123" - /> - {sessions.length > 0 && ( -
- {sessions.map((s) => ( - - ))} -
- )} -
- )} - - {method === 'connection-id' && ( -
- - setConnectionId(e.target.value)} - placeholder={action === 'create' ? 'Auto-generated if empty' : 'e.g., meeting-123'} - autoFocus={action === 'join'} - /> -
- )} - -
- - -
- - {qrCodeUrl && connectionStatus === 'connecting' && action === 'create' && ( -
-

Scan to connect:

- Connection QR Code -

{currentConnectionId}

-
- )} -
-
+ setStep(2)} + onTopicSelect={setTopic} + onDiscoverPeers={discoverPeers} + /> )} {step === 4 && ( -
-
-
-

Connected

-

- Peer: {connectedPeer || 'Unknown'} • ID: {currentConnectionId} -

-
- -
- - {qrCodeUrl && connectionStatus === 'connecting' && ( -
-

Scan to connect:

- Connection QR Code -

{currentConnectionId}

-
- )} - -
- {messages.length === 0 ? ( -

No messages yet. Start chatting!

- ) : ( - messages.map((msg, idx) => ( -
- {msg.messageType === 'text' ? ( -
{msg.text}
- ) : ( -
-
📎
-
-
{msg.file.name}
-
{(msg.file.size / 1024).toFixed(2)} KB
-
- -
- )} -
{msg.timestamp.toLocaleTimeString()}
-
- )) - )} -
- -
- - - setMessageInput(e.target.value)} - onKeyPress={(e) => e.key === 'Enter' && sendMessage()} - placeholder="Type a message..." - disabled={!channelReady} - /> - -
- - {logs.length > 0 && ( -
- Activity Log ({logs.length}) -
- {logs.map((log, idx) => ( -
- [{log.timestamp}] {log.message} -
- ))} -
-
- )} -
+ )}
Your Peer ID: {rdv.peerId}
diff --git a/src/components/ActionSelector.jsx b/src/components/ActionSelector.jsx new file mode 100644 index 0000000..c6101f9 --- /dev/null +++ b/src/components/ActionSelector.jsx @@ -0,0 +1,37 @@ +import QRScanner from './QRScanner'; + +function ActionSelector({ action, onSelectAction, onScanComplete, onScanCancel, log }) { + return ( +
+

Choose Action

+
+ + + +
+ {action === 'scan' && ( + + )} +
+ ); +} + +export default ActionSelector; diff --git a/src/components/ChatView.jsx b/src/components/ChatView.jsx new file mode 100644 index 0000000..53a6127 --- /dev/null +++ b/src/components/ChatView.jsx @@ -0,0 +1,99 @@ +import { useRef } from 'react'; +import Message from './Message'; +import FileUploadProgress from './FileUploadProgress'; + +function ChatView({ + connectedPeer, + currentConnectionId, + messages, + messageInput, + setMessageInput, + channelReady, + logs, + fileUploadProgress, + onSendMessage, + onFileSelect, + onDisconnect, + onDownloadFile, + onCancelUpload +}) { + const fileInputRef = useRef(null); + + return ( +
+
+
+

Connected

+

+ Peer: {connectedPeer || 'Unknown'} • ID: {currentConnectionId} +

+
+ +
+ +
+ {messages.length === 0 ? ( +

No messages yet. Start chatting!

+ ) : ( + messages.map((msg, idx) => ( + + )) + )} +
+ + {fileUploadProgress && ( + + )} + +
+ + + setMessageInput(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && onSendMessage()} + placeholder="Type a message..." + disabled={!channelReady} + /> + +
+ + {logs.length > 0 && ( +
+ Activity Log ({logs.length}) +
+ {logs.map((log, idx) => ( +
+ [{log.timestamp}] {log.message} +
+ ))} +
+
+ )} +
+ ); +} + +export default ChatView; diff --git a/src/components/ConnectionForm.jsx b/src/components/ConnectionForm.jsx new file mode 100644 index 0000000..20466af --- /dev/null +++ b/src/components/ConnectionForm.jsx @@ -0,0 +1,119 @@ +import QRCodeDisplay from './QRCodeDisplay'; + +function ConnectionForm({ + action, + method, + topic, + setTopic, + connectionId, + setConnectionId, + peerId, + setPeerId, + topics, + sessions, + connectionStatus, + qrCodeUrl, + currentConnectionId, + onConnect, + onBack, + onTopicSelect, + onDiscoverPeers +}) { + return ( +
+

Enter Details

+
+ {(method === 'topic' || method === 'peer-id' || (method === 'connection-id' && action === 'create')) && ( +
+ + setTopic(e.target.value)} + placeholder="e.g., game-room" + autoFocus + /> + {topics.length > 0 && ( +
+ {topics.map((t) => ( + + ))} +
+ )} +
+ )} + + {method === 'peer-id' && ( +
+ + setPeerId(e.target.value)} + placeholder="e.g., player-123" + /> + {sessions.length > 0 && ( +
+ {sessions.map((s) => ( + + ))} +
+ )} +
+ )} + + {method === 'connection-id' && ( +
+ + setConnectionId(e.target.value)} + placeholder={action === 'create' ? 'Auto-generated if empty' : 'e.g., meeting-123'} + autoFocus={action === 'join'} + /> +
+ )} + +
+ + +
+ + {qrCodeUrl && connectionStatus === 'connecting' && action === 'create' && ( + + )} +
+
+ ); +} + +export default ConnectionForm; diff --git a/src/components/FileUploadProgress.jsx b/src/components/FileUploadProgress.jsx new file mode 100644 index 0000000..85a0342 --- /dev/null +++ b/src/components/FileUploadProgress.jsx @@ -0,0 +1,17 @@ +function FileUploadProgress({ fileName, progress, onCancel }) { + return ( +
+
+ {fileName} + +
+
+
+ {progress}% +
+
+
+ ); +} + +export default FileUploadProgress; diff --git a/src/components/Header.jsx b/src/components/Header.jsx new file mode 100644 index 0000000..187cf69 --- /dev/null +++ b/src/components/Header.jsx @@ -0,0 +1,32 @@ +function Header() { + return ( +
+
+

Rondevu

+

Meet WebRTC peers by topic, peer ID, or connection ID

+ +
+
+ ); +} + +export default Header; diff --git a/src/components/Message.jsx b/src/components/Message.jsx new file mode 100644 index 0000000..b1b1817 --- /dev/null +++ b/src/components/Message.jsx @@ -0,0 +1,28 @@ +function Message({ message, onDownload }) { + const isFile = message.messageType === 'file'; + + return ( +
+ {isFile ? ( +
+
📎
+
+
{message.file.name}
+
{(message.file.size / 1024).toFixed(2)} KB
+
+ +
+ ) : ( +
{message.text}
+ )} +
{message.timestamp.toLocaleTimeString()}
+
+ ); +} + +export default Message; diff --git a/src/components/MethodSelector.jsx b/src/components/MethodSelector.jsx new file mode 100644 index 0000000..f0c505d --- /dev/null +++ b/src/components/MethodSelector.jsx @@ -0,0 +1,39 @@ +function MethodSelector({ action, onSelectMethod, onBack }) { + return ( +
+

{action === 'create' ? 'Create' : 'Join'} by...

+
+ + {action === 'join' && ( + + )} + +
+ +
+ ); +} + +export default MethodSelector; diff --git a/src/components/QRCodeDisplay.jsx b/src/components/QRCodeDisplay.jsx new file mode 100644 index 0000000..6418ac6 --- /dev/null +++ b/src/components/QRCodeDisplay.jsx @@ -0,0 +1,13 @@ +function QRCodeDisplay({ qrCodeUrl, connectionId }) { + if (!qrCodeUrl) return null; + + return ( +
+

Scan to connect:

+ Connection QR Code +

{connectionId}

+
+ ); +} + +export default QRCodeDisplay; diff --git a/src/components/QRScanner.jsx b/src/components/QRScanner.jsx new file mode 100644 index 0000000..4d6dbc7 --- /dev/null +++ b/src/components/QRScanner.jsx @@ -0,0 +1,74 @@ +import { useRef, useEffect } from 'react'; +import { BrowserQRCodeReader } from '@zxing/library'; + +function QRScanner({ onScan, onCancel, log }) { + const videoRef = useRef(null); + const scannerRef = useRef(null); + + useEffect(() => { + startScanning(); + return () => { + stopScanning(); + }; + }, []); + + const startScanning = async () => { + try { + scannerRef.current = new BrowserQRCodeReader(); + log('Starting QR scanner...', 'info'); + + const videoInputDevices = await scannerRef.current.listVideoInputDevices(); + + if (videoInputDevices.length === 0) { + log('No camera found', 'error'); + return; + } + + // Prefer back camera (environment-facing) + let selectedDeviceId = videoInputDevices[0].deviceId; + const backCamera = videoInputDevices.find(device => + device.label.toLowerCase().includes('back') || + device.label.toLowerCase().includes('rear') || + device.label.toLowerCase().includes('environment') + ); + + if (backCamera) { + selectedDeviceId = backCamera.deviceId; + log('Using back camera', 'info'); + } else { + log('Back camera not found, using default', 'info'); + } + + scannerRef.current.decodeFromVideoDevice( + selectedDeviceId, + videoRef.current, + (result, err) => { + if (result) { + const scannedId = result.getText(); + log(`Scanned: ${scannedId}`, 'success'); + stopScanning(); + onScan(scannedId); + } + } + ); + } catch (error) { + log(`Scanner error: ${error.message}`, 'error'); + } + }; + + const stopScanning = () => { + if (scannerRef.current) { + scannerRef.current.reset(); + log('Scanner stopped', 'info'); + } + }; + + return ( +
+
+ ); +} + +export default QRScanner; diff --git a/src/index.css b/src/index.css index 85abfda..335fc5a 100644 --- a/src/index.css +++ b/src/index.css @@ -514,6 +514,74 @@ input[type="text"]:disabled { transform: scale(1.05); } +.file-upload-progress { + background: #f8f9fa; + border: 2px solid #667eea; + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; +} + +.file-upload-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.file-upload-name { + font-weight: 600; + color: #333; + font-size: 0.9rem; +} + +.file-upload-cancel { + background: #dc3545; + color: white; + border: none; + width: 24px; + height: 24px; + border-radius: 50%; + font-size: 1.2rem; + line-height: 1; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.file-upload-cancel:hover { + background: #c82333; + transform: scale(1.1); +} + +.progress-bar { + background: #e0e0e0; + border-radius: 8px; + height: 24px; + overflow: hidden; + position: relative; +} + +.progress-bar-fill { + background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); + height: 100%; + transition: width 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.progress-text { + color: white; + font-size: 0.75rem; + font-weight: 600; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + .logs { margin-top: 24px; border-top: 2px solid #f0f0f0;