Map-Pools: Overpass, Vertigo und Cache für Competitive, Map-Auswahl im Admin, Veto-Ablauf passend zur Map-Anzahl
This commit is contained in:
1 parent
e3834bb071
commit
2d65634d6e
13 files changed
+459
-45
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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); }
|
||||
Reference in new issue
Block a user