mirror of
https://github.com/xtr-dev/rondevu-demo.git
synced 2025-12-10 10:53:22 +00:00
Refactor app into components and add file upload progress
Components created: - Header: App header with GitHub links - ActionSelector: Step 1 - Choose create/join/scan - MethodSelector: Step 2 - Choose connection method - ConnectionForm: Step 3 - Enter connection details - ChatView: Step 4 - Connected chat interface - Message: Individual message display (text/file) - QRScanner: QR code scanning component - QRCodeDisplay: QR code display component - FileUploadProgress: Progress bar for file uploads Features: - Clean component separation with props - File upload progress bar with percentage - Cancel upload functionality - Disabled file button during upload - Visual progress indicator with gradient - All logic remains in App.jsx for state management
This commit is contained in:
453
src/App.jsx
453
src/App.jsx
@@ -1,7 +1,11 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Rondevu, RondevuClient } from '@xtr-dev/rondevu-client';
|
import { Rondevu, RondevuClient } from '@xtr-dev/rondevu-client';
|
||||||
import QRCode from 'qrcode';
|
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({
|
const rdv = new Rondevu({
|
||||||
baseUrl: 'https://rondevu.xtrdev.workers.dev',
|
baseUrl: 'https://rondevu.xtrdev.workers.dev',
|
||||||
@@ -44,13 +48,12 @@ function App() {
|
|||||||
const [messageInput, setMessageInput] = useState('');
|
const [messageInput, setMessageInput] = useState('');
|
||||||
const [logs, setLogs] = useState([]);
|
const [logs, setLogs] = useState([]);
|
||||||
const [channelReady, setChannelReady] = useState(false);
|
const [channelReady, setChannelReady] = useState(false);
|
||||||
|
const [fileUploadProgress, setFileUploadProgress] = useState(null);
|
||||||
|
|
||||||
const connectionRef = useRef(null);
|
const connectionRef = useRef(null);
|
||||||
const dataChannelRef = useRef(null);
|
const dataChannelRef = useRef(null);
|
||||||
const fileInputRef = useRef(null);
|
|
||||||
const fileTransfersRef = useRef(new Map()); // Track ongoing file transfers
|
const fileTransfersRef = useRef(new Map()); // Track ongoing file transfers
|
||||||
const videoRef = useRef(null);
|
const uploadCancelRef = useRef(false);
|
||||||
const scannerRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
log('Demo initialized', 'info');
|
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 = () => {
|
const sendMessage = () => {
|
||||||
if (!messageInput || !channelReady || !dataChannelRef.current) {
|
if (!messageInput || !channelReady || !dataChannelRef.current) {
|
||||||
return;
|
return;
|
||||||
@@ -281,6 +222,10 @@ function App() {
|
|||||||
|
|
||||||
const CHUNK_SIZE = 16384; // 16KB chunks
|
const CHUNK_SIZE = 16384; // 16KB chunks
|
||||||
const fileId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
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');
|
log(`Sending file: ${file.name} (${(file.size / 1024).toFixed(2)} KB)`, 'info');
|
||||||
|
|
||||||
@@ -292,7 +237,7 @@ function App() {
|
|||||||
name: file.name,
|
name: file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
mimeType: file.type,
|
mimeType: file.type,
|
||||||
chunks: Math.ceil(file.size / CHUNK_SIZE)
|
chunks: totalChunks
|
||||||
};
|
};
|
||||||
dataChannelRef.current.send(JSON.stringify(metadata));
|
dataChannelRef.current.send(JSON.stringify(metadata));
|
||||||
|
|
||||||
@@ -302,11 +247,22 @@ function App() {
|
|||||||
let chunkIndex = 0;
|
let chunkIndex = 0;
|
||||||
|
|
||||||
const readChunk = () => {
|
const readChunk = () => {
|
||||||
|
if (uploadCancelRef.current) {
|
||||||
|
setFileUploadProgress(null);
|
||||||
|
log('File upload cancelled', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const slice = file.slice(offset, offset + CHUNK_SIZE);
|
const slice = file.slice(offset, offset + CHUNK_SIZE);
|
||||||
reader.readAsArrayBuffer(slice);
|
reader.readAsArrayBuffer(slice);
|
||||||
};
|
};
|
||||||
|
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
|
if (uploadCancelRef.current) {
|
||||||
|
setFileUploadProgress(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const chunk = {
|
const chunk = {
|
||||||
type: 'file-chunk',
|
type: 'file-chunk',
|
||||||
fileId,
|
fileId,
|
||||||
@@ -318,6 +274,10 @@ function App() {
|
|||||||
offset += CHUNK_SIZE;
|
offset += CHUNK_SIZE;
|
||||||
chunkIndex++;
|
chunkIndex++;
|
||||||
|
|
||||||
|
// Update progress
|
||||||
|
const progress = Math.round((chunkIndex / totalChunks) * 100);
|
||||||
|
setFileUploadProgress({ fileName: file.name, progress });
|
||||||
|
|
||||||
if (offset < file.size) {
|
if (offset < file.size) {
|
||||||
readChunk();
|
readChunk();
|
||||||
} else {
|
} else {
|
||||||
@@ -338,16 +298,19 @@ function App() {
|
|||||||
timestamp: new Date()
|
timestamp: new Date()
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
|
setFileUploadProgress(null);
|
||||||
log(`File sent: ${file.name}`, 'success');
|
log(`File sent: ${file.name}`, 'success');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
reader.onerror = () => {
|
reader.onerror = () => {
|
||||||
|
setFileUploadProgress(null);
|
||||||
log(`Error reading file: ${file.name}`, 'error');
|
log(`Error reading file: ${file.name}`, 'error');
|
||||||
};
|
};
|
||||||
|
|
||||||
readChunk();
|
readChunk();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
setFileUploadProgress(null);
|
||||||
log(`Error sending file: ${error.message}`, 'error');
|
log(`Error sending file: ${error.message}`, 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,6 +318,11 @@ function App() {
|
|||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cancelFileUpload = () => {
|
||||||
|
uploadCancelRef.current = true;
|
||||||
|
setFileUploadProgress(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleReceivedMessage = (data) => {
|
const handleReceivedMessage = (data) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(data);
|
const message = JSON.parse(data);
|
||||||
@@ -437,7 +405,6 @@ function App() {
|
|||||||
if (connectionRef.current) {
|
if (connectionRef.current) {
|
||||||
connectionRef.current.close();
|
connectionRef.current.close();
|
||||||
}
|
}
|
||||||
stopScanning();
|
|
||||||
setStep(1);
|
setStep(1);
|
||||||
setAction(null);
|
setAction(null);
|
||||||
setMethod(null);
|
setMethod(null);
|
||||||
@@ -455,317 +422,81 @@ function App() {
|
|||||||
dataChannelRef.current = null;
|
dataChannelRef.current = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleScanComplete = (scannedId) => {
|
||||||
|
setConnectionId(scannedId);
|
||||||
|
setAction('join');
|
||||||
|
setMethod('connection-id');
|
||||||
|
setStep(3);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScanCancel = () => {
|
||||||
|
setAction(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<header className="header">
|
<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>
|
|
||||||
<a href="https://github.com/xtr-dev/rondevu-demo" 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>
|
|
||||||
View source
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="main">
|
<main className="main">
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<div className="step-container">
|
<ActionSelector
|
||||||
<h2>Choose Action</h2>
|
action={action}
|
||||||
<div className="button-grid button-grid-three">
|
onSelectAction={setAction}
|
||||||
<button
|
onScanComplete={handleScanComplete}
|
||||||
className="action-button"
|
onScanCancel={handleScanCancel}
|
||||||
onClick={() => {
|
log={log}
|
||||||
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>
|
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<div className="step-container">
|
<MethodSelector
|
||||||
<h2>{action === 'create' ? 'Create' : 'Join'} by...</h2>
|
action={action}
|
||||||
<div className="button-grid">
|
onSelectMethod={(m) => {
|
||||||
<button
|
setMethod(m);
|
||||||
className="action-button"
|
|
||||||
onClick={() => {
|
|
||||||
setMethod('topic');
|
|
||||||
setStep(3);
|
setStep(3);
|
||||||
}}
|
}}
|
||||||
>
|
onBack={() => setStep(1)}
|
||||||
<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 && (
|
{step === 3 && (
|
||||||
<div className="step-container">
|
<ConnectionForm
|
||||||
<h2>Enter Details</h2>
|
action={action}
|
||||||
<div className="form-container">
|
method={method}
|
||||||
{(method === 'topic' || (method === 'peer-id') || (method === 'connection-id' && action === 'create')) && (
|
topic={topic}
|
||||||
<div className="form-group">
|
setTopic={setTopic}
|
||||||
<label>Topic</label>
|
connectionId={connectionId}
|
||||||
<input
|
setConnectionId={setConnectionId}
|
||||||
type="text"
|
peerId={peerId}
|
||||||
value={topic}
|
setPeerId={setPeerId}
|
||||||
onChange={(e) => setTopic(e.target.value)}
|
topics={topics}
|
||||||
placeholder="e.g., game-room"
|
sessions={sessions}
|
||||||
autoFocus
|
connectionStatus={connectionStatus}
|
||||||
|
qrCodeUrl={qrCodeUrl}
|
||||||
|
currentConnectionId={currentConnectionId}
|
||||||
|
onConnect={handleConnect}
|
||||||
|
onBack={() => setStep(2)}
|
||||||
|
onTopicSelect={setTopic}
|
||||||
|
onDiscoverPeers={discoverPeers}
|
||||||
/>
|
/>
|
||||||
{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>
|
|
||||||
|
|
||||||
{qrCodeUrl && connectionStatus === 'connecting' && action === 'create' && (
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 4 && (
|
{step === 4 && (
|
||||||
<div className="chat-container">
|
<ChatView
|
||||||
<div className="chat-header">
|
connectedPeer={connectedPeer}
|
||||||
<div>
|
currentConnectionId={currentConnectionId}
|
||||||
<h2>Connected</h2>
|
messages={messages}
|
||||||
<p className="connection-details">
|
messageInput={messageInput}
|
||||||
Peer: {connectedPeer || 'Unknown'} • ID: {currentConnectionId}
|
setMessageInput={setMessageInput}
|
||||||
</p>
|
channelReady={channelReady}
|
||||||
</div>
|
logs={logs}
|
||||||
<button className="disconnect-button" onClick={reset}>Disconnect</button>
|
fileUploadProgress={fileUploadProgress}
|
||||||
</div>
|
onSendMessage={sendMessage}
|
||||||
|
onFileSelect={handleFileSelect}
|
||||||
{qrCodeUrl && connectionStatus === 'connecting' && (
|
onDisconnect={reset}
|
||||||
<div className="qr-code-container">
|
onDownloadFile={downloadFile}
|
||||||
<p className="qr-label">Scan to connect:</p>
|
onCancelUpload={cancelFileUpload}
|
||||||
<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}`}>
|
|
||||||
{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>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</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={!channelReady}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={sendMessage}
|
|
||||||
disabled={!channelReady}
|
|
||||||
>
|
|
||||||
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>
|
<div className="peer-id-badge">Your Peer ID: {rdv.peerId}</div>
|
||||||
|
|||||||
37
src/components/ActionSelector.jsx
Normal file
37
src/components/ActionSelector.jsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import QRScanner from './QRScanner';
|
||||||
|
|
||||||
|
function ActionSelector({ action, onSelectAction, onScanComplete, onScanCancel, log }) {
|
||||||
|
return (
|
||||||
|
<div className="step-container">
|
||||||
|
<h2>Choose Action</h2>
|
||||||
|
<div className="button-grid button-grid-three">
|
||||||
|
<button
|
||||||
|
className="action-button"
|
||||||
|
onClick={() => onSelectAction('create')}
|
||||||
|
>
|
||||||
|
<div className="button-title">Create</div>
|
||||||
|
<div className="button-description">Start a new connection</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="action-button"
|
||||||
|
onClick={() => onSelectAction('join')}
|
||||||
|
>
|
||||||
|
<div className="button-title">Join</div>
|
||||||
|
<div className="button-description">Connect to existing peers</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="action-button"
|
||||||
|
onClick={() => onSelectAction('scan')}
|
||||||
|
>
|
||||||
|
<div className="button-title">Scan QR</div>
|
||||||
|
<div className="button-description">Scan a connection code</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{action === 'scan' && (
|
||||||
|
<QRScanner onScan={onScanComplete} onCancel={onScanCancel} log={log} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ActionSelector;
|
||||||
99
src/components/ChatView.jsx
Normal file
99
src/components/ChatView.jsx
Normal file
@@ -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 (
|
||||||
|
<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={onDisconnect}>Disconnect</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="messages">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<p className="empty">No messages yet. Start chatting!</p>
|
||||||
|
) : (
|
||||||
|
messages.map((msg, idx) => (
|
||||||
|
<Message key={idx} message={msg} onDownload={onDownloadFile} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fileUploadProgress && (
|
||||||
|
<FileUploadProgress
|
||||||
|
fileName={fileUploadProgress.fileName}
|
||||||
|
progress={fileUploadProgress.progress}
|
||||||
|
onCancel={onCancelUpload}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="message-input">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
onChange={onFileSelect}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="file-button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={!channelReady || fileUploadProgress}
|
||||||
|
title="Send file"
|
||||||
|
>
|
||||||
|
📎
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={messageInput}
|
||||||
|
onChange={(e) => setMessageInput(e.target.value)}
|
||||||
|
onKeyPress={(e) => e.key === 'Enter' && onSendMessage()}
|
||||||
|
placeholder="Type a message..."
|
||||||
|
disabled={!channelReady}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={onSendMessage}
|
||||||
|
disabled={!channelReady}
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChatView;
|
||||||
119
src/components/ConnectionForm.jsx
Normal file
119
src/components/ConnectionForm.jsx
Normal file
@@ -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 (
|
||||||
|
<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={() => {
|
||||||
|
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">
|
||||||
|
<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={onBack}>← Back</button>
|
||||||
|
<button
|
||||||
|
className="primary-button"
|
||||||
|
onClick={onConnect}
|
||||||
|
disabled={
|
||||||
|
connectionStatus === 'connecting' ||
|
||||||
|
(method === 'topic' && !topic) ||
|
||||||
|
(method === 'peer-id' && (!topic || !peerId)) ||
|
||||||
|
(method === 'connection-id' && action === 'join' && !connectionId)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{connectionStatus === 'connecting' ? 'Connecting...' : 'Connect'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{qrCodeUrl && connectionStatus === 'connecting' && action === 'create' && (
|
||||||
|
<QRCodeDisplay qrCodeUrl={qrCodeUrl} connectionId={currentConnectionId} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConnectionForm;
|
||||||
17
src/components/FileUploadProgress.jsx
Normal file
17
src/components/FileUploadProgress.jsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
function FileUploadProgress({ fileName, progress, onCancel }) {
|
||||||
|
return (
|
||||||
|
<div className="file-upload-progress">
|
||||||
|
<div className="file-upload-header">
|
||||||
|
<span className="file-upload-name">{fileName}</span>
|
||||||
|
<button className="file-upload-cancel" onClick={onCancel}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="progress-bar">
|
||||||
|
<div className="progress-bar-fill" style={{ width: `${progress}%` }}>
|
||||||
|
<span className="progress-text">{progress}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FileUploadProgress;
|
||||||
32
src/components/Header.jsx
Normal file
32
src/components/Header.jsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
function Header() {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
<a href="https://github.com/xtr-dev/rondevu-demo" 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>
|
||||||
|
View source
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header;
|
||||||
28
src/components/Message.jsx
Normal file
28
src/components/Message.jsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
function Message({ message, onDownload }) {
|
||||||
|
const isFile = message.messageType === 'file';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`message ${message.type}`}>
|
||||||
|
{isFile ? (
|
||||||
|
<div className="message-file">
|
||||||
|
<div className="file-icon">📎</div>
|
||||||
|
<div className="file-info">
|
||||||
|
<div className="file-name">{message.file.name}</div>
|
||||||
|
<div className="file-size">{(message.file.size / 1024).toFixed(2)} KB</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="file-download"
|
||||||
|
onClick={() => onDownload(message.file)}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="message-text">{message.text}</div>
|
||||||
|
)}
|
||||||
|
<div className="message-time">{message.timestamp.toLocaleTimeString()}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Message;
|
||||||
39
src/components/MethodSelector.jsx
Normal file
39
src/components/MethodSelector.jsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
function MethodSelector({ action, onSelectMethod, onBack }) {
|
||||||
|
return (
|
||||||
|
<div className="step-container">
|
||||||
|
<h2>{action === 'create' ? 'Create' : 'Join'} by...</h2>
|
||||||
|
<div className="button-grid">
|
||||||
|
<button
|
||||||
|
className="action-button"
|
||||||
|
onClick={() => onSelectMethod('topic')}
|
||||||
|
>
|
||||||
|
<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={() => onSelectMethod('peer-id')}
|
||||||
|
>
|
||||||
|
<div className="button-title">Peer ID</div>
|
||||||
|
<div className="button-description">Connect to specific peer</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="action-button"
|
||||||
|
onClick={() => onSelectMethod('connection-id')}
|
||||||
|
>
|
||||||
|
<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={onBack}>← Back</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MethodSelector;
|
||||||
13
src/components/QRCodeDisplay.jsx
Normal file
13
src/components/QRCodeDisplay.jsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
function QRCodeDisplay({ qrCodeUrl, connectionId }) {
|
||||||
|
if (!qrCodeUrl) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">{connectionId}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QRCodeDisplay;
|
||||||
74
src/components/QRScanner.jsx
Normal file
74
src/components/QRScanner.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="scanner-container">
|
||||||
|
<video ref={videoRef} className="scanner-video" />
|
||||||
|
<button className="back-button" onClick={onCancel}>← Cancel</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QRScanner;
|
||||||
@@ -514,6 +514,74 @@ input[type="text"]:disabled {
|
|||||||
transform: scale(1.05);
|
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 {
|
.logs {
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
border-top: 2px solid #f0f0f0;
|
border-top: 2px solid #f0f0f0;
|
||||||
|
|||||||
Reference in New Issue
Block a user