Merge feature/startseite-spieler in main
This commit is contained in:
commit
a3471a2f3c
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([]);
|
||||
|
||||
@@ -645,3 +645,79 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
|
||||
.settings-nav-item small { display: none; }
|
||||
.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; }
|
||||
}
|
||||
Reference in new issue
Block a user