Startseite neu: Spieler zuerst mit Code-Eingabe, eigenem Match und den vier Schritten

This commit is contained in:
mregosar committed 2026-10-29 22:27:06 +01:00
1 parent 7b6ce01d05
commit c62fe4ba1e
2 files changed
+231 -40

No files matched your search

+155 -40
View File
@@ -6,53 +6,145 @@ import { getAccess } from '../lib/session.js';
import { useOverview } from '../lib/useMatch.js'; import { useOverview } from '../lib/useMatch.js';
import MapImage from '../components/MapImage.jsx'; 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() { export default function Overview() {
const { matches } = useOverview(); 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 ( return (
<div className="overview"> <div className="overview home">
<section className="overview-head"> <section className={`home-hero ${mine.length ? 'has-mine' : ''}`}>
<div> <div className="home-intro">
<h1>Match-Lobbys</h1> <h1>Mit deinem Team ins Match</h1>
<p className="lead">Teams treten mit ihrem Einladungscode bei, die Captains führen das Map-Veto, der Server wird automatisch eingerichtet.</p> <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> </div>
<CodeEntry /> {mine.length > 0 && <MyMatches matches={mine} />}
</section> </section>
<Steps current={step} />
<KnifeServers /> <KnifeServers />
{matches === null ? ( {matches === null ? (
<p className="muted">Lade Matches …</p> <p className="muted">Lade Matches …</p>
) : ( ) : (
<div className="groups"> <>
{GROUPS.map((g) => { <section className="group home-live">
const list = matches.filter((m) => m.group === g.key); <h2>Laufend <span className="count">{running.length}</span></h2>
return ( {running.length === 0 ? (
<section key={g.key} className={`group group-${g.key}`}> <p className="muted small">Gerade läuft kein Match.</p>
<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">
<div className="run-grid"> {running.map((m) => <RunningCard key={m.id} match={m} />)}
{list.map((m) => <RunningCard key={m.id} match={m} />)} </div>
</div> )}
) : ( </section>
<ul className="match-list"> <div className="home-lists">
{list.map((m) => <MatchRow key={m.id} match={m} />)} <section className="group">
</ul> <h2>Offene Lobbys <span className="count">{open.length}</span></h2>
)} {open.length === 0 ? (
</section> <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>
</div> )}
</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> </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 }) { function MatchRow({ match: m }) {
const access = getAccess(m.id); const access = getAccess(m.id);
const server = serverLabel(m.server); 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 [code, setCode] = useState('');
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const complete = code.replace(/-/g, '').length === 8;
const submit = async (e) => { const submit = async (e) => {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
setBusy(true);
try { try {
const res = await api('/access', { method: 'POST', body: { code } }); 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) { } catch (err) {
setError(err.message); setError(err.message);
setBusy(false);
} }
}; };
return ( return (
<form className="code-entry" onSubmit={submit}> <form className="ticket" onSubmit={submit}>
<label htmlFor="code">Einladungs- oder Zuschauercode</label> <label className="ticket-stub" htmlFor="code">Code</label>
<div className="row"> <input
<input id="code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="ABCD-EFGH" autoComplete="off" spellCheck="false" /> id="code"
<button className="btn" disabled={code.replace(/[^a-z0-9]/gi, '').length < 8}>Beitreten</button> className="ticket-code"
</div> value={code}
{error && <p className="field-error">{error}</p>} 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> </form>
); );
} }
/** Offene Messer-Server für die Seitenwahl, zum direkten Verbinden. */ /** Offene Messer-Server für die Seitenwahl, zum direkten Verbinden. */
function KnifeServers() { function KnifeServers() {
const [servers, setServers] = useState([]); const [servers, setServers] = useState([]);
+76
View File
@@ -645,3 +645,79 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
.settings-nav-item small { display: none; } .settings-nav-item small { display: none; }
.settings-nav-item.is-active { border-color: var(--primary); } .settings-nav-item.is-active { border-color: var(--primary); }
} }
/* ---------- Startseite: Spieler zuerst ---------- */
.home-hero { display: grid; grid-template-columns: minmax(0, 1fr); gap: 32px; align-items: start; margin: 8px 0 28px; }
.home-hero.has-mine { grid-template-columns: minmax(0, 1.25fr) minmax(280px, 1fr); }
.home-intro h1 { font: 700 clamp(2.6rem, 6vw, 4.4rem)/.95 var(--display); letter-spacing: -.01em; margin: 0 0 14px; max-width: 14ch; }
.home-intro .lead { max-width: 58ch; margin: 0 0 22px; }
/* Code-Eingabe als Eintrittskarte: Abriss links, Code groß in der Mitte */
.ticket {
--notch: 11px;
display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; column-gap: 0;
max-width: 620px; background: var(--surface-2); border: 1px solid var(--line-strong);
clip-path: polygon(var(--chamfer) 0, 100% 0, 100% calc(100% - var(--chamfer)), calc(100% - var(--chamfer)) 100%, 0 100%, 0 var(--chamfer));
}
.ticket-stub {
align-self: stretch; display: flex; align-items: center; padding: 0 18px 0 22px;
font: 700 1.05rem var(--display); color: var(--muted); letter-spacing: .02em;
border-right: 2px dashed var(--line-strong); background: color-mix(in srgb, var(--brand-red) 14%, transparent);
}
.ticket-code {
min-width: 0; width: 100%; border: 0; background: transparent; color: var(--text); outline: none;
font: 700 clamp(1.9rem, 4.6vw, 2.75rem)/1 var(--display); letter-spacing: .14em; padding: 16px 18px;
}
.ticket-code::placeholder { color: color-mix(in srgb, var(--muted) 45%, transparent); }
.ticket:focus-within { border-color: var(--primary); box-shadow: inset 0 0 0 1px var(--primary); }
.ticket-go { margin: 0 14px 0 0; }
.ticket-msg { grid-column: 1 / -1; margin: 0; padding: 8px 22px 12px; border-top: 1px solid var(--line); }
/* Eigene Matches */
.home-mine h2 { font-size: 1.15rem; margin: 6px 0 10px; }
.home-mine ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.mine-card {
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 14px; align-items: center;
padding: 14px 16px; text-decoration: none; color: var(--text);
background: var(--surface); border: 1px solid var(--line); border-left: 3px solid var(--primary); border-radius: var(--radius);
}
.mine-card:hover { border-color: var(--line-strong); border-left-color: var(--primary-hover); }
.mine-card:focus-visible { outline: 2px solid var(--link); outline-offset: 2px; }
.mine-teams { font: 700 1.35rem/1.1 var(--display); grid-column: 1; }
.mine-meta { grid-column: 1; font-size: .85rem; color: var(--muted); }
.mine-next { grid-column: 1; font-weight: 600; margin-top: 4px; }
.mine-go { grid-column: 2; grid-row: 1 / span 3; }
/* Vier Schritte */
.home-steps { list-style: none; margin: 0 0 36px; padding: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-top: 1px solid var(--line); }
.home-steps li { position: relative; display: grid; grid-template-columns: auto 1fr; align-content: start; column-gap: 12px; row-gap: 2px; padding: 14px 16px 4px 0; }
.step-n {
grid-row: 1; align-self: center; width: 34px; height: 34px; display: grid; place-items: center;
font: 700 1.2rem var(--display); color: var(--muted); border: 1px solid var(--line-strong); border-radius: 50%;
}
.step-title { font-weight: 700; align-self: center; }
.step-text { grid-column: 2; font-size: .85rem; color: var(--muted); }
.home-steps li.is-done .step-n { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 60%, transparent); }
.home-steps li.is-current::before { content: ''; position: absolute; top: -2px; left: 0; right: 16px; height: 3px; background: var(--primary); }
.home-steps li.is-current .step-n { color: #fff; background: var(--primary); border-color: var(--primary); }
.home-live { margin-bottom: 36px; }
.home-lists { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 32px; }
.home-lists .match-row-meta { flex-wrap: wrap; }
@media (max-width: 900px) {
.home-hero.has-mine { grid-template-columns: minmax(0, 1fr); }
.home-hero.has-mine .home-mine { order: -1; }
.home-steps { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.home-lists { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 520px) {
.ticket { grid-template-columns: minmax(0, 1fr) auto; }
.ticket-stub { grid-column: 1 / -1; border-right: 0; border-bottom: 2px dashed var(--line-strong); padding: 8px 18px; }
.ticket-code { letter-spacing: .08em; padding: 12px 14px; }
.home-steps { grid-template-columns: minmax(0, 1fr); }
.home-steps li { padding: 10px 0 10px 12px; }
.home-steps li.is-current::before { top: 8px; bottom: 8px; left: 0; right: auto; width: 3px; height: auto; }
.mine-card { grid-template-columns: minmax(0, 1fr); }
.mine-go { grid-column: 1; grid-row: auto; justify-self: start; margin-top: 6px; }
}