Frontend, README und Konfigurationsvorlage

React-Oberfläche mit Übersicht, Match-Erstellung, Beitritt über Team- und
Zuschauercodes, Teamkarten, visuellem Map-Veto, Serie und Serverpanel.
README beschreibt Einrichtung, Umgebungsvariablen, Rollen, Veto-Abläufe,
Serveradapter und CSTV-Zuschauerzugang.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SPP UserandClaude Opus 5.5 committed 2026-10-25 17:02:18 +01:00
1 parent 104aa1a67b
commit 568ccf9b09
24 files changed
+1938 -1

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
export class ApiError extends Error {
constructor(message, status, code) {
super(message);
this.status = status;
this.code = code;
}
}
export async function api(path, { method = 'GET', body, token } = {}) {
let res;
try {
res = await fetch(`/api${path}`, {
method,
headers: {
...(body ? { 'content-type': 'application/json' } : {}),
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
} catch {
throw new ApiError('Keine Verbindung zum Server. Prüfe deine Internetverbindung.', 0, 'network');
}
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new ApiError(data.error ?? `Fehler ${res.status}`, res.status, data.code);
return data;
}
+51
View File
@@ -0,0 +1,51 @@
export const FORMAT_LABEL = { bo1: 'Best of 1', bo3: 'Best of 3', bo5: 'Best of 5' };
export const ROLE_LABEL = {
organizer: 'Organisator',
captain: 'Captain',
player: 'Spieler',
spectator: 'Zuschauer',
public: 'Gast',
};
/** Serverstatus in Worte fassen. Simulierte Server werden nie als laufend bezeichnet. */
export function serverLabel(server) {
if (!server) return { text: 'Kein Server', tone: 'muted' };
const sim = server.simulated;
switch (server.status) {
case 'starting':
return { text: sim ? 'Demo: Start wird simuliert' : 'Server startet', tone: 'pending' };
case 'ready':
return { text: sim ? 'Demo: simuliert bereit' : 'Server bereit', tone: sim ? 'demo' : 'ok' };
case 'configuring':
return { text: sim ? 'Demo: Konfiguration simuliert' : 'Server wird konfiguriert', tone: 'pending' };
case 'configured':
return { text: sim ? 'Demo: simuliert konfiguriert' : 'Server läuft', tone: sim ? 'demo' : 'ok' };
case 'start_failed':
return { text: 'Serverstart fehlgeschlagen', tone: 'error' };
case 'config_failed':
return { text: 'Konfiguration fehlgeschlagen', tone: 'error' };
case 'failed':
return { text: 'Server ausgefallen', tone: 'error' };
case 'stopped':
return { text: 'Server gestoppt', tone: 'muted' };
default:
return { text: server.status, tone: 'muted' };
}
}
export const STEP_VERB = { ban: 'bannt eine Map', pick: 'wählt eine Map', side: 'wählt die Startseite', decider: 'Entscheidungsmap' };
export function timeAgo(ts) {
const s = Math.round((Date.now() - ts) / 1000);
if (s < 60) return 'gerade eben';
const m = Math.round(s / 60);
if (m < 60) return `vor ${m} Min.`;
const h = Math.round(m / 60);
if (h < 24) return `vor ${h} Std.`;
return new Date(ts).toLocaleDateString('de-DE');
}
export function clock(ts) {
return new Date(ts).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
+34
View File
@@ -0,0 +1,34 @@
// Zugangsdaten pro Match im Browser. Nach Neuladen oder Verbindungsabbruch
// meldet sich der Client damit automatisch wieder an.
const KEY = (matchId) => `cs2mv:match:${matchId}`;
export function getAccess(matchId) {
try {
return JSON.parse(localStorage.getItem(KEY(matchId))) ?? null;
} catch {
return null;
}
}
export function setAccess(matchId, access) {
try {
localStorage.setItem(KEY(matchId), JSON.stringify(access));
} catch {
/* privater Modus – Zugang gilt dann nur für diese Sitzung */
}
memory.set(matchId, access);
}
export function clearAccess(matchId) {
try {
localStorage.removeItem(KEY(matchId));
} catch {
/* ignorieren */
}
memory.delete(matchId);
}
const memory = new Map();
export function getToken(matchId) {
return (getAccess(matchId) ?? memory.get(matchId))?.token ?? null;
}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useRef, useState } from 'react';
import { io } from 'socket.io-client';
import { api } from './api.js';
let shared = null;
export function getSocket() {
if (!shared) shared = io({ path: '/socket.io', transports: ['websocket', 'polling'] });
return shared;
}
/** Abonniert den Live-Zustand eines Matches. Meldet sich nach jedem Reconnect neu an. */
export function useMatch(matchId, token) {
const [state, setState] = useState(null);
const [error, setError] = useState(null);
const [connected, setConnected] = useState(false);
const tokenRef = useRef(token);
tokenRef.current = token;
useEffect(() => {
const socket = getSocket();
const subscribe = () => socket.emit('match:subscribe', { matchId, token: tokenRef.current });
const onState = (s) => {
if (s.id !== matchId) return;
setState(s);
setError(null);
};
const onError = (e) => setError(e);
const onConnect = () => {
setConnected(true);
subscribe();
};
const onDisconnect = () => setConnected(false);
socket.on('match:state', onState);
socket.on('match:error', onError);
socket.on('connect', onConnect);
socket.on('disconnect', onDisconnect);
if (socket.connected) onConnect();
// Erster Stand per REST, falls der Socket noch verbindet; neuere Socket-Stände haben Vorrang.
let active = true;
api(`/matches/${matchId}`, { token })
.then((s) => active && setState((prev) => (prev && prev.updatedAt >= s.updatedAt ? prev : s)))
.catch((e) => active && setError({ error: e.message, code: e.code }));
return () => {
active = false;
socket.off('match:state', onState);
socket.off('match:error', onError);
socket.off('connect', onConnect);
socket.off('disconnect', onDisconnect);
};
}, [matchId, token]);
return { state, error, connected };
}
export function useOverview() {
const [matches, setMatches] = useState(null);
const [connected, setConnected] = useState(false);
useEffect(() => {
const socket = getSocket();
const onConnect = () => {
setConnected(true);
socket.emit('overview:subscribe');
};
const onOverview = (d) => setMatches(d.matches);
const onDisconnect = () => setConnected(false);
socket.on('overview', onOverview);
socket.on('connect', onConnect);
socket.on('disconnect', onDisconnect);
if (socket.connected) onConnect();
return () => {
socket.off('overview', onOverview);
socket.off('connect', onConnect);
socket.off('disconnect', onDisconnect);
};
}, []);
return { matches, connected };
}