diff --git a/client/src/pages/Overview.jsx b/client/src/pages/Overview.jsx index 8db33dc..a6be664 100644 --- a/client/src/pages/Overview.jsx +++ b/client/src/pages/Overview.jsx @@ -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 ( -
-
-
-

Match-Lobbys

-

Teams treten mit ihrem Einladungscode bei, die Captains führen das Map-Veto, der Server wird automatisch eingerichtet.

+
+
+
+

Mit deinem Team ins Match

+

+ 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. +

+
- + {mine.length > 0 && }
+ + {matches === null ? (

Lade Matches …

) : ( -
- {GROUPS.map((g) => { - const list = matches.filter((m) => m.group === g.key); - return ( -
-

{g.title} {list.length}

- {list.length === 0 ?

{g.empty}

: g.key === 'running' ? ( -
- {list.map((m) => )} -
- ) : ( -
    - {list.map((m) => )} -
- )} -
- ); - })} -
+ <> +
+

Laufend {running.length}

+ {running.length === 0 ? ( +

Gerade läuft kein Match.

+ ) : ( +
+ {running.map((m) => )} +
+ )} +
+
+
+

Offene Lobbys {open.length}

+ {open.length === 0 ? ( +

Keine offene Lobby. Sobald der Organisator ein Match erstellt, steht es hier.

+ ) : ( +
    {open.map((m) => )}
+ )} +
+
+

Ergebnisse {closed.length}

+ {closed.length === 0 ? ( +

Noch kein Match abgeschlossen.

+ ) : ( +
    {closed.slice(0, 12).map((m) => )}
+ )} +
+
+ )}
); } +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 ( + + ); +} + +/** Die vier Schritte vom Teamlink bis auf den Server. current = Schritt des eigenen Matches (0 = keins). */ +function Steps({ current }) { + return ( +
    + {STEPS.map((st, i) => { + const n = i + 1; + const state = current === 0 ? '' : n < current ? 'is-done' : n === current ? 'is-current' : ''; + return ( +
  1. + {n} + {st.title} + {st.text} +
  2. + ); + })} +
+ ); +} + 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 ( -
- -
- setCode(e.target.value)} placeholder="ABCD-EFGH" autoComplete="off" spellCheck="false" /> - -
- {error &&

{error}

} + + + setCode(formatCode(e.target.value))} + placeholder="ABCD-EFGH" + autoComplete="off" + autoCapitalize="characters" + spellCheck="false" + inputMode="text" + aria-describedby={error ? 'code-error' : 'code-hint'} + /> + + {error + ?

{error}

+ :

Teamcode oder Zuschauercode, acht Zeichen.

}
); } + /** Offene Messer-Server für die Seitenwahl, zum direkten Verbinden. */ function KnifeServers() { const [servers, setServers] = useState([]); diff --git a/client/src/styles.css b/client/src/styles.css index c20b91d..4607bda 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -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; } +}