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
+48
View File
@@ -0,0 +1,48 @@
export const THEMES = ['dark', 'light', 'blueprint', 'glass', 'youtube'] as const;
export const POSITIONS = ['bl', 'bc', 'br', 'tl', 'tc', 'tr'] as const;
export const SIZES = ['s', 'm', 'l', 'xl'] as const;
export const ANIMATIONS = ['fade', 'slide', 'none'] as const;
export type OverlayOptions = {
theme: (typeof THEMES)[number];
pos: (typeof POSITIONS)[number];
size: (typeof SIZES)[number];
anim: (typeof ANIMATIONS)[number];
/** Seconds before the overlay hides the message by itself. 0 keeps it until cleared. */
hide: number;
/** Max card width in pixels. */
width: number;
};
export const DEFAULT_OPTIONS: OverlayOptions = { theme: 'dark', pos: 'bl', size: 'm', anim: 'fade', hide: 0, width: 640 };
function pick<T extends readonly string[]>(list: T, value: string | null, fallback: T[number]): T[number] {
return value && (list as readonly string[]).includes(value) ? (value as T[number]) : fallback;
}
export function parseOverlayOptions(search: string): OverlayOptions {
const params = new URLSearchParams(search);
const hide = Number(params.get('hide'));
const width = Number(params.get('w'));
return {
theme: pick(THEMES, params.get('theme'), DEFAULT_OPTIONS.theme),
pos: pick(POSITIONS, params.get('pos'), DEFAULT_OPTIONS.pos),
size: pick(SIZES, params.get('size'), DEFAULT_OPTIONS.size),
anim: pick(ANIMATIONS, params.get('anim'), DEFAULT_OPTIONS.anim),
hide: Number.isFinite(hide) && hide >= 0 ? Math.floor(hide) : DEFAULT_OPTIONS.hide,
width: Number.isFinite(width) && width >= 240 ? Math.floor(width) : DEFAULT_OPTIONS.width
};
}
/** Only non-default values go in the URL so the plain /overlay/ link keeps working. */
export function buildOverlayUrl(origin: string, options: OverlayOptions): string {
const params = new URLSearchParams();
if (options.theme !== DEFAULT_OPTIONS.theme) params.set('theme', options.theme);
if (options.pos !== DEFAULT_OPTIONS.pos) params.set('pos', options.pos);
if (options.size !== DEFAULT_OPTIONS.size) params.set('size', options.size);
if (options.anim !== DEFAULT_OPTIONS.anim) params.set('anim', options.anim);
if (options.hide !== DEFAULT_OPTIONS.hide) params.set('hide', String(options.hide));
if (options.width !== DEFAULT_OPTIONS.width) params.set('w', String(options.width));
const query = params.toString();
return `${origin}/overlay/${query ? `?${query}` : ''}`;
}