mirror of
https://github.com/xtr-dev/rondevu-demo.git
synced 2025-12-10 10:53:22 +00:00
Add file sharing and QR code features
- Add file sharing with chunked transfer over data channel - Display file messages with download button - Add QR code generation for connection sharing - Add QR scanner for easy connection joining - Update UI with file button and scan option - Add responsive CSS styling for new features
This commit is contained in:
330
src/App.jsx
330
src/App.jsx
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Rondevu, RondevuClient } from '@xtr-dev/rondevu-client';
|
||||
import QRCode from 'qrcode';
|
||||
import { BrowserQRCodeReader } from '@zxing/library';
|
||||
|
||||
const rdv = new Rondevu({
|
||||
baseUrl: 'https://rondevu.xtrdev.workers.dev',
|
||||
@@ -23,8 +25,9 @@ const client = new RondevuClient({
|
||||
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 [action, setAction] = useState(null); // 'create', 'join', or 'scan'
|
||||
const [method, setMethod] = useState(null); // 'topic', 'peer-id', 'connection-id'
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState('');
|
||||
|
||||
// Connection state
|
||||
const [topic, setTopic] = useState('');
|
||||
@@ -40,9 +43,14 @@ function App() {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [messageInput, setMessageInput] = useState('');
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [channelReady, setChannelReady] = useState(false);
|
||||
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
log('Demo initialized', 'info');
|
||||
@@ -107,13 +115,25 @@ function App() {
|
||||
const setupDataChannel = (channel) => {
|
||||
dataChannelRef.current = channel;
|
||||
|
||||
channel.onmessage = (event) => {
|
||||
setMessages(prev => [...prev, {
|
||||
text: event.data,
|
||||
type: 'received',
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
channel.onopen = () => {
|
||||
log('Data channel ready', 'success');
|
||||
setChannelReady(true);
|
||||
};
|
||||
|
||||
channel.onclose = () => {
|
||||
log('Data channel closed', 'info');
|
||||
setChannelReady(false);
|
||||
};
|
||||
|
||||
channel.onmessage = (event) => {
|
||||
handleReceivedMessage(event.data);
|
||||
};
|
||||
|
||||
// If channel is already open (for channels we create)
|
||||
if (channel.readyState === 'open') {
|
||||
log('Data channel ready', 'success');
|
||||
setChannelReady(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = async () => {
|
||||
@@ -152,30 +172,259 @@ function App() {
|
||||
|
||||
setConnectedPeer(connection.remotePeerId || 'Waiting...');
|
||||
setupConnection(connection);
|
||||
|
||||
// Generate QR code if creating a connection
|
||||
if (action === 'create' && currentConnectionId) {
|
||||
try {
|
||||
const qrUrl = await QRCode.toDataURL(currentConnectionId, {
|
||||
width: 256,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#667eea',
|
||||
light: '#ffffff'
|
||||
}
|
||||
});
|
||||
setQrCodeUrl(qrUrl);
|
||||
} catch (err) {
|
||||
log(`QR code generation error: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log(`Error: ${error.message}`, 'error');
|
||||
setConnectionStatus('disconnected');
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const selectedDeviceId = videoInputDevices[0].deviceId;
|
||||
|
||||
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 || !dataChannelRef.current || dataChannelRef.current.readyState !== 'open') {
|
||||
if (!messageInput || !channelReady || !dataChannelRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
dataChannelRef.current.send(messageInput);
|
||||
const message = { type: 'text', content: messageInput };
|
||||
dataChannelRef.current.send(JSON.stringify(message));
|
||||
setMessages(prev => [...prev, {
|
||||
text: messageInput,
|
||||
messageType: 'text',
|
||||
type: 'sent',
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
setMessageInput('');
|
||||
};
|
||||
|
||||
const handleFileSelect = async (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (!file || !channelReady || !dataChannelRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const CHUNK_SIZE = 16384; // 16KB chunks
|
||||
const fileId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
log(`Sending file: ${file.name} (${(file.size / 1024).toFixed(2)} KB)`, 'info');
|
||||
|
||||
try {
|
||||
// Send file metadata
|
||||
const metadata = {
|
||||
type: 'file-start',
|
||||
fileId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
mimeType: file.type,
|
||||
chunks: Math.ceil(file.size / CHUNK_SIZE)
|
||||
};
|
||||
dataChannelRef.current.send(JSON.stringify(metadata));
|
||||
|
||||
// Read and send file in chunks
|
||||
const reader = new FileReader();
|
||||
let offset = 0;
|
||||
let chunkIndex = 0;
|
||||
|
||||
const readChunk = () => {
|
||||
const slice = file.slice(offset, offset + CHUNK_SIZE);
|
||||
reader.readAsArrayBuffer(slice);
|
||||
};
|
||||
|
||||
reader.onload = (e) => {
|
||||
const chunk = {
|
||||
type: 'file-chunk',
|
||||
fileId,
|
||||
index: chunkIndex,
|
||||
data: Array.from(new Uint8Array(e.target.result))
|
||||
};
|
||||
dataChannelRef.current.send(JSON.stringify(chunk));
|
||||
|
||||
offset += CHUNK_SIZE;
|
||||
chunkIndex++;
|
||||
|
||||
if (offset < file.size) {
|
||||
readChunk();
|
||||
} else {
|
||||
// Send completion message
|
||||
const complete = { type: 'file-complete', fileId };
|
||||
dataChannelRef.current.send(JSON.stringify(complete));
|
||||
|
||||
// Add to local messages
|
||||
setMessages(prev => [...prev, {
|
||||
messageType: 'file',
|
||||
file: {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
mimeType: file.type,
|
||||
data: file
|
||||
},
|
||||
type: 'sent',
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
|
||||
log(`File sent: ${file.name}`, 'success');
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
log(`Error reading file: ${file.name}`, 'error');
|
||||
};
|
||||
|
||||
readChunk();
|
||||
} catch (error) {
|
||||
log(`Error sending file: ${error.message}`, 'error');
|
||||
}
|
||||
|
||||
// Reset file input
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const handleReceivedMessage = (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data);
|
||||
|
||||
if (message.type === 'text') {
|
||||
setMessages(prev => [...prev, {
|
||||
text: message.content,
|
||||
messageType: 'text',
|
||||
type: 'received',
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
} else if (message.type === 'file-start') {
|
||||
fileTransfersRef.current.set(message.fileId, {
|
||||
name: message.name,
|
||||
size: message.size,
|
||||
mimeType: message.mimeType,
|
||||
chunks: new Array(message.chunks),
|
||||
receivedChunks: 0
|
||||
});
|
||||
log(`Receiving file: ${message.name}`, 'info');
|
||||
} else if (message.type === 'file-chunk') {
|
||||
const transfer = fileTransfersRef.current.get(message.fileId);
|
||||
if (transfer) {
|
||||
transfer.chunks[message.index] = new Uint8Array(message.data);
|
||||
transfer.receivedChunks++;
|
||||
}
|
||||
} else if (message.type === 'file-complete') {
|
||||
const transfer = fileTransfersRef.current.get(message.fileId);
|
||||
if (transfer) {
|
||||
// Combine all chunks
|
||||
const totalSize = transfer.chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const combined = new Uint8Array(totalSize);
|
||||
let offset = 0;
|
||||
for (const chunk of transfer.chunks) {
|
||||
combined.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
const blob = new Blob([combined], { type: transfer.mimeType });
|
||||
|
||||
setMessages(prev => [...prev, {
|
||||
messageType: 'file',
|
||||
file: {
|
||||
name: transfer.name,
|
||||
size: transfer.size,
|
||||
mimeType: transfer.mimeType,
|
||||
data: blob
|
||||
},
|
||||
type: 'received',
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
|
||||
log(`File received: ${transfer.name}`, 'success');
|
||||
fileTransfersRef.current.delete(message.fileId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Assume it's a plain text message (backward compatibility)
|
||||
setMessages(prev => [...prev, {
|
||||
text: data,
|
||||
messageType: 'text',
|
||||
type: 'received',
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadFile = (file) => {
|
||||
const url = URL.createObjectURL(file.data);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = file.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
if (connectionRef.current) {
|
||||
connectionRef.current.close();
|
||||
}
|
||||
stopScanning();
|
||||
setStep(1);
|
||||
setAction(null);
|
||||
setMethod(null);
|
||||
@@ -187,6 +436,8 @@ function App() {
|
||||
setConnectedPeer(null);
|
||||
setCurrentConnectionId(null);
|
||||
setMessages([]);
|
||||
setChannelReady(false);
|
||||
setQrCodeUrl('');
|
||||
connectionRef.current = null;
|
||||
dataChannelRef.current = null;
|
||||
};
|
||||
@@ -224,7 +475,7 @@ function App() {
|
||||
{step === 1 && (
|
||||
<div className="step-container">
|
||||
<h2>Choose Action</h2>
|
||||
<div className="button-grid">
|
||||
<div className="button-grid button-grid-three">
|
||||
<button
|
||||
className="action-button"
|
||||
onClick={() => {
|
||||
@@ -245,7 +496,22 @@ function App() {
|
||||
<div className="button-title">Join</div>
|
||||
<div className="button-description">Connect to existing peers</div>
|
||||
</button>
|
||||
<button
|
||||
className="action-button"
|
||||
onClick={() => {
|
||||
setAction('scan');
|
||||
}}
|
||||
>
|
||||
<div className="button-title">Scan QR</div>
|
||||
<div className="button-description">Scan a connection code</div>
|
||||
</button>
|
||||
</div>
|
||||
{action === 'scan' && (
|
||||
<div className="scanner-container">
|
||||
<video ref={videoRef} className="scanner-video" />
|
||||
<button className="back-button" onClick={() => setAction(null)}>← Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -398,13 +664,37 @@ function App() {
|
||||
<button className="disconnect-button" onClick={reset}>Disconnect</button>
|
||||
</div>
|
||||
|
||||
{qrCodeUrl && connectionStatus === 'connecting' && (
|
||||
<div className="qr-code-container">
|
||||
<p className="qr-label">Scan to connect:</p>
|
||||
<img src={qrCodeUrl} alt="Connection QR Code" className="qr-code" />
|
||||
<p className="connection-id-display">{currentConnectionId}</p>
|
||||
</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>
|
||||
{msg.messageType === 'text' ? (
|
||||
<div className="message-text">{msg.text}</div>
|
||||
) : (
|
||||
<div className="message-file">
|
||||
<div className="file-icon">📎</div>
|
||||
<div className="file-info">
|
||||
<div className="file-name">{msg.file.name}</div>
|
||||
<div className="file-size">{(msg.file.size / 1024).toFixed(2)} KB</div>
|
||||
</div>
|
||||
<button
|
||||
className="file-download"
|
||||
onClick={() => downloadFile(msg.file)}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="message-time">{msg.timestamp.toLocaleTimeString()}</div>
|
||||
</div>
|
||||
))
|
||||
@@ -412,17 +702,31 @@ function App() {
|
||||
</div>
|
||||
|
||||
<div className="message-input">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<button
|
||||
className="file-button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!channelReady}
|
||||
title="Send file"
|
||||
>
|
||||
📎
|
||||
</button>
|
||||
<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'}
|
||||
disabled={!channelReady}
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={!dataChannelRef.current || dataChannelRef.current.readyState !== 'open'}
|
||||
disabled={!channelReady}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
|
||||
149
src/index.css
149
src/index.css
@@ -103,6 +103,10 @@ body {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.button-grid-three {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.action-button {
|
||||
background: white;
|
||||
border: 3px solid #e0e0e0;
|
||||
@@ -392,7 +396,7 @@ input[type="text"]:disabled {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message-input input {
|
||||
.message-input input[type="text"] {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -409,6 +413,11 @@ input[type="text"]:disabled {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.message-input .file-button {
|
||||
padding: 12px 16px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.message-input button:hover:not(:disabled) {
|
||||
background: #5568d3;
|
||||
transform: translateY(-1px);
|
||||
@@ -421,6 +430,90 @@ input[type="text"]:disabled {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.message-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
background: inherit;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.message.sent .message-file {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.message.received .message-file {
|
||||
background: #f8f9fa;
|
||||
border: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message.sent .file-name {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.message.received .file-name {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.message.sent .file-size {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.message.received .file-size {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.file-download {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.85rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #667eea;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message.sent .file-download {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.file-download:hover {
|
||||
background: white;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.logs {
|
||||
margin-top: 24px;
|
||||
border-top: 2px solid #f0f0f0;
|
||||
@@ -478,6 +571,57 @@ input[type="text"]:disabled {
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.scanner-container {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.scanner-video {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
height: 300px;
|
||||
border-radius: 12px;
|
||||
background: #1e1e1e;
|
||||
margin-bottom: 16px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.qr-code-container {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.qr-label {
|
||||
font-size: 0.95rem;
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
display: block;
|
||||
margin: 0 auto 12px;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
padding: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.connection-id-display {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9rem;
|
||||
color: #333;
|
||||
background: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 40px 20px 30px;
|
||||
@@ -517,7 +661,8 @@ input[type="text"]:disabled {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.button-grid {
|
||||
.button-grid,
|
||||
.button-grid-three {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user