From e9efe38b7600333f6fb42a49b54de04f0a32c7f0 Mon Sep 17 00:00:00 2001 From: SPP User Date: Mon, 26 Oct 2026 11:03:54 +0100 Subject: [PATCH] Admin: Reiter Matches und Einstellungen, Discord-Embeds, CSTV-Zugang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Admin-Bereich bekommt Reiter. Die Discord-Einstellungen liegen unter „Einstellungen“. Meldungen sind als Embeds formatiert, und die Match-Startmeldung enthält den CSTV-Zugang (Passwort und Adresse). Co-Authored-By: Claude Haiku 5.5 --- client/src/pages/AdminPage.jsx | 16 ++++++--- client/src/styles.css | 6 ++++ server/src/adminRoutes.js | 3 +- server/src/discord.js | 6 ++-- server/src/matchService.js | 51 ++++++++++++++++++++++------- server/src/servers/serverManager.js | 18 ++++++++-- 6 files changed, 79 insertions(+), 21 deletions(-) diff --git a/client/src/pages/AdminPage.jsx b/client/src/pages/AdminPage.jsx index c346d84..8f4dd8f 100644 --- a/client/src/pages/AdminPage.jsx +++ b/client/src/pages/AdminPage.jsx @@ -29,6 +29,7 @@ export default function AdminPage() { const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [busy, setBusy] = useState(null); + const [tab, setTab] = useState('matches'); const logout = useCallback(() => { api('/admin/logout', { method: 'POST', token }).catch(() => {}); @@ -92,17 +93,22 @@ export default function AdminPage() { - {token && } + + + {tab === 'settings' && token && } {notice &&
{notice.text}
} {error &&
{error}
} - {matches === null && !error &&

Lade Matches …

} - {matches && matches.length === 0 &&

Noch keine Matches.

} + {tab === 'matches' && matches === null && !error &&

Lade Matches …

} + {tab === 'matches' && matches && matches.length === 0 &&

Noch keine Matches.

} -
+ {tab === 'matches' &&
{matches?.map((m) => ( ))} -
+
} ); } diff --git a/client/src/styles.css b/client/src/styles.css index f0e657b..aa6f0fe 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -517,3 +517,9 @@ input:focus, select:focus { border-color: var(--link); outline: none; } .admin-settings h2 { margin: 0 0 12px; font-size: 1.1rem; } .admin-settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 8px 16px; margin-bottom: 12px; } .admin-check { display: flex; align-items: center; gap: 8px; cursor: pointer; } + +.admin-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin-bottom: 20px; } +.admin-tab { background: none; border: 0; border-bottom: 2px solid transparent; color: var(--muted); padding: 10px 14px; font: inherit; cursor: pointer; } +.admin-tab:hover { color: var(--text); } +.admin-tab.is-active { color: var(--text); border-bottom-color: var(--primary); } + diff --git a/server/src/adminRoutes.js b/server/src/adminRoutes.js index 281a8a2..cdec94e 100644 --- a/server/src/adminRoutes.js +++ b/server/src/adminRoutes.js @@ -81,7 +81,8 @@ export function createAdminRouter({ service, manager, config }) { router.put('/notifications', requireAdmin, wrap((req) => service.setNotificationEvents(req.body?.events))); router.post('/notifications/test', requireAdmin, wrap(async () => { if (!service.notifier.enabled) throw new HttpError(409, 'Kein Discord-Webhook eingerichtet (DISCORD_WEBHOOK_URL)', 'not_configured'); - if (!(await service.notifier.send('Testmeldung aus CS2 Mapveto'))) { + const test = { embeds: [{ title: '🧪 Testmeldung', description: 'Die Verbindung zu CS2 Mapveto funktioniert.', color: 0x3b82f6 }] }; + if (!(await service.notifier.send(test))) { throw new HttpError(502, 'Discord hat die Meldung nicht angenommen (siehe Log)', 'send_failed'); } return { ok: true }; diff --git a/server/src/discord.js b/server/src/discord.js index a9f9e36..97b211f 100644 --- a/server/src/discord.js +++ b/server/src/discord.js @@ -27,12 +27,14 @@ export function createDiscordNotifier(url, { fetchImpl = fetch, logger = console return { enabled: true, /** Liefert true, wenn Discord die Nachricht angenommen hat. */ - async send(content) { + async send(message) { + // Text oder vollständige Discord-Payload (z. B. mit embeds) + const payload = typeof message === 'string' ? { content: message.slice(0, MAX_LENGTH) } : message; try { const res = await fetchImpl(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content: content.slice(0, MAX_LENGTH) }), + body: JSON.stringify(payload), signal: AbortSignal.timeout(TIMEOUT_MS), }); if (res.ok) return true; diff --git a/server/src/matchService.js b/server/src/matchService.js index 4efbaa9..7ecb8ba 100644 --- a/server/src/matchService.js +++ b/server/src/matchService.js @@ -341,7 +341,12 @@ export class MatchService extends EventEmitter { .run(firstTeam, this.now(), this.now(), match.id); const how = match.veto_start === 'coin' ? 'per Münzwurf ausgelost' : 'vom Organisator festgelegt'; this.log(match.id, 'veto', `Veto gestartet – ${this.teamName(match, firstTeam)} beginnt (${how})`); - this.notify('veto_start', match.id, `Veto gestartet: ${this.matchTitle(match.id)}, ${this.teamName(match, firstTeam)} beginnt`); + this.notify('veto_start', match.id, { + title: '🎮 Veto gestartet', + description: `**${this.matchTitle(match.id)}**`, + color: 0x3b82f6, + fields: [{ name: 'Beginnt', value: this.teamName(match, firstTeam), inline: true }], + }); })(); this.changed(viewer.matchId); } @@ -519,8 +524,16 @@ export class MatchService extends EventEmitter { const winner = state.winner ? this.teamName(match, state.winner) : 'Unentschieden'; this.log(matchId, 'score', `Map ${row.number} (${this.mapName(match, row.map)}) beendet: ${match.team_a_name} ${state.score.A}:${state.score.B} ${match.team_b_name} – Sieger: ${winner}`); - this.notify('map_end', matchId, `${this.matchTitle(matchId)} -Map ${row.number} (${this.mapName(match, row.map)}) beendet: ${state.score.A}:${state.score.B}, Sieger ${winner}`); + this.notify('map_end', matchId, { + title: '🗺️ Map beendet', + description: `**${this.matchTitle(matchId)}**`, + color: 0xf59e0b, + fields: [ + { name: `Map ${row.number}`, value: this.mapName(match, row.map), inline: true }, + { name: 'Stand', value: `${state.score.A} : ${state.score.B}`, inline: true }, + { name: 'Sieger', value: winner, inline: true }, + ], + }); } })(); if (ended) { @@ -529,8 +542,18 @@ Map ${row.number} (${this.mapName(match, row.map)}) beendet: ${state.score.A}:${ const needed = Math.floor(BEST_OF[ended.match.format] / 2) + 1; const decided = score.A >= needed ? 'A' : score.B >= needed ? 'B' : null; if (decided) this.log(matchId, 'score', `Serie entschieden: ${this.teamName(ended.match, decided)} gewinnt ${score[decided]}:${score[decided === 'A' ? 'B' : 'A']}`); - if (decided) this.notify('series_end', matchId, `${this.matchTitle(matchId)} -Serie entschieden: ${this.teamName(ended.match, decided)} gewinnt ${score[decided]}:${score[decided === 'A' ? 'B' : 'A']}`); + if (decided) { + const loser = decided === 'A' ? 'B' : 'A'; + this.notify('series_end', matchId, { + title: '🏆 Serie entschieden', + description: `**${this.matchTitle(matchId)}**`, + color: 0x22c55e, + fields: [ + { name: 'Sieger', value: this.teamName(ended.match, decided), inline: true }, + { name: 'Serienstand', value: `${score[decided]} : ${score[loser]}`, inline: true }, + ], + }); + } const hasNext = maps.some((m) => m.number === ended.row.number + 1); this.changed(matchId); this.emit('mapEnded', { matchId, number: ended.row.number, decided, hasNext }); @@ -562,8 +585,12 @@ Serie entschieden: ${this.teamName(ended.match, decided)} gewinnt ${score[decide this.db.prepare("UPDATE series_maps SET status = 'done' WHERE match_id = ? AND status IN ('live','next')").run(match.id); } this.log(match.id, 'status', message ?? (status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen')); - this.notify('match_closed', match.id, `${this.matchTitle(match.id)} -${message ?? (status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen')}`); + const finished = status === 'finished'; + this.notify('match_closed', match.id, { + title: finished ? '✅ Match abgeschlossen' : '⛔ Match abgebrochen', + description: `**${this.matchTitle(match.id)}**${message ? `\n${message}` : ''}`, + color: finished ? 0x6b7280 : 0x9ca3af, + }); this.setStatus(match.id, status); this.emit('closed', match.id); } @@ -610,11 +637,13 @@ ${message ?? (status === 'finished' ? 'Match abgeschlossen' : 'Match abgebrochen return this.notificationSettings(); } - notify(type, matchId, text) { + /** Meldung als Discord-Embed. Ohne Stream-Schlüssel ist der Titel ein Link auf die Match-Übersicht. */ + notify(type, matchId, embed) { if (!this.notifier.enabled || !this.notificationSettings().events[type]) return; - const link = this.config.streamKey ? '' : ` -${this.config.publicUrl}/stream/match/${matchId}`; - this.notifier.send(`${text}${link}`); + const url = this.config.streamKey ? undefined : `${this.config.publicUrl}/stream/match/${matchId}`; + this.notifier.send({ + embeds: [{ ...embed, url, timestamp: new Date(this.now()).toISOString(), footer: { text: 'CS2 Mapveto' } }], + }); } log(matchId, type, message) { diff --git a/server/src/servers/serverManager.js b/server/src/servers/serverManager.js index 2d036c7..c624ea0 100644 --- a/server/src/servers/serverManager.js +++ b/server/src/servers/serverManager.js @@ -265,7 +265,11 @@ export class ServerManager { } else if (status.state === 'failed') { Object.assign(fields, { status: current.status === 'starting' ? 'start_failed' : 'failed', message: status.message }); this.service.log(matchId, 'server', `Server-Fehler: ${status.message}`); - this.service.notify('server_error', matchId, `Server-Fehler bei ${this.service.matchTitle(matchId)}: ${status.message}`); + this.service.notify('server_error', matchId, { + title: '⚠️ Serverfehler', + description: `**${this.service.matchTitle(matchId)}**\n${status.message}`, + color: 0xdc2626, + }); } else if (status.state === 'stopped') { Object.assign(fields, { status: 'stopped', stopped_at: Date.now(), message: status.message ?? 'Server wurde extern beendet' }); this.service.log(matchId, 'server', 'Server wurde außerhalb der Anwendung beendet'); @@ -388,7 +392,17 @@ export class ServerManager { throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error'); } this.service.log(viewer.matchId, 'server', this.adapter.simulated ? 'Aufwärmphase beendet (simuliert)' : 'Aufwärmphase beendet – Match startet'); - this.service.notify('match_start', viewer.matchId, `Match gestartet: ${this.service.matchTitle(viewer.matchId)}`); + // CSTV-Zugang für Zuschauer, gleiche Form wie im Zuschauerbereich der Webseite + const fields = []; + if (this.config.match.tvEnabled && row.tv_port) { + fields.push({ name: '📺 CSTV', value: `\`password ${row.tv_password}; connect ${row.host}:${row.tv_port}\`` }); + } + this.service.notify('match_start', viewer.matchId, { + title: '🔴 Match läuft', + description: `**${this.service.matchTitle(viewer.matchId)}**`, + color: 0xef4444, + fields, + }); if (this.adapter.simulated) this.startDemoSimulation(viewer.matchId); this.service.changed(viewer.matchId); }