Map-Pools: Overpass, Vertigo und Cache für Competitive, Map-Auswahl im Admin, Veto-Ablauf passend zur Map-Anzahl

This commit is contained in:
mregosar committed 2026-10-29 21:03:29 +01:00
1 parent e3834bb071
commit 2d65634d6e
13 files changed
+459 -45

No files matched your search

+95
View File
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import { api } from '../lib/api.js';
import { FORMAT_LABEL, serverLabel } from '../lib/labels.js';
import CopyField from '../components/CopyField.jsx';
import MapImage from '../components/MapImage.jsx';
const KEY = 'cs2mv:admin';
const FILTER_KEY = 'cs2mv:admin:filter';
@@ -146,6 +147,7 @@ export default function AdminPage() {
<button className={`admin-tab ${tab === 'settings' ? 'is-active' : ''}`} onClick={() => setTab('settings')}>Einstellungen</button>
</nav>
{tab === 'settings' && token && <MapPoolsPanel token={token} setNotice={setNotice} />}
{tab === 'settings' && token && <ServerSettingsPanel token={token} setNotice={setNotice} />}
{tab === 'settings' && token && <DockerHostsPanel token={token} setNotice={setNotice} />}
{tab === 'settings' && token && <NotificationSettings token={token} setNotice={setNotice} />}
@@ -905,3 +907,96 @@ function KnifePanel({ token, setNotice }) {
</section>
);
}
const STEP_LABEL = { ban: 'Ban', pick: 'Pick', side: 'Seite' };
/** Veto-Ablauf als kurze Schrittfolge; X = Team, das das Veto beginnt, Y = das andere Team. */
function FlowSteps({ flow }) {
return (
<ol className="flow-steps">
{flow.map((raw, i) => {
if (raw === 'decider') return <li key={i} className="flow-decider">Entscheidung</li>;
const [type, actor] = raw.split(':');
return <li key={i} className={`flow-${type}`}>{STEP_LABEL[type]} {actor === 'X' ? '1' : '2'}</li>;
})}
</ol>
);
}
/** Welche Maps gespielt werden, pro Modus. Gilt für neue Matches, laufende behalten ihre Maps. */
function MapPoolsPanel({ token, setNotice }) {
const [pools, setPools] = useState(null);
const [selection, setSelection] = useState({});
const [busy, setBusy] = useState(false);
const apply = (res) => {
setPools(res.pools);
setSelection(Object.fromEntries(res.pools.map((p) => [p.mode, p.maps.filter((m) => m.active).map((m) => m.id)])));
};
useEffect(() => {
api('/admin/map-pools', { token }).then(apply).catch((err) => setNotice({ tone: 'error', text: err.message }));
}, [token, setNotice]);
if (!pools) return null;
const toggle = (mode, id) => {
const cur = new Set(selection[mode]);
if (cur.has(id)) cur.delete(id);
else cur.add(id);
setSelection({ ...selection, [mode]: [...cur] });
};
const save = async (mode) => {
setBusy(true);
try {
apply(await api('/admin/map-pools', { method: 'PUT', body: { mode, maps: selection[mode] }, token }));
setNotice({ tone: 'ok', text: 'Maps gespeichert – gilt für neue Matches' });
} catch (err) {
setNotice({ tone: 'error', text: err.message });
} finally {
setBusy(false);
}
};
return (
<section className="admin-settings">
<h2>Map-Pools</h2>
<p className="small muted">
Welche Maps im Veto stehen. Der Veto-Ablauf passt sich der Anzahl an: Für jede weitere Map gibt es einen Ban mehr.
Laufende Matches behalten ihre Maps. „1“ ist das Team, das das Veto beginnt.
</p>
{pools.map((p) => {
const chosen = new Set(selection[p.mode]);
const saved = new Set(p.maps.filter((m) => m.active).map((m) => m.id));
const dirty = chosen.size !== saved.size || [...chosen].some((id) => !saved.has(id));
return (
<div key={p.mode} className="setting-group">
<h3>{p.label} <span className="muted small">{chosen.size} von {p.maps.length} Maps</span></h3>
<div className="pool-maps">
{p.maps.map((m) => (
<label key={m.id} className={`pool-map ${chosen.has(m.id) ? 'is-on' : ''}`}>
<input type="checkbox" checked={chosen.has(m.id)} onChange={() => toggle(p.mode, m.id)} />
<MapImage map={m} className="pool-map-art" />
<span className="pool-map-name">{m.name}</span>
</label>
))}
</div>
{!dirty && (
<div className="pool-flows">
{Object.entries(p.formats).map(([f, info]) => (
<div key={f} className="pool-flow">
<strong>{f.toUpperCase()}</strong>
{info.supported ? <FlowSteps flow={info.flow} /> : <span className="muted small">{info.reason}</span>}
</div>
))}
</div>
)}
<div className="admin-actions">
<button className="btn btn-sm btn-primary" disabled={busy || !dirty || chosen.size < 2} onClick={() => save(p.mode)}>Speichern</button>
{dirty && <button className="btn btn-sm" disabled={busy} onClick={() => setSelection({ ...selection, [p.mode]: [...saved] })}>Verwerfen</button>}
{chosen.size < 2 && <span className="small tone-error">Mindestens 2 Maps</span>}
{dirty && chosen.size >= 2 && <span className="small muted">Nach dem Speichern steht hier der neue Veto-Ablauf.</span>}
</div>
</div>
);
})}
</section>
);
}
+14
View File
@@ -612,3 +612,17 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
.knife-list code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .85rem; }
.knife-list .btn { margin-left: auto; }
.knife-mode { display: inline-block; margin: 0; padding: 1px 8px; border-radius: 999px; font-size: .75rem; font-weight: 700; color: var(--primary); border: 1px solid var(--primary); background: transparent; }
.pool-maps { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 8px; margin-bottom: 10px; }
.pool-map { position: relative; display: flex; flex-direction: column; justify-content: flex-end; min-height: 78px; isolation: isolate; border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; cursor: pointer; opacity: .45; transition: opacity .15s ease, border-color .15s ease; }
.pool-map.is-on { opacity: 1; border-color: var(--primary); }
.pool-map input { position: absolute; top: 6px; left: 6px; z-index: 2; }
.pool-map-name { position: relative; z-index: 1; padding: 5px 8px; font-size: .9rem; font-weight: 700; text-shadow: 0 1px 6px rgba(0, 0, 0, .7); }
.pool-flows { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; }
.pool-flow { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: baseline; }
.pool-flow strong { min-width: 36px; font-size: .85rem; }
.flow-steps { display: flex; flex-wrap: wrap; gap: 4px; list-style: none; margin: 0; padding: 0; }
.flow-steps li { font-size: .75rem; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); }
.flow-steps .flow-ban { color: var(--error); border-color: color-mix(in srgb, var(--error) 50%, transparent); }
.flow-steps .flow-pick { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 50%, transparent); }
.flow-steps .flow-decider { color: var(--text); border-color: var(--primary); }