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
-43
View File
@@ -1,43 +0,0 @@
'use client';
import React, { createContext, useContext, useState, useEffect } from 'react';
import { getBrowserTimezone } from './timezone';
interface TimezoneContextType {
timezone: string;
setTimezone: (timezone: string) => void;
}
const TimezoneContext = createContext<TimezoneContextType | undefined>(undefined);
export function TimezoneProvider({ children }: { children: React.ReactNode }) {
// Initialize with detected timezone immediately to avoid empty string
const [timezone, setTimezone] = useState<string>(() => {
try {
return getBrowserTimezone();
} catch (error) {
console.warn('Failed to detect timezone on initialization:', error);
return 'UTC';
}
});
useEffect(() => {
// Re-detect browser timezone on mount (in case it changed)
const detectedTimezone = getBrowserTimezone();
setTimezone(detectedTimezone);
}, []);
return (
<TimezoneContext.Provider value={{ timezone, setTimezone }}>
{children}
</TimezoneContext.Provider>
);
}
export function useTimezone() {
const context = useContext(TimezoneContext);
if (context === undefined) {
throw new Error('useTimezone must be used within a TimezoneProvider');
}
return context;
}
+29
View File
@@ -0,0 +1,29 @@
import { BACKEND_URL } from './config';
async function call<T = unknown>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${BACKEND_URL}${path}`, init);
if (!response.ok) {
let message = `HTTP ${response.status}`;
try {
const body = await response.json();
if (body?.error) message = String(body.error);
} catch {
// body was not JSON
}
throw new Error(message);
}
return response.json() as Promise<T>;
}
const json = (body: unknown): RequestInit => ({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
export const api = {
connect: (liveId: string) => call<{ ok: true; liveId: string }>('/chat/connect', json({ liveId })),
disconnect: () => call('/chat/disconnect', { method: 'POST' }),
select: (id: string) => call('/overlay/selection', json({ id })),
clearSelection: () => call('/overlay/selection', { method: 'DELETE' })
};
+2
View File
@@ -0,0 +1,2 @@
/** Backend origin. Empty means same origin, which is the case when the backend serves the built client. */
export const BACKEND_URL = (process.env.NEXT_PUBLIC_BACKEND_URL ?? '').replace(/\/$/, '');
+27
View File
@@ -0,0 +1,27 @@
export function formatTime(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
const URL_PATTERN = /(https?:\/\/[^\s<>"']+)/gi;
export type TextPart = { type: 'text' | 'link'; content: string };
/** Splits plain text into text and link parts. Used only for runs YouTube did not mark as links. */
export function splitLinks(text: string): TextPart[] {
const parts: TextPart[] = [];
let last = 0;
for (const match of text.matchAll(URL_PATTERN)) {
const index = match.index ?? 0;
if (index > last) parts.push({ type: 'text', content: text.slice(last, index) });
parts.push({ type: 'link', content: match[0] });
last = index + match[0].length;
}
if (last < text.length) parts.push({ type: 'text', content: text.slice(last) });
return parts;
}
export function formatAmount(amount: string, currency: string): string {
return currency ? `${currency} ${amount}` : amount;
}
+6 -16
View File
@@ -1,27 +1,17 @@
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4100';
import { BACKEND_URL } from './config';
/**
* Converts a YouTube CDN image URL to use our backend proxy
* This prevents 429 rate limit errors from YouTube's CDN
*/
const PROXIED_HOSTS = new Set(['yt3.ggpht.com', 'yt4.ggpht.com', 'i.ytimg.com', 'lh3.googleusercontent.com']);
/** Routes YouTube CDN images through the backend cache so OBS and the dashboard do not hit CDN rate limits. */
export function proxyImageUrl(url: string | undefined): string | undefined {
if (!url) return undefined;
// Check if it's a YouTube CDN URL
const youtubeCdnDomains = ['yt3.ggpht.com', 'yt4.ggpht.com', 'i.ytimg.com'];
try {
const urlObj = new URL(url);
if (youtubeCdnDomains.includes(urlObj.hostname)) {
// Proxy through our backend
const parsed = new URL(url);
if (PROXIED_HOSTS.has(parsed.hostname)) {
return `${BACKEND_URL}/proxy/image?url=${encodeURIComponent(url)}`;
}
} catch {
// If URL parsing fails, return as-is
return url;
}
// Return non-YouTube URLs as-is
return url;
}
+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}` : ''}`;
}
-98
View File
@@ -1,98 +0,0 @@
/**
* Timezone utilities for handling browser timezone detection and formatting
*/
export function getBrowserTimezone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch (error) {
console.warn('Failed to detect browser timezone, falling back to UTC:', error);
return 'UTC';
}
}
export function formatTimestamp(
isoString: string,
timezone?: string,
options?: Intl.DateTimeFormatOptions
): string {
const date = new Date(isoString);
if (!date || isNaN(date.getTime())) {
return 'Invalid date';
}
const defaultOptions: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
...options
};
const targetTimezone = timezone || getBrowserTimezone();
// If no valid timezone, fall back to local time
if (!targetTimezone || targetTimezone === '') {
console.warn('No valid timezone provided, using local time');
return date.toLocaleTimeString('en-US', defaultOptions);
}
try {
const result = new Intl.DateTimeFormat('en-US', {
...defaultOptions,
timeZone: targetTimezone
}).format(date);
return result;
} catch (error) {
console.warn('Failed to format timestamp with timezone, falling back to local:', error);
// Try GMT+3 as a fallback if the detected timezone fails
try {
const gmt3Result = new Intl.DateTimeFormat('en-US', {
...defaultOptions,
timeZone: 'Europe/Istanbul' // GMT+3
}).format(date);
return gmt3Result;
} catch (gmt3Error) {
console.warn('GMT+3 fallback also failed:', gmt3Error);
return date.toLocaleTimeString('en-US', defaultOptions);
}
}
}
export function formatTimestampWithDate(
isoString: string,
timezone?: string,
options?: Intl.DateTimeFormatOptions
): string {
const date = new Date(isoString);
if (!date || isNaN(date.getTime())) {
return 'Invalid date';
}
const defaultOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
...options
};
const targetTimezone = timezone || getBrowserTimezone();
try {
return new Intl.DateTimeFormat('en-US', {
...defaultOptions,
timeZone: targetTimezone
}).format(date);
} catch (error) {
console.warn('Failed to format timestamp with timezone, falling back to local:', error);
return date.toLocaleString('en-US', defaultOptions);
}
}
+91
View File
@@ -0,0 +1,91 @@
'use client';
import { useEffect, useReducer } from 'react';
import type { ChatMessage, ConnectionStatus, Poll, ServerEvent, ServerEventType } from '@shared/chat';
import { BACKEND_URL } from './config';
export type StreamState = 'connecting' | 'open' | 'error';
export type EventsState = {
stream: StreamState;
status: ConnectionStatus;
messages: ChatMessage[];
selection: ChatMessage | null;
poll: Poll | null;
};
const MAX_REGULAR = 200;
const EVENT_TYPES: ServerEventType[] = ['init', 'message', 'selection', 'poll', 'status', 'clear'];
const initialState: EventsState = {
stream: 'connecting',
status: { state: 'disconnected', liveId: null },
messages: [],
selection: null,
poll: null
};
export function isSpecial(message: ChatMessage): boolean {
return !!(message.superChat || message.membershipGift || message.membershipGiftPurchase);
}
/** Appends a message and drops the oldest regular ones past the cap. Special messages are always kept. */
function append(list: ChatMessage[], message: ChatMessage): ChatMessage[] {
const next = [...list, message];
let regular = 0;
for (const item of next) if (!isSpecial(item)) regular += 1;
let drop = regular - MAX_REGULAR;
if (drop <= 0) return next;
return next.filter((item) => {
if (drop > 0 && !isSpecial(item)) {
drop -= 1;
return false;
}
return true;
});
}
type Action = ServerEvent | { type: 'stream'; stream: StreamState };
function reducer(state: EventsState, action: Action): EventsState {
switch (action.type) {
case 'stream':
return { ...state, stream: action.stream };
case 'init':
return { ...state, status: action.status, messages: action.messages, selection: action.selection, poll: action.poll };
case 'message':
return { ...state, messages: append(state.messages, action.message) };
case 'selection':
return { ...state, selection: action.message };
case 'poll':
return { ...state, poll: action.poll };
case 'status':
return { ...state, status: action.status };
case 'clear':
return { ...state, messages: [], selection: null, poll: null };
default:
return state;
}
}
/** Single SSE subscription to the backend. EventSource reconnects on its own and the server replays state on `init`. */
export function useEvents(): EventsState {
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
const source = new EventSource(`${BACKEND_URL}/events`);
const onEvent = (event: Event) => {
try {
dispatch(JSON.parse((event as MessageEvent).data));
} catch (error) {
console.error('Bad event payload', error);
}
};
for (const type of EVENT_TYPES) source.addEventListener(type, onEvent);
source.onopen = () => dispatch({ type: 'stream', stream: 'open' });
source.onerror = () => dispatch({ type: 'stream', stream: 'error' });
return () => source.close();
}, []);
return state;
}