mirror of
https://github.com/yusufipk/YTChatHub.git
synced 2026-09-11 10:56:17 +00:00
refactor: migrate from pnpm workspaces to single-package structure with shared types
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ChatMessage } from '@shared/chat';
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4100';
|
||||
const POLL_INTERVAL = 2500;
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { messages, refresh, error: pollError } = useChatMessages();
|
||||
const { selection, status: overlayStatus } = useOverlaySelection();
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (message: ChatMessage) => {
|
||||
try {
|
||||
await fetch(`${BACKEND_URL}/overlay/selection`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: message.id })
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to select message', error);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleClear = useCallback(async () => {
|
||||
try {
|
||||
await fetch(`${BACKEND_URL}/overlay/selection`, { method: 'DELETE' });
|
||||
} catch (error) {
|
||||
console.error('Failed to clear selection', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
refresh();
|
||||
}, POLL_INTERVAL);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [refresh]);
|
||||
|
||||
const statusHint = useMemo(() => {
|
||||
if (pollError) return 'Backend unreachable';
|
||||
if (overlayStatus === 'connecting') return 'Connecting to overlay…';
|
||||
if (overlayStatus === 'error') return 'Overlay stream disconnected';
|
||||
return 'Live';
|
||||
}, [pollError, overlayStatus]);
|
||||
|
||||
return (
|
||||
<main className="dashboard">
|
||||
<header className="dashboard__header">
|
||||
<div>
|
||||
<h1>Operator Dashboard</h1>
|
||||
<p className="muted">Click a message to push it to the OBS overlay stream.</p>
|
||||
</div>
|
||||
<span className={`status status--${overlayStatus}`}>{statusHint}</span>
|
||||
</header>
|
||||
|
||||
<section className="dashboard__content">
|
||||
<article className="panel">
|
||||
<header className="panel__title">Live Chat</header>
|
||||
<div className="chatList">
|
||||
{messages.map((message) => (
|
||||
<button
|
||||
key={message.id}
|
||||
className={
|
||||
selection?.id === message.id ? 'chatItem chatItem--active' : 'chatItem'
|
||||
}
|
||||
onClick={() => handleSelect(message)}
|
||||
>
|
||||
<span className="chatItem__author">{message.author}</span>
|
||||
<span className="chatItem__text">{message.text}</span>
|
||||
<time>{new Date(message.publishedAt).toLocaleTimeString()}</time>
|
||||
</button>
|
||||
))}
|
||||
{messages.length === 0 && <p className="muted">Waiting for chat messages…</p>}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="panel overlayPreview">
|
||||
<header className="panel__title">Overlay Preview</header>
|
||||
{selection ? (
|
||||
<div className="overlayPreview__card">
|
||||
<span className="overlayPreview__author">{selection.author}</span>
|
||||
<p>{selection.text}</p>
|
||||
<time>{new Date(selection.publishedAt).toLocaleTimeString()}</time>
|
||||
<button className="secondary" onClick={handleClear}>
|
||||
Clear selection
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overlayPreview__empty">
|
||||
<p>No message selected yet.</p>
|
||||
<button className="secondary" onClick={handleClear}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function useChatMessages() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/chat/messages`);
|
||||
if (!response.ok) throw new Error(`Request failed: ${response.status}`);
|
||||
const data = await response.json();
|
||||
setMessages(Array.isArray(data.messages) ? data.messages : []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { messages, refresh, error };
|
||||
}
|
||||
|
||||
type OverlayStatus = 'connecting' | 'live' | 'error';
|
||||
|
||||
type SelectionPayload = {
|
||||
message: ChatMessage | null;
|
||||
};
|
||||
|
||||
function useOverlaySelection() {
|
||||
const [selection, setSelection] = useState<ChatMessage | null>(null);
|
||||
const [status, setStatus] = useState<OverlayStatus>('connecting');
|
||||
|
||||
useEffect(() => {
|
||||
const source = new EventSource(`${BACKEND_URL}/overlay/stream`);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
source.addEventListener('selection', onSelection as EventListener);
|
||||
source.addEventListener('heartbeat', () => setStatus('live'));
|
||||
source.onerror = () => setStatus('error');
|
||||
|
||||
return () => {
|
||||
source.removeEventListener('selection', onSelection as EventListener);
|
||||
source.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { selection, status };
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #050608;
|
||||
color: #f4f4f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
main {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.landing {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 4rem 1.5rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: rgba(21, 23, 28, 0.85);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
padding: 2.5rem;
|
||||
max-width: 720px;
|
||||
width: min(100%, 720px);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.panel__title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.primary,
|
||||
.secondary,
|
||||
.chatItem {
|
||||
font: inherit;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primary {
|
||||
display: inline-block;
|
||||
margin-top: 1.5rem;
|
||||
padding: 0.85rem 1.6rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, #ff3b30, #ff9500);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
transition: transform 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(255, 99, 71, 0.35);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
margin-top: 1rem;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #f4f4f5;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: rgba(244, 244, 245, 0.6);
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: 2rem clamp(1rem, 5vw, 3rem);
|
||||
}
|
||||
|
||||
.dashboard__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dashboard__content {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.dashboard__content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.chatList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chatItem {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 0.75rem;
|
||||
align-items: baseline;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
text-align: left;
|
||||
transition: background 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.chatItem:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.chatItem--active {
|
||||
outline: 2px solid rgba(255, 149, 0, 0.65);
|
||||
background: rgba(255, 149, 0, 0.12);
|
||||
}
|
||||
|
||||
.chatItem__author {
|
||||
font-weight: 600;
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.chatItem__text {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.chatItem time {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.overlayPreview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.overlayPreview__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 1.5rem;
|
||||
border-radius: 14px;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.overlayPreview__author {
|
||||
font-weight: 600;
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.overlayPreview__empty {
|
||||
min-height: 240px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 14px;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.overlay__card {
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 18px;
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
color: #f8fafc;
|
||||
max-width: 960px;
|
||||
width: min(90vw, 960px);
|
||||
box-shadow: 0 16px 60px rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
|
||||
.overlay__author {
|
||||
display: block;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.75rem;
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.overlay__text {
|
||||
font-size: clamp(1.4rem, 2.5vw, 2rem);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.overlay__placeholder {
|
||||
padding: 1rem 1.4rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status--connecting {
|
||||
background: rgba(125, 211, 252, 0.2);
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.status--live {
|
||||
background: rgba(74, 222, 128, 0.2);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.status--error {
|
||||
background: rgba(248, 113, 113, 0.2);
|
||||
color: #f87171;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import './globals.css';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export const metadata = {
|
||||
title: 'YouTube Chat Client',
|
||||
description: 'High-performance YouTube Live chat dashboard'
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ChatMessage } from '@shared/chat';
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4100';
|
||||
|
||||
type SelectionPayload = {
|
||||
message: ChatMessage | null;
|
||||
};
|
||||
|
||||
export default function OverlayPage() {
|
||||
const [message, setMessage] = useState<ChatMessage | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const source = new EventSource(`${BACKEND_URL}/overlay/stream`);
|
||||
const onSelection = (event: MessageEvent) => {
|
||||
try {
|
||||
const payload: SelectionPayload = JSON.parse(event.data);
|
||||
setMessage(payload.message);
|
||||
setConnected(true);
|
||||
} catch (error) {
|
||||
console.error('overlay: failed to parse payload', error);
|
||||
}
|
||||
};
|
||||
|
||||
source.addEventListener('selection', onSelection as EventListener);
|
||||
source.addEventListener('heartbeat', () => setConnected(true));
|
||||
source.onerror = () => setConnected(false);
|
||||
|
||||
return () => {
|
||||
source.removeEventListener('selection', onSelection as EventListener);
|
||||
source.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="overlay">
|
||||
{message ? (
|
||||
<div className="overlay__card">
|
||||
<span className="overlay__author">{message.author}</span>
|
||||
<p className="overlay__text">{message.text}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overlay__placeholder">
|
||||
<span>{connected ? 'Awaiting selection…' : 'Reconnecting…'}</span>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main className="landing">
|
||||
<section className="panel">
|
||||
<h1>YouTube Chat Client</h1>
|
||||
<p>
|
||||
Launch the dashboard to monitor live chat and control the overlay that feeds OBS.
|
||||
</p>
|
||||
<Link className="primary" href="/dashboard">
|
||||
Open Dashboard
|
||||
</Link>
|
||||
<p className="muted">Overlay preview lives at /overlay for the OBS browser source.</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,9 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
experimental: {
|
||||
serverActions: true
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "preserve",
|
||||
"types": [
|
||||
"next",
|
||||
"next/types/global",
|
||||
"next/image-types/global"
|
||||
],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"noEmit": true,
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"next-env.d.ts",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user