Compare commits

...
Author SHA1 Message Date
mregosar f90351dd8d Übersicht: laufende Matches als Karten mit Map, Stand und Serie 2026-10-29 09:29:12 +01:00
mregosar d2625d23ab Merge docs/admin-hilfe in main 2026-10-29 09:22:01 +01:00
mregosar 8b6abdb8b2 Merge feature/admin-status in main 2026-10-29 09:22:01 +01:00
mregosar eb80c4845c Docs: Hilfe für Turnier-Admins (PDF mit Screenshots) 2026-10-29 09:21:33 +01:00
mregosar 90996d1b0a Admin: Pausenanzeige, Live-Stand und passender Umschalter
Pausierte Matches zeigen ein Badge und den Knopf Fortsetzen. Laufende Matches
zeigen Map, Stand und Runde. Der Pausenzustand wird gespeichert und bei
Mapwechsel oder Serverstopp zurückgesetzt.
2026-10-29 09:21:32 +01:00
SPP UserandClaude Opus 5.5 da986d44c8 Merge feature/remove-real-logs in main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:38:38 +01:00
7 changed files with 87 additions and 7 deletions

No files matched your search

+13 -2
View File
@@ -271,6 +271,9 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
</button>
);
const hasNext = m.series.some((s) => s.number === m.currentMapNumber + 1);
const current = m.series.find((s) => s.number === m.currentMapNumber);
const configured = m.server?.status === 'configured';
const paused = !!m.server?.paused;
return (
<section className={`panel admin-card status-${m.status}`}>
@@ -283,6 +286,14 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
{m.modeLabel}, {FORMAT_LABEL[m.format]} · {m.statusLabel} · <span className={`tone-${server.tone}`}>{server.text}</span>
{m.format !== 'bo1' && <> · Serie {m.seriesScore.A}:{m.seriesScore.B}</>}
</div>
{m.status === 'live' && current && (
<div className="admin-live">
<span>Map {current.number}: {current.name}</span>
<strong>{current.score.A} : {current.score.B}</strong>
{current.live?.roundsPlayed != null && <span className="muted">Runde {current.live.roundsPlayed + 1}</span>}
</div>
)}
{paused && <span className="admin-badge is-paused">Pausiert</span>}
</div>
<button className="link-btn" onClick={() => setOpen(!open)}>{open ? 'Weniger' : 'Details'}</button>
</div>
@@ -291,8 +302,8 @@ function MatchAdminCard({ m, run, busy, token, reload, setNotice }) {
<Link className="btn btn-sm" to={`/m/${id}`}>Ansehen</Link>
{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('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' })}
{(m.server?.status === 'configured') && btn('Fortsetzen', { path: '/server/resume' })}
{configured && !paused && btn('Pausieren', { path: '/server/pause', confirm: 'Match pausieren?' }, 'btn-warn')}
{configured && paused && btn('Fortsetzen', { path: '/server/resume' }, 'btn-primary')}
{(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 &&
btn('Server neu starten / wiederholen', { path: '/server/retry' })}
+34 -1
View File
@@ -4,6 +4,7 @@ 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';
import MapImage from '../components/MapImage.jsx';
const GROUPS = [
{ key: 'open', title: 'Offen', empty: 'Keine offenen Lobbys. Erstelle ein Match, um zu starten.' },
@@ -32,7 +33,11 @@ export default function Overview() {
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> : (
{list.length === 0 ? <p className="muted small">{g.empty}</p> : g.key === 'running' ? (
<div className="run-grid">
{list.map((m) => <RunningCard key={m.id} match={m} />)}
</div>
) : (
<ul className="match-list">
{list.map((m) => <MatchRow key={m.id} match={m} />)}
</ul>
@@ -79,6 +84,34 @@ function MatchRow({ match: m }) {
);
}
/** Karte für ein laufendes Match: Map als Hintergrund, Stand groß, Serie und Status darunter. */
function RunningCard({ match: m }) {
const server = serverLabel(m.server);
const map = m.currentMap;
const live = map && !map.finished;
const bo1 = m.format === 'bo1';
return (
<Link to={`/m/${m.id}`} className={`run-card ${live ? 'is-live' : ''}`} style={{ '--map': map?.color ?? '#3a4150' }}>
{map?.image && <MapImage map={{ image: map.image, color: map.color, name: map.name }} className="run-art" />}
<div className="run-top">
<span className="run-mode">{m.modeLabel}, {FORMAT_LABEL[m.format]}</span>
{live ? <span className="live-badge run-live">Live</span> : <span className={`run-status tone-${server.tone}`}>{m.statusLabel}</span>}
</div>
<div className="run-teams">
<span className="run-team a">{m.teamA}</span>
<span className="run-score">
{map ? <>{map.score.A}<i>:</i>{map.score.B}</> : <i className="run-vs">vs.</i>}
</span>
<span className="run-team b">{m.teamB}</span>
</div>
<div className="run-foot">
<span>{map ? `Map ${map.number}: ${map.name}` : 'Veto läuft'}</span>
{!bo1 && m.seriesScore && <span className="run-series">Serie {m.seriesScore.A}:{m.seriesScore.B}</span>}
</div>
</Link>
);
}
function CodeEntry() {
const [code, setCode] = useState('');
const [error, setError] = useState(null);
+25
View File
@@ -529,3 +529,28 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
.turn-seconds { min-width: 3ch; text-align: right; color: var(--muted); font-size: .875rem; font-variant-numeric: tabular-nums; }
.turn-timer.is-low .turn-bar span { background: var(--error); }
.turn-timer.is-low .turn-seconds { color: var(--error); font-weight: 600; }
.admin-live { display: flex; gap: 10px; align-items: baseline; margin-top: 4px; font-size: .9rem; }
.admin-live strong { font-variant-numeric: tabular-nums; }
.admin-badge { display: inline-block; margin-top: 6px; padding: 2px 10px; border-radius: 999px; font-size: .8rem; font-weight: 600; }
.admin-badge.is-paused { background: color-mix(in srgb, var(--pending) 22%, transparent); color: var(--pending); border: 1px solid var(--pending); }
.btn-warn { border-color: var(--pending); color: var(--pending); }
/* Laufende Matches in der Übersicht */
.run-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
.run-card { position: relative; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-height: 190px; padding: 16px 18px; border-radius: var(--radius); border: 1px solid var(--line); background: var(--surface); color: var(--text); text-decoration: none; overflow: hidden; isolation: isolate; transition: transform .15s ease, border-color .15s ease; }
.run-card:hover { transform: translateY(-2px); border-color: var(--map); }
.run-card.is-live { box-shadow: inset 3px 0 0 var(--brand-red); }
.run-art { position: absolute; inset: 0; z-index: -1; opacity: .28; }
.run-art::after { background: linear-gradient(180deg, rgba(0,0,0,.2), rgba(0,0,0,.92)); }
.run-top { display: flex; justify-content: space-between; align-items: center; gap: 8px; font-size: .8rem; color: var(--muted); }
.run-live { animation: run-pulse 1.6s ease-in-out infinite; }
@keyframes run-pulse { 50% { opacity: .55; } }
.run-status { font-weight: 600; }
.run-teams { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 8px; text-align: center; }
.run-team { font: 700 1.25rem var(--display); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.run-team.a { color: var(--team-a); text-align: right; }
.run-team.b { color: var(--team-b); text-align: left; }
.run-score { font: 700 2.25rem var(--display); font-variant-numeric: tabular-nums; }
.run-score i { font-style: normal; color: var(--muted); padding: 0 4px; }
.run-vs { font-size: 1rem; color: var(--muted); font-style: normal; }
.run-foot { display: flex; justify-content: space-between; gap: 8px; font-size: .875rem; color: var(--muted); }
.run-series { font-variant-numeric: tabular-nums; }
Binary file not shown.
+2
View File
@@ -128,4 +128,6 @@ function migrate(db) {
for (const [name, type] of add) {
if (!seriesColumns.has(name)) db.exec(`ALTER TABLE series_maps ADD COLUMN ${name} ${type}`);
}
const serverColumns = columns('servers');
if (!serverColumns.has('paused')) db.exec('ALTER TABLE servers ADD COLUMN paused INTEGER NOT NULL DEFAULT 0');
}
+9 -1
View File
@@ -791,7 +791,14 @@ export class MatchService extends EventEmitter {
return {
seriesScore: maps.length ? seriesScore(maps) : null,
currentMap: current && (current.status === 'live' || current.status === 'done')
? { number: current.number, name: this.mapName(m, current.map), score: { A: current.score_a, B: current.score_b }, finished: current.status === 'done' }
? {
number: current.number,
name: this.mapName(m, current.map),
image: JSON.parse(m.maps).find((x) => x.id === current.map)?.image ?? null,
color: JSON.parse(m.maps).find((x) => x.id === current.map)?.color ?? null,
score: { A: current.score_a, B: current.score_b },
finished: current.status === 'done',
}
: null,
id: m.id,
teamA: m.team_a_name,
@@ -944,6 +951,7 @@ export class MatchService extends EventEmitter {
status: row.status,
message: row.message,
playerCount: row.player_count,
paused: !!row.paused,
attempts: row.attempts,
updatedAt: row.updated_at,
idleTimeoutMinutes: this.config.idleTimeoutMinutes,
+4 -3
View File
@@ -308,7 +308,8 @@ export class ServerManager {
if (!seriesMap) throw new HttpError(409, 'Das Veto ist noch nicht abgeschlossen', 'veto_pending');
const mapName = pool.maps.find((m) => m.id === seriesMap.map)?.name ?? seriesMap.map;
this.update(matchId, { status: 'configuring', message: `Lade Map ${seriesMap.number}: ${mapName}` });
// Ein Mapwechsel beendet die Pause, CS2 lädt die Map neu
this.update(matchId, { status: 'configuring', paused: 0, message: `Lade Map ${seriesMap.number}: ${mapName}` });
this.service.log(matchId, 'server', `Konfiguration für Map ${seriesMap.number} (${mapName}) gestartet`);
const plan = buildMapCommands({
@@ -418,8 +419,8 @@ export class ServerManager {
} catch (err) {
throw new HttpError(502, `Befehl konnte nicht gesendet werden: ${err.message}`, 'server_error');
}
this.update(viewer.matchId, { paused: paused ? 1 : 0 });
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. */
@@ -463,7 +464,7 @@ export class ServerManager {
const row = this.row(matchId);
if (!row || row.status === 'stopped') return;
if (row.handle) await this.safeStop(row);
this.update(matchId, { status: 'stopped', stopped_at: Date.now(), message: reason, player_count: null });
this.update(matchId, { status: 'stopped', stopped_at: Date.now(), message: reason, player_count: null, paused: 0 });
this.service.log(matchId, 'server', `Server gestoppt: ${reason}`);
}