Startseite neu: Spieler zuerst mit Code-Eingabe, eigenem Match und den vier Schritten
This commit is contained in:
1 parent
7b6ce01d05
commit
c62fe4ba1e
2 files changed
+231
-40
No files matched your search
+155
-40
@@ -6,53 +6,145 @@ 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();
|
||||
const mine = (matches ?? []).filter((m) => m.group !== 'closed' && getAccess(m.id));
|
||||
const step = mine.length ? Math.max(...mine.map((m) => nextStep(m, getAccess(m.id)).step)) : 0;
|
||||
const running = (matches ?? []).filter((m) => m.group === 'running');
|
||||
const open = (matches ?? []).filter((m) => m.group === 'open');
|
||||
const closed = (matches ?? []).filter((m) => m.group === 'closed');
|
||||
|
||||
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 className="overview home">
|
||||
<section className={`home-hero ${mine.length ? 'has-mine' : ''}`}>
|
||||
<div className="home-intro">
|
||||
<h1>Mit deinem Team ins Match</h1>
|
||||
<p className="lead">
|
||||
Den Teamlink postet der Organisator im Match-Chat auf maxlan.de oder in Discord. Öffne den Link deines Teams.
|
||||
Hast du nur den Code, gib ihn hier ein.
|
||||
</p>
|
||||
<CodeTicket />
|
||||
</div>
|
||||
<CodeEntry />
|
||||
{mine.length > 0 && <MyMatches matches={mine} />}
|
||||
</section>
|
||||
|
||||
<Steps current={step} />
|
||||
|
||||
<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>
|
||||
<>
|
||||
<section className="group home-live">
|
||||
<h2>Laufend <span className="count">{running.length}</span></h2>
|
||||
{running.length === 0 ? (
|
||||
<p className="muted small">Gerade läuft kein Match.</p>
|
||||
) : (
|
||||
<div className="run-grid">
|
||||
{running.map((m) => <RunningCard key={m.id} match={m} />)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<div className="home-lists">
|
||||
<section className="group">
|
||||
<h2>Offene Lobbys <span className="count">{open.length}</span></h2>
|
||||
{open.length === 0 ? (
|
||||
<p className="muted small">Keine offene Lobby. Sobald der Organisator ein Match erstellt, steht es hier.</p>
|
||||
) : (
|
||||
<ul className="match-list">{open.map((m) => <MatchRow key={m.id} match={m} />)}</ul>
|
||||
)}
|
||||
</section>
|
||||
<section className="group">
|
||||
<h2>Ergebnisse <span className="count">{closed.length}</span></h2>
|
||||
{closed.length === 0 ? (
|
||||
<p className="muted small">Noch kein Match abgeschlossen.</p>
|
||||
) : (
|
||||
<ul className="match-list">{closed.slice(0, 12).map((m) => <MatchRow key={m.id} match={m} />)}</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
{ title: 'Teamlink öffnen', text: 'Link aus dem Match-Chat, Namen eingeben' },
|
||||
{ title: 'Bereit klicken', text: 'In der Lobby auf „Ich bin bereit“' },
|
||||
{ title: 'Map-Veto', text: 'Die Captains bannen und wählen' },
|
||||
{ title: 'Auf den Server', text: 'Befehl kopieren oder im LAN-Reiter' },
|
||||
];
|
||||
|
||||
/** Was als Nächstes zu tun ist, je nach Stand des Matches und Rolle. step = Schritt in STEPS (1–4). */
|
||||
function nextStep(m, access) {
|
||||
const organizer = access?.kind === 'organizer';
|
||||
switch (m.status) {
|
||||
case 'lobby':
|
||||
return organizer
|
||||
? { step: 2, text: 'Lobby offen: Veto starten, sobald beide Teams bereit sind', action: 'Zur Lobby' }
|
||||
: { step: 2, text: 'Lobby offen: Klick auf „Ich bin bereit“', action: 'Zur Lobby' };
|
||||
case 'veto':
|
||||
return { step: 3, text: 'Das Map-Veto läuft', action: 'Zum Veto' };
|
||||
case 'server_setup':
|
||||
return { step: 4, text: 'Der Server wird eingerichtet, gleich geht es los', action: 'Zum Match' };
|
||||
default:
|
||||
return organizer
|
||||
? { step: 4, text: 'Match läuft: Aufwärmphase beenden, wenn alle da sind', action: 'Zum Match' }
|
||||
: { step: 4, text: 'Match läuft: Verbindung steht auf der Match-Seite', action: 'Zum Server' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Matches, für die dieser Browser einen Zugang hat, mit dem nächsten Schritt. */
|
||||
function MyMatches({ matches }) {
|
||||
return (
|
||||
<aside className="home-mine" aria-label="Deine Matches">
|
||||
<h2>{matches.length === 1 ? 'Dein Match' : 'Deine Matches'}</h2>
|
||||
<ul>
|
||||
{matches.map((m) => {
|
||||
const access = getAccess(m.id);
|
||||
const next = nextStep(m, access);
|
||||
return (
|
||||
<li key={m.id}>
|
||||
<Link to={`/m/${m.id}`} className="mine-card">
|
||||
<span className="mine-teams">
|
||||
<span className="team-a">{m.teamA}</span> <span className="vs">vs.</span> <span className="team-b">{m.teamB}</span>
|
||||
</span>
|
||||
<span className="mine-meta">
|
||||
{m.modeLabel}, {FORMAT_LABEL[m.format]}
|
||||
{access.kind === 'organizer' ? ', du bist Organisator' : access.kind === 'spectator' ? ', als Zuschauer' : ''}
|
||||
</span>
|
||||
<span className="mine-next">{next.text}</span>
|
||||
<span className="btn btn-primary btn-sm mine-go">{next.action}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/** Die vier Schritte vom Teamlink bis auf den Server. current = Schritt des eigenen Matches (0 = keins). */
|
||||
function Steps({ current }) {
|
||||
return (
|
||||
<ol className="home-steps">
|
||||
{STEPS.map((st, i) => {
|
||||
const n = i + 1;
|
||||
const state = current === 0 ? '' : n < current ? 'is-done' : n === current ? 'is-current' : '';
|
||||
return (
|
||||
<li key={st.title} className={state} aria-current={n === current ? 'step' : undefined}>
|
||||
<span className="step-n">{n}</span>
|
||||
<span className="step-title">{st.title}</span>
|
||||
<span className="step-text">{st.text}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchRow({ match: m }) {
|
||||
const access = getAccess(m.id);
|
||||
const server = serverLabel(m.server);
|
||||
@@ -114,31 +206,54 @@ function RunningCard({ match: m }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CodeEntry() {
|
||||
/** Code in die Form ABCD-EFGH bringen (Großbuchstaben, Bindestrich nach vier Zeichen). */
|
||||
function formatCode(raw) {
|
||||
const clean = raw.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8);
|
||||
return clean.length > 4 ? `${clean.slice(0, 4)}-${clean.slice(4)}` : clean;
|
||||
}
|
||||
|
||||
/** Code-Eingabe im Stil einer Eintrittskarte. */
|
||||
function CodeTicket() {
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const complete = code.replace(/-/g, '').length === 8;
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api('/access', { method: 'POST', body: { code } });
|
||||
navigate(res.access === 'spectator' ? `/watch/${code.trim()}` : `/join/${code.trim()}`);
|
||||
navigate(res.access === 'spectator' ? `/watch/${code}` : `/join/${code}`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
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 className="ticket" onSubmit={submit}>
|
||||
<label className="ticket-stub" htmlFor="code">Code</label>
|
||||
<input
|
||||
id="code"
|
||||
className="ticket-code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(formatCode(e.target.value))}
|
||||
placeholder="ABCD-EFGH"
|
||||
autoComplete="off"
|
||||
autoCapitalize="characters"
|
||||
spellCheck="false"
|
||||
inputMode="text"
|
||||
aria-describedby={error ? 'code-error' : 'code-hint'}
|
||||
/>
|
||||
<button className="btn btn-primary ticket-go" disabled={!complete || busy}>{busy ? 'Prüfe …' : 'Beitreten'}</button>
|
||||
{error
|
||||
? <p id="code-error" className="field-error ticket-msg">{error}</p>
|
||||
: <p id="code-hint" className="muted small ticket-msg">Teamcode oder Zuschauercode, acht Zeichen.</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** Offene Messer-Server für die Seitenwahl, zum direkten Verbinden. */
|
||||
function KnifeServers() {
|
||||
const [servers, setServers] = useState([]);
|
||||
|
||||
Reference in new issue
Block a user