175 lines
6.8 KiB
React
175 lines
6.8 KiB
React
import { useEffect, useState } from 'react';
|
|
import { Link, useNavigate } from 'react-router-dom';
|
|
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.' },
|
|
{ key: 'running', title: 'Laufend', empty: 'Gerade läuft kein Match.' },
|
|
{ key: 'closed', title: 'Abgeschlossen', empty: 'Noch keine abgeschlossenen Matches.' },
|
|
];
|
|
|
|
export default function Overview() {
|
|
const { matches } = useOverview();
|
|
return (
|
|
<div className="overview">
|
|
<section className="overview-head">
|
|
<div>
|
|
<h1>Match-Lobbys</h1>
|
|
<p className="lead">Teams treten mit ihrem Einladungscode bei, die Captains führen das Map-Veto, der Server wird automatisch eingerichtet.</p>
|
|
</div>
|
|
<CodeEntry />
|
|
</section>
|
|
|
|
<KnifeServers />
|
|
|
|
{matches === null ? (
|
|
<p className="muted">Lade Matches …</p>
|
|
) : (
|
|
<div className="groups">
|
|
{GROUPS.map((g) => {
|
|
const list = matches.filter((m) => m.group === g.key);
|
|
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> : 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>
|
|
)}
|
|
</section>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MatchRow({ match: m }) {
|
|
const access = getAccess(m.id);
|
|
const server = serverLabel(m.server);
|
|
return (
|
|
<li>
|
|
<Link to={`/m/${m.id}`} className="match-row">
|
|
<div className="match-row-teams">
|
|
<span className="team-a">{m.teamA}</span>
|
|
{m.seriesScore && m.format !== 'bo1' ? (
|
|
<span className="row-score" title="Serienstand">{m.seriesScore.A}:{m.seriesScore.B}</span>
|
|
) : (
|
|
<span className="vs">vs.</span>
|
|
)}
|
|
<span className="team-b">{m.teamB}</span>
|
|
</div>
|
|
<div className="match-row-meta">
|
|
{m.currentMap && (
|
|
<span className={m.currentMap.finished ? '' : 'row-live'}>
|
|
{m.currentMap.finished ? '' : <span className="live-badge">Live</span>} {m.currentMap.name} {m.currentMap.score.A}:{m.currentMap.score.B}
|
|
</span>
|
|
)}
|
|
<span>{m.modeLabel}</span>
|
|
<span>{FORMAT_LABEL[m.format]}</span>
|
|
{m.group === 'open' && <span>{m.players.A + m.players.B}/{m.players.teamSize * 2} Spieler</span>}
|
|
<span className={`tone-${server.tone}`}>{m.statusLabel}</span>
|
|
{access && <span className="mine">Dein Zugang</span>}
|
|
<span className="muted">{timeAgo(m.createdAt)}</span>
|
|
</div>
|
|
</Link>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
/** 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);
|
|
const navigate = useNavigate();
|
|
const submit = async (e) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
try {
|
|
const res = await api('/access', { method: 'POST', body: { code } });
|
|
navigate(res.access === 'spectator' ? `/watch/${code.trim()}` : `/join/${code.trim()}`);
|
|
} catch (err) {
|
|
setError(err.message);
|
|
}
|
|
};
|
|
return (
|
|
<form className="code-entry" onSubmit={submit}>
|
|
<label htmlFor="code">Einladungs- oder Zuschauercode</label>
|
|
<div className="row">
|
|
<input id="code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="ABCD-EFGH" autoComplete="off" spellCheck="false" />
|
|
<button className="btn" disabled={code.replace(/[^a-z0-9]/gi, '').length < 8}>Beitreten</button>
|
|
</div>
|
|
{error && <p className="field-error">{error}</p>}
|
|
</form>
|
|
);
|
|
}
|
|
/** Offene Messer-Server für die Seitenwahl, zum direkten Verbinden. */
|
|
function KnifeServers() {
|
|
const [servers, setServers] = useState([]);
|
|
useEffect(() => {
|
|
let alive = true;
|
|
const load = () => api('/knife-servers').then((res) => alive && setServers(res.servers)).catch(() => {});
|
|
load();
|
|
const t = setInterval(load, 10_000);
|
|
return () => {
|
|
alive = false;
|
|
clearInterval(t);
|
|
};
|
|
}, []);
|
|
if (servers.length === 0) return null;
|
|
return (
|
|
<section className="group knife-public">
|
|
<h2>Messer-Server für die Seitenwahl <span className="count">{servers.length}</span></h2>
|
|
<p className="muted small">Ohne Passwort, auch im LAN-Reiter von Steam. Nach eurer Messerrunde setzt sich der Server selbst zurück.</p>
|
|
<ul className="knife-list">
|
|
{servers.map((k) => (
|
|
<li key={k.id}>
|
|
<strong>{k.name}</strong>
|
|
{k.modeShort && <span className="knife-mode">{k.modeShort}</span>}
|
|
<span className="muted small">{k.mapName}</span>
|
|
<span className="small">{k.playerCount ? `${k.playerCount} Spieler` : 'frei'}</span>
|
|
<code>{k.connect}</code>
|
|
<a className="btn btn-sm" href={k.steamUrl}>Verbinden</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
);
|
|
}
|