mirror of
https://github.com/xtr-dev/rondevu-demo.git
synced 2025-12-12 03:23:23 +00:00
Host now polls for answered offers every 2 seconds using the new efficient batch endpoint. When an answer is received, the host automatically sets the remote description to complete the WebRTC connection. Changes: - Store offerId to RTCPeerConnection mapping when publishing - Poll for answered offers with timestamp filtering - Automatically handle incoming answers - Track last answer timestamp to avoid reprocessing This completes the bidirectional WebRTC signaling flow.
1531 lines
44 KiB
JavaScript
1531 lines
44 KiB
JavaScript
import React, { useState, useEffect, useRef } from 'react';
|
||
import { Rondevu } from '@xtr-dev/rondevu-client';
|
||
import toast, { Toaster } from 'react-hot-toast';
|
||
|
||
const API_URL = 'https://api.ronde.vu';
|
||
const CHAT_SERVICE = 'chat:2.0.0';
|
||
|
||
// Preset RTC configurations
|
||
const RTC_PRESETS = {
|
||
'ipv4-turn': {
|
||
name: 'IPv4 TURN (Recommended)',
|
||
config: {
|
||
iceServers: [
|
||
{ urls: ["stun:57.129.61.67:3478"] },
|
||
{
|
||
urls: [
|
||
"turn:57.129.61.67:3478?transport=tcp",
|
||
"turn:57.129.61.67:3478?transport=udp",
|
||
],
|
||
username: "webrtcuser",
|
||
credential: "supersecretpassword"
|
||
}
|
||
],
|
||
}
|
||
},
|
||
'hostname-turns': {
|
||
name: 'Hostname TURNS (TLS)',
|
||
config: {
|
||
iceServers: [
|
||
{ urls: ["stun:turn.share.fish:3478"] },
|
||
{
|
||
urls: [
|
||
"turns:turn.share.fish:5349?transport=tcp",
|
||
"turns:turn.share.fish:5349?transport=udp",
|
||
"turn:turn.share.fish:3478?transport=tcp",
|
||
"turn:turn.share.fish:3478?transport=udp",
|
||
],
|
||
username: "webrtcuser",
|
||
credential: "supersecretpassword"
|
||
}
|
||
],
|
||
}
|
||
},
|
||
'google-stun': {
|
||
name: 'Google STUN Only',
|
||
config: {
|
||
iceServers: [
|
||
{ urls: 'stun:stun.l.google.com:19302' },
|
||
{ urls: 'stun:stun1.l.google.com:19302' }
|
||
]
|
||
}
|
||
},
|
||
'relay-only': {
|
||
name: 'Force TURN Relay (Testing)',
|
||
config: {
|
||
iceServers: [
|
||
{ urls: ["stun:57.129.61.67:3478"] },
|
||
{
|
||
urls: [
|
||
"turn:57.129.61.67:3478?transport=tcp",
|
||
"turn:57.129.61.67:3478?transport=udp",
|
||
],
|
||
username: "webrtcuser",
|
||
credential: "supersecretpassword"
|
||
}
|
||
],
|
||
iceTransportPolicy: 'relay'
|
||
}
|
||
},
|
||
'custom': {
|
||
name: 'Custom Configuration',
|
||
config: null // Will be loaded from user input
|
||
}
|
||
};
|
||
|
||
export default function App() {
|
||
const [rondevu, setRondevu] = useState(null);
|
||
const [myUsername, setMyUsername] = useState(null);
|
||
|
||
// Setup
|
||
const [setupStep, setSetupStep] = useState('init'); // init, claim, ready
|
||
const [usernameInput, setUsernameInput] = useState('');
|
||
|
||
// Contacts
|
||
const [contacts, setContacts] = useState([]);
|
||
const [contactInput, setContactInput] = useState('');
|
||
const [onlineUsers, setOnlineUsers] = useState(new Set());
|
||
|
||
// Chat - structure: { [username]: { connection, channel, messages, status, role, serviceFqn, offerId, polling } }
|
||
const [activeChats, setActiveChats] = useState({});
|
||
const [selectedChat, setSelectedChat] = useState(null);
|
||
const [messageInputs, setMessageInputs] = useState({});
|
||
|
||
// Service - we publish one service that can accept multiple connections
|
||
const [myServicePublished, setMyServicePublished] = useState(false);
|
||
const [hostConnections, setHostConnections] = useState({}); // Track incoming connections as host
|
||
const [offerIdToPeerConnection, setOfferIdToPeerConnection] = useState({}); // Map offerId to RTCPeerConnection
|
||
const [lastAnswerTimestamp, setLastAnswerTimestamp] = useState(0); // Track last answer timestamp for polling
|
||
|
||
const chatEndRef = useRef(null);
|
||
|
||
// Settings
|
||
const [showSettings, setShowSettings] = useState(false);
|
||
const [rtcPreset, setRtcPreset] = useState('ipv4-turn');
|
||
const [customRtcConfig, setCustomRtcConfig] = useState('');
|
||
|
||
// Get current RTC configuration
|
||
const getCurrentRtcConfig = () => {
|
||
if (rtcPreset === 'custom') {
|
||
try {
|
||
return JSON.parse(customRtcConfig);
|
||
} catch (err) {
|
||
console.error('Invalid custom RTC config:', err);
|
||
return RTC_PRESETS['ipv4-turn'].config;
|
||
}
|
||
}
|
||
return RTC_PRESETS[rtcPreset]?.config || RTC_PRESETS['ipv4-turn'].config;
|
||
};
|
||
|
||
// Load saved settings
|
||
useEffect(() => {
|
||
const savedPreset = localStorage.getItem('rondevu-rtc-preset');
|
||
const savedCustomConfig = localStorage.getItem('rondevu-rtc-custom');
|
||
|
||
if (savedPreset) {
|
||
setRtcPreset(savedPreset);
|
||
}
|
||
if (savedCustomConfig) {
|
||
setCustomRtcConfig(savedCustomConfig);
|
||
}
|
||
}, []);
|
||
|
||
// Save settings when they change
|
||
useEffect(() => {
|
||
localStorage.setItem('rondevu-rtc-preset', rtcPreset);
|
||
}, [rtcPreset]);
|
||
|
||
useEffect(() => {
|
||
if (customRtcConfig) {
|
||
localStorage.setItem('rondevu-rtc-custom', customRtcConfig);
|
||
}
|
||
}, [customRtcConfig]);
|
||
|
||
// Initialize Rondevu
|
||
useEffect(() => {
|
||
const init = async () => {
|
||
try {
|
||
const savedUsername = localStorage.getItem('rondevu-username');
|
||
const savedKeypair = localStorage.getItem('rondevu-keypair');
|
||
const savedContacts = localStorage.getItem('rondevu-contacts');
|
||
|
||
console.log('[Init] Saved username:', savedUsername);
|
||
console.log('[Init] Has saved keypair:', !!savedKeypair);
|
||
|
||
// Load contacts
|
||
if (savedContacts) {
|
||
try {
|
||
setContacts(JSON.parse(savedContacts));
|
||
} catch (err) {
|
||
console.error('Failed to load contacts:', err);
|
||
}
|
||
}
|
||
|
||
const parsedKeypair = savedKeypair ? JSON.parse(savedKeypair) : undefined;
|
||
|
||
const service = new Rondevu({
|
||
apiUrl: API_URL,
|
||
username: savedUsername || 'temp',
|
||
keypair: parsedKeypair,
|
||
});
|
||
|
||
await service.initialize();
|
||
setRondevu(service);
|
||
|
||
if (savedUsername && savedKeypair) {
|
||
console.log('[Init] Checking if username is claimed...');
|
||
const isClaimed = await service.isUsernameClaimed();
|
||
console.log('[Init] Username claimed:', isClaimed);
|
||
|
||
if (isClaimed) {
|
||
setMyUsername(savedUsername);
|
||
setSetupStep('ready');
|
||
toast.success(`Welcome back, ${savedUsername}!`);
|
||
} else {
|
||
console.warn('[Init] Username not claimed on server, need to claim');
|
||
setSetupStep('claim');
|
||
}
|
||
} else {
|
||
setSetupStep('claim');
|
||
}
|
||
} catch (err) {
|
||
console.error('Initialization failed:', err);
|
||
toast.error(`Failed to initialize: ${err.message}`);
|
||
setSetupStep('claim');
|
||
}
|
||
};
|
||
|
||
init();
|
||
}, []);
|
||
|
||
// Auto-scroll chat
|
||
useEffect(() => {
|
||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||
}, [activeChats, selectedChat]);
|
||
|
||
// Publish service when ready
|
||
useEffect(() => {
|
||
if (setupStep === 'ready' && myUsername && rondevu && !myServicePublished) {
|
||
publishMyService();
|
||
}
|
||
}, [setupStep, myUsername, rondevu, myServicePublished]);
|
||
|
||
// Poll for answered offers (host side)
|
||
useEffect(() => {
|
||
if (!myServicePublished || !rondevu || Object.keys(offerIdToPeerConnection).length === 0) {
|
||
return;
|
||
}
|
||
|
||
console.log('[Answer Polling] Starting to poll for answers...');
|
||
|
||
const pollForAnswers = async () => {
|
||
try {
|
||
const result = await rondevu.getAnsweredOffers(lastAnswerTimestamp);
|
||
|
||
if (result.offers.length > 0) {
|
||
console.log(`[Answer Polling] Found ${result.offers.length} new answered offer(s)`);
|
||
|
||
for (const answer of result.offers) {
|
||
const pc = offerIdToPeerConnection[answer.offerId];
|
||
|
||
if (pc && pc.signalingState !== 'stable') {
|
||
console.log(`[Answer Polling] Setting remote answer for offer ${answer.offerId}`);
|
||
|
||
await pc.setRemoteDescription({
|
||
type: 'answer',
|
||
sdp: answer.sdp
|
||
});
|
||
|
||
// Update last answer timestamp
|
||
setLastAnswerTimestamp(prev => Math.max(prev, answer.answeredAt));
|
||
|
||
console.log(`✅ [Answer Polling] Remote answer set for offer ${answer.offerId}`);
|
||
} else if (pc) {
|
||
console.log(`[Answer Polling] Skipping offer ${answer.offerId} - already in stable state`);
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('[Answer Polling] Error polling for answers:', err);
|
||
}
|
||
};
|
||
|
||
// Poll every 2 seconds
|
||
const interval = setInterval(pollForAnswers, 2000);
|
||
pollForAnswers(); // Initial poll
|
||
|
||
return () => clearInterval(interval);
|
||
}, [myServicePublished, rondevu, offerIdToPeerConnection, lastAnswerTimestamp]);
|
||
|
||
// Check online status periodically
|
||
useEffect(() => {
|
||
if (setupStep !== 'ready' || !rondevu) return;
|
||
|
||
const checkOnlineStatus = async () => {
|
||
const online = new Set();
|
||
for (const contact of contacts) {
|
||
try {
|
||
const fqn = `${CHAT_SERVICE}@${contact}`;
|
||
await rondevu.getService(fqn);
|
||
online.add(contact);
|
||
} catch (err) {
|
||
// User offline or doesn't have service published
|
||
}
|
||
}
|
||
setOnlineUsers(online);
|
||
};
|
||
|
||
checkOnlineStatus();
|
||
const interval = setInterval(checkOnlineStatus, 10000); // Check every 10s
|
||
|
||
return () => clearInterval(interval);
|
||
}, [contacts, setupStep, rondevu]);
|
||
|
||
// Claim username
|
||
const handleClaimUsername = async () => {
|
||
if (!rondevu || !usernameInput) return;
|
||
|
||
try {
|
||
const keypair = rondevu.getKeypair();
|
||
const newService = new Rondevu({
|
||
apiUrl: API_URL,
|
||
username: usernameInput,
|
||
keypair,
|
||
});
|
||
await newService.initialize();
|
||
await newService.claimUsername();
|
||
|
||
setRondevu(newService);
|
||
setMyUsername(usernameInput);
|
||
localStorage.setItem('rondevu-username', usernameInput);
|
||
localStorage.setItem('rondevu-keypair', JSON.stringify(keypair));
|
||
|
||
setSetupStep('ready');
|
||
toast.success(`Welcome, ${usernameInput}!`);
|
||
} catch (err) {
|
||
toast.error(`Error: ${err.message}`);
|
||
}
|
||
};
|
||
|
||
// Publish service to accept incoming connections
|
||
const publishMyService = async () => {
|
||
try {
|
||
// Verify username is claimed with correct keypair before publishing
|
||
console.log('[Publish] Verifying username claim...');
|
||
const isClaimed = await rondevu.isUsernameClaimed();
|
||
console.log('[Publish] Is claimed by us:', isClaimed);
|
||
|
||
if (!isClaimed) {
|
||
console.warn('[Publish] Username not claimed by current keypair');
|
||
|
||
// Check if username is claimed by someone else
|
||
try {
|
||
const usernameCheck = await fetch(`${API_URL}/users/${myUsername}`);
|
||
const checkData = await usernameCheck.json();
|
||
|
||
if (!checkData.available) {
|
||
// Username claimed by different keypair
|
||
console.error('[Publish] Username claimed by different keypair');
|
||
toast.error(
|
||
'Username keypair mismatch. Please logout and try again with a fresh account.',
|
||
{ duration: 10000 }
|
||
);
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
console.error('[Publish] Failed to check username:', e);
|
||
}
|
||
|
||
// Try to claim username
|
||
console.log('[Publish] Attempting to claim username...');
|
||
toast.loading('Claiming username...', { id: 'claim' });
|
||
try {
|
||
await rondevu.claimUsername();
|
||
toast.success('Username claimed!', { id: 'claim' });
|
||
} catch (claimErr) {
|
||
console.error('[Publish] Failed to claim username:', claimErr);
|
||
toast.error(`Failed to claim username: ${claimErr.message}`, { id: 'claim' });
|
||
return;
|
||
}
|
||
}
|
||
|
||
// We'll create a pool of offers manually
|
||
const offers = [];
|
||
const poolSize = 10; // Support up to 10 simultaneous connections
|
||
const connections = []; // Track connections before publishing
|
||
|
||
console.log('[Publish] Creating', poolSize, 'peer connections...');
|
||
for (let i = 0; i < poolSize; i++) {
|
||
const pc = new RTCPeerConnection(getCurrentRtcConfig());
|
||
const dc = pc.createDataChannel('chat');
|
||
|
||
// Setup handlers
|
||
setupHostConnection(pc, dc, myUsername);
|
||
|
||
// Create offer
|
||
const offer = await pc.createOffer();
|
||
await pc.setLocalDescription(offer);
|
||
|
||
offers.push({ sdp: offer.sdp });
|
||
connections.push({ pc, dc, index: i });
|
||
}
|
||
|
||
// Publish service
|
||
const fqn = `${CHAT_SERVICE}@${myUsername}`;
|
||
console.log('[Publish] Publishing service with FQN:', fqn);
|
||
console.log('[Publish] Public key:', rondevu.getPublicKey());
|
||
|
||
const publishResult = await rondevu.publishService({
|
||
serviceFqn: fqn,
|
||
offers,
|
||
ttl: 300000, // 5 minutes
|
||
});
|
||
|
||
// Map offerIds to peer connections
|
||
const offerMapping = {};
|
||
const hostConnMap = {};
|
||
publishResult.offers.forEach((offer, idx) => {
|
||
const conn = connections[idx];
|
||
offerMapping[offer.offerId] = conn.pc;
|
||
hostConnMap[`host-${idx}`] = { pc: conn.pc, dc: conn.dc, offerId: offer.offerId, status: 'waiting' };
|
||
});
|
||
|
||
setOfferIdToPeerConnection(offerMapping);
|
||
setHostConnections(hostConnMap);
|
||
setMyServicePublished(true);
|
||
|
||
console.log('✅ Chat service published successfully with', poolSize, 'offers');
|
||
console.log('[Publish] Offer IDs:', Object.keys(offerMapping));
|
||
toast.success('Chat service started!');
|
||
} catch (err) {
|
||
console.error('[Publish] Failed to publish service:', err);
|
||
toast.error(`Failed to start chat service: ${err.message}`);
|
||
|
||
// Provide helpful guidance based on error type
|
||
if (err.message?.includes('Invalid signature') || err.message?.includes('403')) {
|
||
toast.error(
|
||
'Authentication error. Try logging out and creating a new account.',
|
||
{ duration: 10000 }
|
||
);
|
||
}
|
||
}
|
||
};
|
||
|
||
// Setup host connection (when someone connects to us)
|
||
const setupHostConnection = (pc, dc, hostUsername) => {
|
||
let peerUsername = null;
|
||
|
||
dc.onopen = () => {
|
||
console.log('Host data channel opened');
|
||
};
|
||
|
||
dc.onclose = () => {
|
||
console.log('Host data channel closed');
|
||
if (peerUsername) {
|
||
setActiveChats(prev => ({
|
||
...prev,
|
||
[peerUsername]: { ...prev[peerUsername], status: 'disconnected' }
|
||
}));
|
||
}
|
||
};
|
||
|
||
dc.onmessage = (event) => {
|
||
try {
|
||
const msg = JSON.parse(event.data);
|
||
|
||
if (msg.type === 'identify') {
|
||
// Peer identified themselves
|
||
peerUsername = msg.from;
|
||
console.log(`📡 New chat connection from: ${peerUsername}`);
|
||
|
||
setActiveChats(prev => ({
|
||
...prev,
|
||
[peerUsername]: {
|
||
username: peerUsername,
|
||
channel: dc,
|
||
connection: pc,
|
||
messages: prev[peerUsername]?.messages || [],
|
||
status: 'connected',
|
||
role: 'host'
|
||
}
|
||
}));
|
||
|
||
// Send acknowledgment
|
||
dc.send(JSON.stringify({
|
||
type: 'identify_ack',
|
||
from: hostUsername
|
||
}));
|
||
} else if (msg.type === 'message' && peerUsername) {
|
||
// Chat message
|
||
setActiveChats(prev => ({
|
||
...prev,
|
||
[peerUsername]: {
|
||
...prev[peerUsername],
|
||
messages: [...(prev[peerUsername]?.messages || []), {
|
||
from: peerUsername,
|
||
text: msg.text,
|
||
timestamp: Date.now()
|
||
}]
|
||
}
|
||
}));
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to parse message:', err);
|
||
}
|
||
};
|
||
|
||
pc.onconnectionstatechange = () => {
|
||
console.log('Host connection state:', pc.connectionState);
|
||
};
|
||
};
|
||
|
||
// Add contact
|
||
const handleAddContact = () => {
|
||
if (!contactInput || contacts.includes(contactInput)) {
|
||
toast.error('Invalid or duplicate contact');
|
||
return;
|
||
}
|
||
if (contactInput === myUsername) {
|
||
toast.error("You can't add yourself!");
|
||
return;
|
||
}
|
||
|
||
const newContacts = [...contacts, contactInput];
|
||
setContacts(newContacts);
|
||
localStorage.setItem('rondevu-contacts', JSON.stringify(newContacts));
|
||
setContactInput('');
|
||
toast.success(`Added ${contactInput}`);
|
||
};
|
||
|
||
// Remove contact
|
||
const handleRemoveContact = (contact) => {
|
||
const newContacts = contacts.filter(c => c !== contact);
|
||
setContacts(newContacts);
|
||
localStorage.setItem('rondevu-contacts', JSON.stringify(newContacts));
|
||
if (selectedChat === contact) {
|
||
setSelectedChat(null);
|
||
}
|
||
toast.success(`Removed ${contact}`);
|
||
};
|
||
|
||
// Start chat with contact (answerer role)
|
||
const handleStartChat = async (contact) => {
|
||
if (!rondevu || activeChats[contact]?.status === 'connected') {
|
||
setSelectedChat(contact);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
toast.loading(`Connecting to ${contact}...`, { id: 'connecting' });
|
||
|
||
// Discover peer's service
|
||
const fqn = `${CHAT_SERVICE}@${contact}`;
|
||
const serviceData = await rondevu.getService(fqn);
|
||
|
||
console.log('Found peer service:', serviceData);
|
||
|
||
// Create peer connection
|
||
const pc = new RTCPeerConnection(getCurrentRtcConfig());
|
||
|
||
// Handle incoming data channel
|
||
let dataChannel = null;
|
||
pc.ondatachannel = (event) => {
|
||
console.log('Received data channel from', contact);
|
||
dataChannel = event.channel;
|
||
setupClientChannel(dataChannel, contact, pc);
|
||
};
|
||
|
||
// Set remote offer
|
||
await pc.setRemoteDescription({
|
||
type: 'offer',
|
||
sdp: serviceData.sdp,
|
||
});
|
||
|
||
// Create answer
|
||
const answer = await pc.createAnswer();
|
||
await pc.setLocalDescription(answer);
|
||
|
||
// Send answer
|
||
await rondevu.postOfferAnswer(fqn, serviceData.offerId, answer.sdp);
|
||
|
||
// Poll for ICE candidates
|
||
const lastIceTimestamp = { current: 0 };
|
||
const icePolling = setInterval(async () => {
|
||
try {
|
||
const result = await rondevu.getOfferIceCandidates(
|
||
fqn,
|
||
serviceData.offerId,
|
||
lastIceTimestamp.current
|
||
);
|
||
|
||
for (const item of result.candidates) {
|
||
if (item.candidate && item.candidate.candidate) {
|
||
try {
|
||
const rtcCandidate = new RTCIceCandidate(item.candidate);
|
||
await pc.addIceCandidate(rtcCandidate);
|
||
lastIceTimestamp.current = item.createdAt;
|
||
} catch (err) {
|
||
console.warn('Failed to process ICE candidate:', err);
|
||
lastIceTimestamp.current = item.createdAt;
|
||
}
|
||
} else {
|
||
lastIceTimestamp.current = item.createdAt;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
if (err.message?.includes('404') || err.message?.includes('410')) {
|
||
console.warn('Offer expired, stopping ICE polling');
|
||
clearInterval(icePolling);
|
||
}
|
||
}
|
||
}, 1000);
|
||
|
||
// Send local ICE candidates
|
||
pc.onicecandidate = (event) => {
|
||
if (event.candidate) {
|
||
rondevu.addOfferIceCandidates(
|
||
fqn,
|
||
serviceData.offerId,
|
||
[event.candidate.toJSON()]
|
||
).catch(err => console.error('Failed to send ICE candidate:', err));
|
||
}
|
||
};
|
||
|
||
pc.onconnectionstatechange = () => {
|
||
console.log('Client connection state:', pc.connectionState);
|
||
if (pc.connectionState === 'connected') {
|
||
toast.success(`Connected to ${contact}`, { id: 'connecting' });
|
||
} else if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
|
||
toast.error(`Disconnected from ${contact}`);
|
||
clearInterval(icePolling);
|
||
setActiveChats(prev => ({
|
||
...prev,
|
||
[contact]: { ...prev[contact], status: 'disconnected' }
|
||
}));
|
||
}
|
||
};
|
||
|
||
// Store connection info
|
||
setActiveChats(prev => ({
|
||
...prev,
|
||
[contact]: {
|
||
username: contact,
|
||
connection: pc,
|
||
channel: dataChannel, // Will be set when ondatachannel fires
|
||
messages: prev[contact]?.messages || [],
|
||
status: 'connecting',
|
||
role: 'answerer',
|
||
serviceFqn: fqn,
|
||
offerId: serviceData.offerId,
|
||
icePolling
|
||
}
|
||
}));
|
||
|
||
setSelectedChat(contact);
|
||
|
||
} catch (err) {
|
||
console.error('Failed to connect:', err);
|
||
toast.error(`Failed to connect to ${contact}`, { id: 'connecting' });
|
||
}
|
||
};
|
||
|
||
// Setup client data channel
|
||
const setupClientChannel = (dc, contact, pc) => {
|
||
dc.onopen = () => {
|
||
console.log('Client data channel opened with', contact);
|
||
|
||
// Send identification
|
||
dc.send(JSON.stringify({
|
||
type: 'identify',
|
||
from: myUsername
|
||
}));
|
||
};
|
||
|
||
dc.onclose = () => {
|
||
console.log('Client data channel closed');
|
||
setActiveChats(prev => ({
|
||
...prev,
|
||
[contact]: { ...prev[contact], status: 'disconnected' }
|
||
}));
|
||
};
|
||
|
||
dc.onmessage = (event) => {
|
||
try {
|
||
const msg = JSON.parse(event.data);
|
||
|
||
if (msg.type === 'identify_ack') {
|
||
// Connection acknowledged
|
||
setActiveChats(prev => ({
|
||
...prev,
|
||
[contact]: {
|
||
...prev[contact],
|
||
channel: dc,
|
||
status: 'connected'
|
||
}
|
||
}));
|
||
toast.success(`Connected to ${contact}`, { id: 'connecting' });
|
||
} else if (msg.type === 'message') {
|
||
// Chat message
|
||
setActiveChats(prev => ({
|
||
...prev,
|
||
[contact]: {
|
||
...prev[contact],
|
||
messages: [...(prev[contact]?.messages || []), {
|
||
from: contact,
|
||
text: msg.text,
|
||
timestamp: Date.now()
|
||
}]
|
||
}
|
||
}));
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to parse message:', err);
|
||
}
|
||
};
|
||
|
||
// Update the channel reference in state
|
||
setActiveChats(prev => ({
|
||
...prev,
|
||
[contact]: {
|
||
...prev[contact],
|
||
channel: dc
|
||
}
|
||
}));
|
||
};
|
||
|
||
// Send message
|
||
const handleSendMessage = (contact) => {
|
||
const text = messageInputs[contact];
|
||
if (!text || !activeChats[contact]?.channel) return;
|
||
|
||
const chat = activeChats[contact];
|
||
if (chat.status !== 'connected') {
|
||
toast.error('Not connected');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
chat.channel.send(JSON.stringify({
|
||
type: 'message',
|
||
text
|
||
}));
|
||
|
||
setActiveChats(prev => ({
|
||
...prev,
|
||
[contact]: {
|
||
...prev[contact],
|
||
messages: [...prev[contact].messages, {
|
||
from: myUsername,
|
||
text,
|
||
timestamp: Date.now()
|
||
}]
|
||
}
|
||
}));
|
||
|
||
setMessageInputs(prev => ({ ...prev, [contact]: '' }));
|
||
} catch (err) {
|
||
console.error('Failed to send message:', err);
|
||
toast.error('Failed to send message');
|
||
}
|
||
};
|
||
|
||
// Clear all data
|
||
const handleLogout = () => {
|
||
if (window.confirm('Are you sure you want to logout? This will clear all data.')) {
|
||
localStorage.clear();
|
||
window.location.reload();
|
||
}
|
||
};
|
||
|
||
if (!rondevu) {
|
||
return <div style={styles.loading}>Loading...</div>;
|
||
}
|
||
|
||
return (
|
||
<div style={styles.container}>
|
||
<Toaster position="top-right" />
|
||
|
||
{/* Settings Modal */}
|
||
{showSettings && (
|
||
<div style={styles.modalOverlay} onClick={() => setShowSettings(false)}>
|
||
<div style={styles.modalContent} onClick={(e) => e.stopPropagation()}>
|
||
<h2 style={styles.modalTitle}>WebRTC Configuration</h2>
|
||
|
||
<div style={styles.settingsSection}>
|
||
<label style={styles.settingsLabel}>
|
||
Preset Configuration:
|
||
</label>
|
||
<select
|
||
value={rtcPreset}
|
||
onChange={(e) => setRtcPreset(e.target.value)}
|
||
style={styles.settingsSelect}
|
||
>
|
||
{Object.entries(RTC_PRESETS).map(([key, preset]) => (
|
||
<option key={key} value={key}>
|
||
{preset.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{rtcPreset === 'custom' && (
|
||
<div style={styles.settingsSection}>
|
||
<label style={styles.settingsLabel}>
|
||
Custom RTC Configuration (JSON):
|
||
</label>
|
||
<textarea
|
||
value={customRtcConfig}
|
||
onChange={(e) => setCustomRtcConfig(e.target.value)}
|
||
placeholder={JSON.stringify(RTC_PRESETS['ipv4-turn'].config, null, 2)}
|
||
style={styles.settingsTextarea}
|
||
rows={15}
|
||
/>
|
||
<p style={styles.settingsHint}>
|
||
Enter valid RTCConfiguration JSON
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{rtcPreset !== 'custom' && (
|
||
<div style={styles.settingsSection}>
|
||
<label style={styles.settingsLabel}>
|
||
Current Configuration:
|
||
</label>
|
||
<pre style={styles.settingsPreview}>
|
||
{JSON.stringify(getCurrentRtcConfig(), null, 2)}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
|
||
<div style={styles.modalActions}>
|
||
<button onClick={() => setShowSettings(false)} style={styles.modalBtn}>
|
||
Close
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Setup Screen */}
|
||
{setupStep !== 'ready' && (
|
||
<div style={styles.setupScreen}>
|
||
<div style={styles.setupBox}>
|
||
<h1 style={styles.setupTitle}>Rondevu Chat</h1>
|
||
<p style={styles.setupSubtitle}>Decentralized P2P Chat</p>
|
||
|
||
{setupStep === 'init' && (
|
||
<div>
|
||
<p style={styles.setupDesc}>Initializing...</p>
|
||
</div>
|
||
)}
|
||
|
||
{setupStep === 'claim' && (
|
||
<div>
|
||
<p style={styles.setupDesc}>Choose your unique username</p>
|
||
<input
|
||
type="text"
|
||
placeholder="Enter username"
|
||
value={usernameInput}
|
||
onChange={(e) => setUsernameInput(e.target.value.toLowerCase())}
|
||
onKeyPress={(e) => e.key === 'Enter' && handleClaimUsername()}
|
||
style={styles.setupInput}
|
||
autoFocus
|
||
/>
|
||
<button
|
||
onClick={handleClaimUsername}
|
||
disabled={!usernameInput}
|
||
style={styles.setupButton}
|
||
>
|
||
Claim Username
|
||
</button>
|
||
<p style={styles.setupHint}>
|
||
3-32 characters, lowercase letters, numbers, and dashes only
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Main Chat Screen */}
|
||
{setupStep === 'ready' && (
|
||
<div style={styles.mainScreen}>
|
||
{/* Sidebar */}
|
||
<div style={styles.sidebar}>
|
||
{/* User Header */}
|
||
<div style={styles.userHeader}>
|
||
<div>
|
||
<div style={styles.userHeaderName}>@{myUsername}</div>
|
||
<div style={styles.userHeaderStatus}>
|
||
<span style={styles.onlineDot}></span> Online
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '8px' }}>
|
||
<button onClick={() => setShowSettings(true)} style={styles.settingsBtn} title="Settings">
|
||
⚙️
|
||
</button>
|
||
<button onClick={handleLogout} style={styles.logoutBtn} title="Logout">
|
||
Logout
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Add Contact */}
|
||
<div style={styles.addContactBox}>
|
||
<input
|
||
type="text"
|
||
placeholder="Add friend by username..."
|
||
value={contactInput}
|
||
onChange={(e) => setContactInput(e.target.value.toLowerCase())}
|
||
onKeyPress={(e) => e.key === 'Enter' && handleAddContact()}
|
||
style={styles.contactInput}
|
||
/>
|
||
<button onClick={handleAddContact} style={styles.addBtn} title="Add friend">
|
||
Add
|
||
</button>
|
||
</div>
|
||
|
||
{/* Contacts List */}
|
||
<div style={styles.contactsList}>
|
||
<div style={styles.contactsHeader}>
|
||
Friends ({contacts.length})
|
||
</div>
|
||
{contacts.length === 0 ? (
|
||
<div style={styles.emptyState}>
|
||
<p>No friends yet</p>
|
||
<p style={{ fontSize: '12px', color: '#999' }}>
|
||
Add friends by their username above
|
||
</p>
|
||
</div>
|
||
) : (
|
||
contacts.map(contact => {
|
||
const isOnline = onlineUsers.has(contact);
|
||
const hasActiveChat = activeChats[contact]?.status === 'connected';
|
||
|
||
return (
|
||
<div
|
||
key={contact}
|
||
className="contact-item"
|
||
style={{
|
||
...styles.contactItem,
|
||
...(selectedChat === contact ? styles.contactItemActive : {})
|
||
}}
|
||
onClick={() => hasActiveChat ? setSelectedChat(contact) : handleStartChat(contact)}
|
||
>
|
||
<div style={styles.contactAvatar}>
|
||
{contact[0].toUpperCase()}
|
||
<span style={{
|
||
...styles.contactDot,
|
||
background: isOnline ? '#4caf50' : '#999'
|
||
}}></span>
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={styles.contactName}>{contact}</div>
|
||
<div style={styles.contactStatus}>
|
||
{hasActiveChat ? 'Chatting' : isOnline ? 'Online' : 'Offline'}
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleRemoveContact(contact);
|
||
}}
|
||
style={styles.removeBtn}
|
||
title="Remove friend"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Chat Area */}
|
||
<div style={styles.chatArea}>
|
||
{!selectedChat ? (
|
||
<div style={styles.emptyChat}>
|
||
<h2 style={{ margin: 0 }}>Select a friend to chat</h2>
|
||
<p style={{ marginTop: '10px' }}>
|
||
Click on a friend from the sidebar to start chatting
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* Chat Header */}
|
||
<div style={styles.chatHeader}>
|
||
<div style={styles.chatHeaderAvatar}>
|
||
{selectedChat[0].toUpperCase()}
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={styles.chatHeaderName}>@{selectedChat}</div>
|
||
<div style={styles.chatHeaderStatus}>
|
||
{activeChats[selectedChat]?.status === 'connected' ? (
|
||
<><span style={styles.onlineDot}></span> Connected</>
|
||
) : (
|
||
'Connecting...'
|
||
)}
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => setSelectedChat(null)}
|
||
style={styles.closeChatBtn}
|
||
title="Close chat"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
{/* Messages */}
|
||
<div style={styles.messagesArea}>
|
||
{(!activeChats[selectedChat] || activeChats[selectedChat].messages.length === 0) ? (
|
||
<div style={styles.emptyMessages}>
|
||
<p>No messages yet</p>
|
||
<p style={{ fontSize: '12px', color: '#999' }}>
|
||
Send a message to start the conversation
|
||
</p>
|
||
</div>
|
||
) : (
|
||
activeChats[selectedChat].messages.map((msg, idx) => (
|
||
<div
|
||
key={idx}
|
||
style={{
|
||
...styles.message,
|
||
...(msg.from === myUsername ? styles.messageMe : styles.messageThem)
|
||
}}
|
||
>
|
||
<div style={{
|
||
...styles.messageText,
|
||
background: msg.from === myUsername ? '#4a9eff' : '#2a2a2a',
|
||
color: 'white'
|
||
}}>
|
||
{msg.text}
|
||
</div>
|
||
<div style={styles.messageTime}>
|
||
{new Date(msg.timestamp).toLocaleTimeString([], {
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})}
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
<div ref={chatEndRef} />
|
||
</div>
|
||
|
||
{/* Input */}
|
||
<div style={styles.inputArea}>
|
||
<input
|
||
type="text"
|
||
placeholder="Type a message..."
|
||
value={messageInputs[selectedChat] || ''}
|
||
onChange={(e) => setMessageInputs(prev => ({
|
||
...prev,
|
||
[selectedChat]: e.target.value
|
||
}))}
|
||
onKeyPress={(e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleSendMessage(selectedChat);
|
||
}
|
||
}}
|
||
disabled={activeChats[selectedChat]?.status !== 'connected'}
|
||
style={styles.messageInput}
|
||
autoFocus
|
||
/>
|
||
<button
|
||
onClick={() => handleSendMessage(selectedChat)}
|
||
disabled={!messageInputs[selectedChat] || activeChats[selectedChat]?.status !== 'connected'}
|
||
style={styles.sendBtn}
|
||
title="Send message"
|
||
>
|
||
Send
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const styles = {
|
||
container: {
|
||
height: '100vh',
|
||
background: '#1a1a1a',
|
||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||
},
|
||
loading: {
|
||
height: '100vh',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
color: '#e0e0e0',
|
||
fontSize: '24px'
|
||
},
|
||
setupScreen: {
|
||
height: '100vh',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
padding: '20px'
|
||
},
|
||
setupBox: {
|
||
background: '#2a2a2a',
|
||
borderRadius: '16px',
|
||
padding: '40px',
|
||
maxWidth: '400px',
|
||
width: '100%',
|
||
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
||
textAlign: 'center',
|
||
border: '1px solid #3a3a3a'
|
||
},
|
||
setupTitle: {
|
||
fontSize: '2.5em',
|
||
margin: '0 0 10px 0',
|
||
color: '#e0e0e0'
|
||
},
|
||
setupSubtitle: {
|
||
fontSize: '1.1em',
|
||
color: '#a0a0a0',
|
||
margin: '0 0 30px 0'
|
||
},
|
||
setupDesc: {
|
||
color: '#a0a0a0',
|
||
marginBottom: '20px'
|
||
},
|
||
setupInput: {
|
||
width: '100%',
|
||
padding: '15px',
|
||
fontSize: '16px',
|
||
border: '1px solid #3a3a3a',
|
||
background: '#1a1a1a',
|
||
color: '#e0e0e0',
|
||
borderRadius: '8px',
|
||
marginBottom: '15px',
|
||
boxSizing: 'border-box',
|
||
outline: 'none',
|
||
},
|
||
setupButton: {
|
||
width: '100%',
|
||
padding: '15px',
|
||
fontSize: '16px',
|
||
fontWeight: '600',
|
||
background: '#4a9eff',
|
||
color: 'white',
|
||
border: 'none',
|
||
borderRadius: '8px',
|
||
cursor: 'pointer',
|
||
},
|
||
setupHint: {
|
||
fontSize: '12px',
|
||
color: '#808080',
|
||
marginTop: '10px'
|
||
},
|
||
mainScreen: {
|
||
height: '100vh',
|
||
display: 'flex'
|
||
},
|
||
sidebar: {
|
||
width: '320px',
|
||
background: '#2a2a2a',
|
||
borderRight: '1px solid #3a3a3a',
|
||
display: 'flex',
|
||
flexDirection: 'column'
|
||
},
|
||
userHeader: {
|
||
padding: '20px',
|
||
borderBottom: '1px solid #3a3a3a',
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center'
|
||
},
|
||
userHeaderName: {
|
||
fontSize: '18px',
|
||
fontWeight: '600',
|
||
color: '#e0e0e0'
|
||
},
|
||
userHeaderStatus: {
|
||
fontSize: '12px',
|
||
color: '#a0a0a0',
|
||
marginTop: '4px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '5px'
|
||
},
|
||
onlineDot: {
|
||
width: '8px',
|
||
height: '8px',
|
||
borderRadius: '50%',
|
||
background: '#4caf50',
|
||
display: 'inline-block'
|
||
},
|
||
settingsBtn: {
|
||
padding: '8px 12px',
|
||
background: '#3a3a3a',
|
||
color: '#e0e0e0',
|
||
border: 'none',
|
||
borderRadius: '6px',
|
||
cursor: 'pointer',
|
||
fontSize: '18px',
|
||
lineHeight: '1'
|
||
},
|
||
logoutBtn: {
|
||
padding: '8px 12px',
|
||
background: '#3a3a3a',
|
||
color: '#e0e0e0',
|
||
border: 'none',
|
||
borderRadius: '6px',
|
||
cursor: 'pointer',
|
||
fontSize: '14px'
|
||
},
|
||
addContactBox: {
|
||
padding: '15px',
|
||
borderBottom: '1px solid #3a3a3a',
|
||
display: 'flex',
|
||
gap: '8px'
|
||
},
|
||
contactInput: {
|
||
flex: 1,
|
||
padding: '10px',
|
||
border: '1px solid #3a3a3a',
|
||
background: '#1a1a1a',
|
||
color: '#e0e0e0',
|
||
borderRadius: '6px',
|
||
fontSize: '14px',
|
||
outline: 'none'
|
||
},
|
||
addBtn: {
|
||
padding: '10px 15px',
|
||
background: '#4a9eff',
|
||
color: 'white',
|
||
border: 'none',
|
||
borderRadius: '6px',
|
||
cursor: 'pointer',
|
||
fontSize: '14px'
|
||
},
|
||
contactsList: {
|
||
flex: 1,
|
||
overflowY: 'auto'
|
||
},
|
||
contactsHeader: {
|
||
padding: '15px 20px',
|
||
fontSize: '12px',
|
||
fontWeight: '600',
|
||
color: '#808080',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.5px'
|
||
},
|
||
emptyState: {
|
||
padding: '40px 20px',
|
||
textAlign: 'center',
|
||
color: '#808080'
|
||
},
|
||
contactItem: {
|
||
padding: '15px 20px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '12px',
|
||
cursor: 'pointer',
|
||
transition: 'background 0.2s',
|
||
borderBottom: '1px solid #3a3a3a'
|
||
},
|
||
contactItemActive: {
|
||
background: '#3a3a3a'
|
||
},
|
||
contactAvatar: {
|
||
width: '40px',
|
||
height: '40px',
|
||
borderRadius: '50%',
|
||
background: '#4a9eff',
|
||
color: 'white',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
fontSize: '18px',
|
||
fontWeight: '600',
|
||
position: 'relative'
|
||
},
|
||
contactDot: {
|
||
position: 'absolute',
|
||
bottom: '0',
|
||
right: '0',
|
||
width: '12px',
|
||
height: '12px',
|
||
borderRadius: '50%',
|
||
border: '2px solid #2a2a2a'
|
||
},
|
||
contactName: {
|
||
fontSize: '15px',
|
||
fontWeight: '600',
|
||
color: '#e0e0e0'
|
||
},
|
||
contactStatus: {
|
||
fontSize: '12px',
|
||
color: '#a0a0a0',
|
||
marginTop: '2px'
|
||
},
|
||
removeBtn: {
|
||
padding: '4px 8px',
|
||
background: 'transparent',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
fontSize: '16px',
|
||
color: '#808080',
|
||
opacity: 0.6,
|
||
},
|
||
chatArea: {
|
||
flex: 1,
|
||
background: '#1a1a1a',
|
||
display: 'flex',
|
||
flexDirection: 'column'
|
||
},
|
||
emptyChat: {
|
||
flex: 1,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
color: '#808080'
|
||
},
|
||
chatHeader: {
|
||
padding: '20px',
|
||
borderBottom: '1px solid #3a3a3a',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '15px',
|
||
background: '#2a2a2a'
|
||
},
|
||
chatHeaderAvatar: {
|
||
width: '48px',
|
||
height: '48px',
|
||
borderRadius: '50%',
|
||
background: '#4a9eff',
|
||
color: 'white',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
fontSize: '20px',
|
||
fontWeight: '600'
|
||
},
|
||
chatHeaderName: {
|
||
fontSize: '18px',
|
||
fontWeight: '600',
|
||
color: '#e0e0e0'
|
||
},
|
||
chatHeaderStatus: {
|
||
fontSize: '13px',
|
||
color: '#a0a0a0',
|
||
marginTop: '4px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '5px'
|
||
},
|
||
closeChatBtn: {
|
||
padding: '8px 12px',
|
||
background: '#3a3a3a',
|
||
border: 'none',
|
||
borderRadius: '6px',
|
||
cursor: 'pointer',
|
||
fontSize: '14px',
|
||
color: '#e0e0e0'
|
||
},
|
||
messagesArea: {
|
||
flex: 1,
|
||
overflowY: 'auto',
|
||
padding: '20px',
|
||
background: '#1a1a1a'
|
||
},
|
||
emptyMessages: {
|
||
height: '100%',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
color: '#808080'
|
||
},
|
||
message: {
|
||
marginBottom: '12px',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
maxWidth: '70%'
|
||
},
|
||
messageMe: {
|
||
alignSelf: 'flex-end',
|
||
alignItems: 'flex-end'
|
||
},
|
||
messageThem: {
|
||
alignSelf: 'flex-start',
|
||
alignItems: 'flex-start'
|
||
},
|
||
messageText: {
|
||
padding: '12px 16px',
|
||
borderRadius: '16px',
|
||
fontSize: '15px',
|
||
lineHeight: '1.4',
|
||
wordWrap: 'break-word',
|
||
boxShadow: '0 1px 2px rgba(0,0,0,0.2)'
|
||
},
|
||
messageTime: {
|
||
fontSize: '11px',
|
||
color: '#808080',
|
||
marginTop: '4px',
|
||
padding: '0 8px'
|
||
},
|
||
inputArea: {
|
||
padding: '20px',
|
||
borderTop: '1px solid #3a3a3a',
|
||
display: 'flex',
|
||
gap: '12px',
|
||
background: '#2a2a2a'
|
||
},
|
||
messageInput: {
|
||
flex: 1,
|
||
padding: '12px 16px',
|
||
border: '1px solid #3a3a3a',
|
||
background: '#1a1a1a',
|
||
color: '#e0e0e0',
|
||
borderRadius: '24px',
|
||
fontSize: '15px',
|
||
outline: 'none',
|
||
},
|
||
sendBtn: {
|
||
padding: '12px 24px',
|
||
borderRadius: '24px',
|
||
background: '#4a9eff',
|
||
color: 'white',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
fontSize: '15px',
|
||
fontWeight: '600',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
modalOverlay: {
|
||
position: 'fixed',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
background: 'rgba(0, 0, 0, 0.8)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
zIndex: 1000
|
||
},
|
||
modalContent: {
|
||
background: '#2a2a2a',
|
||
borderRadius: '12px',
|
||
padding: '24px',
|
||
maxWidth: '600px',
|
||
width: '90%',
|
||
maxHeight: '80vh',
|
||
overflowY: 'auto',
|
||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.5)'
|
||
},
|
||
modalTitle: {
|
||
fontSize: '24px',
|
||
color: '#e0e0e0',
|
||
marginBottom: '20px',
|
||
fontWeight: '600'
|
||
},
|
||
settingsSection: {
|
||
marginBottom: '20px'
|
||
},
|
||
settingsLabel: {
|
||
display: 'block',
|
||
color: '#e0e0e0',
|
||
marginBottom: '8px',
|
||
fontSize: '14px',
|
||
fontWeight: '500'
|
||
},
|
||
settingsSelect: {
|
||
width: '100%',
|
||
padding: '10px',
|
||
background: '#1a1a1a',
|
||
color: '#e0e0e0',
|
||
border: '1px solid #3a3a3a',
|
||
borderRadius: '6px',
|
||
fontSize: '14px',
|
||
outline: 'none',
|
||
cursor: 'pointer'
|
||
},
|
||
settingsTextarea: {
|
||
width: '100%',
|
||
padding: '12px',
|
||
background: '#1a1a1a',
|
||
color: '#e0e0e0',
|
||
border: '1px solid #3a3a3a',
|
||
borderRadius: '6px',
|
||
fontSize: '13px',
|
||
fontFamily: 'monospace',
|
||
outline: 'none',
|
||
resize: 'vertical'
|
||
},
|
||
settingsPreview: {
|
||
width: '100%',
|
||
padding: '12px',
|
||
background: '#1a1a1a',
|
||
color: '#4a9eff',
|
||
border: '1px solid #3a3a3a',
|
||
borderRadius: '6px',
|
||
fontSize: '13px',
|
||
fontFamily: 'monospace',
|
||
overflowX: 'auto',
|
||
margin: 0
|
||
},
|
||
settingsHint: {
|
||
fontSize: '12px',
|
||
color: '#808080',
|
||
marginTop: '6px'
|
||
},
|
||
modalActions: {
|
||
display: 'flex',
|
||
justifyContent: 'flex-end',
|
||
gap: '12px',
|
||
marginTop: '24px'
|
||
},
|
||
modalBtn: {
|
||
padding: '10px 20px',
|
||
background: '#4a9eff',
|
||
color: 'white',
|
||
border: 'none',
|
||
borderRadius: '6px',
|
||
cursor: 'pointer',
|
||
fontSize: '14px',
|
||
fontWeight: '600'
|
||
}
|
||
};
|
||
|
||
// Add hover effects via CSS
|
||
if (typeof document !== 'undefined') {
|
||
const style = document.createElement('style');
|
||
style.textContent = `
|
||
button:hover:not(:disabled) {
|
||
opacity: 0.9;
|
||
transform: scale(1.02);
|
||
}
|
||
button:active:not(:disabled) {
|
||
transform: scale(0.98);
|
||
}
|
||
button:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
.contact-item:hover {
|
||
background: #333333 !important;
|
||
}
|
||
.contact-item:active {
|
||
background: #2a2a2a !important;
|
||
}
|
||
input:focus {
|
||
border-color: #4a9eff !important;
|
||
}
|
||
`;
|
||
document.head.appendChild(style);
|
||
}
|