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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user