gaming-hub/web/src/App.tsx

432 lines
16 KiB
TypeScript
Raw Normal View History

import { useState, useEffect, useRef } from 'react';
import RadioTab from './plugins/radio/RadioTab';
import SoundboardTab from './plugins/soundboard/SoundboardTab';
import LolstatsTab from './plugins/lolstats/LolstatsTab';
import StreamingTab from './plugins/streaming/StreamingTab';
import WatchTogetherTab from './plugins/watch-together/WatchTogetherTab';
import GameLibraryTab from './plugins/game-library/GameLibraryTab';
import AdminPanel from './AdminPanel';
interface PluginInfo {
name: string;
version: string;
description: string;
}
// Plugin tab components
const tabComponents: Record<string, React.FC<{ data: any; isAdmin?: boolean }>> = {
radio: RadioTab,
soundboard: SoundboardTab,
lolstats: LolstatsTab,
streaming: StreamingTab,
'watch-together': WatchTogetherTab,
'game-library': GameLibraryTab,
};
export function registerTab(pluginName: string, component: React.FC<{ data: any; isAdmin?: boolean }>) {
tabComponents[pluginName] = component;
}
type UpdateStatus = 'idle' | 'checking' | 'available' | 'downloading' | 'ready' | 'upToDate' | 'error';
export default function App() {
const [connected, setConnected] = useState(false);
const [plugins, setPlugins] = useState<PluginInfo[]>([]);
const [activeTab, setActiveTabRaw] = useState<string>(() => localStorage.getItem('hub_activeTab') ?? '');
const setActiveTab = (tab: string) => {
setActiveTabRaw(tab);
localStorage.setItem('hub_activeTab', tab);
};
const [showVersionModal, setShowVersionModal] = useState(false);
const [pluginData, setPluginData] = useState<Record<string, any>>({});
// Centralized admin login state
const [isAdmin, setIsAdmin] = useState(false);
const [showAdminLogin, setShowAdminLogin] = useState(false);
const [showAdminPanel, setShowAdminPanel] = useState(false);
const [adminPwd, setAdminPwd] = useState('');
const [adminError, setAdminError] = useState('');
// Electron auto-update state
const isElectron = !!(window as any).electronAPI?.isElectron;
const electronVersion = isElectron ? (window as any).electronAPI.version : null;
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
const [updateError, setUpdateError] = useState<string>('');
const eventSourceRef = useRef<EventSource | null>(null);
// Request notification permission
useEffect(() => {
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission();
}
}, []);
// Check admin status on mount (shared cookie — any endpoint works)
useEffect(() => {
fetch('/api/soundboard/admin/status', { credentials: 'include' })
.then(r => r.json())
.then(d => setIsAdmin(!!d.authenticated))
.catch(() => {});
}, []);
// Escape key closes admin login modal
useEffect(() => {
if (!showAdminLogin) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setShowAdminLogin(false); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [showAdminLogin]);
async function handleAdminLogin() {
setAdminError('');
try {
const resp = await fetch('/api/soundboard/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: adminPwd }),
credentials: 'include',
});
if (resp.ok) {
setIsAdmin(true);
setAdminPwd('');
setShowAdminLogin(false);
} else {
setAdminError('Falsches Passwort');
}
} catch {
setAdminError('Verbindung fehlgeschlagen');
}
}
async function handleAdminLogout() {
await fetch('/api/soundboard/admin/logout', { method: 'POST', credentials: 'include' });
setIsAdmin(false);
}
// Electron auto-update listeners
useEffect(() => {
if (!isElectron) return;
const api = (window as any).electronAPI;
api.onUpdateAvailable(() => setUpdateStatus('downloading'));
api.onUpdateReady(() => setUpdateStatus('ready'));
api.onUpdateNotAvailable(() => setUpdateStatus('upToDate'));
api.onUpdateError((msg: string) => {
setUpdateStatus('error');
setUpdateError(msg || 'Unbekannter Fehler');
});
// Sync initial status (Hintergrund-Download könnte bereits laufen)
const currentState = api.getUpdateStatus?.();
if (currentState === 'downloading') setUpdateStatus('downloading');
else if (currentState === 'ready') setUpdateStatus('ready');
else if (currentState === 'checking') setUpdateStatus('checking');
}, [isElectron]);
// Fetch plugin list
useEffect(() => {
fetch('/api/plugins')
.then(r => r.json())
.then((list: PluginInfo[]) => {
setPlugins(list);
// If ?viewStream= is in URL, force switch to streaming tab
const urlParams = new URLSearchParams(location.search);
if (urlParams.has('viewStream') && list.some(p => p.name === 'streaming')) {
setActiveTab('streaming');
return;
}
const saved = localStorage.getItem('hub_activeTab');
const valid = list.some(p => p.name === saved);
if (list.length > 0 && !valid) setActiveTab(list[0].name);
})
.catch(() => {});
}, []);
// SSE connection
useEffect(() => {
let es: EventSource | null = null;
let retryTimer: ReturnType<typeof setTimeout>;
function connect() {
es = new EventSource('/api/events');
eventSourceRef.current = es;
es.onopen = () => setConnected(true);
es.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === 'snapshot') {
setPluginData(prev => ({ ...prev, ...msg }));
} else if (msg.plugin) {
setPluginData(prev => ({
...prev,
[msg.plugin]: { ...(prev[msg.plugin] || {}), ...msg },
}));
}
} catch {}
};
es.onerror = () => {
setConnected(false);
es?.close();
retryTimer = setTimeout(connect, 3000);
};
}
connect();
return () => { es?.close(); clearTimeout(retryTimer); };
}, []);
const version = (import.meta as any).env?.VITE_APP_VERSION ?? 'dev';
// Close version modal on Escape
useEffect(() => {
if (!showVersionModal) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setShowVersionModal(false); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [showVersionModal]);
// Tab icon mapping
const tabIcons: Record<string, string> = {
radio: '\u{1F30D}',
soundboard: '\u{1F3B5}',
lolstats: '\u{2694}\uFE0F',
stats: '\u{1F4CA}',
events: '\u{1F4C5}',
games: '\u{1F3B2}',
gamevote: '\u{1F3AE}',
streaming: '\u{1F4FA}',
'watch-together': '\u{1F3AC}',
'game-library': '\u{1F3AE}',
};
return (
<div className="hub-app">
<header className="hub-header">
<div className="hub-header-left">
<span className="hub-logo">{'\u{1F3AE}'}</span>
<span className="hub-title">Gaming Hub</span>
<span className={`hub-conn-dot ${connected ? 'online' : ''}`} />
</div>
<nav className="hub-tabs">
{plugins.filter(p => p.name in tabComponents).map(p => (
<button
key={p.name}
className={`hub-tab ${activeTab === p.name ? 'active' : ''}`}
onClick={() => setActiveTab(p.name)}
title={p.description}
>
<span className="hub-tab-icon">{tabIcons[p.name] ?? '\u{1F4E6}'}</span>
<span className="hub-tab-label">{p.name}</span>
</button>
))}
</nav>
<div className="hub-header-right">
{!(window as any).electronAPI && (
<a
className="hub-download-btn"
href="/downloads/GamingHub-Setup.exe"
download
title="Desktop App herunterladen"
>
<span className="hub-download-icon">{'\u2B07\uFE0F'}</span>
<span className="hub-download-label">Desktop App</span>
</a>
)}
<button
className={`hub-admin-btn ${isAdmin ? 'active' : ''}`}
onClick={() => isAdmin ? setShowAdminPanel(true) : setShowAdminLogin(true)}
onContextMenu={e => { if (isAdmin) { e.preventDefault(); handleAdminLogout(); } }}
title={isAdmin ? 'Admin Panel (Rechtsklick = Abmelden)' : 'Admin Login'}
>
{isAdmin ? '\uD83D\uDD13' : '\uD83D\uDD12'}
</button>
<button
className="hub-refresh-btn"
onClick={() => window.location.reload()}
title="Seite neu laden"
>
{'\u{1F504}'}
</button>
<span
className="hub-version hub-version-clickable"
onClick={() => {
// Status vom Main-Prozess synchronisieren bevor Modal öffnet
if (isElectron) {
const api = (window as any).electronAPI;
const s = api.getUpdateStatus?.();
if (s === 'downloading') setUpdateStatus('downloading');
else if (s === 'ready') setUpdateStatus('ready');
else if (s === 'checking') setUpdateStatus('checking');
}
setShowVersionModal(true);
}}
title="Versionsinformationen"
>
v{version}
</span>
</div>
</header>
{showVersionModal && (
<div className="hub-version-overlay" onClick={() => setShowVersionModal(false)}>
<div className="hub-version-modal" onClick={e => e.stopPropagation()}>
<div className="hub-version-modal-header">
<span>Versionsinformationen</span>
<button className="hub-version-modal-close" onClick={() => setShowVersionModal(false)}>
{'\u2715'}
</button>
</div>
<div className="hub-version-modal-body">
<div className="hub-version-modal-row">
<span className="hub-version-modal-label">Hub-Version</span>
<span className="hub-version-modal-value">v{version}</span>
</div>
{isElectron && (
<div className="hub-version-modal-row">
<span className="hub-version-modal-label">Desktop-App</span>
<span className="hub-version-modal-value">v{electronVersion}</span>
</div>
)}
<div className="hub-version-modal-row">
<span className="hub-version-modal-label">Server</span>
<span className="hub-version-modal-value">
<span className={`hub-version-modal-dot ${connected ? 'online' : ''}`} />
{connected ? 'Verbunden' : 'Getrennt'}
</span>
</div>
{isElectron && (
<div className="hub-version-modal-update">
{updateStatus === 'idle' && (
<button
className="hub-version-modal-update-btn"
onClick={() => {
setUpdateStatus('checking');
setUpdateError('');
(window as any).electronAPI.checkForUpdates();
}}
>
{'\u{1F504}'} Nach Updates suchen
</button>
)}
{updateStatus === 'checking' && (
<div className="hub-version-modal-update-status">
<span className="hub-update-spinner" />
Suche nach Updates
</div>
)}
{updateStatus === 'downloading' && (
<div className="hub-version-modal-update-status">
<span className="hub-update-spinner" />
Update wird heruntergeladen
</div>
)}
{updateStatus === 'ready' && (
<button
className="hub-version-modal-update-btn ready"
onClick={() => (window as any).electronAPI.installUpdate()}
>
{'\u2705'} Jetzt installieren & neu starten
</button>
)}
{updateStatus === 'upToDate' && (
<div className="hub-version-modal-update-status success">
{'\u2705'} App ist aktuell
<button
className="hub-version-modal-update-retry"
onClick={() => setUpdateStatus('idle')}
>
Erneut prüfen
</button>
</div>
)}
{updateStatus === 'error' && (
<div className="hub-version-modal-update-status error">
{'\u274C'} {updateError}
<button
className="hub-version-modal-update-retry"
onClick={() => setUpdateStatus('idle')}
>
Erneut versuchen
</button>
</div>
)}
</div>
)}
</div>
</div>
</div>
)}
{showAdminLogin && (
<div className="hub-admin-overlay" onClick={() => setShowAdminLogin(false)}>
<div className="hub-admin-modal" onClick={e => e.stopPropagation()}>
<div className="hub-admin-modal-header">
<span>{'\uD83D\uDD12'} Admin Login</span>
<button className="hub-admin-modal-close" onClick={() => setShowAdminLogin(false)}>
{'\u2715'}
</button>
</div>
<div className="hub-admin-modal-body">
<input
type="password"
className="hub-admin-input"
placeholder="Admin-Passwort..."
value={adminPwd}
onChange={e => setAdminPwd(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAdminLogin()}
autoFocus
/>
{adminError && <p className="hub-admin-error">{adminError}</p>}
<button className="hub-admin-submit" onClick={handleAdminLogin}>
Login
</button>
</div>
</div>
</div>
)}
{showAdminPanel && isAdmin && (
<AdminPanel onClose={() => setShowAdminPanel(false)} onLogout={() => { handleAdminLogout(); setShowAdminPanel(false); }} />
)}
<main className="hub-content">
{plugins.length === 0 ? (
<div className="hub-empty">
<span className="hub-empty-icon">{'\u{1F4E6}'}</span>
<h2>Keine Plugins geladen</h2>
<p>Plugins werden im Server konfiguriert.</p>
</div>
) : (
/* Render ALL tabs, hide inactive ones to preserve state.
Active tab gets full dimensions; hidden tabs stay in DOM but invisible. */
plugins.map(p => {
const Comp = tabComponents[p.name];
if (!Comp) return null;
const isActive = activeTab === p.name;
return (
<div
key={p.name}
className={`hub-tab-panel ${isActive ? 'active' : ''}`}
style={isActive
? { display: 'flex', flexDirection: 'column', width: '100%', height: '100%' }
: { display: 'none' }
}
>
<Comp data={pluginData[p.name] || {}} isAdmin={isAdmin} />
</div>
);
})
)}
</main>
</div>
);
}