'use client'; import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import type { ChatMessage } from '@shared/chat'; const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4100'; const POLL_INTERVAL = 2500; // Global singleton to prevent multiple SSE connections across all renders/remounts let globalSSEConnection: EventSource | null = null; let globalConnectionListeners: Set<(payload: any) => void> = new Set(); export default function DashboardPage() { const { messages, refresh, error: pollError } = useChatMessages(); const { selection, status: overlayStatus } = useOverlaySelection(); const { connected, liveId, connect, disconnect, connecting } = useConnection(); const handleSelect = useCallback( async (message: ChatMessage) => { try { await fetch(`${BACKEND_URL}/overlay/selection`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: message.id }) }); } catch (error) { console.error('Failed to select message', error); } }, [] ); const handleClear = useCallback(async () => { try { await fetch(`${BACKEND_URL}/overlay/selection`, { method: 'DELETE' }); } catch (error) { console.error('Failed to clear selection', error); } }, []); useEffect(() => { const timer = setInterval(() => { refresh(); }, POLL_INTERVAL); return () => clearInterval(timer); }, [refresh]); const statusHint = useMemo(() => { if (pollError) return 'Backend unreachable'; if (overlayStatus === 'connecting') return 'Connecting to overlay…'; if (overlayStatus === 'error') return 'Overlay stream disconnected'; return 'Live'; }, [pollError, overlayStatus]); const superChats = useMemo(() => messages.filter(m => m.superChat), [messages]); const newMembers = useMemo(() => messages.filter(m => m.membershipGift || m.membershipGiftPurchase), [messages]); const regularMessages = useMemo(() => messages.filter(m => !m.superChat && !m.membershipGift && !m.membershipGiftPurchase), [messages]); return ( {!connected && ( Connect to YouTube Live Stream { e.preventDefault(); const formData = new FormData(e.currentTarget); const liveId = formData.get('liveId') as string; if (liveId.trim()) { connect(liveId.trim()); } }}> {connecting ? 'Connecting...' : 'Connect'} )} {connected && ( <> Messages {regularMessages.length} Superchats {superChats.length} Members {newMembers.length} 🔄 💬 CHAT MESSAGES CLEAR ( handleSelect(message)} /> )} emptyState={ ⏳ Waiting for chat messages... } /> 🔥 SUPER CHATS ( handleSelect(message)} /> )} emptyState={ No super chats yet } /> ⭐ MEMBERSHIPS & MILESTONES ( handleSelect(message)} /> )} emptyState={ No new members yet } /> > )} ); } function useChatMessages() { const [messages, setMessages] = useState([]); const [error, setError] = useState(null); const refresh = useCallback(async () => { try { const response = await fetch(`${BACKEND_URL}/chat/messages`); if (!response.ok) throw new Error(`Request failed: ${response.status}`); const data = await response.json(); setMessages(Array.isArray(data.messages) ? data.messages : []); setError(null); } catch (err) { setError(err as Error); } }, []); useEffect(() => { refresh(); }, [refresh]); return { messages, refresh, error }; } type OverlayStatus = 'connecting' | 'live' | 'error'; type SelectionPayload = { message: ChatMessage | null; }; function useOverlaySelection() { const [selection, setSelection] = useState(null); const [status, setStatus] = useState('connecting'); const listenerRef = useRef<((payload: any) => void) | null>(null); useEffect(() => { // Create global connection if it doesn't exist if (!globalSSEConnection) { console.log('[Dashboard] Creating GLOBAL SSE connection'); globalSSEConnection = new EventSource(`${BACKEND_URL}/overlay/stream`); globalSSEConnection.addEventListener('selection', ((event: MessageEvent) => { try { const payload: SelectionPayload = JSON.parse(event.data); // Broadcast to all listeners globalConnectionListeners.forEach(listener => listener(payload)); } catch (error) { console.error('Failed to parse selection payload', error); } }) as EventListener); globalSSEConnection.addEventListener('heartbeat', () => { // Heartbeat - connection is alive }); globalSSEConnection.onerror = (error) => { console.error('[Dashboard] SSE connection error - will auto-reconnect', error); // EventSource automatically reconnects, no action needed }; } // Register this component's listener const myListener = (payload: SelectionPayload) => { setSelection(payload.message); setStatus('live'); }; listenerRef.current = myListener; globalConnectionListeners.add(myListener); return () => { // Unregister this component's listener if (listenerRef.current) { globalConnectionListeners.delete(listenerRef.current); } // Don't close global connection - other components might use it }; }, []); return { selection, status }; } function useConnection() { const [connected, setConnected] = useState(false); const [liveId, setLiveId] = useState(null); const [connecting, setConnecting] = useState(false); const checkStatus = useCallback(async () => { try { const response = await fetch(`${BACKEND_URL}/health`); const data = await response.json(); setConnected(data.connected); setLiveId(data.liveId); } catch (error) { console.error('Failed to check connection status', error); } }, []); useEffect(() => { checkStatus(); const interval = setInterval(checkStatus, 5000); return () => clearInterval(interval); }, [checkStatus]); const connect = useCallback(async (liveId: string) => { setConnecting(true); try { const response = await fetch(`${BACKEND_URL}/chat/connect`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ liveId }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to connect'); } await checkStatus(); } catch (error) { console.error('Failed to connect', error); alert('Failed to connect to YouTube Live chat. Please check the Live ID.'); } finally { setConnecting(false); } }, [checkStatus]); const disconnect = useCallback(async () => { try { await fetch(`${BACKEND_URL}/chat/disconnect`, { method: 'POST' }); await checkStatus(); } catch (error) { console.error('Failed to disconnect', error); } }, [checkStatus]); return { connected, liveId, connect, disconnect, connecting }; } type ConnectionControlProps = { connected: boolean; liveId: string | null; connecting: boolean; onConnect: (liveId: string) => void; onDisconnect: () => void; inline?: boolean; }; function ConnectionControl({ connected, liveId, connecting, onConnect, onDisconnect, inline }: ConnectionControlProps) { const [inputValue, setInputValue] = useState(''); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (inputValue.trim()) { onConnect(inputValue.trim()); } }; return ( {connected ? ( 🟢 Connected Live ID: {liveId} Disconnect ) : ( YouTube Live Stream ID or URL setInputValue(e.target.value)} placeholder="e.g., dQw4w9WgXcQ or https://youtube.com/watch?v=..." disabled={connecting} /> {connecting ? 'Connecting...' : 'Connect to Stream'} )} ); } type ChatItemProps = { message: ChatMessage; isSelected: boolean; onSelect: () => void; }; type ChatListPanelProps = { messages: ChatMessage[]; renderItem: (message: ChatMessage) => React.ReactNode; emptyState: React.ReactNode; }; function ChatListPanel({ messages, renderItem, emptyState }: ChatListPanelProps) { const scrollRef = useRef(null); const wasAtBottomRef = useRef(true); useEffect(() => { const node = scrollRef.current; if (!node) return; const handleScroll = () => { const isAtBottom = node.scrollHeight - node.scrollTop - node.clientHeight <= 150; wasAtBottomRef.current = isAtBottom; }; node.addEventListener('scroll', handleScroll); return () => node.removeEventListener('scroll', handleScroll); }, []); useEffect(() => { const node = scrollRef.current; if (node && wasAtBottomRef.current) { // Use setTimeout with requestAnimationFrame for more reliable scrolling setTimeout(() => { requestAnimationFrame(() => { if (node && wasAtBottomRef.current) { node.scrollTop = node.scrollHeight; } }); }, 0); } }, [messages]); return ( {messages.length > 0 ? messages.map(renderItem) : emptyState} ); } function ChatItem({ message, isSelected, onSelect }: ChatItemProps) { return ( {message.authorPhoto && ( )} {message.membershipLevel && ( {message.membershipLevel}: )} {message.author} {message.badges && message.badges.map((badge, i) => ( badge.imageUrl ? ( ) : ( {badge.type === 'moderator' && '🛡️'} {badge.type === 'member' && '⭐'} {badge.type === 'verified' && '✓'} ) ))} {message.superChat && ( {message.superChat.currency}{message.superChat.currency ? ' ' : ''}{message.superChat.amount} )} {new Date(message.publishedAt).toLocaleTimeString()} {message.runs?.length ? ( {message.runs.map((r, i) => r.emojiUrl ? ( ) : ( {r.text} ) )} ) : message.text && {message.text}} ); } function MemberItem({ message, isSelected, onSelect }: ChatItemProps) { return ( {message.authorPhoto && ( )} {message.author} {message.membershipGiftPurchase && message.giftCount ? `Sent ${message.giftCount} gift membership${message.giftCount > 1 ? 's' : ''}` : message.membershipLevel || 'New member'} {new Date(message.publishedAt).toLocaleTimeString()} {message.runs?.length ? ( {message.runs.map((r, i) => r.emojiUrl ? ( ) : ( {r.text} ) )} ) : message.text && {message.text}} ); }
⏳ Waiting for chat messages...
No super chats yet
No new members yet
{message.runs.map((r, i) => r.emojiUrl ? ( ) : ( {r.text} ) )}
{message.text}