feat: enhance superchat parsing and redesign dashboard with tabbed layout

This commit is contained in:
Yusuf İpek
2025-10-05 04:42:47 +03:00
parent 5d96e2e3ff
commit 29492fb75e
6 changed files with 413 additions and 213 deletions
+82 -11
View File
@@ -52,6 +52,11 @@ export async function bootstrapInnertube(videoId: string): Promise<IngestionCont
}); });
liveChat.on('error', (err: unknown) => { liveChat.on('error', (err: unknown) => {
const msg = (err as any)?.message || String(err);
if (msg && msg.includes('LiveChatReportModerationStateCommand not found')) {
console.warn('[Ingestion] Non-fatal parser drift (ignored):', msg);
return; // ignore noisy parser drift that YouTube.js JITs around
}
console.error('[Ingestion] Live chat error:', err); console.error('[Ingestion] Live chat error:', err);
emitter.emit('error', err); emitter.emit('error', err);
}); });
@@ -163,20 +168,86 @@ function extractSuperChatInfo(item: any): SuperChatInfo | undefined {
const isSuper = superTypes.has(typeName) || !!(item.purchase_amount_text || item.purchaseAmountText); const isSuper = superTypes.has(typeName) || !!(item.purchase_amount_text || item.purchaseAmountText);
if (!isSuper) return undefined; if (!isSuper) return undefined;
// Try multiple possible field names for the amount // Try multiple possible field names for the amount (search shallow + nested header)
let amount = ''; let amount = '';
const amt1 = item.purchase_amount_text; const candidates = [
const amt2 = item.purchaseAmountText; item.purchase_amount_text,
if (amt1) { item.purchaseAmountText,
amount = typeof amt1 === 'string' ? amt1 : (amt1.simpleText || amt1.toString()); item.header?.purchase_amount_text,
} else if (amt2) { item.header?.purchaseAmountText,
amount = typeof amt2 === 'string' ? amt2 : (amt2.simpleText || amt2.toString()); item.amount,
} else if (item.amount) { item.header?.amount,
amount = String(item.amount); ];
function toText(v: any): string {
if (!v) return '';
if (typeof v === 'string') return v;
if (typeof v === 'number') return String(v);
if (typeof v.simpleText === 'string') return v.simpleText;
if (Array.isArray(v.runs)) return v.runs.map((r: any) => r.text ?? '').join('');
if (typeof v.toString === 'function') return v.toString();
return '';
} }
// Try multiple possible field names for color for (const v of candidates) {
const color = (item.body_background_color ?? item.bodyBackgroundColor ?? item.headerBackgroundColor ?? '#1e3a8a').toString(); amount = toText(v);
if (amount) break;
}
// Fallback deep scan for a purchase/amount text-like field
if (!amount) {
try {
const stack: any[] = [item];
const seen = new Set<any>();
while (stack.length) {
const cur = stack.pop();
if (!cur || typeof cur !== 'object' || seen.has(cur)) continue;
seen.add(cur);
for (const [k, v] of Object.entries(cur)) {
if (/purchase.*amount.*text|purchaseAmountText|amountText|purchase_amount_text/i.test(k)) {
amount = toText(v);
if (amount) throw new Error('_found');
}
if (v && typeof v === 'object') stack.push(v);
}
}
} catch (e: any) {
if (e?.message !== '_found') throw e;
}
}
// Last resort: search any string-like leaf for a currency pattern
if (!amount) {
const currencyPattern = /([€$£¥₹]|AUD|USD|EUR|GBP|JPY|INR)\s?\d{1,3}(?:[\,\s]?\d{3})*(?:[\.,]\d{1,2})?/i;
const stack: any[] = [item];
const seen = new Set<any>();
while (stack.length) {
const cur = stack.pop();
if (!cur || typeof cur !== 'object' || seen.has(cur)) continue;
seen.add(cur);
for (const v of Object.values(cur)) {
if (typeof v === 'string') {
const m = v.match(currencyPattern);
if (m) { amount = m[0]; stack.length = 0; break; }
} else if (v && typeof v === 'object') {
stack.push(v);
}
}
}
}
// Normalize color; Innertube often provides ARGB/number
let rawColor = item.body_background_color ?? item.bodyBackgroundColor ?? item.headerBackgroundColor;
let color = '#1e3a8a';
if (rawColor != null) {
if (typeof rawColor === 'number') {
const rgb = (rawColor & 0x00ffffff).toString(16).padStart(6, '0');
color = `#${rgb}`;
} else {
const s = String(rawColor);
color = s.startsWith('#') ? s : `#${s}`;
}
}
return { return {
amount: amount || 'Super Chat', amount: amount || 'Super Chat',
+113 -87
View File
@@ -59,98 +59,121 @@ export default function DashboardPage() {
return ( return (
<main className="dashboard"> <main className="dashboard">
<header className="dashboard__header"> {!connected && (
<div className="dashboard__title"> <div className="connectionPrompt">
<h1>🎬 Live Chat Monitor</h1> <div className="connectionPrompt__content">
<p className="muted">Select a message to display on your OBS overlay</p> <h2>Connect to YouTube Live Stream</h2>
</div> <form className="connectionPrompt__form" onSubmit={(e) => {
<div className="dashboard__connect"> e.preventDefault();
<ConnectionControl const formData = new FormData(e.currentTarget);
inline const liveId = formData.get('liveId') as string;
connected={connected} if (liveId.trim()) {
liveId={liveId} connect(liveId.trim());
connecting={connecting} }
onConnect={connect} }}>
onDisconnect={disconnect} <input
/> name="liveId"
</div> type="text"
<div className="dashboard__status"> placeholder="Enter YouTube Live Stream ID or URL"
<span className={`status status--${overlayStatus}`}>{statusHint}</span> disabled={connecting}
<span className="message-count">{messages.length} messages</span> autoFocus
</div> />
</header> <button type="submit" disabled={connecting}>
{connecting ? 'Connecting...' : 'Connect'}
<section className="dashboard__grid">
<div className="panel panel--chat">
<div className="panel__header">
<h2>💬 Live Chat</h2>
{selection && (
<button className="btn-clear" onClick={handleClear}>
Clear Selection
</button> </button>
)} </form>
</div>
<div className="chatList">
{regularMessages.map((message) => (
<ChatItem
key={message.id}
message={message}
isSelected={selection?.id === message.id}
onSelect={() => handleSelect(message)}
/>
))}
{regularMessages.length === 0 && (
<div className="chatList__empty">
<p> Waiting for chat messages...</p>
</div>
)}
</div> </div>
</div> </div>
)}
<div className="panel panel--super"> {connected && (
<div className="panel__header"> <>
<h2>💰 Super Chats</h2> <header className="dashboard__tabs">
<span className="badge badge--count">{superChats.length}</span> <div className="tab">
</div> Messages <span className="tab__count">{regularMessages.length}</span>
<div className="chatList"> </div>
{superChats.map((message) => ( <div className="tab">
<ChatItem Superchats <span className="tab__count">{superChats.length}</span>
key={message.id} </div>
message={message} <div className="tab">
isSelected={selection?.id === message.id} Members <span className="tab__count">{newMembers.length}</span>
onSelect={() => handleSelect(message)} </div>
/> </header>
))}
{superChats.length === 0 && (
<div className="chatList__empty">
<p>No super chats yet</p>
</div>
)}
</div>
</div>
<div className="panel panel--members"> <section className="dashboard__grid">
<div className="panel__header"> <div className="panel panel--chat">
<h2> New Members</h2> <div className="panel__header">
<span className="badge badge--count">{newMembers.length}</span> <h2>💬 CHAT MESSAGES</h2>
</div>
<div className="chatList">
{newMembers.map((message) => (
<MemberItem
key={message.id}
message={message}
isSelected={selection?.id === message.id}
onSelect={() => handleSelect(message)}
/>
))}
{newMembers.length === 0 && (
<div className="chatList__empty">
<p>No new members yet</p>
</div> </div>
)} <div className="chatList">
</div> {regularMessages.map((message) => (
</div> <ChatItem
</section> key={message.id}
message={message}
isSelected={selection?.id === message.id}
onSelect={() => handleSelect(message)}
/>
))}
{regularMessages.length === 0 && (
<div className="chatList__empty">
<p> Waiting for chat messages...</p>
</div>
)}
</div>
</div>
<div className="dashboard__grid-right">
<div className="panel panel--super">
<div className="panel__header">
<h2>🔥 SUPER CHATS</h2>
</div>
<div className="chatList">
{superChats.map((message) => (
<ChatItem
key={message.id}
message={message}
isSelected={selection?.id === message.id}
onSelect={() => handleSelect(message)}
/>
))}
{superChats.length === 0 && (
<div className="chatList__empty">
<p>No super chats yet</p>
</div>
)}
</div>
</div>
<div className="panel panel--members">
<div className="panel__header">
<h2> MEMBERSHIPS & MILESTONES</h2>
</div>
<div className="chatList">
{newMembers.map((message) => (
<MemberItem
key={message.id}
message={message}
isSelected={selection?.id === message.id}
onSelect={() => handleSelect(message)}
/>
))}
{newMembers.length === 0 && (
<div className="chatList__empty">
<p>No new members yet</p>
</div>
)}
</div>
</div>
</div>
</section>
{selection && (
<button className="btn-clear-fixed" onClick={handleClear}>
CLEAR
</button>
)}
</>
)}
</main> </main>
); );
} }
@@ -369,6 +392,9 @@ function ChatItem({ message, isSelected, onSelect }: ChatItemProps) {
)} )}
<div className="chatItem__meta"> <div className="chatItem__meta">
<div className="chatItem__authorLine"> <div className="chatItem__authorLine">
{message.membershipLevel && (
<span className="chatItem__membership">{message.membershipLevel}:</span>
)}
<span className="chatItem__author">{message.author}</span> <span className="chatItem__author">{message.author}</span>
{message.badges && message.badges.map((badge, i) => ( {message.badges && message.badges.map((badge, i) => (
<span key={i} className={`badge badge--${badge.type}`} title={badge.label}> <span key={i} className={`badge badge--${badge.type}`} title={badge.label}>
@@ -383,7 +409,7 @@ function ChatItem({ message, isSelected, onSelect }: ChatItemProps) {
</div> </div>
{message.superChat && ( {message.superChat && (
<div className="chatItem__superchat" style={{ backgroundColor: message.superChat.color }}> <div className="chatItem__superchat" style={{ backgroundColor: message.superChat.color }}>
💰 {message.superChat.amount} 💰 {message.superChat.amount} {message.superChat.currency}
</div> </div>
)} )}
<p className="chatItem__text">{message.text}</p> <p className="chatItem__text">{message.text}</p>
+207 -113
View File
@@ -5,7 +5,7 @@
body { body {
margin: 0; margin: 0;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #050608; background: #0a0a0a;
color: #f4f4f5; color: #f4f4f5;
min-height: 100vh; min-height: 100vh;
} }
@@ -82,43 +82,109 @@ main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100vh;
overflow-y: auto; overflow-y: hidden;
overflow-x: hidden; overflow-x: hidden;
padding-bottom: 2rem; background: #0a0a0a;
background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%);
} }
.dashboard__header { .connectionPrompt {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6rem clamp(0.75rem, 2.2vw, 1.25rem);
background: rgba(15, 23, 42, 0.6);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
}
.dashboard__title h1 {
font-size: 1.4rem;
margin: 0;
background: linear-gradient(135deg, #60a5fa, #a78bfa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.dashboard__title .muted { font-size: 0.8rem; margin-top: 0.25rem; }
.dashboard__status {
display: flex;
align-items: center;
gap: 0.5rem;
}
.dashboard__connect {
flex: 1;
display: flex; display: flex;
justify-content: center; justify-content: center;
padding: 0 0.5rem; align-items: center;
min-height: 100vh;
padding: 2rem;
}
.connectionPrompt__content {
max-width: 500px;
width: 100%;
}
.connectionPrompt__content h2 {
font-size: 1.5rem;
margin-bottom: 1.5rem;
color: #e5e7eb;
text-align: center;
}
.connectionPrompt__form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.connectionPrompt__form input {
padding: 0.875rem 1rem;
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 6px;
color: #e5e7eb;
font-size: 0.95rem;
transition: border-color 150ms ease;
}
.connectionPrompt__form input:focus {
outline: none;
border-color: #60a5fa;
}
.connectionPrompt__form input::placeholder {
color: #6b7280;
}
.connectionPrompt__form button {
padding: 0.875rem 1rem;
background: #60a5fa;
border: none;
border-radius: 6px;
color: #fff;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 150ms ease;
}
.connectionPrompt__form button:hover:not(:disabled) {
background: #3b82f6;
}
.connectionPrompt__form button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.dashboard__tabs {
display: flex;
justify-content: space-around;
align-items: center;
background: #1a1a1a;
border-bottom: 1px solid #2a2a2a;
padding: 0.3rem 0.5rem;
}
.tab {
flex: 1;
text-align: center;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
color: #9ca3af;
background: #141414;
border-radius: 6px;
margin: 0 0.2rem;
cursor: pointer;
transition: all 200ms ease;
}
.tab:hover {
background: #1f1f1f;
color: #d1d5db;
}
.tab__count {
color: #60a5fa;
font-weight: 700;
margin-left: 0.25rem;
} }
.message-count { .message-count {
@@ -132,57 +198,53 @@ main {
.dashboard__grid { .dashboard__grid {
flex: 1; flex: 1;
display: grid; display: flex;
grid-template-columns: minmax(420px, 2.1fr) minmax(340px, 1.5fr); gap: 0;
justify-content: center; padding: 0;
align-items: stretch;
column-gap: 0.1rem;
row-gap: 3rem;
align-content: start;
padding: 1rem clamp(1rem, 3vw, 3rem);
width: 100%; width: 100%;
max-width: 1400px;
margin: 0 auto;
min-height: 0; min-height: 0;
overflow: visible; height: 100%;
overflow: hidden;
}
.dashboard__grid-right {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
} }
.panel--chat { .panel--chat {
grid-column: 1; flex: 2;
grid-row: 1 / span 2; border-right: 1px solid #2a2a2a;
min-width: 0;
} }
.panel--super { .panel--super {
grid-column: 2; flex: 0 1 auto;
grid-row: 1; border-bottom: 1px solid #2a2a2a;
min-height: 200px;
max-height: 40%;
} }
.panel--members { .panel--members {
grid-column: 2; flex: 1;
grid-row: 2; min-height: 200px;
margin-top: 0rem;
}
@media (max-width: 1180px) {
.dashboard__grid {
grid-template-columns: minmax(360px, 1.8fr) minmax(300px, 1fr);
column-gap: 0.5rem;
row-gap: 0.75rem;
}
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
.dashboard__grid { .dashboard__grid {
grid-template-columns: minmax(0, 1fr); flex-direction: column;
max-width: 760px;
} }
.panel--chat, .panel--chat {
.panel--super, border-right: none;
.panel--members { border-bottom: 1px solid #2a2a2a;
grid-column: auto; }
grid-row: auto;
margin-top: 0; .dashboard__grid-right {
flex-direction: column;
} }
} }
@@ -365,38 +427,39 @@ main {
.panel { .panel {
background: rgba(30, 41, 59, 0.4); background: #141414;
border: 1px solid rgba(255, 255, 255, 0.14); border: none;
border-radius: 16px; border-radius: 0;
padding: 1rem; padding: 1rem 1.25rem;
backdrop-filter: blur(10px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
min-width: 0; min-width: 0;
max-width: 100%; max-width: 100%;
height: 100%; height: 100%;
margin-bottom: 0.5rem;
overflow: hidden; overflow: hidden;
} }
.panel__header { .panel__header {
display: flex; display: flex;
justify-content: space-between; justify-content: flex-start;
align-items: center; align-items: center;
margin-bottom: 1rem; margin-bottom: 0.875rem;
padding-bottom: 0.75rem; padding-bottom: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1); border-bottom: none;
flex-shrink: 0; flex-shrink: 0;
} }
.panel__header h2 { .panel__header h2 {
margin: 0; margin: 0;
font-size: 1.2rem; font-size: 0.8125rem;
color: #e2e8f0; color: #9ca3af;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.4rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
} }
.badge--count { .badge--count {
@@ -424,10 +487,34 @@ main {
transform: translateY(-1px); transform: translateY(-1px);
} }
.btn-clear-fixed {
position: fixed;
bottom: 2rem;
right: 2rem;
padding: 0.75rem 2rem;
background: #2a2a2a;
color: #e5e7eb;
border: 1px solid #3a3a3a;
border-radius: 6px;
font-weight: 600;
font-size: 0.9rem;
letter-spacing: 0.5px;
cursor: pointer;
transition: all 200ms ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
z-index: 100;
}
.btn-clear-fixed:hover {
background: #3a3a3a;
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.6);
}
.chatList { .chatList {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.375rem;
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
@@ -436,21 +523,21 @@ main {
} }
.chatList::-webkit-scrollbar { .chatList::-webkit-scrollbar {
width: 8px; width: 6px;
} }
.chatList::-webkit-scrollbar-track { .chatList::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05); background: #0a0a0a;
border-radius: 10px; border-radius: 10px;
} }
.chatList::-webkit-scrollbar-thumb { .chatList::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2); background: #3a3a3a;
border-radius: 10px; border-radius: 10px;
} }
.chatList::-webkit-scrollbar-thumb:hover { .chatList::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3); background: #4a4a4a;
} }
.chatList__empty { .chatList__empty {
@@ -464,11 +551,11 @@ main {
.chatItem { .chatItem {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.375rem;
padding: 0.75rem 1rem; padding: 0.625rem 0.875rem;
border-radius: 10px; border-radius: 6px;
background: rgba(15, 23, 42, 0.6); background: #1f1f1f;
border: 1px solid rgba(255, 255, 255, 0.06); border: 1px solid #2a2a2a;
text-align: left; text-align: left;
transition: all 150ms ease; transition: all 150ms ease;
cursor: pointer; cursor: pointer;
@@ -476,15 +563,15 @@ main {
} }
.chatItem:hover { .chatItem:hover {
background: rgba(30, 41, 59, 0.8); background: #262626;
border-color: rgba(96, 165, 250, 0.3); border-color: #3a3a3a;
transform: translateX(4px); transform: translateX(2px);
} }
.chatItem--active { .chatItem--active {
background: rgba(59, 130, 246, 0.15); background: rgba(59, 130, 246, 0.15);
border-color: rgba(59, 130, 246, 0.5); border-color: rgba(59, 130, 246, 0.5);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
} }
.chatItem__header { .chatItem__header {
@@ -516,9 +603,16 @@ main {
} }
.chatItem__author { .chatItem__author {
font-weight: 700; font-weight: 600;
font-size: 0.95rem; font-size: 0.9rem;
color: #60a5fa; color: #e5e7eb;
}
.chatItem__membership {
font-weight: 500;
font-size: 0.85rem;
color: #10b981;
font-style: italic;
} }
.chatItem__time { .chatItem__time {
@@ -529,10 +623,10 @@ main {
.chatItem__text { .chatItem__text {
margin: 0; margin: 0;
line-height: 1.5; line-height: 1.5;
color: #e2e8f0; color: #9ca3af;
word-break: break-word; word-break: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
font-size: 0.95rem; font-size: 0.875rem;
} }
.chatItem__superchat { .chatItem__superchat {
@@ -580,11 +674,11 @@ main {
.memberItem { .memberItem {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.375rem;
padding: 0.75rem 1rem; padding: 0.625rem 0.875rem;
border-radius: 10px; border-radius: 6px;
background: linear-gradient(135deg, rgba(16, 185, 129, 0.1), rgba(5, 150, 105, 0.1)); background: #1f1f1f;
border: 1px solid rgba(16, 185, 129, 0.2); border: 1px solid #2a2a2a;
text-align: left; text-align: left;
transition: all 150ms ease; transition: all 150ms ease;
cursor: pointer; cursor: pointer;
@@ -592,15 +686,15 @@ main {
} }
.memberItem:hover { .memberItem:hover {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2), rgba(5, 150, 105, 0.2)); background: #262626;
border-color: rgba(16, 185, 129, 0.4); border-color: #3a3a3a;
transform: translateX(4px); transform: translateX(2px);
} }
.memberItem--active { .memberItem--active {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.25), rgba(5, 150, 105, 0.25)); background: rgba(16, 185, 129, 0.15);
border-color: rgba(16, 185, 129, 0.6); border-color: rgba(16, 185, 129, 0.5);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1); box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
} }
.memberItem__header { .memberItem__header {
@@ -625,9 +719,9 @@ main {
} }
.memberItem__author { .memberItem__author {
font-weight: 700; font-weight: 600;
font-size: 0.95rem; font-size: 0.9rem;
color: #10b981; color: #e5e7eb;
} }
.memberItem__level { .memberItem__level {
+1 -1
View File
@@ -86,7 +86,7 @@ export default function OverlayPage() {
</div> </div>
{message.superChat && ( {message.superChat && (
<div className="overlay__superchat" style={{ backgroundColor: message.superChat.color }}> <div className="overlay__superchat" style={{ backgroundColor: message.superChat.color }}>
💰 {message.superChat.amount} 💰 {message.superChat.amount} {message.superChat.currency}
</div> </div>
)} )}
{message.membershipGift && ( {message.membershipGift && (
+7 -1
View File
@@ -2,7 +2,8 @@
## Current Focus ## Current Focus
- **Live YouTube integration active**: Backend connects to real YouTube Live chat via Innertube - **Live YouTube integration active**: Backend connects to real YouTube Live chat via Innertube
- **Enhanced UI**: Modern dashboard with centered layout, gradient backgrounds, and comprehensive chat features - **Dark minimalist UI redesign**: Clean dashboard with dark theme (#0a0a0a background), tab-based navigation, grid layout, and minimal spacing
- **Connection flow**: Shows connection prompt on startup, disappears after connecting to YouTube Live stream
- **Rich message parsing**: Full support for superchats, memberships, badges (moderator, member, verified) - **Rich message parsing**: Full support for superchats, memberships, badges (moderator, member, verified)
## Recent Decisions ## Recent Decisions
@@ -16,8 +17,13 @@
- Moved the connection control into the header (inline, centered between logo/title and status) with a compact input width - Moved the connection control into the header (inline, centered between logo/title and status) with a compact input width
- Fixed panel heights to fit within the viewport (no page scroll); grid rows split 1fr/1fr with internal lists scrolling so bottom edges are always visible - Fixed panel heights to fit within the viewport (no page scroll); grid rows split 1fr/1fr with internal lists scrolling so bottom edges are always visible
- Improved parsing so superchat amounts/colors show reliably; overlay and dashboard both display the amount - Improved parsing so superchat amounts/colors show reliably; overlay and dashboard both display the amount
- Strengthened superchat amount extraction with nested field handling and regex-based fallback; UI now shows amount + currency on overlay and dashboard
- Overlay membership banner now shows the membership level text - Overlay membership banner now shows the membership level text
- Membership gifts are recognized and included in the New Members list - Membership gifts are recognized and included in the New Members list
- Completely redesigned UI to match provided screenshot: minimal dark theme (#0a0a0a bg), compact tabs with counts, tight spacing throughout
- Added connection prompt screen that shows initially and disappears after successful YouTube Live stream connection
- Reduced all padding and gaps to create a more compact, space-efficient layout
- Fixed CLEAR button to appear in bottom-right corner only when a message is selected
## Immediate Next Steps ## Immediate Next Steps
1. Test with live YouTube stream to verify badge parsing and superchat detection 1. Test with live YouTube stream to verify badge parsing and superchat detection
+3
View File
@@ -30,7 +30,10 @@
"devDependencies": { "devDependencies": {
"@types/node": "^20.16.5", "@types/node": "^20.16.5",
"@types/react": "19.2.0", "@types/react": "19.2.0",
"autoprefixer": "^10.4.21",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.14",
"tsx": "^4.19.1", "tsx": "^4.19.1",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }