Rewrite dashboard and overlay, single /events stream, static client served by the backend

Backend: one SSE stream for messages, selection, poll and connection status; reconnect with backoff; no on-disk Innertube session cache; mock only with MOCK_CHAT=1; binds to 127.0.0.1; serves client/out so production runs on one port. Full URLs for links YouTube truncates in chat.

Client: components split out, Lucide icons, stream title and status, on-air strip, search, pause, keyboard shortcuts, overlay settings dialog. Overlay themes, position, size, animation and auto-hide via URL, sized for 1080p and scaled with the source. New blueprint theme for the brand background.

Removed Tailwind, better-sqlite3, zod, the timezone helpers, Cline memory bank and AGENTS.md. Added ESLint, node:test tests and CI.
This commit is contained in:
2026-09-02 22:05:58 +03:00
parent 19b412a82a
commit 6bcbd5400b
44 changed files with 6582 additions and 3395 deletions
+78
View File
@@ -0,0 +1,78 @@
'use client';
import { ArrowDown } from 'lucide-react';
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import type { ChatMessage } from '@shared/chat';
type Props = {
messages: ChatMessage[];
paused?: boolean;
render: (message: ChatMessage) => ReactNode;
empty: string;
};
const BOTTOM_THRESHOLD = 80;
/**
* Scroll container that follows new messages while the user is at the bottom.
* Scrolling up stops following and shows a jump button with the unseen count.
* While paused the list is frozen at its current contents.
*/
export function ChatList({ messages, paused = false, render, empty }: Props) {
const scroller = useRef<HTMLDivElement>(null);
const following = useRef(true);
const [unseen, setUnseen] = useState(0);
const [frozen, setFrozen] = useState<ChatMessage[] | null>(null);
useEffect(() => {
setFrozen(paused ? messages : null);
// Only the pause flag should snapshot; message updates while paused must not refresh the snapshot.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [paused]);
const shown = frozen ?? messages;
useLayoutEffect(() => {
const node = scroller.current;
if (!node) return;
if (following.current) {
node.scrollTop = node.scrollHeight;
setUnseen(0);
} else {
setUnseen((count) => count + 1);
}
}, [shown]);
const onScroll = () => {
const node = scroller.current;
if (!node) return;
const atBottom = node.scrollHeight - node.scrollTop - node.clientHeight <= BOTTOM_THRESHOLD;
following.current = atBottom;
if (atBottom) setUnseen(0);
};
const jump = () => {
const node = scroller.current;
if (!node) return;
following.current = true;
node.scrollTop = node.scrollHeight;
setUnseen(0);
};
return (
<div className="list">
<div className="list__scroll" ref={scroller} onScroll={onScroll}>
{shown.length ? shown.map(render) : <p className="list__empty">{empty}</p>}
</div>
{unseen > 0 && !following.current && (
<button type="button" className="list__jump" onClick={jump}>
<ArrowDown size={14} />
{unseen} new
</button>
)}
{paused && frozen && messages.length > frozen.length && (
<span className="list__paused">Paused, {messages.length - frozen.length} waiting</span>
)}
</div>
);
}