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.
This commit is contained in:
1 parent
f08297dd0d
commit
f7520a6ca7
24 files changed
+1938
-1
No files matched your search
@@ -0,0 +1,60 @@
|
||||
# Kopieren nach .env und anpassen. Alle Werte sind optional, außer beim jeweiligen Serveradapter angegeben.
|
||||
|
||||
# --- Webanwendung ---
|
||||
PORT=3000
|
||||
# Öffentliche Adresse der Webseite, wird für Einladungslinks verwendet.
|
||||
# Entwicklung mit Vite: http://localhost:5173, Produktion: z. B. https://veto.example.de
|
||||
PUBLIC_URL=http://localhost:5173
|
||||
DATABASE_FILE=data/cs2-mapveto.db
|
||||
MAP_POOL_FILE=server/config/map-pools.json
|
||||
# Wenn gesetzt, ist dieses Passwort zum Erstellen von Matches nötig.
|
||||
CREATE_MATCH_PASSWORD=
|
||||
# true: Veto erst, wenn beide Teams vollständig (2 bzw. 5 Spieler) und bereit sind.
|
||||
REQUIRE_FULL_TEAMS=true
|
||||
# true, wenn die App hinter einem Reverse-Proxy läuft (für korrekte Client-IP beim Rate-Limit).
|
||||
TRUST_PROXY=false
|
||||
|
||||
# --- Serververwaltung ---
|
||||
# demo = simuliert, startet KEINE echten Server (Standard)
|
||||
# docker = startet pro Match einen CS2-Container
|
||||
# static = nutzt vorhandene, selbst betriebene CS2-Server per RCON
|
||||
SERVER_ADAPTER=demo
|
||||
SERVER_IDLE_TIMEOUT_MINUTES=30
|
||||
SERVER_MAX_LIFETIME_HOURS=8
|
||||
SERVER_STATUS_POLL_SECONDS=5
|
||||
SERVER_REAPER_INTERVAL_SECONDS=60
|
||||
|
||||
# --- Match-Einstellungen auf dem CS2-Server ---
|
||||
CS2_TV_ENABLE=true
|
||||
CS2_TV_DELAY=90
|
||||
CS2_MAXROUNDS_COMPETITIVE=24
|
||||
CS2_MAXROUNDS_WINGMAN=16
|
||||
CS2_OVERTIME=true
|
||||
# Zusätzliche Konsolenbefehle nach dem Mapwechsel, durch ; getrennt
|
||||
CS2_EXTRA_COMMANDS=
|
||||
|
||||
# --- Demo-Adapter ---
|
||||
DEMO_START_SECONDS=20
|
||||
# true: erste Konfiguration schlägt absichtlich fehl (zum Testen von "Erneut versuchen")
|
||||
DEMO_FAIL_FIRST_CONFIGURE=false
|
||||
|
||||
# --- Docker-Adapter (SERVER_ADAPTER=docker) ---
|
||||
# Pflicht: Game Server Login Token für App-ID 730, https://steamcommunity.com/dev/managegameservers
|
||||
CS2_GSLT=
|
||||
# Pflicht: Adresse, unter der Spieler den Docker-Host erreichen (IP oder DNS-Name)
|
||||
CS2_PUBLIC_HOST=
|
||||
# Adresse, unter der das Backend die RCON-Ports der Container erreicht
|
||||
CS2_RCON_HOST=127.0.0.1
|
||||
CS2_DOCKER_IMAGE=cm2network/cs2:latest
|
||||
# Pro Match werden zwei Ports belegt (Spiel TCP+UDP, CSTV UDP)
|
||||
CS2_PORT_RANGE_START=27015
|
||||
CS2_PORT_RANGE_END=27115
|
||||
# Gemeinsames Volume für die Spieldateien (~60 GB), damit nicht jedes Match neu herunterlädt
|
||||
CS2_DOCKER_VOLUME=cs2-mapveto-gamefiles
|
||||
CS2_DOCKER_EXTRA_ARGS=
|
||||
CS2_START_TIMEOUT_MINUTES=20
|
||||
DOCKER_BIN=docker
|
||||
|
||||
# --- Static-Pool-Adapter (SERVER_ADAPTER=static) ---
|
||||
# host:port:rconPasswort[:tvPort], mehrere durch Komma getrennt
|
||||
CS2_STATIC_SERVERS=
|
||||
@@ -8,3 +8,4 @@ data/
|
||||
.env
|
||||
.env.local
|
||||
npm-debug.log*
|
||||
.claude/
|
||||
@@ -0,0 +1,327 @@
|
||||
# CS2 Mapveto
|
||||
|
||||
Webanwendung zur Organisation von Counter-Strike-2-Matches: Match-Lobbys mit Team-Einladungen, abwechselndes Map-Veto der Captains, Zuschauerzugang und automatische Verwaltung eines CS2-Servers pro Match.
|
||||
|
||||
- **Frontend:** React 18 + Vite, deutschsprachig, responsives dunkles Design
|
||||
- **Backend:** Node.js (≥ 18.18), Express, Socket.IO
|
||||
- **Datenbank:** SQLite (better-sqlite3), Datei unter `data/`
|
||||
- **Echtzeit:** Socket.IO; jede Änderung schickt allen Teilnehmern ihren rollenabhängigen Match-Zustand
|
||||
- **Serververwaltung:** austauschbare Adapter hinter einer Schnittstelle (`start`, `status`, `configure`, `stop`)
|
||||
|
||||
> **Standardmäßig läuft der Demo-Adapter.** Er startet **keine** echten CS2-Server. Oberfläche, Statusanzeigen und Verbindungsdaten sind dann überall als „Demo“ bzw. „simuliert“ gekennzeichnet. Wie die echte Anbindung eingerichtet wird, steht im Abschnitt [Echte Server anbinden](#echte-server-anbinden).
|
||||
|
||||
---
|
||||
|
||||
## Schnellstart
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env # optional, Standardwerte funktionieren für die Demo
|
||||
npm run dev # Backend auf :3000, Frontend auf :5173
|
||||
```
|
||||
|
||||
Dann <http://localhost:5173> öffnen.
|
||||
|
||||
**Produktion (ein Prozess, Backend liefert das gebaute Frontend aus):**
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
PUBLIC_URL=https://veto.example.de npm start # lauscht auf PORT (Standard 3000)
|
||||
```
|
||||
|
||||
**Tests:**
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Die Tests decken Veto-Engine und Ablaufvalidierung, RCON-Protokoll (gegen einen lokalen Test-RCON-Server), Befehls-Bereinigung und den kompletten API-Ablauf ab. Dazu gehören Berechtigungen, doppelte Aktionen, Server-Konfiguration mit Wiederholung, nächste Map, automatisches Herunterfahren und dass keine RCON-Daten an Clients gelangen.
|
||||
|
||||
---
|
||||
|
||||
## Ablauf: von der Match-Erstellung bis zum Serverbeitritt
|
||||
|
||||
1. **Match erstellen** (`/neu`): Der Organisator trägt beide Teamnamen ein und wählt Spielmodus (Wingman 2 gegen 2 oder Competitive 5 gegen 5), Serie (Best of 1/3/5) und wer das Veto beginnt (Münzwurf oder festes Team). Formate, für die der Map-Pool keinen gültigen Veto-Ablauf hat, sind ausgegraut und werden auch vom Backend abgelehnt.
|
||||
Der Browser des Organisators erhält einen geheimen Organisator-Token. Er wird lokal gespeichert und lässt sich über „Organisator-Zugang auf einem anderen Gerät öffnen“ übertragen.
|
||||
2. **Server vorbereiten:** Direkt beim Anlegen der Lobby fordert das Backend im Hintergrund einen Server an. Er fährt also schon während Lobby und Veto hoch. Der Status ist auf der Match-Seite live sichtbar.
|
||||
3. **Einladungen:** Das Match hat drei Zugangscodes (Format `ABCD-EFGH`) mit Links:
|
||||
- Team A: `/join/<code>`
|
||||
- Team B: `/join/<code>`
|
||||
- Zuschauer: `/watch/<code>` für Stream und Kommentatoren
|
||||
|
||||
Diese Codes gelten nur für die Webseite. Der CS2-Serverzugang wird getrennt bereitgestellt (Schritt 6). Der Organisator kann jeden Code jederzeit neu erzeugen; der alte Link wird damit ungültig.
|
||||
4. **Spieler treten bei:** Über den Teamlink gibt ein Spieler seinen Namen ein (optional die SteamID64) und landet in genau diesem Team. Pro Team sind maximal 2 bzw. 5 Spieler erlaubt.
|
||||
- Der erste Spieler eines Teams wird Captain. Organisator oder Captain können den Captain wechseln.
|
||||
- Jeder Spieler setzt sich auf „bereit“. Ein Team ist bereit, wenn es vollständig ist (abschaltbar mit `REQUIRE_FULL_TEAMS=false`), einen Captain hat und alle bereit sind.
|
||||
5. **Map-Veto:** Sind beide Teams bereit, startet der Organisator das Veto. Die Captains bannen und wählen abwechselnd nach dem festen Ablauf (siehe unten). Die Seite zeigt jederzeit:
|
||||
- welches Team am Zug ist
|
||||
- welche Aktion ansteht
|
||||
- den Zustand jeder Map (verfügbar, gebannt, gewählt, Entscheidungsmap)
|
||||
- den gesamten Verlauf mit Uhrzeit
|
||||
|
||||
Die letzte verbleibende Map wird automatisch als Entscheidungsmap übernommen. Zuschauer und Organisator sehen alles live, können aber nicht wählen.
|
||||
6. **Server konfigurieren:** Nach dem Veto wird der bereits laufende Server automatisch eingerichtet: Spielmodus, erste Map, Teamnamen und Startseiten, Match-Einstellungen, Server- und CSTV-Passwort. Die restliche Map-Reihenfolge ist gespeichert.
|
||||
- Spieler sehen danach den kopierbaren Konsolenbefehl `password …; connect host:port` und einen `steam://connect`-Link.
|
||||
- Zuschauer sehen den CSTV-Zugang (eigener Port, eigenes Passwort, Verzögerung).
|
||||
7. **Während der Serie:** Der Organisator kann die Aufwärmphase beenden (`mp_warmup_end`), die nächste Map laden, den Server stoppen und das Match abschließen oder abbrechen.
|
||||
8. **Fehlerfälle:** Startet der Server nicht, schlägt die Konfiguration fehl oder fällt der Server aus, zeigt die Seite den Grund. Der Organisator bekommt einen Knopf für den erneuten Versuch: „Server neu starten“ bzw. „Konfiguration wiederholen“.
|
||||
|
||||
### Rollen und Berechtigungen
|
||||
|
||||
| Rolle | Zugang über | Darf |
|
||||
|---|---|---|
|
||||
| Organisator | Token beim Erstellen | Veto starten, Spieler entfernen, Captain setzen, Codes erneuern, Server neu starten/stoppen/konfigurieren, nächste Map, Match beenden; sieht Einladungen, Spieler- und CSTV-Verbindung |
|
||||
| Captain | Teamcode (erster Spieler oder ernannt) | alles wie Spieler, dazu Veto-Aktionen seines Teams und Captain-Übergabe |
|
||||
| Spieler | Teamcode | bereit melden, Team verlassen; sieht die Spieler-Verbindungsdaten nach dem Veto |
|
||||
| Zuschauer | Zuschauercode | alles live verfolgen; sieht nur den CSTV-Zugang, nicht das Spielerpasswort |
|
||||
| Gast | ohne Code | nur Teamnamen und Status |
|
||||
|
||||
Alle Prüfungen passieren im Backend. Die Oberfläche blendet nur zusätzlich aus, was die Rolle nicht darf.
|
||||
|
||||
### Schutzmaßnahmen
|
||||
|
||||
- **Fremde Teambeitritte:** Jeder Code gehört zu genau einem Team eines Matches. Zuschauercodes erlauben keinen Beitritt. Volle Teams, doppelte Namen und doppelte Steam-IDs werden abgelehnt. Codebasierte Endpunkte sind pro IP begrenzt (20 Versuche pro Minute).
|
||||
- **Doppelte Aktionen:** Jede Veto-Aktion trägt die Nummer des Schritts. Sie wird in einer SQLite-Transaktion geprüft und gespeichert; `(match_id, idx)` ist Primärschlüssel. Ein doppelter Klick oder ein veralteter Stand liefert „Diese Aktion ist veraltet“. Serveraktionen sind pro Match gesperrt, solange eine läuft.
|
||||
- **Unberechtigte Befehle:** Veto nur durch den Captain des Teams am Zug, Server- und Match-Befehle nur durch den Organisator.
|
||||
- **Tokens:** Organisator- und Spieler-Tokens sind 256 Bit zufällig und werden nur als SHA-256-Hash gespeichert.
|
||||
- **Server-Zugangsdaten:** RCON-Passwort, Container-Handle und GSLT liegen ausschließlich in der Datenbank bzw. den Umgebungsvariablen des Backends. Die Snapshot-Funktion gibt sie nie aus (per Test abgesichert). Server- und CSTV-Passwort sind als Beitrittsdaten nötig und werden nur an die jeweils berechtigte Rolle geliefert.
|
||||
- **Befehls-Injection:** Teamnamen werden vor der Übergabe an die Serverkonsole von Anführungszeichen, `;` und Steuerzeichen bereinigt. Passwörter sind rein alphanumerisch.
|
||||
|
||||
### Wiederherstellung nach Neuladen oder Verbindungsabbruch
|
||||
|
||||
Der gesamte Zustand liegt in SQLite. Der Browser speichert pro Match nur seinen Zugangstoken (`localStorage`).
|
||||
|
||||
- **Neuladen der Seite:** Die Seite lädt den Stand per REST und abonniert danach den Socket.
|
||||
- **Abgebrochene Socket-Verbindung:** Nach dem Reconnect meldet sich der Client automatisch neu an und erhält den vollständigen aktuellen Stand. Socket-Nachrichten enthalten immer den kompletten Zustand und keine Deltas, deshalb geht nichts verloren.
|
||||
- **Backend-Neustart:** Unterbrochene Serverstarts werden als fehlgeschlagen markiert (mit „Server neu starten“). Unterbrochene Konfigurationen werden automatisch wiederholt.
|
||||
|
||||
### Automatisches Herunterfahren
|
||||
|
||||
Alle `SERVER_REAPER_INTERVAL_SECONDS` prüft das Backend alle aktiven Server:
|
||||
|
||||
- Ein Match ist **abgeschlossen oder abgebrochen**: Server wird gestoppt.
|
||||
- **Lobby, Veto oder Server ohne Spielerzahl:** Ist niemand mit der Match-Seite verbunden und gab es länger als `SERVER_IDLE_TIMEOUT_MINUTES` keine Aktivität, wird der Server gestoppt.
|
||||
- **Konfigurierter echter Server:** Gestoppt wird, wenn länger als die Frist keine Spieler auf dem Server waren. Die Spielerzahl kommt per RCON aus `status`.
|
||||
- **Unabhängig davon:** Nach `SERVER_MAX_LIFETIME_HOURS` wird jeder Server gestoppt.
|
||||
|
||||
Ein gestoppter Server lässt sich vom Organisator neu starten.
|
||||
|
||||
---
|
||||
|
||||
## Map-Pools und Veto-Abläufe
|
||||
|
||||
Konfiguration in [`server/config/map-pools.json`](server/config/map-pools.json) (Pfad änderbar über `MAP_POOL_FILE`). Jeder Pool hat:
|
||||
|
||||
- eine eigene Map-Liste mit ID, Anzeigename und Kartenfarbe
|
||||
- Teamgröße und `game_type`/`game_mode`
|
||||
- je Format einen Ablauf
|
||||
|
||||
Schritte im Ablauf:
|
||||
|
||||
| Schritt | Bedeutung |
|
||||
|---|---|
|
||||
| `ban:X` / `ban:Y` | Team X bzw. Y bannt eine Map |
|
||||
| `pick:X` / `pick:Y` | Team X bzw. Y wählt eine Map für die Serie |
|
||||
| `side:X` / `side:Y` | Team wählt die Startseite (CT/T) auf der zuletzt gewählten Map bzw. der Entscheidungsmap |
|
||||
| `decider` | die letzte verbleibende Map wird automatisch übernommen |
|
||||
|
||||
**X** ist das Team, das das Veto beginnt (Münzwurf oder vom Organisator festgelegt), **Y** das andere.
|
||||
|
||||
Beim Start wird jeder Ablauf geprüft:
|
||||
|
||||
- Anzahl Picks = Maps der Serie − 1
|
||||
- Bans + Picks + 1 = Pool-Größe
|
||||
- genau eine Entscheidungsmap
|
||||
- Seitenwahl nur direkt nach einem Pick oder der Entscheidungsmap
|
||||
|
||||
Ungültige oder fehlende Abläufe machen nur dieses Format unauswählbar (mit Begründung in der Oberfläche).
|
||||
|
||||
### Mitgelieferte Abläufe
|
||||
|
||||
**Competitive (7 Maps):** Ancient, Anubis, Dust II, Inferno, Mirage, Nuke, Train
|
||||
|
||||
| Format | Ablauf |
|
||||
|---|---|
|
||||
| BO1 | X bannt, Y bannt, X bannt, Y bannt, X bannt, Y bannt → Entscheidungsmap, X wählt Seite |
|
||||
| BO3 | X bannt, Y bannt, X pickt (Y wählt Seite), Y pickt (X wählt Seite), X bannt, Y bannt → Entscheidungsmap, X wählt Seite |
|
||||
| BO5 | X bannt, Y bannt, X pickt (Y Seite), Y pickt (X Seite), X pickt (Y Seite), Y pickt (X Seite) → Entscheidungsmap, Y wählt Seite |
|
||||
|
||||
**Wingman (4 Maps):** Inferno, Nuke, Overpass, Vertigo
|
||||
|
||||
| Format | Ablauf |
|
||||
|---|---|
|
||||
| BO1 | X bannt, Y bannt, X bannt → Entscheidungsmap, Y wählt Seite |
|
||||
| BO3 | X bannt, Y pickt (X Seite), X pickt (Y Seite) → Entscheidungsmap, X wählt Seite |
|
||||
| BO5 | **nicht unterstützt:** Mit 4 Maps sind keine 5 Karten möglich. Mit einem Pool von mindestens 5 Maps und einem `bo5`-Ablauf wird es automatisch auswählbar. |
|
||||
|
||||
### Regel für Startseiten
|
||||
|
||||
- Auf einer **gewählten Map** wählt immer das **Gegnerteam des wählenden Teams** die Startseite.
|
||||
- Auf der **Entscheidungsmap** wählt das Team, das im Ablauf dafür vorgesehen ist. In BO1 ist das in Competitive Team X, weil Y den letzten Ban hatte, und in Wingman Team Y. Ohne `side`-Schritt werden die Seiten im Spiel festgelegt.
|
||||
|
||||
Die Wahl wird als `mp_teamname_1` (Team mit CT-Start) und `mp_teamname_2` (T-Start) an den Server übergeben.
|
||||
|
||||
> Die mitgelieferten Map-Pools entsprechen dem Stand der Entwicklung. Valve ändert den aktiven Map-Pool regelmäßig. Map-IDs bitte gegen die eigene Serverinstallation prüfen. Eine Map, die nicht existiert, führt zu „Konfiguration fehlgeschlagen“ mit Meldung.
|
||||
|
||||
---
|
||||
|
||||
## Echte Server anbinden
|
||||
|
||||
Die Serververwaltung ist hinter [`server/src/servers/adapter.js`](server/src/servers/adapter.js) gekapselt. Auswahl über `SERVER_ADAPTER`.
|
||||
|
||||
### Ablauf, den jeder Adapter bedient
|
||||
|
||||
1. **`start({ matchId, mode, secrets })`**: Server starten bzw. reservieren.
|
||||
- Rückgabe: `{ handle, host, port, tvPort }`.
|
||||
- `secrets` (RCON-, Server- und CSTV-Passwort) erzeugt das Backend pro Match.
|
||||
2. **`status(handle)`**: liefert `starting | running | stopped | failed`, optional mit Spielerzahl.
|
||||
- Wird alle `SERVER_STATUS_POLL_SECONDS` abgefragt, solange der Server startet, danach alle 30 s.
|
||||
3. **`configure(handle, plan)`**: in zwei Phasen.
|
||||
1. `game_type`, `game_mode`, `sv_password`, `tv_enable`, `tv_delay`, `tv_password`, dann `changelevel <map>`.
|
||||
2. Warten, bis `status` die Map meldet (max. 2 Minuten), dann die Match-Einstellungen (`mp_teamname_1/2`, `mp_maxrounds`, `mp_overtime_enable`, `mp_match_can_clinch`, `mp_warmup_pausetimer 1`, `CS2_EXTRA_COMMANDS`).
|
||||
|
||||
Die zweite Phase kommt erst nach dem Mapwechsel, weil die `gamemode_*.cfg` des Spiels diese Werte beim Laden der Map sonst überschreibt.
|
||||
4. **`stop(handle)`**: Server beenden bzw. freigeben.
|
||||
|
||||
### Option A: Docker (`SERVER_ADAPTER=docker`)
|
||||
|
||||
Startet pro Match einen Container mit dem Community-Image [`cm2network/cs2`](https://github.com/CM2Walki/CS2) (SteamCMD, CS2-Dedicated-Server App 730).
|
||||
|
||||
Voraussetzungen:
|
||||
|
||||
1. Docker auf dem Backend-Host. Das Backend ruft die Docker-CLI auf und braucht dafür Zugriff auf den Docker-Daemon.
|
||||
2. **Game Server Login Token** für App-ID 730 unter <https://steamcommunity.com/dev/managegameservers> erstellen und als `CS2_GSLT` eintragen. Ohne GSLT sind Server nicht öffentlich erreichbar. Pro gleichzeitig laufendem Server ist ein eigenes Token empfehlenswert.
|
||||
3. `CS2_PUBLIC_HOST`: IP oder DNS-Name, unter dem Spieler den Host erreichen.
|
||||
4. Ports `CS2_PORT_RANGE_START`–`CS2_PORT_RANGE_END` in Firewall bzw. Router freigeben:
|
||||
- pro Match zwei aufeinanderfolgende Ports
|
||||
- Spielport per UDP und TCP (TCP wird für RCON genutzt, nur für das Backend freigeben)
|
||||
- CSTV-Port per UDP
|
||||
5. Etwa 60 GB Speicher für die Spieldateien im Volume `CS2_DOCKER_VOLUME`. Das Volume wird von allen Match-Containern geteilt. Der erste Start lädt das Spiel herunter und kann deutlich länger als 10 Minuten dauern (`CS2_START_TIMEOUT_MINUTES`). Spieldateien am besten vorab mit einem einzelnen Container laden, bevor mehrere Matches gleichzeitig starten.
|
||||
|
||||
Container heißen `cs2mv-<matchId>` und tragen das Label `cs2-mapveto=1`. Stoppen bedeutet `docker rm -f`.
|
||||
|
||||
### Option B: Vorhandene Server (`SERVER_ADAPTER=static`)
|
||||
|
||||
Für selbst betriebene, dauerhaft laufende CS2-Server:
|
||||
|
||||
```env
|
||||
SERVER_ADAPTER=static
|
||||
CS2_STATIC_SERVERS=203.0.113.10:27015:rconPasswort1:27020,203.0.113.10:27025:rconPasswort2:27030
|
||||
```
|
||||
|
||||
Auf jedem Server müssen gesetzt sein:
|
||||
|
||||
- `rcon_password`
|
||||
- CSTV-Port `tv_port`, falls abweichend von Spielport + 5 (dieser Wert wird angenommen, wenn kein vierter Teil angegeben ist)
|
||||
- ein GSLT (`sv_setsteamaccount`)
|
||||
|
||||
Bei diesem Adapter bedeutet „Start“: einen freien Server reservieren und per RCON prüfen. „Stopp“ gibt ihn frei und setzt ein neues zufälliges `sv_password`. Der Serverprozess selbst wird nicht beendet.
|
||||
|
||||
### Eigener Adapter (z. B. Hosting-API)
|
||||
|
||||
1. Klasse mit `name`, `simulated = false` und den vier Methoden anlegen. Vorlage: `dockerAdapter.js`. Für die Konfiguration per RCON gibt es `configureViaRcon` aus `rconConfigure.js`.
|
||||
2. In `createServerAdapter` in `adapter.js` registrieren.
|
||||
3. Zugangsdaten der Hosting-API ausschließlich über Umgebungsvariablen in `config.js` einlesen.
|
||||
|
||||
### Verbindung für Spieler
|
||||
|
||||
```
|
||||
password <serverpasswort>; connect <host>:<port>
|
||||
```
|
||||
|
||||
Zusätzlich bietet die Seite einen `steam://connect/<host>:<port>/<passwort>`-Link an.
|
||||
|
||||
### Zuschauer: CSTV (früher GOTV)
|
||||
|
||||
CS2 bringt **CSTV** mit (in der Konsole weiterhin `tv_*`-Befehle; früher GOTV). Die Anwendung setzt:
|
||||
|
||||
- `tv_enable 1` (greift erst nach dem Mapwechsel, deshalb vor `changelevel`)
|
||||
- `tv_delay` (Standard 90 s) als Verzögerung gegen Ghosting
|
||||
- `tv_password` als eigenes Passwort, getrennt vom Spielerpasswort
|
||||
|
||||
Zuschauer verbinden sich mit `password <tvpasswort>; connect <host>:<tvPort>`. Sie belegen keinen Spielerplatz und können die Spieler nicht beeinflussen. Für größere Zuschauerzahlen unterstützt CS2 CSTV-Relays (`tv_relay`) und HTTP-Broadcast (`tv_broadcast_url`). Beides ist nicht eingebaut und kann über `CS2_EXTRA_COMMANDS` bzw. die Serverkonfiguration ergänzt werden.
|
||||
|
||||
### Prüfstand der CS2-Angaben
|
||||
|
||||
Die offizielle Seite [Counter-Strike 2/Dedicated Servers](https://developer.valvesoftware.com/wiki/Counter-Strike_2/Dedicated_Servers) im Valve Developer Wiki war während der Entwicklung wegen eines Bot-Schutzes nicht automatisiert abrufbar. Grundlage waren:
|
||||
|
||||
- das [Source-RCON-Protokoll](https://developer.valvesoftware.com/wiki/Source_RCON_Protocol)
|
||||
- die Dokumentation des Images [cm2network/cs2](https://github.com/CM2Walki/CS2): Umgebungsvariablen, Ports, `TV_*`
|
||||
- Hoster-Anleitungen zu CSTV, z. B. [ZAP-Hosting](https://zap-hosting.com/guides/de/docs/cs2-cstv)
|
||||
- [FACEIT zu CSTV-Zuschauern](https://support.faceit.com/hc/en-us/articles/17050697184668)
|
||||
|
||||
Bekannte Werte: `game_type 0` + `game_mode 1` = Competitive, `game_mode 2` = Wingman; CSTV-Standardport 27020.
|
||||
|
||||
**Nicht gegen einen echten CS2-Server getestet:** Docker- und Static-Adapter sowie die Auswertung der `status`-Ausgabe (Map-Name, Spielerzahl). Das RCON-Protokoll ist gegen einen Test-Server geprüft. Vor dem ersten echten Einsatz bitte ein Testmatch durchspielen und bei Bedarf `parseStatus` in `server/src/servers/rcon.js` an die tatsächliche Ausgabe anpassen.
|
||||
|
||||
### Bekannte Grenzen von Vanilla-CS2
|
||||
|
||||
- Ohne Server-Plugin kann CS2 Spieler **nicht automatisch einem Team zuordnen**. Spieler wählen ihr Team im Spiel selbst. Teamnamen und Startseiten setzt die Anwendung. Für feste Teamzuordnung, Pausen und automatischen Mapwechsel eignen sich Plugins wie MatchZy oder Get5-Nachfolger. Sie lassen sich über einen eigenen Adapter anbinden; die SteamID64 der Spieler wird dafür bereits erfasst.
|
||||
- Ergebnisse werden nicht ausgelesen. Der Organisator lädt die nächste Map manuell.
|
||||
|
||||
---
|
||||
|
||||
## Umgebungsvariablen
|
||||
|
||||
Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die wichtigsten:
|
||||
|
||||
| Variable | Standard | Zweck |
|
||||
|---|---|---|
|
||||
| `PORT` | `3000` | Port des Backends |
|
||||
| `PUBLIC_URL` | `http://localhost:5173` | Basis für Einladungslinks |
|
||||
| `DATABASE_FILE` | `data/cs2-mapveto.db` | SQLite-Datei |
|
||||
| `CREATE_MATCH_PASSWORD` | leer | Passwort zum Erstellen von Matches |
|
||||
| `REQUIRE_FULL_TEAMS` | `true` | Veto nur mit vollständigen Teams |
|
||||
| `SERVER_ADAPTER` | `demo` | `demo`, `docker` oder `static` |
|
||||
| `SERVER_IDLE_TIMEOUT_MINUTES` | `30` | Frist bis zum automatischen Herunterfahren |
|
||||
| `SERVER_MAX_LIFETIME_HOURS` | `8` | harte Obergrenze pro Server |
|
||||
| `CS2_TV_DELAY` | `90` | CSTV-Verzögerung in Sekunden |
|
||||
| `CS2_GSLT` | – | **Pflicht bei Docker:** Game Server Login Token |
|
||||
| `CS2_PUBLIC_HOST` | – | **Pflicht bei Docker:** öffentliche Adresse |
|
||||
| `CS2_STATIC_SERVERS` | – | **Pflicht bei Static:** Serverliste |
|
||||
| `DEMO_FAIL_FIRST_CONFIGURE` | `false` | Demo: Konfigurationsfehler simulieren |
|
||||
|
||||
Benötigte Zugangsdaten für den Echtbetrieb:
|
||||
|
||||
- **Docker:** GSLT, Zugriff auf den Docker-Daemon, öffentliche IP bzw. Portfreigaben
|
||||
- **Static:** RCON-Passwörter der Server
|
||||
|
||||
RCON-Passwörter für Docker-Container erzeugt das Backend selbst.
|
||||
|
||||
---
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
client/ React-Frontend (Vite)
|
||||
src/pages/ Übersicht, Match erstellen, Match-Seite, Beitritt
|
||||
src/components/ Teamkarten, Veto-Board, Serverpanel, Einladungen, Verlauf
|
||||
src/lib/ API, Socket-Hook, lokale Zugangsdaten
|
||||
server/
|
||||
config/map-pools.json Map-Pools und Veto-Abläufe
|
||||
src/veto.js reine Veto-Logik und Ablaufvalidierung
|
||||
src/matchService.js Lobby, Rollen, Veto, rollenabhängige Snapshots
|
||||
src/servers/ Adapter-Schnittstelle, Demo/Docker/Static, RCON, ServerManager
|
||||
src/http.js REST-API
|
||||
src/realtime.js Socket.IO
|
||||
test/ node:test-Tests
|
||||
```
|
||||
|
||||
### REST-API (Auszug)
|
||||
|
||||
Alle Aktionen laufen per `POST` mit `Authorization: Bearer <token>`. Der Token ist der Organisator- oder Spieler-Token bzw. der Zuschauercode.
|
||||
|
||||
| Endpunkt | Wer |
|
||||
|---|---|
|
||||
| `GET /api/config`, `GET /api/matches` | alle |
|
||||
| `POST /api/matches` | alle (ggf. mit `CREATE_MATCH_PASSWORD`) |
|
||||
| `POST /api/access` `{code}` | prüft einen Code |
|
||||
| `GET /api/matches/:id` | rollenabhängiger Zustand |
|
||||
| `POST /api/matches/:id/join` `{code, nickname, steamId?}` | mit Teamcode |
|
||||
| `…/ready`, `…/leave` | Spieler |
|
||||
| `…/veto/action` `{stepIndex, map?, side?}` | Captain am Zug |
|
||||
| `…/captain` `{playerId}` | Organisator, Captain des Teams |
|
||||
| `…/veto/start`, `…/kick`, `…/codes/regenerate`, `…/server/retry`, `…/server/stop`, `…/server/warmup-end`, `…/series/next`, `…/finish`, `…/cancel` | Organisator |
|
||||
|
||||
Socket.IO: `match:subscribe {matchId, token}` liefert `match:state` bei jeder Änderung; `overview:subscribe` liefert `overview`.
|
||||
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#10151f" />
|
||||
<title>CS2 Mapveto</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path d='M4 4h24l-4 24H8z' fill='%235ba4e6'/><path d='M10 10l12 12M22 10L10 22' stroke='%2310151f' stroke-width='3'/></svg>" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=Barlow:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, Route, Routes } from 'react-router-dom';
|
||||
import { api } from './lib/api.js';
|
||||
import Overview from './pages/Overview.jsx';
|
||||
import CreateMatch from './pages/CreateMatch.jsx';
|
||||
import MatchPage from './pages/MatchPage.jsx';
|
||||
import { JoinPage, WatchPage } from './pages/AccessPages.jsx';
|
||||
|
||||
export default function App() {
|
||||
const [config, setConfig] = useState(null);
|
||||
const [configError, setConfigError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
api('/config').then(setConfig).catch((e) => setConfigError(e.message));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="topbar">
|
||||
<Link to="/" className="brand" aria-label="Zur Übersicht">
|
||||
<svg viewBox="0 0 32 32" width="26" height="26" aria-hidden="true">
|
||||
<path d="M4 4h24l-4 24H8z" fill="var(--ct)" />
|
||||
<path d="M10 10l12 12M22 10L10 22" stroke="var(--bg)" strokeWidth="3" />
|
||||
</svg>
|
||||
<span>CS2 Mapveto</span>
|
||||
</Link>
|
||||
{config?.server.simulated && (
|
||||
<span className="demo-flag" title="SERVER_ADAPTER=demo – es werden keine echten CS2-Server gestartet">
|
||||
Demo-Modus: keine echten Server
|
||||
</span>
|
||||
)}
|
||||
<nav>
|
||||
<Link to="/" className="nav-link">Übersicht</Link>
|
||||
<Link to="/neu" className="btn btn-primary btn-sm">Match erstellen</Link>
|
||||
</nav>
|
||||
</header>
|
||||
<main className="content">
|
||||
{configError && <div className="alert alert-error">Backend nicht erreichbar: {configError}</div>}
|
||||
<Routes>
|
||||
<Route path="/" element={<Overview />} />
|
||||
<Route path="/neu" element={<CreateMatch config={config} />} />
|
||||
<Route path="/m/:id" element={<MatchPage />} />
|
||||
<Route path="/join/:code" element={<JoinPage config={config} />} />
|
||||
<Route path="/watch/:code" element={<WatchPage />} />
|
||||
<Route path="*" element={<div className="empty"><h2>Seite nicht gefunden</h2><Link to="/">Zur Übersicht</Link></div>} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function CopyField({ label, value, secret = false }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [revealed, setRevealed] = useState(!secret);
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} catch {
|
||||
// Fallback für Browser ohne Clipboard-API (z. B. HTTP ohne localhost)
|
||||
const el = document.createElement('textarea');
|
||||
el.value = value;
|
||||
document.body.appendChild(el);
|
||||
el.select();
|
||||
document.execCommand('copy');
|
||||
el.remove();
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
};
|
||||
return (
|
||||
<div className="copy-field">
|
||||
{label && <span className="copy-label">{label}</span>}
|
||||
<div className="copy-row">
|
||||
<code className={revealed ? '' : 'blurred'} onClick={() => setRevealed(true)}>{value}</code>
|
||||
{secret && !revealed && <button type="button" className="link-btn" onClick={() => setRevealed(true)}>Anzeigen</button>}
|
||||
<button type="button" className="btn btn-sm" onClick={copy}>{copied ? 'Kopiert' : 'Kopieren'}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { clock } from '../lib/labels.js';
|
||||
|
||||
export default function EventLog({ events }) {
|
||||
return (
|
||||
<section className="panel event-log">
|
||||
<h2>Verlauf</h2>
|
||||
{events.length === 0 ? <p className="muted small">Noch keine Ereignisse.</p> : (
|
||||
<ol>
|
||||
{events.map((e, i) => (
|
||||
<li key={`${e.at}-${i}`} className={`ev-${e.type}`}>
|
||||
<time>{clock(e.at)}</time>
|
||||
<span>{e.message}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import CopyField from './CopyField.jsx';
|
||||
|
||||
export default function OrganizerPanel({ state, act, busy, matchId, token }) {
|
||||
const { codes, links } = state.organizer;
|
||||
const open = !['finished', 'cancelled'].includes(state.status);
|
||||
const orgLink = `${window.location.origin}/m/${matchId}#org=${token}`;
|
||||
|
||||
const regen = (target, label) =>
|
||||
window.confirm(`Neuen Code für ${label} erzeugen? Der alte Link funktioniert dann nicht mehr. Bereits beigetretene Spieler bleiben im Team.`) &&
|
||||
act('codes/regenerate', { target }, `Neuer Code für ${label}`);
|
||||
|
||||
return (
|
||||
<section className="panel organizer-panel">
|
||||
<h2>Einladungen</h2>
|
||||
<p className="small muted">Jeder Link führt nur in das jeweilige Team. Gib ihn ausschließlich an dieses Team weiter.</p>
|
||||
{[
|
||||
['A', state.teams.A.name, 'team-A'],
|
||||
['B', state.teams.B.name, 'team-B'],
|
||||
['spectator', 'Zuschauer (Stream, Kommentatoren)', 'spectator'],
|
||||
].map(([key, label, cls]) => (
|
||||
<div key={key} className={`invite ${cls}`}>
|
||||
<div className="invite-head">
|
||||
<strong>{label}</strong>
|
||||
<span className="invite-code">{codes[key]}</span>
|
||||
{open && <button className="link-btn" disabled={!!busy} onClick={() => regen(key, label)}>Neuer Code</button>}
|
||||
</div>
|
||||
<CopyField value={links[key]} />
|
||||
</div>
|
||||
))}
|
||||
<details className="org-link">
|
||||
<summary>Organisator-Zugang auf einem anderen Gerät öffnen</summary>
|
||||
<p className="small muted">Dieser Link gibt volle Kontrolle über das Match. Nicht weitergeben.</p>
|
||||
<CopyField value={orgLink} secret />
|
||||
</details>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
const STATUS = { pending: 'Ausstehend', next: 'Wird eingerichtet', live: 'Aktuell', done: 'Gespielt' };
|
||||
|
||||
export default function SeriesList({ state }) {
|
||||
const { teams } = state;
|
||||
return (
|
||||
<div className="series">
|
||||
<h2>Map-Reihenfolge</h2>
|
||||
<ol className="series-list">
|
||||
{state.series.map((s) => {
|
||||
const map = state.maps.find((m) => m.id === s.map);
|
||||
const ct = s.ctTeam ? teams[s.ctTeam].name : null;
|
||||
const t = s.ctTeam ? teams[s.ctTeam === 'A' ? 'B' : 'A'].name : null;
|
||||
return (
|
||||
<li key={s.number} className={`series-item is-${s.status}`} style={{ '--map': map?.color ?? '#556' }}>
|
||||
<span className="series-no">Map {s.number}</span>
|
||||
<strong className="series-map">{s.mapName}</strong>
|
||||
<span className="series-origin">
|
||||
{s.pickedBy ? `Pick von ${teams[s.pickedBy].name}` : 'Entscheidungsmap'}
|
||||
</span>
|
||||
<span className="series-sides">
|
||||
{ct ? <><span className="side-ct">CT: {ct}</span> <span className="side-t">T: {t}</span></> : 'Seiten im Spiel festlegen'}
|
||||
</span>
|
||||
<span className="series-status">{STATUS[s.status]}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { serverLabel } from '../lib/labels.js';
|
||||
import CopyField from './CopyField.jsx';
|
||||
|
||||
const RETRY = {
|
||||
start_failed: 'Server neu starten',
|
||||
failed: 'Server neu starten',
|
||||
stopped: 'Server neu starten',
|
||||
config_failed: 'Konfiguration wiederholen',
|
||||
ready: 'Konfiguration starten',
|
||||
};
|
||||
const ACTIVE = ['starting', 'ready', 'configuring', 'configured', 'config_failed', 'failed'];
|
||||
|
||||
export default function ServerPanel({ state, isOrg, act, busy }) {
|
||||
const { server } = state;
|
||||
const label = serverLabel(server);
|
||||
const closed = ['finished', 'cancelled'].includes(state.status);
|
||||
const sim = server?.simulated;
|
||||
const canRetry = server && !closed && RETRY[server.status] && (server.status !== 'ready' || state.status === 'server_setup');
|
||||
const hasNext = state.series.some((s) => s.number === (state.currentMapNumber ?? 0) + 1);
|
||||
|
||||
return (
|
||||
<section className={`panel server-panel tone-border-${label.tone}`}>
|
||||
<h2>Spielserver</h2>
|
||||
<div className="server-status">
|
||||
<span className={`status-dot tone-${label.tone} ${['starting', 'configuring'].includes(server?.status) ? 'pulse' : ''}`} />
|
||||
<strong className={`tone-${label.tone}`}>{label.text}</strong>
|
||||
</div>
|
||||
{server?.message && <p className="small">{server.message}</p>}
|
||||
|
||||
{sim && (
|
||||
<div className="alert alert-demo">
|
||||
Demo-Adapter aktiv: Es läuft kein echter CS2-Server. Start, Konfiguration und Verbindungsdaten sind simuliert.
|
||||
Wie die echte Anbindung eingerichtet wird, steht in der README.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!server && <p className="muted small">Für dieses Match wurde noch kein Server angefordert.</p>}
|
||||
{server && ['starting', 'ready'].includes(server.status) && state.status !== 'server_setup' && (
|
||||
<p className="muted small">
|
||||
{sim ? 'Der Start wird während des Vetos simuliert.' : 'Der Server fährt während des Vetos hoch.'} Die Verbindungsdaten erscheinen nach dem Veto.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{server?.connect && (
|
||||
<div className="connect">
|
||||
<h3>{sim ? 'Spieler-Verbindung (Demo-Beispiel, nicht verbindbar)' : 'Verbindung für Spieler'}</h3>
|
||||
<CopyField label="In die CS2-Konsole einfügen" value={server.connect.command} secret />
|
||||
{!sim && <a className="btn btn-primary" href={server.connect.steamUrl}>In CS2 verbinden</a>}
|
||||
<p className="small muted">Server {server.connect.host}:{server.connect.port}. Das Passwort ist nur für Spieler dieses Matches sichtbar.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{server?.gotv && (
|
||||
<div className="connect gotv">
|
||||
<h3>{sim ? 'CSTV/GOTV (Demo-Beispiel, nicht verbindbar)' : 'Zuschauen über CSTV (GOTV)'}</h3>
|
||||
<CopyField label="Für Stream und Kommentatoren" value={server.gotv.command} secret />
|
||||
<p className="small muted">
|
||||
Übertragung mit {server.gotv.delaySeconds} Sekunden Verzögerung gegen Ghosting. Zuschauer belegen keinen Spielerplatz.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{state.viewer.role === 'spectator' && server?.status === 'configured' && !server.gotv && (
|
||||
<p className="small muted">CSTV ist auf diesem Server deaktiviert.</p>
|
||||
)}
|
||||
|
||||
{isOrg && server && !closed && (
|
||||
<div className="org-controls">
|
||||
{canRetry && (
|
||||
<button className="btn btn-primary" disabled={!!busy} onClick={() => act('server/retry', null, 'Erneuter Versuch gestartet')}>
|
||||
{RETRY[server.status]}
|
||||
</button>
|
||||
)}
|
||||
{server.status === 'configured' && (
|
||||
<button className="btn" disabled={!!busy} onClick={() => act('server/warmup-end', null, 'Aufwärmphase beendet')}>
|
||||
Aufwärmphase beenden
|
||||
</button>
|
||||
)}
|
||||
{server.status === 'configured' && state.status === 'live' && hasNext && (
|
||||
<button className="btn" disabled={!!busy} onClick={() => act('series/next', null, 'Nächste Map wird geladen')}>
|
||||
Nächste Map laden
|
||||
</button>
|
||||
)}
|
||||
{ACTIVE.includes(server.status) && (
|
||||
<button className="btn btn-ghost" disabled={!!busy}
|
||||
onClick={() => window.confirm('Server wirklich stoppen?') && act('server/stop', null, 'Server gestoppt')}>
|
||||
Server stoppen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isOrg && !closed && (
|
||||
<div className="org-controls">
|
||||
{['live', 'server_setup'].includes(state.status) && (
|
||||
<button className="btn" disabled={!!busy}
|
||||
onClick={() => window.confirm('Match abschließen und Server herunterfahren?') && act('finish', null, 'Match abgeschlossen')}>
|
||||
Match abschließen
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-ghost danger" disabled={!!busy}
|
||||
onClick={() => window.confirm('Match abbrechen? Das kann nicht rückgängig gemacht werden.') && act('cancel', null, 'Match abgebrochen')}>
|
||||
Match abbrechen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{server && ACTIVE.includes(server.status) && (
|
||||
<p className="small muted">Ungenutzte Server werden nach {server.idleTimeoutMinutes} Minuten automatisch heruntergefahren.</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
export default function TeamCard({ team, side, state, viewer, isOrg, act, busy }) {
|
||||
const isOwnTeam = viewer.team === side;
|
||||
const inLobby = state.status === 'lobby';
|
||||
const me = team.players.find((p) => p.isYou);
|
||||
const emptySlots = Math.max(0, team.teamSize - team.players.length);
|
||||
const canManageCaptain = isOrg || (viewer.role === 'captain' && isOwnTeam);
|
||||
const { readiness } = team;
|
||||
|
||||
return (
|
||||
<section className={`team-card team-${side} ${readiness.ready ? 'is-ready' : ''}`} aria-label={`Team ${team.name}`}>
|
||||
<header>
|
||||
<h2>{team.name}</h2>
|
||||
<span className={`ready-state ${readiness.ready ? 'on' : ''}`}>
|
||||
{readiness.ready ? 'Bereit' : `${readiness.count}/${team.teamSize}`}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<ul className="roster">
|
||||
{team.players.map((p) => (
|
||||
<li key={p.id} className={`${p.isYou ? 'is-you' : ''}`}>
|
||||
<span className={`presence ${p.online ? 'on' : ''}`} title={p.online ? 'Online' : 'Offline'} />
|
||||
<span className="nick">
|
||||
{p.nickname}
|
||||
{p.isYou && <small> (du)</small>}
|
||||
</span>
|
||||
{p.isCaptain && <span className="captain" title="Captain – führt das Veto">Captain</span>}
|
||||
{inLobby && <span className={`ready-dot ${p.ready ? 'on' : ''}`}>{p.ready ? 'bereit' : 'wartet'}</span>}
|
||||
{(canManageCaptain && !p.isCaptain) || (isOrg && inLobby) ? (
|
||||
<span className="row-actions">
|
||||
{canManageCaptain && !p.isCaptain && (
|
||||
<button className="link-btn" disabled={!!busy} onClick={() => act('captain', { playerId: p.id }, `${p.nickname} ist jetzt Captain`)}>
|
||||
Zum Captain machen
|
||||
</button>
|
||||
)}
|
||||
{isOrg && inLobby && (
|
||||
<button className="link-btn danger" disabled={!!busy} onClick={() => act('kick', { playerId: p.id }, `${p.nickname} entfernt`)}>
|
||||
Entfernen
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
{Array.from({ length: emptySlots }, (_, i) => (
|
||||
<li key={`empty-${i}`} className="slot-empty">Freier Platz</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{me && inLobby && (
|
||||
<div className="team-actions">
|
||||
<button className={`btn ${me.ready ? '' : 'btn-primary'}`} disabled={!!busy}
|
||||
onClick={() => act('ready', { ready: !me.ready })}>
|
||||
{me.ready ? 'Doch nicht bereit' : 'Ich bin bereit'}
|
||||
</button>
|
||||
<button className="link-btn" disabled={!!busy}
|
||||
onClick={() => window.confirm('Lobby wirklich verlassen?') && act('leave', null, 'Du hast das Team verlassen')}>
|
||||
Team verlassen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{inLobby && !readiness.enough && <p className="small muted">Es fehlen noch {team.teamSize - readiness.count} Spieler.</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { clock } from '../lib/labels.js';
|
||||
|
||||
const STEP_SHORT = { ban: 'Ban', pick: 'Pick', side: 'Seite', decider: 'Decider' };
|
||||
|
||||
export default function VetoBoard({ state, viewer, act, busy, compact = false }) {
|
||||
const { veto, teams } = state;
|
||||
const { current } = veto;
|
||||
const [selected, setSelected] = useState(null);
|
||||
useEffect(() => setSelected(null), [current?.index]);
|
||||
|
||||
const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m]));
|
||||
const yourTurn = !!current && viewer.role === 'captain' && viewer.team === current.team;
|
||||
const choosingMap = yourTurn && (current.type === 'ban' || current.type === 'pick');
|
||||
const sideMap = current?.type === 'side' ? mapsById[current.map] : null;
|
||||
|
||||
const submitMap = () => act('veto/action', { stepIndex: current.index, map: selected });
|
||||
const submitSide = (side) => act('veto/action', { stepIndex: current.index, side });
|
||||
|
||||
return (
|
||||
<div className={`veto ${compact ? 'veto-compact' : ''}`}>
|
||||
{current && (
|
||||
<div className={`turn turn-${current.team} ${yourTurn ? 'is-you' : ''}`} aria-live="polite">
|
||||
<span className="turn-team">{teams[current.team].name}</span>
|
||||
<span className="turn-action">
|
||||
{current.type === 'ban' && 'bannt eine Map'}
|
||||
{current.type === 'pick' && 'wählt eine Map'}
|
||||
{current.type === 'side' && `wählt die Startseite auf ${sideMap?.name}`}
|
||||
</span>
|
||||
<span className="turn-hint">
|
||||
{yourTurn
|
||||
? 'Du bist am Zug.'
|
||||
: viewer.team === current.team
|
||||
? 'Dein Captain entscheidet.'
|
||||
: viewer.role === 'spectator' || viewer.role === 'organizer'
|
||||
? 'Live-Ansicht – nur Captains können wählen.'
|
||||
: 'Das andere Team ist am Zug.'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!current && veto.done && !compact && <div className="turn turn-done">Veto abgeschlossen</div>}
|
||||
|
||||
<div className="map-grid" role="list">
|
||||
{veto.maps.map((m) => {
|
||||
const meta = mapsById[m.id];
|
||||
const selectable = choosingMap && m.status === 'available';
|
||||
const isSel = selected === m.id;
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
role="listitem"
|
||||
className={`map-tile status-${m.status} ${m.by ? `by-${m.by}` : ''} ${isSel ? `is-selected sel-${current.type}` : ''} ${sideMap?.id === m.id ? 'is-side' : ''}`}
|
||||
style={{ '--map': meta?.color ?? '#556' }}
|
||||
disabled={!selectable || !!busy}
|
||||
onClick={() => setSelected(isSel ? null : m.id)}
|
||||
aria-pressed={isSel}
|
||||
aria-label={`${meta.name}: ${statusText(m, teams)}`}
|
||||
>
|
||||
<span className="map-art" aria-hidden="true" />
|
||||
<span className="map-name">{meta.name}</span>
|
||||
<span className="map-status">{statusText(m, teams)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{choosingMap && (
|
||||
<div className="veto-confirm">
|
||||
<button className={`btn btn-lg ${current.type === 'ban' ? 'btn-danger' : 'btn-primary'}`} disabled={!selected || !!busy} onClick={submitMap}>
|
||||
{selected
|
||||
? `${mapsById[selected].name} ${current.type === 'ban' ? 'bannen' : 'wählen'}`
|
||||
: `Map zum ${current.type === 'ban' ? 'Bannen' : 'Wählen'} antippen`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{yourTurn && current.type === 'side' && (
|
||||
<div className="side-choice">
|
||||
<p>Auf welcher Seite startet {teams[viewer.team].name} auf {sideMap?.name}?</p>
|
||||
<div className="row">
|
||||
<button className="btn btn-ct btn-lg" disabled={!!busy} onClick={() => submitSide('CT')}>Counter-Terrorists</button>
|
||||
<button className="btn btn-t btn-lg" disabled={!!busy} onClick={() => submitSide('T')}>Terrorists</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ol className="step-track" aria-label="Veto-Ablauf">
|
||||
{veto.steps.map((s) => (
|
||||
<li key={s.index} className={`step step-${s.type} ${s.team ? `step-${s.team}` : ''} ${s.done ? 'done' : ''} ${current?.index === s.index ? 'now' : ''}`}
|
||||
title={s.team ? `${teams[s.team].name}: ${STEP_SHORT[s.type]}` : 'Übrig gebliebene Map'}>
|
||||
<span>{STEP_SHORT[s.type]}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{veto.history.length > 0 && !compact && (
|
||||
<ol className="veto-history">
|
||||
{veto.history.map((h) => (
|
||||
<li key={h.index} className={`hist-${h.type} ${h.team ? `hist-${h.team}` : ''}`}>
|
||||
<time>{clock(h.at)}</time> {h.text}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusText(m, teams) {
|
||||
switch (m.status) {
|
||||
case 'banned': return `Gebannt von ${teams[m.by].name}`;
|
||||
case 'picked': return `Gewählt von ${teams[m.by].name}`;
|
||||
case 'decider': return 'Entscheidungsmap';
|
||||
default: return 'Verfügbar';
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App.jsx';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { api } from '../lib/api.js';
|
||||
import { getAccess, setAccess } from '../lib/session.js';
|
||||
|
||||
function useCode() {
|
||||
const { code } = useParams();
|
||||
const [info, setInfo] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
useEffect(() => {
|
||||
api('/access', { method: 'POST', body: { code } }).then(setInfo).catch((e) => setError(e.message));
|
||||
}, [code]);
|
||||
return { code, info, error };
|
||||
}
|
||||
|
||||
export function JoinPage() {
|
||||
const { code, info, error } = useCode();
|
||||
const navigate = useNavigate();
|
||||
const [nickname, setNickname] = useState('');
|
||||
const [steamId, setSteamId] = useState('');
|
||||
const [submitError, setSubmitError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!info) return;
|
||||
if (info.access === 'spectator') navigate(`/watch/${code}`, { replace: true });
|
||||
// Schon beigetreten? Dann direkt zur Lobby.
|
||||
else if (getAccess(info.matchId)) navigate(`/m/${info.matchId}`, { replace: true });
|
||||
}, [info, code, navigate]);
|
||||
|
||||
if (error) return <AccessError message={error} />;
|
||||
if (!info) return <p className="muted">Prüfe Code …</p>;
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const res = await api(`/matches/${info.matchId}/join`, { method: 'POST', body: { code, nickname, steamId } });
|
||||
setAccess(info.matchId, { token: res.playerToken, kind: 'player' });
|
||||
navigate(`/m/${info.matchId}`);
|
||||
} catch (err) {
|
||||
setSubmitError(err.message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={`join-card team-${info.team}`} onSubmit={submit}>
|
||||
<p className="muted">{info.title}</p>
|
||||
<h1>Beitreten als {info.teamName}</h1>
|
||||
<label className="field">
|
||||
<span>Spielername</span>
|
||||
<input value={nickname} onChange={(e) => setNickname(e.target.value)} required minLength={2} maxLength={24} autoFocus />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>SteamID64 (optional)</span>
|
||||
<input value={steamId} onChange={(e) => setSteamId(e.target.value)} inputMode="numeric" placeholder="7656119…" pattern="\d{17}" />
|
||||
<small className="hint">Hilft dem Organisator, dich auf dem Server zuzuordnen.</small>
|
||||
</label>
|
||||
{submitError && <div className="alert alert-error">{submitError}</div>}
|
||||
<button className="btn btn-primary btn-lg" disabled={busy}>{busy ? 'Trete bei …' : `${info.teamName} beitreten`}</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function WatchPage() {
|
||||
const { code, info, error } = useCode();
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
if (!info) return;
|
||||
if (info.access !== 'spectator') {
|
||||
navigate(`/join/${code}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
// Spieler oder Organisator behalten ihren stärkeren Zugang.
|
||||
if (!getAccess(info.matchId)) setAccess(info.matchId, { token: code, kind: 'spectator' });
|
||||
navigate(`/m/${info.matchId}`, { replace: true });
|
||||
}, [info, code, navigate]);
|
||||
if (error) return <AccessError message={error} />;
|
||||
return <p className="muted">Öffne Zuschauerzugang …</p>;
|
||||
}
|
||||
|
||||
function AccessError({ message }) {
|
||||
return (
|
||||
<div className="empty">
|
||||
<h2>Code nicht gültig</h2>
|
||||
<p className="muted">{message}. Prüfe den Link oder frag den Organisator nach einem neuen Code.</p>
|
||||
<Link to="/" className="btn">Zur Übersicht</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../lib/api.js';
|
||||
import { FORMAT_LABEL } from '../lib/labels.js';
|
||||
import { setAccess } from '../lib/session.js';
|
||||
|
||||
export default function CreateMatch({ config }) {
|
||||
const navigate = useNavigate();
|
||||
const [form, setForm] = useState({ teamA: '', teamB: '', mode: 'competitive', format: 'bo3', vetoStart: 'coin', password: '' });
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const pool = config?.pools.find((p) => p.mode === form.mode);
|
||||
const set = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value }));
|
||||
|
||||
// Wird ein Format für den neuen Modus nicht unterstützt, auf das erste unterstützte wechseln.
|
||||
useEffect(() => {
|
||||
if (pool && !pool.formats[form.format]?.supported) {
|
||||
const first = Object.keys(pool.formats).find((f) => pool.formats[f].supported);
|
||||
if (first) setForm((f) => ({ ...f, format: first }));
|
||||
}
|
||||
}, [pool, form.format]);
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api('/matches', { method: 'POST', body: form });
|
||||
setAccess(res.id, { token: res.organizerToken, kind: 'organizer' });
|
||||
navigate(`/m/${res.id}`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!config) return <p className="muted">Lade Einstellungen …</p>;
|
||||
|
||||
return (
|
||||
<form className="create" onSubmit={submit}>
|
||||
<h1>Match erstellen</h1>
|
||||
<p className="lead">Mit dem Erstellen der Lobby wird sofort ein Server angefordert, damit er während des Vetos hochfahren kann.</p>
|
||||
|
||||
<div className="create-teams">
|
||||
<label className="field team-field-a">
|
||||
<span>Team A</span>
|
||||
<input value={form.teamA} onChange={set('teamA')} maxLength={32} required placeholder="z. B. Nordlicht" />
|
||||
</label>
|
||||
<span className="vs big">vs.</span>
|
||||
<label className="field team-field-b">
|
||||
<span>Team B</span>
|
||||
<input value={form.teamB} onChange={set('teamB')} maxLength={32} required placeholder="z. B. Hafenkante" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset className="choice">
|
||||
<legend>Spielmodus</legend>
|
||||
<div className="choice-row">
|
||||
{config.pools.map((p) => (
|
||||
<label key={p.mode} className={`choice-card ${form.mode === p.mode ? 'is-on' : ''}`}>
|
||||
<input type="radio" name="mode" value={p.mode} checked={form.mode === p.mode} onChange={set('mode')} />
|
||||
<strong>{p.teamSize} gegen {p.teamSize}</strong>
|
||||
<span>{p.label}</span>
|
||||
<small>{p.maps.length} Maps im Pool</small>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="choice">
|
||||
<legend>Serie</legend>
|
||||
<div className="choice-row">
|
||||
{Object.entries(pool.formats).map(([f, info]) => (
|
||||
<label key={f} className={`choice-card ${form.format === f ? 'is-on' : ''} ${info.supported ? '' : 'is-off'}`}
|
||||
title={info.supported ? undefined : info.reason}>
|
||||
<input type="radio" name="format" value={f} checked={form.format === f} onChange={set('format')} disabled={!info.supported} />
|
||||
<strong>{FORMAT_LABEL[f]}</strong>
|
||||
<span>{info.supported ? `${f.slice(2)} Map${f === 'bo1' ? '' : 's'}` : 'Mit diesem Map-Pool nicht möglich'}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{pool.formats[form.format]?.flow && <FlowPreview flow={pool.formats[form.format].flow} />}
|
||||
</fieldset>
|
||||
|
||||
<label className="field">
|
||||
<span>Wer beginnt das Veto?</span>
|
||||
<select value={form.vetoStart} onChange={set('vetoStart')}>
|
||||
<option value="coin">Münzwurf beim Veto-Start</option>
|
||||
<option value="A">{form.teamA || 'Team A'}</option>
|
||||
<option value="B">{form.teamB || 'Team B'}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{config.createRequiresPassword && (
|
||||
<label className="field">
|
||||
<span>Organisator-Passwort</span>
|
||||
<input type="password" value={form.password} onChange={set('password')} required autoComplete="current-password" />
|
||||
</label>
|
||||
)}
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
<button className="btn btn-primary btn-lg" disabled={busy}>{busy ? 'Erstelle Lobby …' : 'Lobby erstellen'}</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const STEP_TEXT = { ban: 'Ban', pick: 'Pick', side: 'Seite', decider: 'Entscheidungsmap' };
|
||||
|
||||
function FlowPreview({ flow }) {
|
||||
return (
|
||||
<ol className="flow-preview" aria-label="Veto-Ablauf">
|
||||
{flow.map((raw, i) => {
|
||||
const [type, actor] = raw.split(':');
|
||||
return (
|
||||
<li key={i} className={`flow-step flow-${type} ${actor ? `flow-${actor}` : ''}`}>
|
||||
<span>{STEP_TEXT[type]}</span>
|
||||
{actor && <small>{actor === 'X' ? 'Erstes Team' : 'Zweites Team'}</small>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { api } from '../lib/api.js';
|
||||
import { FORMAT_LABEL, ROLE_LABEL, serverLabel } from '../lib/labels.js';
|
||||
import { clearAccess, getToken, setAccess } from '../lib/session.js';
|
||||
import { useMatch } from '../lib/useMatch.js';
|
||||
import TeamCard from '../components/TeamCard.jsx';
|
||||
import VetoBoard from '../components/VetoBoard.jsx';
|
||||
import ServerPanel from '../components/ServerPanel.jsx';
|
||||
import OrganizerPanel from '../components/OrganizerPanel.jsx';
|
||||
import SeriesList from '../components/SeriesList.jsx';
|
||||
import EventLog from '../components/EventLog.jsx';
|
||||
|
||||
export default function MatchPage() {
|
||||
const { id } = useParams();
|
||||
const [token, setToken] = useState(() => {
|
||||
// Organisator-Link für ein weiteres Gerät: /m/<id>#org=<token>
|
||||
const hash = new URLSearchParams(window.location.hash.slice(1));
|
||||
const org = hash.get('org');
|
||||
if (org) {
|
||||
setAccess(id, { token: org, kind: 'organizer' });
|
||||
window.history.replaceState(null, '', window.location.pathname);
|
||||
}
|
||||
return getToken(id);
|
||||
});
|
||||
const { state, error, connected } = useMatch(id, token);
|
||||
const [busy, setBusy] = useState(null);
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
// Gespeicherter Zugang ist ungültig geworden (z. B. aus dem Team entfernt)
|
||||
const lostAccess = state && token && state.viewer.role === 'public';
|
||||
useEffect(() => {
|
||||
if (lostAccess) {
|
||||
clearAccess(id);
|
||||
setToken(null);
|
||||
setToast({ tone: 'error', text: 'Dein gespeicherter Zugang ist nicht mehr gültig. Tritt mit deinem Teamcode erneut bei.' });
|
||||
}
|
||||
}, [lostAccess, id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) return undefined;
|
||||
const t = setTimeout(() => setToast(null), 6000);
|
||||
return () => clearTimeout(t);
|
||||
}, [toast]);
|
||||
|
||||
/** Führt eine Aktion aus; verhindert Doppelklicks, solange sie läuft. */
|
||||
const act = useCallback(async (path, body, success) => {
|
||||
if (busy) return;
|
||||
setBusy(path);
|
||||
try {
|
||||
await api(`/matches/${id}/${path}`, { method: 'POST', body: body ?? {}, token });
|
||||
if (success) setToast({ tone: 'ok', text: success });
|
||||
} catch (err) {
|
||||
setToast({ tone: 'error', text: err.message });
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}, [busy, id, token]);
|
||||
|
||||
if (error && !state) {
|
||||
return (
|
||||
<div className="empty">
|
||||
<h2>{error.code === 'not_found' ? 'Match nicht gefunden' : 'Fehler'}</h2>
|
||||
<p className="muted">{error.error}</p>
|
||||
<Link to="/" className="btn">Zur Übersicht</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!state) return <p className="muted">Verbinde mit Lobby …</p>;
|
||||
|
||||
const { viewer } = state;
|
||||
const isOrg = viewer.role === 'organizer';
|
||||
const server = serverLabel(state.server);
|
||||
const ctx = { state, act, busy, isOrg, viewer };
|
||||
|
||||
return (
|
||||
<div className="match">
|
||||
{!connected && <div className="reconnect">Verbindung unterbrochen – stelle wieder her …</div>}
|
||||
{toast && <div className={`toast toast-${toast.tone}`} role="status">{toast.text}</div>}
|
||||
|
||||
<header className="match-head">
|
||||
<div className="versus">
|
||||
<h1>
|
||||
<span className="team-a">{state.teams.A.name}</span>
|
||||
<span className="vs">vs.</span>
|
||||
<span className="team-b">{state.teams.B.name}</span>
|
||||
</h1>
|
||||
</div>
|
||||
<dl className="match-facts">
|
||||
<div><dt>Modus</dt><dd>{state.modeLabel}</dd></div>
|
||||
<div><dt>Serie</dt><dd>{FORMAT_LABEL[state.format]}</dd></div>
|
||||
<div><dt>Status</dt><dd>{state.statusLabel}</dd></div>
|
||||
<div><dt>Server</dt><dd className={`tone-${server.tone}`}>{server.text}</dd></div>
|
||||
<div><dt>Du bist</dt><dd>{ROLE_LABEL[viewer.role]}{viewer.team && `, ${state.teams[viewer.team].name}`}</dd></div>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
{viewer.role === 'public' && <PublicNotice />}
|
||||
|
||||
<div className="match-grid">
|
||||
<TeamCard team={state.teams.A} side="A" {...ctx} />
|
||||
<section className="stage">
|
||||
<Stage {...ctx} />
|
||||
</section>
|
||||
<TeamCard team={state.teams.B} side="B" {...ctx} />
|
||||
</div>
|
||||
|
||||
{viewer.role !== 'public' && (
|
||||
<div className="match-lower">
|
||||
<ServerPanel {...ctx} />
|
||||
{isOrg && <OrganizerPanel {...ctx} matchId={id} token={token} />}
|
||||
<EventLog events={state.events} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stage(ctx) {
|
||||
const { state, isOrg, act, busy } = ctx;
|
||||
if (state.viewer.role === 'public') {
|
||||
return <div className="stage-note"><h2>{state.statusLabel}</h2><p className="muted">Details sehen nur Teilnehmer.</p></div>;
|
||||
}
|
||||
if (state.status === 'lobby') {
|
||||
const { A, B } = state.teams;
|
||||
const missing = [A, B].filter((t) => !t.readiness.ready);
|
||||
return (
|
||||
<div className="stage-note">
|
||||
<h2>Warten auf beide Teams</h2>
|
||||
<p className="muted">
|
||||
{missing.length === 0
|
||||
? 'Beide Teams sind bereit.'
|
||||
: missing.map((t) => `${t.name}: ${t.readiness.count}/${t.teamSize} Spieler, ${t.readiness.allReady ? 'alle bereit' : 'noch nicht alle bereit'}`).join(' – ')}
|
||||
</p>
|
||||
{isOrg ? (
|
||||
<button className="btn btn-primary btn-lg" disabled={!state.canStartVeto || !!busy}
|
||||
onClick={() => act('veto/start', null, 'Veto gestartet')}>
|
||||
Veto starten
|
||||
</button>
|
||||
) : (
|
||||
<p className="small muted">Der Organisator startet das Veto, sobald beide Teams bereit sind.</p>
|
||||
)}
|
||||
<MapPoolPreview maps={state.maps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state.status === 'veto') return <VetoBoard {...ctx} />;
|
||||
return (
|
||||
<>
|
||||
<SeriesList {...ctx} />
|
||||
<VetoBoard {...ctx} compact />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MapPoolPreview({ maps }) {
|
||||
return (
|
||||
<div className="pool-preview" aria-label="Map-Pool">
|
||||
{maps.map((m) => (
|
||||
<span key={m.id} className="pool-chip" style={{ '--map': m.color ?? '#556' }}>{m.name}</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PublicNotice() {
|
||||
return (
|
||||
<div className="alert">
|
||||
Du siehst diese Lobby ohne Zugang. Spieler treten über den Einladungslink ihres Teams bei,
|
||||
Zuschauer über den Zuschauerlink. Beides bekommst du vom Organisator.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../lib/api.js';
|
||||
import { FORMAT_LABEL, serverLabel, timeAgo } from '../lib/labels.js';
|
||||
import { getAccess } from '../lib/session.js';
|
||||
import { useOverview } from '../lib/useMatch.js';
|
||||
|
||||
const GROUPS = [
|
||||
{ key: 'open', title: 'Offen', empty: 'Keine offenen Lobbys. Erstelle ein Match, um zu starten.' },
|
||||
{ key: 'running', title: 'Laufend', empty: 'Gerade läuft kein Match.' },
|
||||
{ key: 'closed', title: 'Abgeschlossen', empty: 'Noch keine abgeschlossenen Matches.' },
|
||||
];
|
||||
|
||||
export default function Overview() {
|
||||
const { matches } = useOverview();
|
||||
return (
|
||||
<div className="overview">
|
||||
<section className="overview-head">
|
||||
<div>
|
||||
<h1>Match-Lobbys</h1>
|
||||
<p className="lead">Teams treten mit ihrem Einladungscode bei, die Captains führen das Map-Veto, der Server wird automatisch eingerichtet.</p>
|
||||
</div>
|
||||
<CodeEntry />
|
||||
</section>
|
||||
|
||||
{matches === null ? (
|
||||
<p className="muted">Lade Matches …</p>
|
||||
) : (
|
||||
<div className="groups">
|
||||
{GROUPS.map((g) => {
|
||||
const list = matches.filter((m) => m.group === g.key);
|
||||
return (
|
||||
<section key={g.key} className={`group group-${g.key}`}>
|
||||
<h2>{g.title} <span className="count">{list.length}</span></h2>
|
||||
{list.length === 0 ? <p className="muted small">{g.empty}</p> : (
|
||||
<ul className="match-list">
|
||||
{list.map((m) => <MatchRow key={m.id} match={m} />)}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchRow({ match: m }) {
|
||||
const access = getAccess(m.id);
|
||||
const server = serverLabel(m.server);
|
||||
return (
|
||||
<li>
|
||||
<Link to={`/m/${m.id}`} className="match-row">
|
||||
<div className="match-row-teams">
|
||||
<span className="team-a">{m.teamA}</span>
|
||||
<span className="vs">vs.</span>
|
||||
<span className="team-b">{m.teamB}</span>
|
||||
</div>
|
||||
<div className="match-row-meta">
|
||||
<span>{m.modeLabel}</span>
|
||||
<span>{FORMAT_LABEL[m.format]}</span>
|
||||
{m.group === 'open' && <span>{m.players.A + m.players.B}/{m.players.teamSize * 2} Spieler</span>}
|
||||
<span className={`tone-${server.tone}`}>{m.statusLabel}</span>
|
||||
{access && <span className="mine">Dein Zugang</span>}
|
||||
<span className="muted">{timeAgo(m.createdAt)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeEntry() {
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api('/access', { method: 'POST', body: { code } });
|
||||
navigate(res.access === 'spectator' ? `/watch/${code.trim()}` : `/join/${code.trim()}`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<form className="code-entry" onSubmit={submit}>
|
||||
<label htmlFor="code">Einladungs- oder Zuschauercode</label>
|
||||
<div className="row">
|
||||
<input id="code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="ABCD-EFGH" autoComplete="off" spellCheck="false" />
|
||||
<button className="btn" disabled={code.replace(/[^a-z0-9]/gi, '').length < 8}>Beitreten</button>
|
||||
</div>
|
||||
{error && <p className="field-error">{error}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
/* ---------- Tokens ---------- */
|
||||
:root {
|
||||
--bg: #10151f;
|
||||
--surface: #161d2a;
|
||||
--surface-2: #1d2636;
|
||||
--line: #2a3448;
|
||||
--line-strong: #3a4760;
|
||||
--text: #e6ebf2;
|
||||
--muted: #8a96ab;
|
||||
|
||||
--team-a: #3cc6c0;
|
||||
--team-b: #e86fa0;
|
||||
--ct: #6ca8e8;
|
||||
--t: #dda84a;
|
||||
|
||||
--ok: #49d48f;
|
||||
--pending: #e8c25a;
|
||||
--error: #f0605d;
|
||||
--demo: #b39bff;
|
||||
|
||||
--display: 'Barlow Condensed', 'Arial Narrow', sans-serif;
|
||||
--body: 'Barlow', system-ui, sans-serif;
|
||||
|
||||
--radius: 6px;
|
||||
--chamfer: 14px;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 400 16px/1.5 var(--body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
h1, h2, h3 { font-family: var(--display); font-weight: 600; line-height: 1.1; margin: 0 0 .5em; letter-spacing: .01em; }
|
||||
h1 { font-size: clamp(2rem, 4vw, 2.75rem); }
|
||||
h2 { font-size: 1.375rem; }
|
||||
h3 { font-size: 1.0625rem; }
|
||||
p { margin: 0 0 .75em; }
|
||||
a { color: var(--ct); }
|
||||
code { font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; font-size: .875em; }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: .875rem; }
|
||||
.lead { color: var(--muted); max-width: 60ch; font-size: 1.0625rem; }
|
||||
:focus-visible { outline: 2px solid var(--ct); outline-offset: 2px; }
|
||||
|
||||
.tone-ok { color: var(--ok); }
|
||||
.tone-pending { color: var(--pending); }
|
||||
.tone-error { color: var(--error); }
|
||||
.tone-demo { color: var(--demo); }
|
||||
.tone-muted { color: var(--muted); }
|
||||
.team-a { color: var(--team-a); }
|
||||
.team-b { color: var(--team-b); }
|
||||
|
||||
/* ---------- Layout ---------- */
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
padding: 12px clamp(16px, 4vw, 40px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 10px; color: var(--text); text-decoration: none; font: 700 1.25rem var(--display); }
|
||||
.topbar nav { margin-left: auto; display: flex; align-items: center; gap: 16px; }
|
||||
.nav-link { color: var(--muted); text-decoration: none; }
|
||||
.nav-link:hover { color: var(--text); }
|
||||
.demo-flag {
|
||||
font-size: .8125rem; color: var(--demo); border: 1px solid color-mix(in srgb, var(--demo) 50%, transparent);
|
||||
padding: 2px 10px; border-radius: 999px;
|
||||
}
|
||||
.content { max-width: 1320px; margin: 0 auto; padding: 32px clamp(16px, 4vw, 40px) 80px; }
|
||||
|
||||
/* ---------- Buttons & Felder ---------- */
|
||||
.btn {
|
||||
--btn: var(--surface-2);
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||
font: 600 .9375rem var(--body);
|
||||
color: var(--text); background: var(--btn);
|
||||
border: 1px solid var(--line-strong); border-radius: var(--radius);
|
||||
padding: 9px 16px; cursor: pointer; text-decoration: none;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
.btn:hover:not(:disabled) { border-color: var(--muted); }
|
||||
.btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.btn-primary { background: var(--team-a); border-color: var(--team-a); color: #06201f; }
|
||||
.btn-primary:hover:not(:disabled) { background: #5ad6d0; border-color: #5ad6d0; }
|
||||
.btn-danger { background: var(--error); border-color: var(--error); color: #2a0606; }
|
||||
.btn-ct { background: color-mix(in srgb, var(--ct) 22%, var(--surface)); border-color: var(--ct); }
|
||||
.btn-t { background: color-mix(in srgb, var(--t) 22%, var(--surface)); border-color: var(--t); }
|
||||
.btn-ghost { background: transparent; }
|
||||
.btn-sm { padding: 5px 12px; font-size: .875rem; }
|
||||
.btn-lg { padding: 13px 22px; font-size: 1.0625rem; }
|
||||
.btn.danger, .link-btn.danger { color: var(--error); }
|
||||
.link-btn { background: none; border: 0; padding: 0; color: var(--ct); font: inherit; font-size: .875rem; cursor: pointer; }
|
||||
.link-btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 18px; }
|
||||
.field > span { font-weight: 600; font-size: .9375rem; }
|
||||
input, select {
|
||||
font: inherit; color: var(--text); background: var(--bg);
|
||||
border: 1px solid var(--line-strong); border-radius: var(--radius); padding: 10px 12px; min-width: 0;
|
||||
}
|
||||
input:focus, select:focus { border-color: var(--ct); outline: none; }
|
||||
.hint { color: var(--muted); font-size: .8125rem; }
|
||||
.field-error { color: var(--error); font-size: .875rem; margin-top: 6px; }
|
||||
.row { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
.alert { border: 1px solid var(--line-strong); background: var(--surface); padding: 12px 16px; border-radius: var(--radius); margin-bottom: 16px; }
|
||||
.alert-error { border-color: var(--error); color: #ffc9c7; }
|
||||
.alert-demo { border-color: color-mix(in srgb, var(--demo) 55%, transparent); background: color-mix(in srgb, var(--demo) 9%, var(--surface)); color: #ded3ff; font-size: .875rem; }
|
||||
|
||||
.empty { text-align: center; padding: 80px 16px; }
|
||||
|
||||
/* ---------- Übersicht ---------- */
|
||||
.overview-head { display: flex; gap: 32px; justify-content: space-between; align-items: flex-end; flex-wrap: wrap; margin-bottom: 40px; }
|
||||
.code-entry { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px; width: min(100%, 380px); }
|
||||
.code-entry label { display: block; font-weight: 600; margin-bottom: 8px; }
|
||||
.code-entry input { flex: 1; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.groups { display: grid; gap: 40px; }
|
||||
.group h2 { display: flex; align-items: baseline; gap: 10px; }
|
||||
.count { font: 500 .9375rem var(--body); color: var(--muted); }
|
||||
.match-list { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
|
||||
.match-row {
|
||||
display: flex; flex-wrap: wrap; gap: 8px 24px; align-items: center; justify-content: space-between;
|
||||
padding: 14px 4px; border-bottom: 1px solid var(--line); color: var(--text); text-decoration: none;
|
||||
}
|
||||
.match-row:hover { background: var(--surface); }
|
||||
.match-row-teams { font: 600 1.375rem var(--display); display: flex; gap: 10px; align-items: baseline; }
|
||||
.vs { color: var(--muted); font: 500 .9375rem var(--body); }
|
||||
.vs.big { font-size: 1.25rem; align-self: center; }
|
||||
.match-row-meta { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: .875rem; color: var(--muted); }
|
||||
.mine { color: var(--team-a); }
|
||||
.group-closed .match-row-teams { opacity: .7; }
|
||||
|
||||
/* ---------- Match erstellen ---------- */
|
||||
.create, .join-card { max-width: 720px; }
|
||||
.create-teams { display: grid; grid-template-columns: 1fr auto 1fr; gap: 16px; align-items: end; }
|
||||
.team-field-a input { border-left: 4px solid var(--team-a); }
|
||||
.team-field-b input { border-left: 4px solid var(--team-b); }
|
||||
.choice { border: 0; padding: 0; margin: 8px 0 22px; }
|
||||
.choice legend { font-weight: 600; margin-bottom: 8px; }
|
||||
.choice-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; }
|
||||
.choice-card {
|
||||
position: relative; display: flex; flex-direction: column; gap: 2px;
|
||||
padding: 14px 16px; border: 1px solid var(--line-strong); border-radius: var(--radius); background: var(--surface); cursor: pointer;
|
||||
}
|
||||
.choice-card input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.choice-card strong { font: 600 1.375rem var(--display); }
|
||||
.choice-card span { color: var(--muted); font-size: .875rem; }
|
||||
.choice-card small { color: var(--muted); }
|
||||
.choice-card.is-on { border-color: var(--team-a); box-shadow: inset 0 0 0 1px var(--team-a); }
|
||||
.choice-card.is-off { opacity: .45; cursor: not-allowed; }
|
||||
.choice-card:has(input:focus-visible) { outline: 2px solid var(--ct); outline-offset: 2px; }
|
||||
.flow-preview { list-style: none; display: flex; flex-wrap: wrap; gap: 4px; padding: 0; margin: 12px 0 0; }
|
||||
.flow-step { font-size: .75rem; padding: 3px 8px; border-radius: 3px; background: var(--surface-2); display: flex; flex-direction: column; line-height: 1.2; }
|
||||
.flow-step small { color: var(--muted); font-size: .6875rem; }
|
||||
.flow-ban { box-shadow: inset 0 -2px 0 var(--error); }
|
||||
.flow-pick { box-shadow: inset 0 -2px 0 var(--ok); }
|
||||
.flow-decider { box-shadow: inset 0 -2px 0 var(--text); }
|
||||
|
||||
.join-card { margin: 0 auto; background: var(--surface); border: 1px solid var(--line); border-top: 4px solid var(--team-a); padding: 28px; border-radius: var(--radius); }
|
||||
.join-card.team-B { border-top-color: var(--team-b); }
|
||||
|
||||
/* ---------- Match-Seite ---------- */
|
||||
.match-head { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px 40px; align-items: flex-end; margin-bottom: 28px; }
|
||||
.versus h1 { display: flex; flex-wrap: wrap; align-items: baseline; gap: .1em .4em; font-size: clamp(2.25rem, 5.5vw, 4rem); font-weight: 700; margin: 0; }
|
||||
.match-facts { display: flex; flex-wrap: wrap; gap: 12px 28px; margin: 0; }
|
||||
.match-facts dt { color: var(--muted); font-size: .8125rem; }
|
||||
.match-facts dd { margin: 0; font-weight: 600; }
|
||||
|
||||
.match-grid { display: grid; grid-template-columns: minmax(220px, 280px) minmax(0, 1fr) minmax(220px, 280px); gap: 20px; align-items: start; }
|
||||
.stage { min-width: 0; }
|
||||
.stage-note { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 24px; }
|
||||
.stage-note .btn { margin: 8px 0 20px; }
|
||||
.pool-preview { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.pool-chip { padding: 4px 10px; border-left: 3px solid var(--map); background: var(--surface-2); font-size: .875rem; border-radius: 3px; }
|
||||
|
||||
.team-card { --team: var(--team-a); background: var(--surface); border: 1px solid var(--line); border-top: 4px solid var(--team); border-radius: var(--radius); padding: 16px; }
|
||||
.team-card.team-B { --team: var(--team-b); }
|
||||
.team-card header { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
|
||||
.team-card h2 { color: var(--team); font-size: 1.625rem; margin: 0 0 10px; overflow-wrap: anywhere; }
|
||||
.ready-state { font-size: .8125rem; color: var(--muted); white-space: nowrap; }
|
||||
.ready-state.on { color: var(--ok); font-weight: 600; }
|
||||
.roster { list-style: none; padding: 0; margin: 0 0 12px; }
|
||||
.roster li { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; padding: 8px 0; border-bottom: 1px solid var(--line); }
|
||||
.roster li.is-you .nick { color: var(--team); }
|
||||
.nick { font-weight: 600; flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.nick small { font-weight: 400; color: var(--muted); }
|
||||
.presence { width: 8px; height: 8px; border-radius: 50%; background: var(--line-strong); flex: none; }
|
||||
.presence.on { background: var(--ok); }
|
||||
.captain { font-size: .75rem; padding: 1px 6px; border: 1px solid var(--team); color: var(--team); border-radius: 3px; }
|
||||
.ready-dot { font-size: .75rem; color: var(--muted); }
|
||||
.ready-dot.on { color: var(--ok); }
|
||||
.row-actions { flex-basis: 100%; display: flex; gap: 14px; padding-left: 16px; }
|
||||
.slot-empty { color: var(--muted); font-style: italic; font-size: .9375rem; border-bottom-style: dashed !important; }
|
||||
.team-actions { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
|
||||
.team-card.is-ready { box-shadow: 0 0 0 1px color-mix(in srgb, var(--team) 35%, transparent); }
|
||||
|
||||
/* ---------- Veto ---------- */
|
||||
.veto { display: grid; gap: 16px; }
|
||||
.turn {
|
||||
--team: var(--team-a);
|
||||
display: grid; grid-template-columns: auto 1fr; gap: 2px 12px; align-items: baseline;
|
||||
padding: 14px 18px; background: color-mix(in srgb, var(--team) 10%, var(--surface));
|
||||
border-left: 4px solid var(--team); border-radius: var(--radius);
|
||||
}
|
||||
.turn-B { --team: var(--team-b); }
|
||||
.turn-team { font: 700 1.75rem var(--display); color: var(--team); }
|
||||
.turn-action { font: 600 1.375rem var(--display); }
|
||||
.turn-hint { grid-column: 1 / -1; color: var(--muted); font-size: .9375rem; }
|
||||
.turn.is-you { box-shadow: 0 0 0 1px var(--team); }
|
||||
.turn.is-you .turn-hint { color: var(--text); font-weight: 600; }
|
||||
.turn-done { font: 600 1.375rem var(--display); --team: var(--ok); }
|
||||
|
||||
.map-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
|
||||
.map-tile {
|
||||
--by: var(--line-strong);
|
||||
position: relative; display: flex; flex-direction: column; justify-content: flex-end; text-align: left;
|
||||
aspect-ratio: 4 / 3; padding: 12px; border: 0; color: var(--text); font: inherit; cursor: default;
|
||||
background: var(--surface-2);
|
||||
clip-path: polygon(0 0, calc(100% - var(--chamfer)) 0, 100% var(--chamfer), 100% 100%, var(--chamfer) 100%, 0 calc(100% - var(--chamfer)));
|
||||
transition: transform .15s, filter .25s;
|
||||
}
|
||||
.map-tile.by-A { --by: var(--team-a); }
|
||||
.map-tile.by-B { --by: var(--team-b); }
|
||||
.map-art {
|
||||
position: absolute; inset: 0; z-index: 0;
|
||||
background:
|
||||
repeating-linear-gradient(135deg, transparent 0 9px, color-mix(in srgb, var(--map) 16%, transparent) 9px 10px),
|
||||
radial-gradient(120% 90% at 85% 0%, color-mix(in srgb, var(--map) 70%, transparent), transparent 60%),
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--map) 35%, var(--surface-2)), var(--surface) 85%);
|
||||
}
|
||||
.map-name, .map-status { position: relative; z-index: 1; }
|
||||
.map-name { font: 700 1.625rem/1 var(--display); text-shadow: 0 1px 8px rgba(0, 0, 0, .5); }
|
||||
.map-status { font-size: .8125rem; color: color-mix(in srgb, var(--text) 75%, transparent); margin-top: 4px; }
|
||||
.map-tile:not(:disabled) { cursor: pointer; }
|
||||
.map-tile:not(:disabled):hover { transform: translateY(-2px); }
|
||||
.map-tile:focus-visible { outline: none; }
|
||||
.map-tile:focus-visible::after { content: ''; position: absolute; inset: 0; z-index: 3; box-shadow: inset 0 0 0 3px var(--ct); }
|
||||
|
||||
/* Gebannt: entsättigt, mit diagonalem Strich in der Farbe des bannenden Teams */
|
||||
.map-tile.status-banned { filter: grayscale(.9) brightness(.6); }
|
||||
.map-tile.status-banned::after {
|
||||
content: ''; position: absolute; inset: 0; z-index: 2; pointer-events: none;
|
||||
background: linear-gradient(to top right, transparent calc(50% - 3px), var(--by) calc(50% - 3px), var(--by) calc(50% + 3px), transparent calc(50% + 3px));
|
||||
}
|
||||
.map-tile.status-banned .map-status { color: var(--text); }
|
||||
/* Gewählt, Entscheidungsmap, Auswahl: Rahmen als Overlay über dem Map-Bild */
|
||||
.map-tile.status-picked::after, .map-tile.status-decider::after, .map-tile.is-selected::after, .map-tile.is-side::after {
|
||||
content: ''; position: absolute; inset: 0; z-index: 2; pointer-events: none;
|
||||
box-shadow: inset 0 0 0 3px var(--frame, var(--by));
|
||||
}
|
||||
.map-tile.status-picked .map-status { color: var(--by); font-weight: 600; }
|
||||
.map-tile.status-decider { --by: var(--text); }
|
||||
.map-tile.status-decider .map-status { font-weight: 600; }
|
||||
.map-tile.is-selected { transform: translateY(-2px); }
|
||||
.map-tile.is-selected.sel-ban { --frame: var(--error); }
|
||||
.map-tile.is-selected.sel-pick { --frame: var(--ok); }
|
||||
.map-tile.is-side::after { animation: side-pulse 1.6s ease-in-out infinite; }
|
||||
@keyframes side-pulse { 50% { box-shadow: inset 0 0 0 3px var(--pending); } }
|
||||
|
||||
.veto-confirm .btn { width: 100%; }
|
||||
.side-choice { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px; }
|
||||
.side-choice .btn { flex: 1; }
|
||||
|
||||
.step-track { list-style: none; display: flex; flex-wrap: wrap; gap: 4px; padding: 0; margin: 0; }
|
||||
.step {
|
||||
--team: var(--muted);
|
||||
font-size: .75rem; padding: 4px 8px; border-radius: 3px; background: var(--surface);
|
||||
border-bottom: 2px solid color-mix(in srgb, var(--team) 45%, transparent); color: var(--muted);
|
||||
}
|
||||
.step-A { --team: var(--team-a); }
|
||||
.step-B { --team: var(--team-b); }
|
||||
.step.done { color: var(--text); background: var(--surface-2); border-bottom-color: var(--team); }
|
||||
.step.now { color: var(--bg); background: var(--team); border-bottom-color: var(--team); font-weight: 600; }
|
||||
.step-decider.now, .step-decider.done { --team: var(--text); }
|
||||
|
||||
.veto-history { list-style: none; padding: 0; margin: 0; font-size: .9375rem; border-top: 1px solid var(--line); }
|
||||
.veto-history li { padding: 6px 0 6px 12px; border-bottom: 1px solid var(--line); border-left: 3px solid transparent; }
|
||||
.veto-history li.hist-A { border-left-color: var(--team-a); }
|
||||
.veto-history li.hist-B { border-left-color: var(--team-b); }
|
||||
.veto-history time, .event-log time { color: var(--muted); font-size: .8125rem; margin-right: 8px; font-variant-numeric: tabular-nums; }
|
||||
.veto-compact .map-grid { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; }
|
||||
.veto-compact .map-tile { aspect-ratio: 16 / 9; padding: 8px; --chamfer: 10px; }
|
||||
.veto-compact .map-name { font-size: 1.125rem; }
|
||||
|
||||
/* ---------- Serie ---------- */
|
||||
.series { margin-bottom: 20px; }
|
||||
.series-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
|
||||
.series-item {
|
||||
display: grid; grid-template-columns: auto 1fr auto; gap: 2px 16px; align-items: baseline;
|
||||
padding: 14px 16px; background: var(--surface); border-left: 4px solid var(--map); border-radius: var(--radius);
|
||||
}
|
||||
.series-no { color: var(--muted); font-size: .875rem; }
|
||||
.series-map { font: 700 1.625rem var(--display); }
|
||||
.series-status { font-size: .875rem; color: var(--muted); text-align: right; grid-column: 3; grid-row: 1; }
|
||||
.series-origin, .series-sides { grid-column: 2 / -1; font-size: .875rem; color: var(--muted); }
|
||||
.series-item.is-live { box-shadow: inset 0 0 0 1px var(--map); }
|
||||
.series-item.is-live .series-status, .series-item.is-next .series-status { color: var(--pending); font-weight: 600; }
|
||||
.series-item.is-done { opacity: .6; }
|
||||
.side-ct { color: var(--ct); }
|
||||
.side-t { color: var(--t); margin-left: 12px; }
|
||||
|
||||
/* ---------- Panels unten ---------- */
|
||||
.match-lower { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; margin-top: 28px; align-items: start; }
|
||||
.panel { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 18px; min-width: 0; }
|
||||
.server-status { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; font-size: 1.0625rem; }
|
||||
.status-dot { width: 10px; height: 10px; border-radius: 50%; background: currentColor; flex: none; }
|
||||
.status-dot.pulse { animation: pulse 1.2s ease-in-out infinite; }
|
||||
@keyframes pulse { 50% { opacity: .3; } }
|
||||
.tone-border-error { border-color: color-mix(in srgb, var(--error) 60%, transparent); }
|
||||
.connect { border-top: 1px solid var(--line); padding-top: 14px; margin-top: 14px; display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; justify-items: start; }
|
||||
.connect .copy-field { width: 100%; }
|
||||
.org-controls { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
|
||||
|
||||
.copy-field { display: grid; grid-template-columns: minmax(0, 1fr); gap: 4px; }
|
||||
.copy-label { font-size: .8125rem; color: var(--muted); }
|
||||
.copy-row { display: flex; gap: 8px; align-items: center; }
|
||||
.copy-row code { flex: 1; min-width: 0; background: var(--bg); border: 1px solid var(--line); border-radius: var(--radius); padding: 8px 10px; overflow-x: auto; white-space: nowrap; }
|
||||
.copy-row code.blurred { filter: blur(5px); cursor: pointer; user-select: none; }
|
||||
|
||||
.invite { padding: 12px 0; border-top: 1px solid var(--line); border-left: 3px solid transparent; padding-left: 12px; }
|
||||
.invite.team-A { border-left-color: var(--team-a); }
|
||||
.invite.team-B { border-left-color: var(--team-b); }
|
||||
.invite.spectator { border-left-color: var(--muted); }
|
||||
.invite-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 12px; margin-bottom: 6px; }
|
||||
.invite-code { font: 600 1.125rem var(--display); letter-spacing: .1em; }
|
||||
.invite-head .link-btn { margin-left: auto; }
|
||||
.org-link { margin-top: 12px; }
|
||||
.org-link summary { cursor: pointer; color: var(--muted); font-size: .875rem; }
|
||||
|
||||
.event-log ol { list-style: none; padding: 0; margin: 0; max-height: 360px; overflow-y: auto; font-size: .875rem; }
|
||||
.event-log li { display: flex; gap: 4px; padding: 5px 0; border-bottom: 1px solid var(--line); }
|
||||
.event-log li.ev-server span { color: color-mix(in srgb, var(--text) 80%, var(--ct)); }
|
||||
|
||||
.toast {
|
||||
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 50;
|
||||
padding: 12px 18px; border-radius: var(--radius); background: var(--surface-2); border: 1px solid var(--line-strong);
|
||||
max-width: min(560px, calc(100vw - 32px)); box-shadow: 0 8px 30px rgba(0, 0, 0, .45);
|
||||
}
|
||||
.toast-error { border-color: var(--error); }
|
||||
.toast-ok { border-color: var(--ok); }
|
||||
.reconnect { background: var(--pending); color: #2a2000; padding: 8px 14px; border-radius: var(--radius); margin-bottom: 16px; font-weight: 600; }
|
||||
|
||||
/* ---------- Responsiv ---------- */
|
||||
@media (max-width: 1040px) {
|
||||
.match-grid { grid-template-columns: 1fr 1fr; }
|
||||
.stage { grid-column: 1 / -1; grid-row: 1; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.match-grid { grid-template-columns: 1fr; }
|
||||
.topbar { flex-wrap: wrap; gap: 8px 16px; }
|
||||
.demo-flag { order: 3; }
|
||||
.create-teams { grid-template-columns: 1fr; }
|
||||
.vs.big { justify-self: center; }
|
||||
.map-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.series-item { grid-template-columns: 1fr auto; }
|
||||
.series-no { grid-column: 1 / -1; }
|
||||
.series-status { grid-column: 2; grid-row: 2; }
|
||||
.series-origin, .series-sides { grid-column: 1 / -1; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation: none !important; transition: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
const backend = process.env.BACKEND_URL ?? 'http://localhost:3000';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': backend,
|
||||
'/socket.io': { target: backend, ws: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -53,7 +53,6 @@ export function loadConfig(overrides = {}) {
|
||||
.split(';')
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean),
|
||||
matchzy: bool('MATCHZY_ENABLED', false),
|
||||
},
|
||||
|
||||
demo: {
|
||||
|
||||
Reference in new issue
Block a user