Frontend, README und Konfigurationsvorlage
React-Oberfläche mit Übersicht, Match-Erstellung, Beitritt über Team- und Zuschauercodes, Teamkarten, visuellem Map-Veto, Serie und Serverpanel. README beschreibt Einrichtung, Umgebungsvariablen, Rollen, Veto-Abläufe, Serveradapter und CSTV-Zuschauerzugang. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
104aa1a67b
commit
568ccf9b09
24 files changed
+1938
-1
No files matched your search
@@ -0,0 +1,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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user