From ae1c41f0aedd22cf1f310da99b4d03b3cd6c27e5 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 5 Mar 2026 22:52:13 +0100 Subject: [PATCH] Initial commit: Gaming Hub foundation Plugin-based Discord bot framework with web frontend: - Core: Discord.js client, SSE broadcast, JSON persistence - Plugin system: lifecycle hooks (init, onReady, routes, snapshot, destroy) - Web: React 19 + Vite 6 + TypeScript, tab-based navigation - Docker: multi-stage build (Node 24, static ffmpeg, yt-dlp) - GitLab CI: Kaniko with LAN registry caching Ready for plugin development. --- .dockerignore | 6 + .gitlab-ci.yml | 63 ++++++ Dockerfile | 45 +++++ server/package.json | 24 +++ server/src/core/discord.ts | 14 ++ server/src/core/persistence.ts | 39 ++++ server/src/core/plugin.ts | 39 ++++ server/src/core/sse.ts | 22 +++ server/src/index.ts | 136 +++++++++++++ server/src/plugins/.gitkeep | 0 server/tsconfig.json | 19 ++ web/index.html | 13 ++ web/package.json | 21 ++ web/src/App.tsx | 129 ++++++++++++ web/src/main.tsx | 5 + web/src/styles.css | 350 +++++++++++++++++++++++++++++++++ web/src/vite-env.d.ts | 1 + web/tsconfig.json | 15 ++ web/vite.config.ts | 13 ++ 19 files changed, 954 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitlab-ci.yml create mode 100644 Dockerfile create mode 100644 server/package.json create mode 100644 server/src/core/discord.ts create mode 100644 server/src/core/persistence.ts create mode 100644 server/src/core/plugin.ts create mode 100644 server/src/core/sse.ts create mode 100644 server/src/index.ts create mode 100644 server/src/plugins/.gitkeep create mode 100644 server/tsconfig.json create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/src/App.tsx create mode 100644 web/src/main.tsx create mode 100644 web/src/styles.css create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9734831 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.git +*.md +.gitlab-ci.yml +server/dist +web/dist diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..7dca13e --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,63 @@ +stages: + - build + +variables: + INTERNAL_REGISTRY: "10.10.10.10:9080" + IMAGE_NAME: "$INTERNAL_REGISTRY/$CI_PROJECT_PATH" + CI_SERVER_URL: "http://10.10.10.10:9080" + GITLAB_FEATURES: "" + +docker-build: + stage: build + image: + name: gcr.io/kaniko-project/executor:v1.23.2-debug + entrypoint: [""] + rules: + - if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH + before_script: + - mkdir -p /kaniko/.docker + - | + cat > /kaniko/.docker/config.json < = {}; + +export function loadState(): void { + try { + if (fs.existsSync(stateFile)) { + state = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); + } + } catch (e) { + console.error('Failed to load state:', e); + } +} + +export function saveState(): void { + try { + fs.mkdirSync(path.dirname(stateFile), { recursive: true }); + fs.writeFileSync(stateFile, JSON.stringify(state, null, 2)); + } catch (e) { + console.error('Failed to save state:', e); + } +} + +export function getState(key: string, defaultValue?: T): T { + return (state[key] as T) ?? (defaultValue as T); +} + +export function setState(key: string, value: any): void { + state[key] = value; + saveState(); +} + +export function getFullState(): Record { + return { ...state }; +} diff --git a/server/src/core/plugin.ts b/server/src/core/plugin.ts new file mode 100644 index 0000000..8d8d2fe --- /dev/null +++ b/server/src/core/plugin.ts @@ -0,0 +1,39 @@ +import { Client } from 'discord.js'; +import express from 'express'; + +export interface Plugin { + name: string; + version: string; + description: string; + + /** Called once when plugin is loaded */ + init(ctx: PluginContext): Promise; + + /** Called when Discord client is ready */ + onReady?(ctx: PluginContext): Promise; + + /** Called to register Express routes */ + registerRoutes?(app: express.Application, ctx: PluginContext): void; + + /** Called to build SSE snapshot data for new clients */ + getSnapshot?(ctx: PluginContext): Record; + + /** Called on graceful shutdown */ + destroy?(ctx: PluginContext): Promise; +} + +export interface PluginContext { + client: Client; + dataDir: string; +} + +const loadedPlugins: Plugin[] = []; + +export function registerPlugin(plugin: Plugin): void { + loadedPlugins.push(plugin); + console.log(`[Plugin] Registered: ${plugin.name} v${plugin.version}`); +} + +export function getPlugins(): Plugin[] { + return [...loadedPlugins]; +} diff --git a/server/src/core/sse.ts b/server/src/core/sse.ts new file mode 100644 index 0000000..0875cd1 --- /dev/null +++ b/server/src/core/sse.ts @@ -0,0 +1,22 @@ +import { Response } from 'express'; + +const sseClients = new Set(); + +export function addSSEClient(res: Response): void { + sseClients.add(res); +} + +export function removeSSEClient(res: Response): void { + sseClients.delete(res); +} + +export function sseBroadcast(data: Record): void { + const msg = `data: ${JSON.stringify(data)}\n\n`; + for (const c of sseClients) { + try { c.write(msg); } catch { sseClients.delete(c); } + } +} + +export function getSSEClientCount(): number { + return sseClients.size; +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..5945380 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,136 @@ +import express from 'express'; +import path from 'node:path'; +import client from './core/discord.js'; +import { addSSEClient, removeSSEClient, sseBroadcast, getSSEClientCount } from './core/sse.js'; +import { loadState, getFullState } from './core/persistence.js'; +import { getPlugins, registerPlugin, PluginContext } from './core/plugin.js'; + +// ── Config ── +const PORT = Number(process.env.PORT ?? 8080); +const DATA_DIR = process.env.DATA_DIR ?? '/data'; +const DISCORD_TOKEN = process.env.DISCORD_TOKEN ?? ''; + +// ── Persistence ── +loadState(); + +// ── Express ── +const app = express(); +app.use(express.json()); +app.use(express.static(path.join(import.meta.dirname ?? __dirname, '..', '..', 'web', 'dist'))); + +// ── Plugin Context ── +const ctx: PluginContext = { client, dataDir: DATA_DIR }; + +// ── SSE Events ── +app.get('/api/events', (_req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + res.flushHeaders(); + + // Send snapshot from all plugins + const snapshot: Record = { type: 'snapshot' }; + for (const p of getPlugins()) { + if (p.getSnapshot) { + Object.assign(snapshot, p.getSnapshot(ctx)); + } + } + try { res.write(`data: ${JSON.stringify(snapshot)}\n\n`); } catch {} + + const ping = setInterval(() => { try { res.write(':\n\n'); } catch {} }, 15_000); + addSSEClient(res); + + _req.on('close', () => { + removeSSEClient(res); + clearInterval(ping); + try { res.end(); } catch {} + }); +}); + +// ── Health ── +app.get('/api/health', (_req, res) => { + res.json({ + status: 'ok', + uptime: process.uptime(), + plugins: getPlugins().map(p => ({ name: p.name, version: p.version })), + sseClients: getSSEClientCount(), + }); +}); + +// ── API: List plugins ── +app.get('/api/plugins', (_req, res) => { + res.json(getPlugins().map(p => ({ + name: p.name, + version: p.version, + description: p.description, + }))); +}); + +// ── SPA Fallback ── +app.get('*', (_req, res) => { + res.sendFile(path.join(import.meta.dirname ?? __dirname, '..', '..', 'web', 'dist', 'index.html')); +}); + +// ── Discord Ready ── +client.once('ready', async () => { + console.log(`[Discord] Logged in as ${client.user?.tag}`); + console.log(`[Discord] Serving ${client.guilds.cache.size} guild(s)`); + + for (const p of getPlugins()) { + if (p.onReady) { + try { await p.onReady(ctx); } catch (e) { console.error(`[Plugin:${p.name}] onReady error:`, e); } + } + } +}); + +// ── Init Plugins ── +async function boot(): Promise { + // --- Load plugins dynamically here --- + // Example: import('./plugins/soundboard/index.js').then(m => registerPlugin(m.default)); + + // Init all plugins + for (const p of getPlugins()) { + try { + await p.init(ctx); + p.registerRoutes?.(app, ctx); + console.log(`[Plugin:${p.name}] Initialized`); + } catch (e) { + console.error(`[Plugin:${p.name}] Init error:`, e); + } + } + + // Start Express + app.listen(PORT, () => console.log(`[HTTP] Listening on :${PORT}`)); + + // Login Discord + if (DISCORD_TOKEN) { + await client.login(DISCORD_TOKEN); + } else { + console.warn('[Discord] No DISCORD_TOKEN set - running without Discord'); + } +} + +// ── Graceful Shutdown ── +async function shutdown(signal: string): Promise { + console.log(`\n[${signal}] Shutting down...`); + for (const p of getPlugins()) { + if (p.destroy) { + try { await p.destroy(ctx); } catch (e) { console.error(`[Plugin:${p.name}] destroy error:`, e); } + } + } + client.destroy(); + process.exit(0); +} + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); +process.on('uncaughtException', (err) => { console.error('Uncaught:', err); }); +process.on('unhandledRejection', (err) => { console.error('Unhandled:', err); }); +process.on('warning', (w) => { + if (w.name === 'TimeoutNegativeWarning') return; + console.warn(w.name + ': ' + w.message); +}); + +boot().catch(console.error); diff --git a/server/src/plugins/.gitkeep b/server/src/plugins/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..cc487cc --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..f9bb5cd --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + Gaming Hub + + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..8b7260d --- /dev/null +++ b/web/package.json @@ -0,0 +1,21 @@ +{ + "name": "gaming-hub-web", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..3126619 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,129 @@ +import { useState, useEffect, useRef } from 'react'; + +interface PluginInfo { + name: string; + version: string; + description: string; +} + +// Plugin tab components will be registered here +const tabComponents: Record> = {}; + +export function registerTab(pluginName: string, component: React.FC<{ data: any }>) { + tabComponents[pluginName] = component; +} + +export default function App() { + const [connected, setConnected] = useState(false); + const [plugins, setPlugins] = useState([]); + const [activeTab, setActiveTab] = useState(''); + const [pluginData, setPluginData] = useState>({}); + const eventSourceRef = useRef(null); + + // Fetch plugin list + useEffect(() => { + fetch('/api/plugins') + .then(r => r.json()) + .then((list: PluginInfo[]) => { + setPlugins(list); + if (list.length > 0 && !activeTab) setActiveTab(list[0].name); + }) + .catch(() => {}); + }, []); + + // SSE connection + useEffect(() => { + let es: EventSource | null = null; + let retryTimer: ReturnType; + + 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 TabComponent = activeTab ? tabComponents[activeTab] : null; + const version = (import.meta as any).env?.VITE_APP_VERSION ?? '1.0.0'; + + // Tab icon mapping + const tabIcons: Record = { + soundboard: '\u{1F3B5}', + stats: '\u{1F4CA}', + events: '\u{1F4C5}', + games: '\u{1F3B2}', + gamevote: '\u{1F3AE}', + }; + + return ( +
+
+
+ {'\u{1F3AE}'} + Gaming Hub + +
+ + + +
+ v{version} +
+
+ +
+ {plugins.length === 0 ? ( +
+ {'\u{1F4E6}'} +

Keine Plugins geladen

+

Plugins werden im Server konfiguriert.

+
+ ) : TabComponent ? ( + + ) : ( +
+ {tabIcons[activeTab] ?? '\u{1F4E6}'} +

{activeTab}

+

Plugin-UI wird geladen...

+
+ )} +
+
+ ); +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..3658304 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,5 @@ +import { createRoot } from 'react-dom/client'; +import App from './App'; +import './styles.css'; + +createRoot(document.getElementById('root')!).render(); diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..c71d5e1 --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,350 @@ +/* ── CSS Variables ── */ +:root { + --bg-deep: #1a1b1e; + --bg-primary: #1e1f22; + --bg-secondary: #2b2d31; + --bg-tertiary: #313338; + --text-normal: #dbdee1; + --text-muted: #949ba4; + --text-faint: #6d6f78; + --accent: #e67e22; + --accent-rgb: 230, 126, 34; + --accent-hover: #d35400; + --success: #57d28f; + --danger: #ed4245; + --warning: #fee75c; + --border: rgba(255, 255, 255, 0.06); + --radius: 8px; + --radius-lg: 12px; + --transition: 150ms ease; + --font: 'Segoe UI', system-ui, -apple-system, sans-serif; + --header-height: 56px; +} + +/* ── Reset & Base ── */ +*, +*::before, +*::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + height: 100%; + font-family: var(--font); + font-size: 15px; + color: var(--text-normal); + background: var(--bg-deep); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow: hidden; +} + +#root { + height: 100%; +} + +/* ── App Shell ── */ +.hub-app { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +/* ── Header ── */ +.hub-header { + position: sticky; + top: 0; + z-index: 100; + display: flex; + align-items: center; + height: var(--header-height); + min-height: var(--header-height); + padding: 0 16px; + background: var(--bg-primary); + border-bottom: 1px solid var(--border); + gap: 16px; +} + +.hub-header-left { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.hub-logo { + font-size: 24px; + line-height: 1; +} + +.hub-title { + font-size: 18px; + font-weight: 700; + color: var(--text-normal); + letter-spacing: -0.02em; + white-space: nowrap; +} + +/* ── Connection Status Dot ── */ +.hub-conn-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--danger); + flex-shrink: 0; + transition: background var(--transition); + box-shadow: 0 0 0 2px rgba(237, 66, 69, 0.25); +} + +.hub-conn-dot.online { + background: var(--success); + box-shadow: 0 0 0 2px rgba(87, 210, 143, 0.25); + animation: pulse-dot 2s ease-in-out infinite; +} + +@keyframes pulse-dot { + 0%, 100% { + box-shadow: 0 0 0 2px rgba(87, 210, 143, 0.25); + } + 50% { + box-shadow: 0 0 0 6px rgba(87, 210, 143, 0.1); + } +} + +/* ── Tab Navigation ── */ +.hub-tabs { + display: flex; + align-items: center; + gap: 4px; + flex: 1; + overflow-x: auto; + scrollbar-width: none; + -ms-overflow-style: none; + padding: 0 4px; +} + +.hub-tabs::-webkit-scrollbar { + display: none; +} + +.hub-tab { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + border: none; + background: transparent; + color: var(--text-muted); + font-family: var(--font); + font-size: 14px; + font-weight: 500; + cursor: pointer; + border-radius: var(--radius); + white-space: nowrap; + transition: all var(--transition); + position: relative; + user-select: none; +} + +.hub-tab:hover { + color: var(--text-normal); + background: var(--bg-secondary); +} + +.hub-tab.active { + color: var(--accent); + background: rgba(var(--accent-rgb), 0.1); +} + +.hub-tab.active::after { + content: ''; + position: absolute; + bottom: -1px; + left: 50%; + transform: translateX(-50%); + width: calc(100% - 16px); + height: 2px; + background: var(--accent); + border-radius: 1px; +} + +.hub-tab-icon { + font-size: 16px; + line-height: 1; +} + +.hub-tab-label { + text-transform: capitalize; +} + +/* ── Header Right ── */ +.hub-header-right { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.hub-version { + font-size: 12px; + color: var(--text-faint); + font-weight: 500; + font-variant-numeric: tabular-nums; + background: var(--bg-secondary); + padding: 4px 8px; + border-radius: 4px; +} + +/* ── Main Content Area ── */ +.hub-content { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + background: var(--bg-deep); + scrollbar-width: thin; + scrollbar-color: var(--bg-tertiary) transparent; +} + +.hub-content::-webkit-scrollbar { + width: 6px; +} + +.hub-content::-webkit-scrollbar-track { + background: transparent; +} + +.hub-content::-webkit-scrollbar-thumb { + background: var(--bg-tertiary); + border-radius: 3px; +} + +.hub-content::-webkit-scrollbar-thumb:hover { + background: var(--text-faint); +} + +/* ── Empty State ── */ +.hub-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + min-height: 300px; + text-align: center; + padding: 32px; + animation: fade-in 300ms ease; +} + +.hub-empty-icon { + font-size: 64px; + line-height: 1; + margin-bottom: 20px; + opacity: 0.6; + filter: grayscale(30%); +} + +.hub-empty h2 { + font-size: 22px; + font-weight: 700; + color: var(--text-normal); + margin-bottom: 8px; +} + +.hub-empty p { + font-size: 15px; + color: var(--text-muted); + max-width: 360px; + line-height: 1.5; +} + +/* ── Animations ── */ +@keyframes fade-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ── Selection ── */ +::selection { + background: rgba(var(--accent-rgb), 0.3); + color: var(--text-normal); +} + +/* ── Focus Styles ── */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* ── Responsive ── */ +@media (max-width: 768px) { + :root { + --header-height: 48px; + } + + .hub-header { + padding: 0 10px; + gap: 8px; + } + + .hub-title { + font-size: 15px; + } + + .hub-logo { + font-size: 20px; + } + + .hub-tab { + padding: 6px 10px; + font-size: 13px; + gap: 4px; + } + + .hub-tab-label { + display: none; + } + + .hub-tab-icon { + font-size: 18px; + } + + .hub-version { + font-size: 11px; + } + + .hub-empty-icon { + font-size: 48px; + } + + .hub-empty h2 { + font-size: 18px; + } + + .hub-empty p { + font-size: 14px; + } +} + +@media (max-width: 480px) { + .hub-header-right { + display: none; + } + + .hub-header { + padding: 0 8px; + gap: 6px; + } + + .hub-title { + font-size: 14px; + } +} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..ddec37a --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "resolveJsonModule": true + }, + "include": ["src"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..d8d9468 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { '/api': 'http://localhost:8080' }, + }, + define: { + 'import.meta.env.VITE_APP_VERSION': JSON.stringify(process.env.VITE_APP_VERSION ?? '1.0.0-dev'), + 'import.meta.env.VITE_BUILD_CHANNEL': JSON.stringify(process.env.VITE_BUILD_CHANNEL ?? 'dev'), + }, +});