Beamer-Diashow der laufenden Matches unter /beamer
Ein Match pro Folie, groß und aus der Ferne lesbar: Teams, Serienstand, aktuelle Map mit Rundenstand und Seiten, Maps der Serie, im Veto wer dran ist. Zum Schluss eine Folie mit Lobbys und Ergebnissen. Schaltet automatisch weiter (?sekunden=), Vollbild per Klick oder F, Pfeiltasten und Leertaste. In der Kopfzeile verlinkt.
This commit is contained in:
1 parent
6a922f80de
commit
dc601a32fe
5 files changed
+346
-1
No files matched your search
@@ -6,6 +6,7 @@ import CreateMatch from './pages/CreateMatch.jsx';
|
||||
import MatchPage from './pages/MatchPage.jsx';
|
||||
import StreamPage from './pages/StreamPage.jsx';
|
||||
import StreamOverviewPage from './pages/StreamOverviewPage.jsx';
|
||||
import BeamerPage from './pages/BeamerPage.jsx';
|
||||
import AdminPage from './pages/AdminPage.jsx';
|
||||
import { JoinPage, WatchPage } from './pages/AccessPages.jsx';
|
||||
|
||||
@@ -16,6 +17,8 @@ export default function App() {
|
||||
<Route path="/stream/:code" element={<StreamPage />} />
|
||||
<Route path="/stream/match/:matchId" element={<StreamOverviewPage />} />
|
||||
<Route path="/stream" element={<StreamOverviewPage />} />
|
||||
{/* Diashow der laufenden Matches für den Beamer, ein Match pro Folie */}
|
||||
<Route path="/beamer" element={<BeamerPage />} />
|
||||
<Route path="*" element={<MainLayout />} />
|
||||
</Routes>
|
||||
);
|
||||
@@ -44,6 +47,7 @@ function MainLayout() {
|
||||
<nav>
|
||||
<Link to="/" className="nav-link">Übersicht</Link>
|
||||
<a href="/stream" target="_blank" rel="noreferrer" className="nav-link">Stream-Übersicht</a>
|
||||
<a href="/beamer" target="_blank" rel="noreferrer" className="nav-link">Beamer</a>
|
||||
<Link to="/admin" className="nav-link">Admin</Link>
|
||||
<Link to="/neu" className="btn btn-primary btn-sm">Match erstellen</Link>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/* ---------- Beamer-Diashow (1920×1080, skaliert): ein Match pro Folie, groß und aus der Ferne lesbar ---------- */
|
||||
.bm-root {
|
||||
position: relative; width: 100%; height: 100%; overflow: hidden; cursor: pointer;
|
||||
display: grid; grid-template-rows: 104px 1fr 56px; padding: 0 72px;
|
||||
color: var(--text); font-family: var(--body);
|
||||
background:
|
||||
radial-gradient(900px 600px at 0% 40%, rgba(52, 74, 153, .3), transparent 70%),
|
||||
radial-gradient(900px 600px at 100% 40%, rgba(167, 42, 28, .28), transparent 70%),
|
||||
#000;
|
||||
}
|
||||
.bm-root > :not(.bm-art) { position: relative; z-index: 1; }
|
||||
.bm-art { position: absolute; inset: 0; opacity: .32; }
|
||||
.bm-art::after { background: linear-gradient(180deg, rgba(0, 0, 0, .55), rgba(0, 0, 0, .92) 70%); }
|
||||
|
||||
.bm-main { display: grid; min-height: 0; animation: bm-in .6s ease both; }
|
||||
@keyframes bm-in { from { opacity: 0; transform: translateY(18px); } }
|
||||
|
||||
.bm-slide { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 26px; min-height: 0; padding: 20px 0; }
|
||||
.bm-meta { display: flex; align-items: center; gap: 22px; font: 600 32px var(--display); color: var(--muted); }
|
||||
.bm-meta .so-badge { font-size: 30px; padding: 6px 18px; }
|
||||
|
||||
.bm-hero { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 56px; width: 100%; }
|
||||
.bm-team { margin: 0; font: 700 128px/1 var(--display); letter-spacing: -.01em; overflow-wrap: anywhere; }
|
||||
.bm-team.a { color: var(--team-a); text-align: right; }
|
||||
.bm-team.b { color: var(--team-b); }
|
||||
.bm-series { display: flex; flex-direction: column; align-items: center; font: 700 130px/1 var(--display); font-variant-numeric: tabular-nums; }
|
||||
.bm-series span { display: flex; align-items: center; }
|
||||
.bm-series i { font-style: normal; color: var(--muted); padding: 0 14px; }
|
||||
.bm-series small { font: 600 26px var(--display); color: var(--muted); text-transform: uppercase; letter-spacing: .12em; }
|
||||
.bm-vs { font-size: 90px; }
|
||||
|
||||
.bm-map { display: flex; align-items: baseline; gap: 26px; }
|
||||
.bm-map strong { font: 700 64px var(--display); }
|
||||
.bm-map span { font: 600 38px var(--display); color: var(--muted); }
|
||||
|
||||
.bm-score { display: flex; align-items: center; gap: 34px; font: 700 200px/1 var(--display); font-variant-numeric: tabular-nums; }
|
||||
.bm-score i { font-style: normal; color: var(--muted); }
|
||||
.bm-score b.a { color: var(--team-a); }
|
||||
.bm-score b.b { color: var(--team-b); }
|
||||
.bm-score b.won { text-shadow: 0 0 40px currentColor; }
|
||||
|
||||
.bm-maps { list-style: none; margin: 0; padding: 0; display: flex; gap: 18px; justify-content: center; }
|
||||
.bm-maps li {
|
||||
position: relative; overflow: hidden; width: 250px; height: 104px; border-radius: 8px; padding: 12px 16px;
|
||||
display: flex; align-items: flex-end; justify-content: space-between; gap: 10px; border: 2px solid var(--line-strong);
|
||||
}
|
||||
.bm-maps li .map-art::after { background: linear-gradient(180deg, rgba(0, 0, 0, .2), rgba(0, 0, 0, .85)); }
|
||||
.bm-maps li > span:not(.map-art), .bm-maps li b { position: relative; z-index: 1; font: 700 28px var(--display); text-shadow: 0 2px 8px #000; }
|
||||
.bm-maps li.is-pending { filter: grayscale(.8) brightness(.6); }
|
||||
.bm-maps li.is-current { border-color: var(--brand-red); box-shadow: 0 0 0 3px var(--brand-red); }
|
||||
|
||||
.bm-note { font: 600 44px var(--display); color: var(--muted); margin: 0; }
|
||||
.bm-turn { display: flex; flex-direction: column; align-items: center; gap: 6px; margin: 0; font: 600 52px var(--display); }
|
||||
.bm-turn .a { color: var(--team-a); }
|
||||
.bm-turn .b { color: var(--team-b); }
|
||||
.bm-turn small { font-size: 28px; color: var(--muted); }
|
||||
.bm-vmaps { width: 100%; height: 330px; }
|
||||
.bm-vmaps .so-vmap-name { font-size: 34px; }
|
||||
|
||||
.bm-summary { display: flex; flex-direction: column; justify-content: center; gap: 40px; min-height: 0; }
|
||||
.bm-idle { margin: 0; text-align: center; font: 700 80px var(--display); }
|
||||
.bm-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 72px; }
|
||||
.bm-columns h3 { margin: 0 0 18px; font: 700 40px var(--display); color: var(--muted); text-transform: uppercase; letter-spacing: .1em; border-bottom: 3px solid var(--brand-red); padding-bottom: 10px; }
|
||||
.bm-columns ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 14px; }
|
||||
.bm-columns li { display: flex; align-items: baseline; gap: 18px; font: 700 44px var(--display); }
|
||||
.bm-columns li .a { color: var(--team-a); }
|
||||
.bm-columns li .b { color: var(--team-b); }
|
||||
.bm-columns li i { font-style: normal; color: var(--muted); font-size: 32px; }
|
||||
.bm-columns li b { font-variant-numeric: tabular-nums; }
|
||||
.bm-columns li .won { text-decoration: underline; text-underline-offset: 8px; }
|
||||
.bm-columns li small { margin-left: auto; font: 600 28px var(--display); color: var(--muted); }
|
||||
.bm-columns .so-none { font-size: 32px; }
|
||||
|
||||
.bm-bottom { position: relative; display: flex; align-items: center; justify-content: center; }
|
||||
.bm-dots { display: flex; gap: 12px; }
|
||||
.bm-dots span { width: 14px; height: 14px; border-radius: 50%; background: var(--line-strong); }
|
||||
.bm-dots span.on { background: var(--brand-red); }
|
||||
.bm-progress { position: absolute; left: 0; bottom: 0; height: 6px; width: 100%; background: var(--brand-red); transform-origin: left; animation: bm-progress linear both; }
|
||||
@keyframes bm-progress { from { transform: scaleX(0); } to { transform: scaleX(1); } }
|
||||
@@ -0,0 +1,254 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { FORMAT_LABEL } from '../lib/labels.js';
|
||||
import MapImage from '../components/MapImage.jsx';
|
||||
import SideIcon from '../components/SideIcon.jsx';
|
||||
import { ScaledStage, useClock } from './StreamPage.jsx';
|
||||
import { useStreamOverview } from './StreamOverviewPage.jsx';
|
||||
import '../stream.css';
|
||||
import '../stream-overview.css';
|
||||
import '../beamer.css';
|
||||
|
||||
const DEFAULT_SECONDS = 15;
|
||||
|
||||
/**
|
||||
* Diashow der laufenden Matches für den Beamer: /beamer
|
||||
* Ein Match pro Folie, schaltet automatisch weiter. Am Ende eine Folie mit Lobbys und Ergebnissen.
|
||||
* Optional ?sekunden=20 (5 bis 120) und ?key=<STREAM_KEY>.
|
||||
* Tasten: ← → blättern, Leertaste anhalten, F Vollbild. Klick schaltet Vollbild um.
|
||||
*/
|
||||
export default function BeamerPage() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const key = params.get('key') ?? '';
|
||||
const seconds = Math.max(5, Math.min(120, Number.parseInt(params.get('sekunden'), 10) || DEFAULT_SECONDS));
|
||||
const { data, error } = useStreamOverview(key);
|
||||
const now = useClock();
|
||||
const [index, setIndex] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.add('stream-mode');
|
||||
return () => document.documentElement.classList.remove('stream-mode');
|
||||
}, []);
|
||||
|
||||
const slides = buildSlides(data);
|
||||
const count = slides.length;
|
||||
const current = slides[count ? index % count : 0];
|
||||
// Folie über die ID merken, damit sich bei neuen Daten nicht die angezeigte Folie verschiebt
|
||||
const currentId = current?.id;
|
||||
|
||||
const go = useCallback((step) => setIndex((i) => (count ? (i + step + count) % count : 0)), [count]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused || count <= 1) return undefined;
|
||||
const t = setTimeout(() => go(1), seconds * 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [index, paused, count, seconds, go, currentId]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'ArrowRight') go(1);
|
||||
else if (e.key === 'ArrowLeft') go(-1);
|
||||
else if (e.key === ' ') { e.preventDefault(); setPaused((p) => !p); } else if (e.key === 'f' || e.key === 'F') toggleFullscreen();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [go]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ScaledStage>
|
||||
<div className="st-message">
|
||||
<img src="/brand/maxlan-logo.webp" alt="Maxlan e.V." />
|
||||
<h1>Diashow nicht verfügbar</h1>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
);
|
||||
}
|
||||
|
||||
const live = (data?.active ?? []).filter((m) => m.status === 'live').length;
|
||||
|
||||
return (
|
||||
<ScaledStage>
|
||||
<div className="bm-root" onClick={toggleFullscreen}>
|
||||
{current?.type === 'match' && <Backdrop m={current.match} />}
|
||||
<header className="st-top bm-top">
|
||||
<img className="st-logo" src="/brand/maxlan-logo.webp" alt="Maxlan e.V." />
|
||||
<div className="so-title">
|
||||
<strong>Counter-Strike 2</strong>
|
||||
{live > 0 && <span className="so-count so-count-live">{live} live</span>}
|
||||
{count > 1 && <span className="so-count">{(index % count) + 1} / {count}</span>}
|
||||
{paused && <span className="so-count">angehalten</span>}
|
||||
</div>
|
||||
<div className="st-clock"><time>{now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</time></div>
|
||||
</header>
|
||||
|
||||
<main className="bm-main" key={currentId ?? 'leer'}>
|
||||
{!data && <div className="so-empty">Lade Matches …</div>}
|
||||
{current?.type === 'match' && <MatchSlide m={current.match} />}
|
||||
{current?.type === 'summary' && <SummarySlide data={data} />}
|
||||
</main>
|
||||
|
||||
<footer className="bm-bottom">
|
||||
{count > 1 && (
|
||||
<div className="bm-dots">
|
||||
{slides.map((s, i) => <span key={s.id} className={i === index % count ? 'on' : ''} />)}
|
||||
</div>
|
||||
)}
|
||||
{count > 1 && !paused && <div className="bm-progress" key={`${currentId}-${index}`} style={{ animationDuration: `${seconds}s` }} />}
|
||||
</footer>
|
||||
</div>
|
||||
</ScaledStage>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ein Match pro Folie, danach eine Übersicht mit Lobbys und Ergebnissen (wenn es welche gibt). */
|
||||
function buildSlides(data) {
|
||||
if (!data) return [];
|
||||
const slides = data.active.map((m) => ({ id: m.id, type: 'match', match: m }));
|
||||
if (data.upcoming.length || data.results.length || slides.length === 0) slides.push({ id: 'summary', type: 'summary' });
|
||||
return slides;
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (document.fullscreenElement) document.exitFullscreen?.().catch(() => {});
|
||||
else document.documentElement.requestFullscreen?.().catch(() => {});
|
||||
}
|
||||
|
||||
function currentMap(m) {
|
||||
return m.series.find((s) => s.number === m.currentMapNumber);
|
||||
}
|
||||
|
||||
function Backdrop({ m }) {
|
||||
const map = currentMap(m);
|
||||
if (m.status === 'veto' || !map) return null;
|
||||
return <MapImage map={map} className="bm-art" />;
|
||||
}
|
||||
|
||||
function MatchSlide({ m }) {
|
||||
const map = currentMap(m);
|
||||
const isBo1 = m.format === 'bo1';
|
||||
const playing = m.status === 'live' && map?.live?.status === 'live' && map.status !== 'done';
|
||||
const badge = m.status === 'veto' ? 'Map-Veto' : m.status === 'server_setup' ? 'Server startet' : playing ? 'Live' : map?.status === 'done' ? 'Map beendet' : 'Aufwärmphase';
|
||||
|
||||
return (
|
||||
<section className={`bm-slide status-${m.status}`}>
|
||||
<div className="bm-meta">
|
||||
<span>{m.modeLabel}, {FORMAT_LABEL[m.format]}</span>
|
||||
<span className={`so-badge ${playing ? 'badge-live' : ''}`}>{badge}</span>
|
||||
</div>
|
||||
<div className="bm-hero">
|
||||
<h1 className="bm-team a">{m.teams.A}</h1>
|
||||
<div className="bm-series">
|
||||
{!isBo1 && m.status !== 'veto' ? (
|
||||
<>
|
||||
<span>{m.seriesScore.A}<i>:</i>{m.seriesScore.B}</span>
|
||||
<small>Maps</small>
|
||||
</>
|
||||
) : <i className="bm-vs">vs</i>}
|
||||
</div>
|
||||
<h1 className="bm-team b">{m.teams.B}</h1>
|
||||
</div>
|
||||
|
||||
{m.status === 'veto' && <VetoPart m={m} />}
|
||||
{m.status === 'server_setup' && map && <p className="bm-note">Server wird eingerichtet: Map {map.number}, {map.name}</p>}
|
||||
{m.status === 'live' && map && <LivePart m={m} map={map} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LivePart({ m, map }) {
|
||||
const sides = map.live?.sides;
|
||||
const sideOf = (t) => (sides ? (sides.CT === t ? 'CT' : 'T') : null);
|
||||
const finished = map.status === 'done';
|
||||
const rounds = map.live?.roundsPlayed ?? 0;
|
||||
return (
|
||||
<>
|
||||
<div className="bm-map">
|
||||
<strong>{map.name}</strong>
|
||||
<span>{finished ? 'Endstand' : map.live?.status === 'live' ? `Runde ${rounds + 1}` : 'Aufwärmphase'}</span>
|
||||
</div>
|
||||
<div className="bm-score">
|
||||
{sideOf('A') && <SideIcon side={sideOf('A')} size={72} />}
|
||||
<b className={`a ${map.winner === 'A' ? 'won' : ''}`}>{map.score.A}</b>
|
||||
<i>:</i>
|
||||
<b className={`b ${map.winner === 'B' ? 'won' : ''}`}>{map.score.B}</b>
|
||||
{sideOf('B') && <SideIcon side={sideOf('B')} size={72} />}
|
||||
</div>
|
||||
{m.series.length > 1 && (
|
||||
<ol className="bm-maps">
|
||||
{m.series.map((s) => (
|
||||
<li key={s.number} className={`is-${s.status} ${s.number === map.number ? 'is-current' : ''}`} style={{ '--map': s.color ?? '#556' }}>
|
||||
<MapImage map={s} />
|
||||
<span>{s.name}</span>
|
||||
{(s.status === 'done' || s.number === map.number) && <b>{s.score.A}:{s.score.B}</b>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VetoPart({ m }) {
|
||||
const { veto, teams } = m;
|
||||
const c = veto.current;
|
||||
const action = c && ({ ban: 'bannt eine Map', pick: 'wählt eine Map', side: 'wählt die Seite' })[c.type];
|
||||
return (
|
||||
<>
|
||||
<p className="bm-turn">
|
||||
{c ? <><strong className={c.team === 'A' ? 'a' : 'b'}>{teams[c.team]}</strong> {action}</> : 'Veto abgeschlossen'}
|
||||
<small>Schritt {Math.min(veto.stepsDone + 1, veto.stepsTotal)} von {veto.stepsTotal}</small>
|
||||
</p>
|
||||
<div className="so-vmaps bm-vmaps" style={{ '--count': veto.maps.length }}>
|
||||
{veto.maps.map((x) => (
|
||||
<div key={x.id} className={`so-vmap status-${x.status} ${x.by ? `by-${x.by}` : ''}`} style={{ '--map': x.color ?? '#556' }}>
|
||||
<MapImage map={x} />
|
||||
<span className="so-vmap-name">{x.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SummarySlide({ data }) {
|
||||
const score = (m) => (m.format === 'bo1' && m.series[0] ? m.series[0].score : m.seriesScore);
|
||||
return (
|
||||
<section className="bm-summary">
|
||||
{data.active.length === 0 && <h2 className="bm-idle">Gerade läuft kein Match</h2>}
|
||||
<div className="bm-columns">
|
||||
<div>
|
||||
<h3>Gleich</h3>
|
||||
{data.upcoming.length ? (
|
||||
<ul>
|
||||
{data.upcoming.map((m) => (
|
||||
<li key={m.id}>
|
||||
<span className="a">{m.teams.A}</span><i>vs</i><span className="b">{m.teams.B}</span>
|
||||
<small>{FORMAT_LABEL[m.format]}{m.ready.A && m.ready.B ? ', bereit' : ''}</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : <p className="so-none">Keine offenen Lobbys</p>}
|
||||
</div>
|
||||
<div>
|
||||
<h3>Ergebnisse</h3>
|
||||
{data.results.length ? (
|
||||
<ul>
|
||||
{data.results.map((m) => {
|
||||
const s = score(m);
|
||||
return (
|
||||
<li key={m.id}>
|
||||
<span className={`a ${s.A > s.B ? 'won' : ''}`}>{m.teams.A}</span>
|
||||
<b>{s.A}:{s.B}</b>
|
||||
<span className={`b ${s.B > s.A ? 'won' : ''}`}>{m.teams.B}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : <p className="so-none">Noch keine Ergebnisse</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -141,7 +141,7 @@ function matchHref(id, key) {
|
||||
return `/stream/match/${encodeURIComponent(id)}${key ? `?key=${encodeURIComponent(key)}` : ''}`;
|
||||
}
|
||||
|
||||
function useStreamOverview(key) {
|
||||
export function useStreamOverview(key) {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
useEffect(() => {
|
||||
|
||||
Reference in new issue
Block a user