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:
1 parent
104aa1a67b
commit
568ccf9b09
24 files changed
+1938
-1
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user