From c973b8b1768ac62076c2b1a4e8611fe5ba9e1c8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Sun, 5 Oct 2025 01:57:27 +0300 Subject: [PATCH] feat: improve superchat and membership handling with fallback fields and UI panels --- backend/src/ingestion/youtubei.ts | 37 +++- client/app/dashboard/page.tsx | 275 +++++++++++++++++++----------- client/app/globals.css | 198 +++++++++++++++++---- client/app/overlay/page.tsx | 13 +- shared/chat.ts | 1 + 5 files changed, 388 insertions(+), 136 deletions(-) diff --git a/backend/src/ingestion/youtubei.ts b/backend/src/ingestion/youtubei.ts index c1a2268..04392fc 100644 --- a/backend/src/ingestion/youtubei.ts +++ b/backend/src/ingestion/youtubei.ts @@ -154,12 +154,32 @@ function extractBadges(item: any): Badge[] { function extractSuperChatInfo(item: any): SuperChatInfo | undefined { if (item.type !== 'LiveChatPaidMessage') return undefined; - const amount = item.purchase_amount_text?.toString() ?? ''; + // Try multiple possible field names for the amount + let amount = ''; + if (item.purchase_amount_text) { + amount = typeof item.purchase_amount_text === 'string' + ? item.purchase_amount_text + : item.purchase_amount_text.simpleText || item.purchase_amount_text.toString(); + } else if (item.purchaseAmountText) { + amount = typeof item.purchaseAmountText === 'string' + ? item.purchaseAmountText + : item.purchaseAmountText.simpleText || item.purchaseAmountText.toString(); + } else if (item.amount) { + amount = item.amount.toString(); + } + + // Try multiple possible field names for color + const color = item.body_background_color?.toString() + || item.bodyBackgroundColor?.toString() + || item.headerBackgroundColor?.toString() + || '#1e3a8a'; + + console.log('[SuperChat] Extracted:', { amount, color, rawItem: item }); return { - amount, + amount: amount || 'Super Chat', currency: item.currency ?? 'USD', - color: item.body_background_color?.toString() ?? '#1e3a8a' + color }; } @@ -181,6 +201,14 @@ function normalizeAction(action: any): ChatMessage | null { const isMember = badges.some(b => b.type === 'member'); const isVerified = badges.some(b => b.type === 'verified'); + // Extract membership level for new members + let membershipLevel: string | undefined; + if (item.type === 'LiveChatMembershipItem') { + membershipLevel = item.header_subtext?.toString() || + item.header_primary_text?.toString() || + 'New member'; + } + return { id: String(item.id ?? item.timestamp_usec ?? Date.now()), author: String(item.author?.name ?? 'Unknown'), @@ -192,7 +220,8 @@ function normalizeAction(action: any): ChatMessage | null { isMember, isVerified, superChat: extractSuperChatInfo(item), - membershipGift: item.type === 'LiveChatMembershipItem' + membershipGift: item.type === 'LiveChatMembershipItem', + membershipLevel }; } diff --git a/client/app/dashboard/page.tsx b/client/app/dashboard/page.tsx index 61061d4..68c47d6 100644 --- a/client/app/dashboard/page.tsx +++ b/client/app/dashboard/page.tsx @@ -1,11 +1,15 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +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(); @@ -49,6 +53,10 @@ export default function DashboardPage() { return 'Live'; }, [pollError, overlayStatus]); + const superChats = useMemo(() => messages.filter(m => m.superChat), [messages]); + const newMembers = useMemo(() => messages.filter(m => m.membershipGift), [messages]); + const regularMessages = useMemo(() => messages.filter(m => !m.superChat && !m.membershipGift), [messages]); + return (
@@ -70,95 +78,84 @@ export default function DashboardPage() { onDisconnect={disconnect} /> -
-
-
-

Live Chat Stream

- {selection && ( - - )} -
-
- {messages.map((message) => ( - + )} +
+
+ {regularMessages.map((message) => ( + handleSelect(message)} + /> + ))} + {regularMessages.length === 0 && ( +
+

⏳ Waiting for chat messages...

- {message.superChat && ( -
- 💰 Super Chat: {message.superChat.amount} -
- )} - {message.membershipGift && ( -
- 🎁 New Member! -
- )} -

{message.text}

- - ))} - {messages.length === 0 && ( -
-

⏳ Waiting for chat messages...

-
- )} + )} +
- {selection && ( -
-

🎯 Selected for Overlay

-
-
- {selection.authorPhoto && ( - {selection.author} - )} -
-
- {selection.author} - {selection.badges && selection.badges.map((badge, i) => ( - - {badge.type === 'moderator' && '🛡️'} - {badge.type === 'member' && '⭐'} - {badge.type === 'verified' && '✓'} - - ))} -
- -
-
- {selection.superChat && ( -
- 💰 {selection.superChat.amount} + {/* Right Split Column */} +
+ {/* Super Chats - Top Half */} +
+
+

💰 Super Chats

+ {superChats.length} +
+
+ {superChats.map((message) => ( + handleSelect(message)} + /> + ))} + {superChats.length === 0 && ( +
+

No super chats yet

)} -

{selection.text}

- )} + + {/* New Members - Bottom Half */} +
+
+

⭐ New Members

+ {newMembers.length} +
+
+ {newMembers.map((message) => ( + handleSelect(message)} + /> + ))} + {newMembers.length === 0 && ( +
+

No new members yet

+
+ )} +
+
+
); @@ -196,27 +193,48 @@ type SelectionPayload = { function useOverlaySelection() { const [selection, setSelection] = useState(null); const [status, setStatus] = useState('connecting'); + const listenerRef = useRef<((payload: any) => void) | null>(null); useEffect(() => { - const source = new EventSource(`${BACKEND_URL}/overlay/stream`); + // 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); - const onSelection = (event: MessageEvent) => { - try { - const payload: SelectionPayload = JSON.parse(event.data); - setSelection(payload.message); - setStatus('live'); - } catch (error) { - console.error('Failed to parse selection payload', error); - } + globalSSEConnection.addEventListener('heartbeat', () => { + // Heartbeat - connection is alive + }); + + globalSSEConnection.onerror = () => { + console.error('[Dashboard] SSE error'); + }; + } + + // Register this component's listener + const myListener = (payload: SelectionPayload) => { + setSelection(payload.message); + setStatus('live'); }; - - source.addEventListener('selection', onSelection as EventListener); - source.addEventListener('heartbeat', () => setStatus('live')); - source.onerror = () => setStatus('error'); + + listenerRef.current = myListener; + globalConnectionListeners.add(myListener); return () => { - source.removeEventListener('selection', onSelection as EventListener); - source.close(); + // Unregister this component's listener + if (listenerRef.current) { + globalConnectionListeners.delete(listenerRef.current); + } + // Don't close global connection - other components might use it }; }, []); @@ -337,3 +355,66 @@ function ConnectionControl({ connected, liveId, connecting, onConnect, onDisconn ); } + +type ChatItemProps = { + message: ChatMessage; + isSelected: boolean; + onSelect: () => void; +}; + +function ChatItem({ message, isSelected, onSelect }: ChatItemProps) { + return ( + + ); +} + +function MemberItem({ message, isSelected, onSelect }: ChatItemProps) { + return ( + + ); +} diff --git a/client/app/globals.css b/client/app/globals.css index dde5fdb..d617d34 100644 --- a/client/app/globals.css +++ b/client/app/globals.css @@ -81,7 +81,8 @@ main { .dashboard { display: flex; flex-direction: column; - min-height: 100vh; + height: 100vh; + overflow: hidden; background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%); } @@ -89,7 +90,7 @@ main { display: flex; justify-content: space-between; align-items: center; - padding: 2rem clamp(1.5rem, 5vw, 4rem); + padding: 1.25rem clamp(1rem, 3vw, 2rem); background: rgba(15, 23, 42, 0.6); border-bottom: 1px solid rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px); @@ -119,21 +120,32 @@ main { color: #94a3b8; } -.dashboard__main { +.dashboard__grid { flex: 1; display: flex; - flex-direction: column; - align-items: center; - padding: 0 clamp(1rem, 5vw, 3rem) 2rem; - gap: 2rem; - max-width: 1400px; + gap: 0.75rem; + padding: 1rem clamp(1rem, 3vw, 2rem); width: 100%; + max-width: 100%; margin: 0 auto; + min-height: 0; + overflow: hidden; +} + +@media (max-width: 1100px) { + .dashboard__grid { + flex-direction: column; + } + + .chatColumn, + .splitColumn { + flex: 1 1 auto; + } } .connectionControl { width: 100%; - padding: 1.5rem clamp(1rem, 5vw, 3rem); + padding: 1rem clamp(1rem, 3vw, 2rem); background: rgba(30, 41, 59, 0.5); border-bottom: 1px solid rgba(255, 255, 255, 0.1); } @@ -265,28 +277,71 @@ main { } } -.chatPanel { - width: 100%; - background: rgba(30, 41, 59, 0.4); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 20px; - padding: 1.5rem; - backdrop-filter: blur(10px); +.chatColumn { + display: flex; + flex-direction: column; + flex: 1 1 55%; + min-height: 0; + min-width: 0; + overflow: hidden; } -.chatPanel__header { +.splitColumn { + display: flex; + flex-direction: column; + flex: 1 1 45%; + gap: 0.5rem; + min-height: 0; + min-width: 0; + overflow: hidden; +} + +.splitColumn .panel { + flex: 1; + min-height: 0; +} + +.panel { + background: rgba(30, 41, 59, 0.4); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 16px; + padding: 1rem; + backdrop-filter: blur(10px); + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + max-width: 100%; + height: 100%; + overflow: hidden; +} + +.panel__header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1.5rem; - padding-bottom: 1rem; + margin-bottom: 1rem; + padding-bottom: 0.75rem; border-bottom: 1px solid rgba(255, 255, 255, 0.1); + flex-shrink: 0; } -.chatPanel__header h2 { +.panel__header h2 { margin: 0; - font-size: 1.3rem; + font-size: 1.2rem; color: #e2e8f0; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.badge--count { + background: rgba(96, 165, 250, 0.2); + color: #60a5fa; + padding: 0.25rem 0.6rem; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 700; } .btn-clear { @@ -308,10 +363,12 @@ main { .chatList { display: flex; flex-direction: column; - gap: 0.75rem; - max-height: 65vh; + gap: 0.5rem; + flex: 1; overflow-y: auto; - padding-right: 0.5rem; + overflow-x: hidden; + padding-right: 0.25rem; + min-height: 0; } .chatList::-webkit-scrollbar { @@ -343,14 +400,15 @@ main { .chatItem { display: flex; flex-direction: column; - gap: 0.75rem; - padding: 1rem 1.25rem; - border-radius: 12px; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-radius: 10px; background: rgba(15, 23, 42, 0.6); border: 1px solid rgba(255, 255, 255, 0.06); text-align: left; transition: all 150ms ease; cursor: pointer; + flex-shrink: 0; } .chatItem:hover { @@ -408,6 +466,8 @@ main { margin: 0; line-height: 1.5; color: #e2e8f0; + word-break: break-word; + overflow-wrap: break-word; font-size: 0.95rem; } @@ -453,6 +513,77 @@ main { color: #93c5fd; } +.memberItem { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-radius: 10px; + background: linear-gradient(135deg, rgba(16, 185, 129, 0.1), rgba(5, 150, 105, 0.1)); + border: 1px solid rgba(16, 185, 129, 0.2); + text-align: left; + transition: all 150ms ease; + cursor: pointer; + flex-shrink: 0; +} + +.memberItem:hover { + background: linear-gradient(135deg, rgba(16, 185, 129, 0.2), rgba(5, 150, 105, 0.2)); + border-color: rgba(16, 185, 129, 0.4); + transform: translateX(4px); +} + +.memberItem--active { + background: linear-gradient(135deg, rgba(16, 185, 129, 0.25), rgba(5, 150, 105, 0.25)); + border-color: rgba(16, 185, 129, 0.6); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1); +} + +.memberItem__header { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.memberItem__avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + border: 2px solid rgba(16, 185, 129, 0.4); +} + +.memberItem__info { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.memberItem__author { + font-weight: 700; + font-size: 0.95rem; + color: #10b981; +} + +.memberItem__level { + font-size: 0.85rem; + color: #6ee7b7; + font-weight: 600; +} + +.memberItem__time { + font-size: 0.7rem; + color: #64748b; +} + +.memberItem__text { + margin: 0; + line-height: 1.4; + color: #d1fae5; + font-size: 0.9rem; +} + .selectedPreview { width: 100%; background: rgba(59, 130, 246, 0.1); @@ -523,7 +654,7 @@ main { min-height: 100vh; display: grid; place-items: center; - background: rgba(0, 0, 0, 0); + background: transparent; } @keyframes fadeIn { @@ -551,15 +682,14 @@ main { .overlay__card { padding: 2rem 2.5rem; border-radius: 24px; - background: linear-gradient(135deg, rgba(15, 23, 42, 0.95), rgba(30, 41, 59, 0.95)); + background: linear-gradient(135deg, #0f172a, #1e293b); border: 2px solid rgba(96, 165, 250, 0.3); - color: #f8fafc; - max-width: 1000px; - width: min(90vw, 1000px); - box-shadow: 0 20px 80px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.1); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); display: flex; flex-direction: column; - gap: 1.25rem; + gap: 1.5rem; + min-width: min(600px, 90vw); + max-width: min(900px, 95vw); animation: fadeIn 0.4s ease-out; } diff --git a/client/app/overlay/page.tsx b/client/app/overlay/page.tsx index 19b91fb..3da09cf 100644 --- a/client/app/overlay/page.tsx +++ b/client/app/overlay/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import type { ChatMessage } from '@shared/chat'; const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4100'; @@ -13,9 +13,18 @@ export default function OverlayPage() { const [message, setMessage] = useState(null); const [connected, setConnected] = useState(false); const [fadingOut, setFadingOut] = useState(false); + const connectionRef = useRef(null); useEffect(() => { + // Prevent multiple connections + if (connectionRef.current) { + console.log('[Overlay] SSE already connected'); + return; + } + + console.log('[Overlay] Creating SSE connection'); const source = new EventSource(`${BACKEND_URL}/overlay/stream`); + connectionRef.current = source; const onSelection = (event: MessageEvent) => { try { @@ -47,8 +56,10 @@ export default function OverlayPage() { source.onerror = () => setConnected(false); return () => { + console.log('[Overlay] Closing SSE connection'); source.removeEventListener('selection', onSelection as EventListener); source.close(); + connectionRef.current = null; }; }, []); // Empty dependency array - only connect once diff --git a/shared/chat.ts b/shared/chat.ts index 5e617be..000f323 100644 --- a/shared/chat.ts +++ b/shared/chat.ts @@ -22,4 +22,5 @@ export type ChatMessage = { isVerified?: boolean; superChat?: SuperChatInfo; membershipGift?: boolean; + membershipLevel?: string; // e.g., "New member", "Member (6 months)", etc. };