Compare commits
24
Commits
No files matched your search
@@ -14,7 +14,6 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
{/* Stream-Übersicht ohne Kopfzeile, als Vollbild bzw. OBS-Browserquelle */}
|
{/* Stream-Übersicht ohne Kopfzeile, als Vollbild bzw. OBS-Browserquelle */}
|
||||||
<Route path="/stream/:code" element={<StreamPage />} />
|
<Route path="/stream/:code" element={<StreamPage />} />
|
||||||
<Route path="/stream/match/:matchId" element={<StreamOverviewPage />} />
|
|
||||||
<Route path="/stream" element={<StreamOverviewPage />} />
|
<Route path="/stream" element={<StreamOverviewPage />} />
|
||||||
<Route path="*" element={<MainLayout />} />
|
<Route path="*" element={<MainLayout />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ export default function OrganizerPanel({ state, act, busy, matchId, token }) {
|
|||||||
<div className="stream-link">
|
<div className="stream-link">
|
||||||
<CopyField label="Stream-Übersicht (als Browserquelle in OBS, 1920×1080)" value={links.stream} />
|
<CopyField label="Stream-Übersicht (als Browserquelle in OBS, 1920×1080)" value={links.stream} />
|
||||||
<a className="btn btn-sm" href={links.stream} target="_blank" rel="noreferrer">Stream-Ansicht öffnen</a>
|
<a className="btn btn-sm" href={links.stream} target="_blank" rel="noreferrer">Stream-Ansicht öffnen</a>
|
||||||
<CopyField label="Match-Übersicht (nur dieses Match, ohne Zuschauercode)" value={links.overview} />
|
|
||||||
<a className="btn btn-sm" href={links.overview} target="_blank" rel="noreferrer">Match-Übersicht öffnen</a>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -217,8 +217,6 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
|
|||||||
<Link className="btn btn-sm" to={`/m/${id}`}>Ansehen</Link>
|
<Link className="btn btn-sm" to={`/m/${id}`}>Ansehen</Link>
|
||||||
{m.status === 'lobby' && btn('Veto starten', { path: '/veto/start' }, 'btn-primary')}
|
{m.status === 'lobby' && btn('Veto starten', { path: '/veto/start' }, 'btn-primary')}
|
||||||
{(m.server?.status === 'configured') && btn('Aufwärmphase beenden', { path: '/server/warmup-end' })}
|
{(m.server?.status === 'configured') && btn('Aufwärmphase beenden', { path: '/server/warmup-end' })}
|
||||||
{(m.server?.status === 'configured') && btn('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' })}
|
|
||||||
{(m.server?.status === 'configured') && btn('Fortsetzen', { path: '/server/resume' })}
|
|
||||||
{(m.server?.status === 'configured') && hasNext && btn('Nächste Map', { path: '/series/next' })}
|
{(m.server?.status === 'configured') && hasNext && btn('Nächste Map', { path: '/series/next' })}
|
||||||
{m.server && ['start_failed', 'failed', 'stopped', 'config_failed', 'ready'].includes(m.server.status) && !closed &&
|
{m.server && ['start_failed', 'failed', 'stopped', 'config_failed', 'ready'].includes(m.server.status) && !closed &&
|
||||||
btn('Server neu starten / wiederholen', { path: '/server/retry' })}
|
btn('Server neu starten / wiederholen', { path: '/server/retry' })}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
import { api } from '../lib/api.js';
|
import { api } from '../lib/api.js';
|
||||||
import { FORMAT_LABEL } from '../lib/labels.js';
|
import { FORMAT_LABEL } from '../lib/labels.js';
|
||||||
import { getSocket } from '../lib/useMatch.js';
|
import { getSocket } from '../lib/useMatch.js';
|
||||||
@@ -15,13 +14,11 @@ const PAGE_SECONDS = 12;
|
|||||||
/**
|
/**
|
||||||
* Stream-Übersicht aller laufenden Vetos und Matches: /stream
|
* Stream-Übersicht aller laufenden Vetos und Matches: /stream
|
||||||
* Mit STREAM_KEY: /stream?key=<schlüssel>. Optional ?bg=transparent.
|
* Mit STREAM_KEY: /stream?key=<schlüssel>. Optional ?bg=transparent.
|
||||||
* Ein einzelnes Match: /stream/match/<matchId>, daraus führen die Karten der Übersicht.
|
|
||||||
*/
|
*/
|
||||||
export default function StreamOverviewPage() {
|
export default function StreamOverviewPage() {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const key = params.get('key') ?? '';
|
const key = params.get('key') ?? '';
|
||||||
const transparent = params.get('bg') === 'transparent';
|
const transparent = params.get('bg') === 'transparent';
|
||||||
const { matchId } = useParams();
|
|
||||||
const { data, error } = useStreamOverview(key);
|
const { data, error } = useStreamOverview(key);
|
||||||
const now = useClock();
|
const now = useClock();
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
@@ -32,10 +29,8 @@ export default function StreamOverviewPage() {
|
|||||||
return () => document.documentElement.classList.remove('stream-mode', 'stream-transparent');
|
return () => document.documentElement.classList.remove('stream-mode', 'stream-transparent');
|
||||||
}, [transparent]);
|
}, [transparent]);
|
||||||
|
|
||||||
const activeMatches = (data?.active ?? []).filter((m) => !matchId || m.id === matchId);
|
|
||||||
|
|
||||||
// Mehr Matches als auf eine Seite passen: automatisch weiterblättern
|
// Mehr Matches als auf eine Seite passen: automatisch weiterblättern
|
||||||
const pages = Math.max(1, Math.ceil(activeMatches.length / PER_PAGE));
|
const pages = data ? Math.max(1, Math.ceil(data.active.length / PER_PAGE)) : 1;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pages <= 1) { setPage(0); return undefined; }
|
if (pages <= 1) { setPage(0); return undefined; }
|
||||||
const t = setInterval(() => setPage((p) => (p + 1) % pages), PAGE_SECONDS * 1000);
|
const t = setInterval(() => setPage((p) => (p + 1) % pages), PAGE_SECONDS * 1000);
|
||||||
@@ -54,7 +49,7 @@ export default function StreamOverviewPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const active = activeMatches;
|
const active = data?.active ?? [];
|
||||||
const shown = active.slice(page * PER_PAGE, page * PER_PAGE + PER_PAGE);
|
const shown = active.slice(page * PER_PAGE, page * PER_PAGE + PER_PAGE);
|
||||||
const liveCount = active.filter((m) => m.status === 'live').length;
|
const liveCount = active.filter((m) => m.status === 'live').length;
|
||||||
const vetoCount = active.filter((m) => m.status === 'veto').length;
|
const vetoCount = active.filter((m) => m.status === 'veto').length;
|
||||||
@@ -78,24 +73,11 @@ export default function StreamOverviewPage() {
|
|||||||
{!data && <div className="so-empty">Lade Matches …</div>}
|
{!data && <div className="so-empty">Lade Matches …</div>}
|
||||||
{data && shown.length === 0 && (
|
{data && shown.length === 0 && (
|
||||||
<div className="so-empty">
|
<div className="so-empty">
|
||||||
{matchId ? (
|
<h2>Gerade läuft kein Match</h2>
|
||||||
<>
|
<p>{data.upcoming.length ? 'Die nächsten Lobbys stehen unten.' : 'Sobald ein Veto beginnt, erscheint es hier.'}</p>
|
||||||
<h2>Dieses Match läuft gerade nicht</h2>
|
|
||||||
<p>Es erscheint hier, sobald das Veto oder das Match beginnt.</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<h2>Gerade läuft kein Match</h2>
|
|
||||||
<p>{data.upcoming.length ? 'Die nächsten Lobbys stehen unten.' : 'Sobald ein Veto beginnt, erscheint es hier.'}</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{shown.map((m) => (
|
{shown.map((m) => <MatchCard key={m.id} m={m} />)}
|
||||||
<a key={m.id} className="so-link" href={matchHref(m.id, key)}>
|
|
||||||
<MatchCard m={m} />
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="so-bottom">
|
<footer className="so-bottom">
|
||||||
@@ -105,10 +87,8 @@ export default function StreamOverviewPage() {
|
|||||||
<ul>
|
<ul>
|
||||||
{data.upcoming.map((m) => (
|
{data.upcoming.map((m) => (
|
||||||
<li key={m.id}>
|
<li key={m.id}>
|
||||||
<a className="so-link" href={matchHref(m.id, key)}>
|
<span className="a">{m.teams.A}</span> <span className="vs">vs</span> <span className="b">{m.teams.B}</span>
|
||||||
<span className="a">{m.teams.A}</span> <span className="vs">vs</span> <span className="b">{m.teams.B}</span>
|
<small>{FORMAT_LABEL[m.format]}{m.ready.A && m.ready.B ? ', bereit' : ''}</small>
|
||||||
<small>{FORMAT_LABEL[m.format]}{m.ready.A && m.ready.B ? ', bereit' : ''}</small>
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -120,11 +100,9 @@ export default function StreamOverviewPage() {
|
|||||||
<ul>
|
<ul>
|
||||||
{data.results.map((m) => (
|
{data.results.map((m) => (
|
||||||
<li key={m.id}>
|
<li key={m.id}>
|
||||||
<a className="so-link" href={matchHref(m.id, key)}>
|
<span className={`a ${winner(m) === 'A' ? 'won' : ''}`}>{m.teams.A}</span>
|
||||||
<span className={`a ${winner(m) === 'A' ? 'won' : ''}`}>{m.teams.A}</span>
|
<b>{resultScore(m)}</b>
|
||||||
<b>{resultScore(m)}</b>
|
<span className={`b ${winner(m) === 'B' ? 'won' : ''}`}>{m.teams.B}</span>
|
||||||
<span className={`b ${winner(m) === 'B' ? 'won' : ''}`}>{m.teams.B}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -136,11 +114,6 @@ export default function StreamOverviewPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Link zur Stream-Übersicht eines einzelnen Matches. Der Zuschauercode bleibt geheim, daher nur die Match-ID. */
|
|
||||||
function matchHref(id, key) {
|
|
||||||
return `/stream/match/${encodeURIComponent(id)}${key ? `?key=${encodeURIComponent(key)}` : ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function useStreamOverview(key) {
|
function useStreamOverview(key) {
|
||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState(null);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
@@ -172,6 +145,7 @@ function winner(m) {
|
|||||||
const [a, b] = resultScore(m).split(':').map(Number);
|
const [a, b] = resultScore(m).split(':').map(Number);
|
||||||
return a === b ? null : a > b ? 'A' : 'B';
|
return a === b ? null : a > b ? 'A' : 'B';
|
||||||
}
|
}
|
||||||
|
|
||||||
function MatchCard({ m }) {
|
function MatchCard({ m }) {
|
||||||
const current = m.series.find((s) => s.number === m.currentMapNumber);
|
const current = m.series.find((s) => s.number === m.currentMapNumber);
|
||||||
const isBo1 = m.format === 'bo1';
|
const isBo1 = m.format === 'bo1';
|
||||||
|
|||||||
@@ -114,9 +114,3 @@
|
|||||||
.so-strip li b { font-variant-numeric: tabular-nums; }
|
.so-strip li b { font-variant-numeric: tabular-nums; }
|
||||||
.so-strip li .won { text-decoration: underline; text-underline-offset: 5px; }
|
.so-strip li .won { text-decoration: underline; text-underline-offset: 5px; }
|
||||||
.so-none { color: var(--muted); font-size: 22px; margin: 0; }
|
.so-none { color: var(--muted); font-size: 22px; margin: 0; }
|
||||||
|
|
||||||
/* Klickbare Karten und Listeneinträge führen zur Übersicht des jeweiligen Matches */
|
|
||||||
.so-link { display: block; color: inherit; text-decoration: none; }
|
|
||||||
.so-strip li .so-link { display: flex; align-items: baseline; gap: 10px; color: inherit; text-decoration: none; }
|
|
||||||
.so-link:hover { filter: brightness(1.15); }
|
|
||||||
.so-link:focus-visible { outline: 3px solid var(--brand-red); outline-offset: 4px; }
|
|
||||||
@@ -92,8 +92,6 @@ export function createAdminRouter({ service, manager, config }) {
|
|||||||
action('server/stop', (v) => manager.stopByOrganizer(v));
|
action('server/stop', (v) => manager.stopByOrganizer(v));
|
||||||
action('server/retry', (v) => manager.retry(v));
|
action('server/retry', (v) => manager.retry(v));
|
||||||
action('server/warmup-end', (v) => manager.endWarmup(v));
|
action('server/warmup-end', (v) => manager.endWarmup(v));
|
||||||
action('server/pause', (v) => manager.setPaused(v, true));
|
|
||||||
action('server/resume', (v) => manager.setPaused(v, false));
|
|
||||||
action('series/next', (v) => manager.loadNextMap(v));
|
action('series/next', (v) => manager.loadNextMap(v));
|
||||||
action('veto/start', (v) => service.startVeto(v));
|
action('veto/start', (v) => service.startVeto(v));
|
||||||
action('veto/action', (v, b) => service.vetoAction(
|
action('veto/action', (v, b) => service.vetoAction(
|
||||||
|
|||||||
@@ -796,8 +796,6 @@ export class MatchService extends EventEmitter {
|
|||||||
spectator: `${base}/watch/${formatCode(match.code_spectator)}`,
|
spectator: `${base}/watch/${formatCode(match.code_spectator)}`,
|
||||||
// Vollbild-Übersicht für den Stream (OBS-Browserquelle), nutzt den Zuschauercode
|
// Vollbild-Übersicht für den Stream (OBS-Browserquelle), nutzt den Zuschauercode
|
||||||
stream: `${base}/stream/${formatCode(match.code_spectator)}`,
|
stream: `${base}/stream/${formatCode(match.code_spectator)}`,
|
||||||
// Stream-Übersicht nur für dieses Match, ohne Zuschauercode (Schlüssel nur wenn gesetzt)
|
|
||||||
overview: `${base}/stream/match/${match.id}${this.config.streamKey ? `?key=${encodeURIComponent(this.config.streamKey)}` : ''}`,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -391,21 +391,6 @@ export class ServerManager {
|
|||||||
this.service.changed(viewer.matchId);
|
this.service.changed(viewer.matchId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Admin: pausiert oder setzt das laufende Match auf dem Server fort (mp_pause_match / mp_unpause_match). */
|
|
||||||
async setPaused(viewer, paused) {
|
|
||||||
this.service.require(viewer, 'organizer');
|
|
||||||
const row = this.row(viewer.matchId);
|
|
||||||
if (row?.status !== 'configured') throw new HttpError(409, 'Der Server ist nicht bereit', 'wrong_state');
|
|
||||||
if (!this.adapter.sendCommands) throw new HttpError(501, 'Dieser Adapter unterstützt keine Befehle', 'unsupported');
|
|
||||||
try {
|
|
||||||
await this.adapter.sendCommands(JSON.parse(row.handle), [paused ? 'mp_pause_match' : 'mp_unpause_match']);
|
|
||||||
} catch (err) {
|
|
||||||
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
|
|
||||||
}
|
|
||||||
this.service.log(viewer.matchId, 'server', paused ? 'Match pausiert (Admin)' : 'Match fortgesetzt (Admin)');
|
|
||||||
this.service.changed(viewer.matchId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Demo-Modus: simuliert einen Matchverlauf, indem echte CS2-Logzeilen erzeugt und ausgewertet werden. */
|
/** Demo-Modus: simuliert einen Matchverlauf, indem echte CS2-Logzeilen erzeugt und ausgewertet werden. */
|
||||||
startDemoSimulation(matchId) {
|
startDemoSimulation(matchId) {
|
||||||
const match = this.service.getMatch(matchId);
|
const match = this.service.getMatch(matchId);
|
||||||
|
|||||||
@@ -107,9 +107,6 @@ test('kompletter Ablauf: Lobby, Berechtigungen, Veto, Server-Konfiguration', asy
|
|||||||
const orgView = (await call('GET', `/matches/${id}`, { token: org })).body;
|
const orgView = (await call('GET', `/matches/${id}`, { token: org })).body;
|
||||||
assert.equal(orgView.viewer.role, 'organizer');
|
assert.equal(orgView.viewer.role, 'organizer');
|
||||||
const { codes } = orgView.organizer;
|
const { codes } = orgView.organizer;
|
||||||
// Match-Übersicht nur für dieses Match, ohne Zuschauercode
|
|
||||||
assert.match(orgView.organizer.links.overview, new RegExp(`/stream/match/${id}$`));
|
|
||||||
assert.ok(!orgView.organizer.links.overview.includes(codes.spectator));
|
|
||||||
|
|
||||||
// Zuschauer- und fremde Codes erlauben keinen Teambeitritt
|
// Zuschauer- und fremde Codes erlauben keinen Teambeitritt
|
||||||
assert.equal((await call('POST', `/matches/${id}/join`, { body: { code: codes.spectator, nickname: 'spy' } })).status, 403);
|
assert.equal((await call('POST', `/matches/${id}/join`, { body: { code: codes.spectator, nickname: 'spy' } })).status, 403);
|
||||||
@@ -401,7 +398,6 @@ test('Admin-Modus: Anmeldung, Verwaltung aller Matches, Sperre ohne Anmeldung',
|
|||||||
|
|
||||||
// Ein Match abbrechen wie ein Admin
|
// Ein Match abbrechen wie ein Admin
|
||||||
assert.equal((await call('POST', `/admin/matches/${id}/cancel`, { ...admin, body: {} })).status, 200);
|
assert.equal((await call('POST', `/admin/matches/${id}/cancel`, { ...admin, body: {} })).status, 200);
|
||||||
assert.equal((await call('POST', `/admin/matches/${id}/server/pause`, { ...admin, body: {} })).status, 409, 'ohne Server keine Pause');
|
|
||||||
assert.equal(app.service.getMatch(id).status, 'cancelled');
|
assert.equal(app.service.getMatch(id).status, 'cancelled');
|
||||||
|
|
||||||
// Endgültig löschen
|
// Endgültig löschen
|
||||||
|
|||||||
Reference in new issue
Block a user