Spaces:
Running
Running
| import React, { useEffect, useRef, useState, useCallback } from 'react'; | |
| import { io, Socket } from 'socket.io-client'; | |
| import { | |
| Video, VideoOff, Mic, MicOff, Send, SkipForward, | |
| StopCircle, MessageSquare, User, Loader2, Camera, | |
| Shield, Globe, Zap, Target, ChevronDown, ChevronUp, | |
| MessageCircle, Sparkles, Users, Lock | |
| } from 'lucide-react'; | |
| import { motion, AnimatePresence } from 'motion/react'; | |
| type Page = 'landing' | 'setup' | 'chat'; | |
| type ChatType = 'text' | 'video'; | |
| type Message = { sender: 'me' | 'stranger' | 'system'; text: string }; | |
| // ✅ FIXED: ExpressTURN free TURN server (replaces broken openrelay) | |
| const STUN_SERVERS = { | |
| iceServers: [ | |
| { urls: "stun:stun.l.google.com:19302" }, | |
| { urls: "stun:stun1.l.google.com:19302" }, | |
| { urls: "stun:stun2.l.google.com:19302" }, | |
| { | |
| urls: "turn:free.expressturn.com:3478", | |
| username: "000000002092388734", | |
| credential: "hLJdGxtDrgYmtIB4JWcBgXm6Xw0=" | |
| }, | |
| { | |
| urls: "turn:free.expressturn.com:3478?transport=tcp", | |
| username: "000000002092388734", | |
| credential: "hLJdGxtDrgYmtIB4JWcBgXm6Xw0=" | |
| } | |
| ] | |
| }; | |
| export default function App() { | |
| const [currentPage, setCurrentPage] = useState<Page>('landing'); | |
| const [chatType, setChatType] = useState<ChatType>('text'); | |
| const [interests, setInterests] = useState(''); | |
| const [onlineCount, setOnlineCount] = useState(0); | |
| const [messages, setMessages] = useState<Message[]>([]); | |
| const [inputText, setInputText] = useState(''); | |
| const [isMuted, setIsMuted] = useState(false); | |
| const [isVideoOff, setIsVideoOff] = useState(false); | |
| const [showPermissionModal, setShowPermissionModal] = useState(false); | |
| const [isSearching, setIsSearching] = useState(false); | |
| const [isConnected, setIsConnected] = useState(false); // ✅ NEW: track if matched | |
| const [error, setError] = useState<string | null>(null); | |
| const socketRef = useRef<Socket | null>(null); | |
| const localStreamRef = useRef<MediaStream | null>(null); | |
| const pcRef = useRef<RTCPeerConnection | null>(null); | |
| const localVideoRef = useRef<HTMLVideoElement>(null); | |
| const remoteVideoRef = useRef<HTMLVideoElement>(null); | |
| const chatEndRef = useRef<HTMLDivElement>(null); | |
| const hasJoinedQueueRef = useRef(false); | |
| const isMatchedRef = useRef(false); // ✅ NEW: prevent re-joining after match | |
| // Initialize socket | |
| useEffect(() => { | |
| const socket = io({ transports: ["websocket"] }); | |
| socketRef.current = socket; | |
| socket.on('online_count', (count: number) => { | |
| setOnlineCount(count); | |
| }); | |
| socket.on('waiting', () => { | |
| setIsSearching(true); | |
| setIsConnected(false); | |
| setMessages([{ sender: 'system', text: 'Looking for a stranger...' }]); | |
| }); | |
| socket.on('matched', async ({ role, type }) => { | |
| // ✅ FIXED: Mark as matched immediately so no more queue joins happen | |
| isMatchedRef.current = true; | |
| hasJoinedQueueRef.current = true; | |
| setIsSearching(false); | |
| setIsConnected(true); | |
| setMessages([{ sender: 'system', text: `You are now chatting with a random stranger. Say hi!` }]); | |
| if (type === 'video') { | |
| await setupWebRTC(role); | |
| } | |
| }); | |
| socket.on('signal', async (data) => { | |
| if (!pcRef.current) return; | |
| try { | |
| if (data.type === 'offer') { | |
| await pcRef.current.setRemoteDescription(new RTCSessionDescription(data)); | |
| const answer = await pcRef.current.createAnswer(); | |
| await pcRef.current.setLocalDescription(answer); | |
| socketRef.current?.emit('signal', answer); | |
| } else if (data.type === 'answer') { | |
| await pcRef.current.setRemoteDescription(new RTCSessionDescription(data)); | |
| } else if (data.type === 'ice') { | |
| await pcRef.current.addIceCandidate(new RTCIceCandidate(data.candidate)); | |
| } | |
| } catch (err) { | |
| console.error('Signaling error:', err); | |
| } | |
| }); | |
| socket.on('receive-message', (text: string) => { | |
| setMessages(prev => [...prev, { sender: 'stranger', text }]); | |
| }); | |
| socket.on('partner-disconnected', () => { | |
| cleanupPC(); | |
| isMatchedRef.current = false; | |
| setIsConnected(false); | |
| setMessages(prev => [...prev, { sender: 'system', text: 'Stranger has disconnected.' }]); | |
| }); | |
| return () => { | |
| socket.disconnect(); | |
| }; | |
| }, []); | |
| useEffect(() => { | |
| chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); | |
| }, [messages]); | |
| const cleanupPC = () => { | |
| if (pcRef.current) { | |
| pcRef.current.close(); | |
| pcRef.current = null; | |
| } | |
| if (remoteVideoRef.current) { | |
| remoteVideoRef.current.srcObject = null; | |
| } | |
| if (localStreamRef.current) { | |
| localStreamRef.current.getTracks().forEach(track => track.stop()); | |
| localStreamRef.current = null; | |
| } | |
| if (localVideoRef.current) { | |
| localVideoRef.current.srcObject = null; | |
| } | |
| }; | |
| const setupWebRTC = async (role: 'initiator' | 'receiver') => { | |
| cleanupPC(); | |
| // ✅ Get fresh media stream before creating peer connection | |
| const stream = await getMedia(); | |
| if (!stream) { | |
| console.error('No media stream available for WebRTC'); | |
| return; | |
| } | |
| const pc = new RTCPeerConnection(STUN_SERVERS); | |
| pcRef.current = pc; | |
| // ✅ Log ICE connection state for debugging | |
| pc.oniceconnectionstatechange = () => { | |
| console.log('ICE state:', pc.iceConnectionState); | |
| }; | |
| pc.onicecandidate = (event) => { | |
| if (event.candidate) { | |
| socketRef.current?.emit('signal', { type: 'ice', candidate: event.candidate }); | |
| } | |
| }; | |
| pc.ontrack = (event) => { | |
| console.log('Got remote track!', event.streams[0]); | |
| if (remoteVideoRef.current && event.streams[0]) { | |
| remoteVideoRef.current.srcObject = event.streams[0]; | |
| // ✅ Force play on mobile | |
| remoteVideoRef.current.play().catch(e => console.log('Play error:', e)); | |
| } | |
| }; | |
| // ✅ Add tracks from fresh stream | |
| stream.getTracks().forEach(track => { | |
| pc.addTrack(track, stream); | |
| }); | |
| if (role === 'initiator') { | |
| const offer = await pc.createOffer({ | |
| offerToReceiveAudio: true, | |
| offerToReceiveVideo: true | |
| }); | |
| await pc.setLocalDescription(offer); | |
| socketRef.current?.emit('signal', offer); | |
| } | |
| }; | |
| const getMedia = async () => { | |
| if (localStreamRef.current) return localStreamRef.current; | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ | |
| video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } }, | |
| audio: true | |
| }); | |
| localStreamRef.current = stream; | |
| if (localVideoRef.current) { | |
| localVideoRef.current.srcObject = stream; | |
| localVideoRef.current.play().catch(e => console.log('Local play error:', e)); | |
| } | |
| return stream; | |
| } catch (err) { | |
| console.error('Media access error:', err); | |
| setError('Please allow camera & microphone access to use video chat.'); | |
| return null; | |
| } | |
| }; | |
| const startChatting = async (type: ChatType) => { | |
| setChatType(type); | |
| if (type === 'video') { | |
| const hasPermission = localStorage.getItem('camera_permission_granted'); | |
| if (!hasPermission) { | |
| setShowPermissionModal(true); | |
| return; | |
| } | |
| const stream = await getMedia(); | |
| if (!stream) return; | |
| } | |
| setCurrentPage('chat'); | |
| setMessages([]); | |
| isMatchedRef.current = false; // ✅ Reset match state | |
| if (!hasJoinedQueueRef.current) { | |
| hasJoinedQueueRef.current = true; | |
| socketRef.current?.emit('join-queue', { type, interests }); | |
| } | |
| }; | |
| const handlePermissionConfirm = async () => { | |
| setShowPermissionModal(false); | |
| const stream = await getMedia(); | |
| if (stream) { | |
| localStorage.setItem('camera_permission_granted', 'true'); | |
| setCurrentPage('chat'); | |
| setMessages([]); | |
| isMatchedRef.current = false; // ✅ Reset match state | |
| if (!hasJoinedQueueRef.current) { | |
| hasJoinedQueueRef.current = true; | |
| socketRef.current?.emit('join-queue', { type: 'video', interests }); | |
| } | |
| } | |
| }; | |
| const handleNext = () => { | |
| cleanupPC(); | |
| setMessages([]); | |
| isMatchedRef.current = false; // ✅ Reset for next match | |
| hasJoinedQueueRef.current = true; | |
| setIsConnected(false); | |
| socketRef.current?.emit('leave-chat'); | |
| socketRef.current?.emit('join-queue', { type: chatType, interests }); | |
| }; | |
| const handleStop = () => { | |
| cleanupPC(); | |
| hasJoinedQueueRef.current = false; | |
| isMatchedRef.current = false; | |
| socketRef.current?.emit('leave-chat'); | |
| setCurrentPage('setup'); | |
| setMessages([]); | |
| setIsSearching(false); | |
| setIsConnected(false); | |
| }; | |
| const sendMessage = (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| if (inputText.trim()) { | |
| socketRef.current?.emit('send-message', inputText); | |
| setMessages(prev => [...prev, { sender: 'me', text: inputText }]); | |
| setInputText(''); | |
| } | |
| }; | |
| const toggleMute = () => { | |
| if (localStreamRef.current) { | |
| localStreamRef.current.getAudioTracks().forEach(track => { | |
| track.enabled = !track.enabled; | |
| }); | |
| setIsMuted(prev => !prev); | |
| } | |
| }; | |
| const toggleVideo = () => { | |
| if (localStreamRef.current) { | |
| localStreamRef.current.getVideoTracks().forEach(track => { | |
| track.enabled = !track.enabled; | |
| }); | |
| setIsVideoOff(prev => !prev); | |
| } | |
| }; | |
| const FeatureCard = ({ icon: Icon, title, desc }: { icon: any, title: string, desc: string }) => ( | |
| <div className="p-6 rounded-2xl flex flex-col items-center text-center gap-3 bg-zinc-900/40 border border-zinc-800/50 hover:border-indigo-500/30 transition-all group"> | |
| <div className="p-3 rounded-xl bg-indigo-500/10 text-indigo-400 group-hover:scale-110 transition-transform"> | |
| <Icon size={24} /> | |
| </div> | |
| <h3 className="text-sm font-bold uppercase tracking-widest text-zinc-200">{title}</h3> | |
| <p className="text-[10px] text-zinc-500 leading-relaxed">{desc}</p> | |
| </div> | |
| ); | |
| const FAQItem = ({ question }: { question: string }) => { | |
| const [isOpen, setIsOpen] = useState(false); | |
| return ( | |
| <div className="border-b border-zinc-800/50"> | |
| <button | |
| onClick={() => setIsOpen(!isOpen)} | |
| className="w-full py-5 flex items-center justify-between text-left group" | |
| > | |
| <span className="text-sm font-medium text-zinc-300 group-hover:text-indigo-400 transition-colors">{question}</span> | |
| {isOpen ? <ChevronUp size={18} className="text-indigo-500" /> : <ChevronDown size={18} className="text-zinc-600" />} | |
| </button> | |
| <AnimatePresence> | |
| {isOpen && ( | |
| <motion.div | |
| initial={{ height: 0, opacity: 0 }} | |
| animate={{ height: 'auto', opacity: 1 }} | |
| exit={{ height: 0, opacity: 0 }} | |
| className="overflow-hidden" | |
| > | |
| <div className="pb-5 text-xs text-zinc-500 leading-relaxed"> | |
| MingleHub uses advanced matching algorithms to connect you with people who share your interests while maintaining a safe and moderated environment for everyone. | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| ); | |
| }; | |
| return ( | |
| <div className="h-screen font-sans overflow-hidden bg-[#050505] text-white"> | |
| <AnimatePresence mode="wait"> | |
| {/* PAGE 1: LANDING */} | |
| {currentPage === 'landing' && ( | |
| <motion.div | |
| key="landing" | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| className="h-full flex flex-col" | |
| > | |
| <header className="h-20 border-b border-zinc-900 flex items-center justify-between px-8"> | |
| <div className="flex items-center gap-3"> | |
| <div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center shadow-lg shadow-indigo-600/20"> | |
| <Sparkles size={24} className="text-white" /> | |
| </div> | |
| <span className="text-2xl font-black tracking-tighter uppercase italic">MingleHub</span> | |
| </div> | |
| <div className="hidden md:flex items-center gap-8 text-[10px] font-bold uppercase tracking-widest text-zinc-500"> | |
| <button | |
| onClick={() => window.open(window.location.href, '_blank')} | |
| className="flex items-center gap-2 px-4 py-2 bg-zinc-900 border border-zinc-800 rounded-lg hover:text-indigo-400 hover:border-indigo-500/30 transition-all" | |
| > | |
| <Globe size={14} /> | |
| Open in New Tab | |
| </button> | |
| <a href="#" className="hover:text-indigo-400 transition-colors">Safety</a> | |
| <a href="#" className="hover:text-indigo-400 transition-colors">Community</a> | |
| <a href="#" className="hover:text-indigo-400 transition-colors">Support</a> | |
| </div> | |
| </header> | |
| <main className="flex-1 overflow-y-auto"> | |
| <div className="max-w-4xl mx-auto py-20 px-8 space-y-24"> | |
| <div className="text-center space-y-8"> | |
| <motion.div | |
| initial={{ y: 20, opacity: 0 }} | |
| animate={{ y: 0, opacity: 1 }} | |
| transition={{ delay: 0.2 }} | |
| > | |
| <h1 className="text-5xl md:text-7xl font-black tracking-tight leading-none"> | |
| Connect with the <br /> | |
| <span className="text-transparent bg-clip-text bg-gradient-to-r from-indigo-400 to-purple-400">World Instantly.</span> | |
| </h1> | |
| </motion.div> | |
| <p className="max-w-xl mx-auto text-zinc-400 text-lg leading-relaxed"> | |
| MingleHub is a premium anonymous video and text chat platform. Meet interesting people, make new friends, and explore the world from your screen. | |
| </p> | |
| <div className="pt-8 flex flex-col items-center gap-6"> | |
| <button | |
| onClick={() => setCurrentPage('setup')} | |
| className="group relative px-10 py-5 bg-indigo-600 hover:bg-indigo-700 text-white font-black rounded-2xl transition-all transform active:scale-95 shadow-xl shadow-indigo-600/20 overflow-hidden" | |
| > | |
| <span className="relative z-10 flex items-center gap-2"> | |
| START MINGLING NOW | |
| <Zap size={18} className="group-hover:animate-pulse" /> | |
| </span> | |
| </button> | |
| <div className="flex items-center gap-2 text-[10px] font-bold text-zinc-600 uppercase tracking-widest"> | |
| <Users size={14} className="text-indigo-500" /> | |
| {onlineCount.toLocaleString()} PEOPLE CURRENTLY ONLINE | |
| </div> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-1 md:grid-cols-3 gap-6"> | |
| <FeatureCard icon={Lock} title="Private & Secure" desc="End-to-end encrypted signaling and anonymous profiles keep your identity safe." /> | |
| <FeatureCard icon={Globe} title="Global Reach" desc="Connect with people from over 190 countries instantly with low latency." /> | |
| <FeatureCard icon={Zap} title="Zero Lag" desc="Optimized WebRTC infrastructure ensures smooth video and instant messaging." /> | |
| </div> | |
| <div className="space-y-8 pt-12"> | |
| <h2 className="text-3xl font-black text-center">Common Questions</h2> | |
| <div className="max-w-2xl mx-auto space-y-2"> | |
| <FAQItem question="How does MingleHub ensure user safety?" /> | |
| <FAQItem question="Do I need to create an account to chat?" /> | |
| <FAQItem question="Is MingleHub free to use?" /> | |
| <FAQItem question="Can I use MingleHub on my mobile device?" /> | |
| </div> | |
| </div> | |
| <footer className="pt-20 pb-12 border-t border-zinc-900 flex flex-col md:flex-row items-center justify-between gap-8 text-[10px] text-zinc-600 uppercase tracking-widest font-bold"> | |
| <div className="flex items-center gap-2"> | |
| <Sparkles size={14} className="text-indigo-500" /> | |
| <span>© 2026 MingleHub Global</span> | |
| </div> | |
| <div className="flex gap-8"> | |
| <a href="#" className="hover:text-indigo-400">Terms</a> | |
| <a href="#" className="hover:text-indigo-400">Privacy</a> | |
| <a href="#" className="hover:text-indigo-400">Guidelines</a> | |
| <a href="#" className="hover:text-indigo-400">Contact</a> | |
| </div> | |
| </footer> | |
| </div> | |
| </main> | |
| </motion.div> | |
| )} | |
| {/* PAGE 2: SETUP */} | |
| {currentPage === 'setup' && ( | |
| <motion.div | |
| key="setup" | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| className="h-full flex flex-col" | |
| > | |
| <header className="h-20 border-b border-zinc-900 flex items-center justify-between px-8"> | |
| <div className="flex items-center gap-3"> | |
| <div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center"> | |
| <Sparkles size={24} className="text-white" /> | |
| </div> | |
| <span className="text-2xl font-black tracking-tighter uppercase italic">MingleHub</span> | |
| </div> | |
| <div className="flex items-center gap-6"> | |
| <button | |
| onClick={() => window.open(window.location.href, '_blank')} | |
| className="hidden md:flex items-center gap-2 px-4 py-2 bg-zinc-900 border border-zinc-800 rounded-lg text-[10px] font-bold uppercase tracking-widest text-zinc-500 hover:text-indigo-400 hover:border-indigo-500/30 transition-all" | |
| > | |
| <Globe size={14} /> | |
| Open in New Tab | |
| </button> | |
| <button | |
| onClick={() => setCurrentPage('landing')} | |
| className="text-[10px] font-bold text-zinc-500 hover:text-white uppercase tracking-widest transition-colors" | |
| > | |
| Back to Home | |
| </button> | |
| </div> | |
| </header> | |
| <main className="flex-1 overflow-y-auto"> | |
| <div className="max-w-xl mx-auto py-20 px-8 space-y-12"> | |
| <div className="text-center space-y-4"> | |
| <h1 className="text-4xl font-black tracking-tight">Configure Chat</h1> | |
| <p className="text-zinc-500 text-sm">Choose how you want to connect today.</p> | |
| </div> | |
| <div className="space-y-10"> | |
| <div className="grid grid-cols-2 gap-4"> | |
| <button | |
| onClick={() => startChatting('text')} | |
| className={`p-8 rounded-3xl border-2 transition-all flex flex-col items-center gap-4 group ${chatType === 'text' ? 'border-indigo-600 bg-indigo-600/10' : 'border-zinc-800 bg-zinc-900/50 hover:border-zinc-700'}`} | |
| > | |
| <div className={`p-4 rounded-2xl transition-colors ${chatType === 'text' ? 'bg-indigo-600 text-white' : 'bg-zinc-800 text-zinc-500 group-hover:text-zinc-300'}`}> | |
| <MessageSquare size={32} /> | |
| </div> | |
| <span className="font-bold uppercase tracking-widest text-sm">Text Chat</span> | |
| </button> | |
| <button | |
| onClick={() => startChatting('video')} | |
| className={`p-8 rounded-3xl border-2 transition-all flex flex-col items-center gap-4 group ${chatType === 'video' ? 'border-indigo-600 bg-indigo-600/10' : 'border-zinc-800 bg-zinc-900/50 hover:border-zinc-700'}`} | |
| > | |
| <div className={`p-4 rounded-2xl transition-colors ${chatType === 'video' ? 'bg-indigo-600 text-white' : 'bg-zinc-800 text-zinc-500 group-hover:text-zinc-300'}`}> | |
| <Video size={32} /> | |
| </div> | |
| <span className="font-bold uppercase tracking-widest text-sm">Video Chat</span> | |
| </button> | |
| </div> | |
| <div className="space-y-4"> | |
| <div className="flex items-center justify-between"> | |
| <label className="text-xs font-bold uppercase tracking-widest text-zinc-500">Your Interests</label> | |
| <span className="text-[10px] text-zinc-600 font-bold uppercase tracking-widest">Optional</span> | |
| </div> | |
| <input | |
| type="text" | |
| value={interests} | |
| onChange={(e) => setInterests(e.target.value)} | |
| placeholder="e.g. Music, Travel, Coding..." | |
| className="w-full px-6 py-4 bg-zinc-900 border border-zinc-800 rounded-2xl text-sm focus:outline-none focus:border-indigo-600 transition-all text-white placeholder:text-zinc-700" | |
| /> | |
| </div> | |
| <div className="p-6 bg-indigo-600/5 border border-indigo-500/10 rounded-2xl flex items-start gap-4"> | |
| <Shield size={20} className="text-indigo-500 shrink-0 mt-1" /> | |
| <div className="space-y-1"> | |
| <h4 className="text-xs font-bold uppercase tracking-widest text-indigo-400">Safety First</h4> | |
| <p className="text-[10px] text-zinc-500 leading-relaxed"> | |
| MingleHub is a moderated community. Please be respectful to others. Harassment or inappropriate behavior will result in a permanent ban. | |
| </p> | |
| </div> | |
| </div> | |
| <button | |
| onClick={() => startChatting(chatType)} | |
| className="w-full py-5 bg-indigo-600 hover:bg-indigo-700 text-white font-black rounded-2xl transition-all transform active:scale-95 shadow-xl shadow-indigo-600/20" | |
| > | |
| ENTER CHAT ROOM | |
| </button> | |
| </div> | |
| </div> | |
| </main> | |
| </motion.div> | |
| )} | |
| {/* PAGE 3: CHAT */} | |
| {currentPage === 'chat' && ( | |
| <motion.div | |
| key="chat" | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| className="h-full flex flex-col bg-[#050505]" | |
| > | |
| <header className="h-16 border-b border-zinc-900 flex items-center justify-between px-6"> | |
| <div className="flex items-center gap-3"> | |
| <div className="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center"> | |
| <Sparkles size={18} className="text-white" /> | |
| </div> | |
| <span className="font-black tracking-tighter text-sm uppercase italic">MingleHub</span> | |
| </div> | |
| <div className="flex items-center gap-6"> | |
| <div className="flex items-center gap-2 px-3 py-1 bg-green-500/10 border border-green-500/20 rounded-full"> | |
| <div className="w-1.5 h-1.5 bg-green-500 rounded-full animate-pulse" /> | |
| <span className="text-[9px] font-bold text-green-500 uppercase tracking-widest">Live</span> | |
| </div> | |
| {chatType === 'video' && ( | |
| <div className="flex items-center gap-2"> | |
| <button | |
| onClick={toggleMute} | |
| className={`p-2 rounded-lg transition-colors ${isMuted ? 'bg-red-500/20 text-red-400' : 'text-zinc-500 hover:text-white'}`} | |
| > | |
| {isMuted ? <MicOff size={18} /> : <Mic size={18} />} | |
| </button> | |
| <button | |
| onClick={toggleVideo} | |
| className={`p-2 rounded-lg transition-colors ${isVideoOff ? 'bg-red-500/20 text-red-400' : 'text-zinc-500 hover:text-white'}`} | |
| > | |
| {isVideoOff ? <VideoOff size={18} /> : <Video size={18} />} | |
| </button> | |
| </div> | |
| )} | |
| <button | |
| onClick={handleStop} | |
| className="p-2 text-zinc-600 hover:text-white transition-colors" | |
| > | |
| <StopCircle size={22} /> | |
| </button> | |
| </div> | |
| </header> | |
| <main className="flex-1 flex flex-col md:flex-row overflow-hidden"> | |
| <div className="flex-1 flex flex-col bg-black relative"> | |
| {chatType === 'video' ? ( | |
| <div className="flex-1 grid grid-rows-2 md:grid-rows-1 md:grid-cols-2 gap-1 p-1"> | |
| <div className="relative bg-zinc-900 rounded-2xl overflow-hidden border border-zinc-800/50"> | |
| <video | |
| ref={remoteVideoRef} | |
| autoPlay | |
| playsInline | |
| className="w-full h-full object-cover" | |
| /> | |
| <div className="absolute top-4 left-4 px-3 py-1 bg-black/60 backdrop-blur-md rounded-lg text-[9px] font-bold uppercase tracking-widest border border-white/5">Stranger</div> | |
| {!isConnected && !isSearching && ( | |
| <div className="absolute inset-0 flex items-center justify-center flex-col gap-4"> | |
| <User size={48} className="text-zinc-800" /> | |
| <p className="text-zinc-600 text-[10px] font-bold uppercase tracking-widest">Waiting for video...</p> | |
| </div> | |
| )} | |
| </div> | |
| <div className="relative bg-zinc-900 rounded-2xl overflow-hidden border border-zinc-800/50"> | |
| <video | |
| ref={localVideoRef} | |
| autoPlay | |
| muted | |
| playsInline | |
| className="w-full h-full object-cover scale-x-[-1]" | |
| /> | |
| <div className="absolute top-4 left-4 px-3 py-1 bg-black/60 backdrop-blur-md rounded-lg text-[9px] font-bold uppercase tracking-widest border border-white/5">You</div> | |
| {isVideoOff && ( | |
| <div className="absolute inset-0 bg-zinc-950 flex items-center justify-center"> | |
| <VideoOff size={32} className="text-zinc-800" /> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="flex-1 flex items-center justify-center flex-col gap-8 p-12 text-center"> | |
| <div className="w-28 h-28 bg-indigo-600/10 rounded-full flex items-center justify-center border border-indigo-500/20"> | |
| <MessageSquare size={48} className="text-indigo-500" /> | |
| </div> | |
| <div className="space-y-3"> | |
| <h2 className="text-3xl font-black tracking-tight">Secure Text Chat</h2> | |
| <p className="text-zinc-500 text-sm max-w-xs mx-auto">You are now connected with a stranger. Be kind and enjoy the conversation.</p> | |
| </div> | |
| </div> | |
| )} | |
| {isSearching && ( | |
| <div className="absolute inset-0 z-50 bg-black/90 backdrop-blur-md flex flex-col items-center justify-center gap-8"> | |
| <div className="relative"> | |
| <div className="w-20 h-20 border-4 border-indigo-500/20 border-t-indigo-500 rounded-full animate-spin" /> | |
| <div className="absolute inset-0 flex items-center justify-center"> | |
| <Globe size={28} className="text-zinc-700" /> | |
| </div> | |
| </div> | |
| <div className="text-center space-y-3"> | |
| <h3 className="text-2xl font-black tracking-tight">Searching...</h3> | |
| <p className="text-zinc-500 text-[10px] font-bold uppercase tracking-[0.2em]">Matching with a global stranger</p> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| <div className="w-full md:w-[450px] flex flex-col border-l border-zinc-900 bg-[#080808]"> | |
| <div className="flex-1 overflow-y-auto p-6 space-y-6 scrollbar-hide"> | |
| <AnimatePresence initial={false}> | |
| {messages.map((msg, i) => ( | |
| <motion.div | |
| key={i} | |
| initial={{ opacity: 0, y: 10 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| className={`flex flex-col ${msg.sender === 'me' ? 'items-end' : msg.sender === 'system' ? 'items-center' : 'items-start'}`} | |
| > | |
| {msg.sender === 'system' ? ( | |
| <div className="px-5 py-2 bg-zinc-900/40 border border-zinc-800/50 rounded-full text-[9px] font-bold text-zinc-500 uppercase tracking-[0.15em]"> | |
| {msg.text} | |
| </div> | |
| ) : ( | |
| <> | |
| <span className="text-[8px] font-black uppercase tracking-widest text-zinc-600 mb-1.5 px-1"> | |
| {msg.sender === 'me' ? 'You' : 'Stranger'} | |
| </span> | |
| <div className={`px-5 py-3 rounded-2xl text-[13px] max-w-[85%] leading-relaxed ${ | |
| msg.sender === 'me' | |
| ? 'bg-indigo-600 text-white rounded-tr-none shadow-lg shadow-indigo-600/10' | |
| : 'bg-zinc-900 text-zinc-200 rounded-tl-none border border-zinc-800/50' | |
| }`}> | |
| {msg.text} | |
| </div> | |
| </> | |
| )} | |
| </motion.div> | |
| ))} | |
| </AnimatePresence> | |
| <div ref={chatEndRef} /> | |
| </div> | |
| <div className="p-6 border-t border-zinc-900 space-y-6 bg-[#080808]"> | |
| <div className="flex gap-3"> | |
| <button | |
| onClick={handleStop} | |
| className="px-6 py-4 bg-zinc-900 hover:bg-zinc-800 text-zinc-400 font-bold rounded-2xl transition-all text-xs uppercase tracking-widest" | |
| > | |
| Stop | |
| </button> | |
| <button | |
| onClick={handleNext} | |
| className="flex-1 py-4 bg-indigo-600 hover:bg-indigo-700 text-white font-black rounded-2xl transition-all flex items-center justify-center gap-3 shadow-lg shadow-indigo-600/20 uppercase tracking-widest text-xs" | |
| > | |
| <SkipForward size={18} /> | |
| Next Partner | |
| </button> | |
| </div> | |
| <form onSubmit={sendMessage} className="relative"> | |
| <input | |
| type="text" | |
| value={inputText} | |
| onChange={(e) => setInputText(e.target.value)} | |
| placeholder="Type something..." | |
| className="w-full bg-zinc-900 border border-zinc-800 rounded-2xl py-5 pl-6 pr-16 text-sm focus:outline-none focus:border-indigo-600 transition-all text-white placeholder:text-zinc-700" | |
| /> | |
| <button | |
| type="submit" | |
| disabled={!inputText.trim()} | |
| className="absolute right-3 top-1/2 -translate-y-1/2 p-3 text-indigo-500 hover:text-indigo-400 disabled:text-zinc-800 transition-colors" | |
| > | |
| <Send size={22} /> | |
| </button> | |
| </form> | |
| </div> | |
| </div> | |
| </main> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| {/* Permission Modal */} | |
| <AnimatePresence> | |
| {showPermissionModal && ( | |
| <div className="fixed inset-0 z-[100] flex items-center justify-center p-6"> | |
| <motion.div | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| className="absolute inset-0 bg-black/95 backdrop-blur-2xl" | |
| /> | |
| <motion.div | |
| initial={{ opacity: 0, scale: 0.9, y: 20 }} | |
| animate={{ opacity: 1, scale: 1, y: 0 }} | |
| exit={{ opacity: 0, scale: 0.9, y: 20 }} | |
| className="relative w-full max-w-sm bg-zinc-900 border border-zinc-800 rounded-[40px] p-12 text-center space-y-10 shadow-2xl" | |
| > | |
| <div className="w-28 h-28 bg-indigo-600/10 rounded-full flex items-center justify-center mx-auto border border-indigo-500/20"> | |
| <Camera size={56} className="text-indigo-500" /> | |
| </div> | |
| <div className="space-y-4"> | |
| <h2 className="text-4xl font-black tracking-tight text-white leading-none">Enable <br /> Camera</h2> | |
| <p className="text-zinc-500 text-sm leading-relaxed"> | |
| To connect via video, MingleHub requires access to your camera and microphone. | |
| </p> | |
| </div> | |
| <div className="flex flex-col gap-4"> | |
| <button | |
| onClick={handlePermissionConfirm} | |
| className="w-full py-6 bg-indigo-600 text-white font-black rounded-3xl hover:bg-indigo-700 transition-all shadow-xl shadow-indigo-600/20 uppercase tracking-widest text-xs" | |
| > | |
| ALLOW ACCESS | |
| </button> | |
| <button | |
| onClick={() => setShowPermissionModal(false)} | |
| className="w-full py-6 bg-zinc-800 text-zinc-500 font-bold rounded-3xl hover:bg-zinc-700 transition-all uppercase tracking-widest text-[10px]" | |
| > | |
| MAYBE LATER | |
| </button> | |
| </div> | |
| </motion.div> | |
| </div> | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| ); | |
| } | |