- Add Steam OpenID 2.0 authentication routes (login + callback) - Enable Steam button in LoginModal (was placeholder) - Unified user ID system: getUserId() supports Discord, Steam, Admin - Update soundboard user-sound endpoints for Steam users - UserSettings now works for both Discord and Steam providers - Steam hover uses brand color #66c0f4 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
492 lines
17 KiB
TypeScript
492 lines
17 KiB
TypeScript
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';
|
|
import LoginModal from './LoginModal';
|
|
import UserSettings from './UserSettings';
|
|
|
|
interface PluginInfo {
|
|
name: string;
|
|
version: string;
|
|
description: string;
|
|
}
|
|
|
|
interface AuthUser {
|
|
authenticated: boolean;
|
|
provider?: 'discord' | 'steam' | 'admin';
|
|
discordId?: string;
|
|
steamId?: string;
|
|
username?: string;
|
|
avatar?: string | null;
|
|
globalName?: string | null;
|
|
isAdmin?: boolean;
|
|
}
|
|
|
|
interface AuthProviders {
|
|
discord: boolean;
|
|
steam: boolean;
|
|
admin: boolean;
|
|
}
|
|
|
|
// 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>>({});
|
|
|
|
// ── Unified Auth State ──
|
|
const [user, setUser] = useState<AuthUser>({ authenticated: false });
|
|
const [providers, setProviders] = useState<AuthProviders>({ discord: false, steam: false, admin: false });
|
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
|
const [showUserSettings, setShowUserSettings] = useState(false);
|
|
const [showAdminPanel, setShowAdminPanel] = useState(false);
|
|
|
|
// Derived state
|
|
const isAdmin = user.authenticated && (user.provider === 'admin' || user.isAdmin === true);
|
|
const isDiscordUser = user.authenticated && user.provider === 'discord';
|
|
const isSteamUser = user.authenticated && user.provider === 'steam';
|
|
const isRegularUser = isDiscordUser || isSteamUser;
|
|
|
|
// 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 auth status + providers on mount
|
|
useEffect(() => {
|
|
fetch('/api/auth/me', { credentials: 'include' })
|
|
.then(r => r.json())
|
|
.then((data: AuthUser) => setUser(data))
|
|
.catch(() => {});
|
|
|
|
fetch('/api/auth/providers')
|
|
.then(r => r.json())
|
|
.then((data: AuthProviders) => setProviders(data))
|
|
.catch(() => {});
|
|
|
|
// Also check legacy admin cookie (backward compat)
|
|
fetch('/api/soundboard/admin/status', { credentials: 'include' })
|
|
.then(r => r.json())
|
|
.then(d => {
|
|
if (d.authenticated) {
|
|
setUser(prev => prev.authenticated ? prev : { authenticated: true, provider: 'admin', username: 'Admin', isAdmin: true });
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
// Admin login handler (for LoginModal)
|
|
async function handleAdminLogin(password: string): Promise<boolean> {
|
|
try {
|
|
const resp = await fetch('/api/auth/admin', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password }),
|
|
credentials: 'include',
|
|
});
|
|
if (resp.ok) {
|
|
setUser({ authenticated: true, provider: 'admin', username: 'Admin', isAdmin: true });
|
|
return true;
|
|
}
|
|
return false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Unified logout
|
|
async function handleLogout() {
|
|
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
|
|
setUser({ authenticated: false });
|
|
setShowUserSettings(false);
|
|
setShowAdminPanel(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}',
|
|
};
|
|
|
|
// What happens when the user button is clicked
|
|
function handleUserButtonClick() {
|
|
if (!user.authenticated) {
|
|
setShowLoginModal(true);
|
|
} else if (isAdmin) {
|
|
setShowAdminPanel(true);
|
|
} else if (isRegularUser) {
|
|
setShowUserSettings(true);
|
|
}
|
|
}
|
|
|
|
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>
|
|
)}
|
|
|
|
{/* Unified Login / User button */}
|
|
<button
|
|
className={`hub-user-btn ${user.authenticated ? 'logged-in' : ''} ${isAdmin ? 'admin' : ''}`}
|
|
onClick={handleUserButtonClick}
|
|
onContextMenu={e => {
|
|
if (user.authenticated) { e.preventDefault(); handleLogout(); }
|
|
}}
|
|
title={
|
|
user.authenticated
|
|
? `${user.globalName || user.username || 'Admin'} (Rechtsklick = Abmelden)`
|
|
: 'Anmelden'
|
|
}
|
|
>
|
|
{user.authenticated ? (
|
|
isRegularUser && user.avatar ? (
|
|
<img src={user.avatar} alt="" className="hub-user-avatar" />
|
|
) : (
|
|
<span className="hub-user-icon">{isAdmin ? '\uD83D\uDD27' : '\uD83D\uDC64'}</span>
|
|
)
|
|
) : (
|
|
<>
|
|
<span className="hub-user-icon">{'\uD83D\uDD12'}</span>
|
|
<span className="hub-user-label">Login</span>
|
|
</>
|
|
)}
|
|
</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>
|
|
)}
|
|
|
|
{/* Login Modal */}
|
|
{showLoginModal && (
|
|
<LoginModal
|
|
onClose={() => setShowLoginModal(false)}
|
|
onAdminLogin={handleAdminLogin}
|
|
providers={providers}
|
|
/>
|
|
)}
|
|
|
|
{/* User Settings (Discord + Steam users) */}
|
|
{showUserSettings && isRegularUser && (
|
|
<UserSettings
|
|
user={{
|
|
id: user.discordId || user.steamId || '',
|
|
provider: user.provider as 'discord' | 'steam',
|
|
username: user.username ?? '',
|
|
avatar: user.avatar ?? null,
|
|
globalName: user.globalName ?? null,
|
|
}}
|
|
onClose={() => setShowUserSettings(false)}
|
|
onLogout={handleLogout}
|
|
/>
|
|
)}
|
|
|
|
{/* Admin Panel */}
|
|
{showAdminPanel && isAdmin && (
|
|
<AdminPanel onClose={() => setShowAdminPanel(false)} onLogout={() => { handleLogout(); 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>
|
|
);
|
|
}
|