feat(scoreboard): Landscape-Scoreboard für 4–6 Spieler

Nach Issue #10 — Tabelle wie im Anhang mit Kategorien, Summenzeile,
Spieler ein/ausblenden und localStorage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
cursor
2026-07-17 19:45:34 +00:00
parent f276249349
commit 26c87b24cf
+579
View File
@@ -0,0 +1,579 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Scoreboard für 46 Spieler — Landscape, plain JS">
<title>Scoreboard</title>
<style>
:root {
--bg: #1a1814;
--paper: #f4efe4;
--ink: #1c1915;
--muted: #6b6358;
--line: #d4cbb8;
--line-strong: #a89880;
--accent: #c45c26;
--sum-bg: #ebe4d4;
--shadow: rgba(12, 10, 8, 0.35);
--player-colors: #3d7a4a, #2f6fad, #b84d8a, #c45c26, #6b5b95, #4a8f8f;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html {
background: var(--bg);
color: var(--ink);
}
body {
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
min-height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem;
background:
radial-gradient(ellipse 120% 80% at 50% -20%, rgba(196, 92, 38, 0.18), transparent 55%),
var(--bg);
}
.sheet {
width: min(100%, 72rem);
background: var(--paper);
border-radius: 0.65rem;
box-shadow: 0 18px 50px var(--shadow), 0 2px 0 rgba(255, 255, 255, 0.06) inset;
padding: 1rem 1.1rem 0.85rem;
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.topbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
padding-bottom: 0.35rem;
border-bottom: 2px solid var(--line-strong);
}
.topbar h1 {
font-size: 1.05rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
}
.controls label {
font-size: 0.78rem;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.controls select,
.controls button {
font: inherit;
font-size: 0.85rem;
border: 1px solid var(--line-strong);
background: #fff;
color: var(--ink);
border-radius: 0.4rem;
padding: 0.35rem 0.65rem;
cursor: pointer;
}
.controls button:hover { background: #faf7f0; }
.controls button:active { transform: scale(0.98); }
.controls button.danger {
color: #9b2c2c;
border-color: #d4a0a0;
}
.board-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.board {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
min-width: 34rem;
}
.board th,
.board td {
border: 1.5px solid var(--line-strong);
text-align: center;
vertical-align: middle;
}
.board thead th {
background: #fff;
padding: 0.15rem;
position: relative;
}
.board thead th.corner {
width: 3.25rem;
background: var(--sum-bg);
border-color: transparent;
}
.player-head {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.2rem;
padding: 0.2rem 0.15rem 0.35rem;
min-height: 3.1rem;
border-top: 4px solid var(--player-color, var(--accent));
border-radius: 0.15rem 0.15rem 0 0;
}
.player-head.inactive {
opacity: 0.42;
}
.player-head.inactive .player-name {
text-decoration: line-through;
text-decoration-thickness: 2px;
}
.player-name {
width: 100%;
border: none;
background: transparent;
font: inherit;
font-weight: 800;
font-size: clamp(0.82rem, 2.2vw, 1rem);
text-align: center;
color: var(--ink);
outline: none;
padding: 0.15rem 0.1rem;
border-bottom: 1px dashed var(--line);
}
.player-name:focus {
border-bottom-color: var(--player-color, var(--accent));
background: rgba(255, 255, 255, 0.65);
}
.toggle-player {
align-self: center;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
border: none;
background: transparent;
cursor: pointer;
padding: 0.1rem 0.25rem;
}
.toggle-player:hover { color: var(--ink); }
.cat-label {
width: 3.25rem;
background: var(--sum-bg);
font-weight: 800;
font-size: clamp(0.95rem, 2.4vw, 1.15rem);
letter-spacing: 0.02em;
padding: 0.35rem 0.2rem;
color: var(--ink);
}
.cat-label input {
width: 100%;
border: none;
background: transparent;
font: inherit;
font-weight: 800;
text-align: center;
color: inherit;
outline: none;
}
.cat-label input:focus {
background: #fff;
border-radius: 0.2rem;
}
.score-cell {
padding: 0.2rem;
background: #fff;
}
.score-cell.inactive {
background: #f0ebe0;
}
.score-input {
width: 100%;
border: none;
background: transparent;
font: inherit;
font-weight: 700;
font-size: clamp(1.1rem, 3vw, 1.55rem);
font-variant-numeric: tabular-nums;
text-align: center;
color: var(--ink);
outline: none;
padding: 0.35rem 0.15rem;
border-radius: 0.25rem;
}
.score-input:focus {
background: #fffbe8;
box-shadow: 0 0 0 2px rgba(196, 92, 38, 0.35);
}
.score-input.zero {
color: var(--muted);
text-decoration: line-through;
text-decoration-thickness: 2px;
}
.score-input:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.sum-row .cat-label {
font-size: 1.1rem;
}
.sum-row td {
background: var(--sum-bg);
padding: 0.35rem 0.2rem;
}
.sum-value {
font-weight: 900;
font-size: clamp(1.2rem, 3.2vw, 1.7rem);
font-variant-numeric: tabular-nums;
color: var(--ink);
}
.sum-value.inactive {
text-decoration: line-through;
color: var(--muted);
}
.hint {
font-size: 0.75rem;
color: var(--muted);
text-align: center;
line-height: 1.45;
padding-top: 0.15rem;
}
footer {
margin-top: 0.55rem;
font-size: 0.72rem;
color: #8a8074;
text-align: center;
}
footer a { color: #d4a574; }
@media (orientation: landscape) and (min-height: 420px) {
body { padding: 0.5rem 0.75rem; }
.sheet { padding: 0.75rem 1rem 0.65rem; }
}
@media print {
@page { size: landscape; margin: 10mm; }
body { background: #fff; padding: 0; }
.topbar .controls { display: none; }
.sheet { box-shadow: none; width: 100%; border-radius: 0; }
.toggle-player { display: none; }
.hint, footer { color: #666; }
}
</style>
</head>
<body>
<main class="sheet">
<div class="topbar">
<h1>Scoreboard</h1>
<div class="controls">
<label for="player-count">Spieler</label>
<select id="player-count" aria-label="Anzahl Spieler">
<option value="4">4</option>
<option value="5">5</option>
<option value="6" selected>6</option>
</select>
<button type="button" id="reset-btn" class="danger">Zurücksetzen</button>
</div>
</div>
<div class="board-wrap">
<table class="board" id="board" aria-label="Punktestand">
<thead>
<tr id="head-row">
<th class="corner" scope="col"></th>
</tr>
</thead>
<tbody id="body-rows"></tbody>
<tfoot>
<tr class="sum-row" id="sum-row">
<th class="cat-label" scope="row">Σ</th>
</tr>
</tfoot>
</table>
</div>
<p class="hint">Nullen durchgestrichen · Spieler antippen zum Ausblenden · Stand wird lokal gespeichert</p>
</main>
<footer>
@cursor · Issue #10 ·
<a href="https://gitea.schwenk.online/froxxxy/playground/issues/10">scoreboard</a>
</footer>
<script>
const STORAGE_KEY = 'playground-scoreboard-v1';
const DEFAULT_CATEGORIES = ['Ø', 'Le', 'L', 'La', 'P', 'Zi', 'Z'];
const DEFAULT_NAMES = ['Marc', 'Nepo', 'Franzi', 'Frank', 'Dirk', 'Gast'];
const PLAYER_COLORS = ['#3d7a4a', '#2f6fad', '#b84d8a', '#c45c26', '#6b5b95', '#4a8f8f'];
const playerCountEl = document.getElementById('player-count');
const headRow = document.getElementById('head-row');
const bodyRows = document.getElementById('body-rows');
const sumRow = document.getElementById('sum-row');
const resetBtn = document.getElementById('reset-btn');
let state = loadState();
function defaultState() {
return {
playerCount: 6,
categories: [...DEFAULT_CATEGORIES],
names: [...DEFAULT_NAMES],
active: [true, true, true, true, false, false],
scores: DEFAULT_CATEGORIES.map(() => Array(6).fill(0))
};
}
function loadState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return defaultState();
const parsed = JSON.parse(raw);
const base = defaultState();
return {
playerCount: clamp(parsed.playerCount ?? base.playerCount, 4, 6),
categories: normalizeCategories(parsed.categories, base.categories),
names: normalizeNames(parsed.names, base.names),
active: normalizeActive(parsed.active, base.active),
scores: normalizeScores(parsed.scores, base.scores)
};
} catch {
return defaultState();
}
}
function clamp(n, min, max) {
return Math.min(max, Math.max(min, Number(n) || min));
}
function normalizeCategories(cats, fallback) {
const out = Array.isArray(cats) ? cats.slice(0, 7) : [];
while (out.length < 7) out.push(fallback[out.length] ?? '');
return out.map((c, i) => String(c ?? fallback[i] ?? '').slice(0, 4) || fallback[i]);
}
function normalizeNames(names, fallback) {
const out = Array.isArray(names) ? names.slice(0, 6) : [];
while (out.length < 6) out.push(fallback[out.length] ?? `Spieler ${out.length + 1}`);
return out.map((n, i) => String(n ?? fallback[i]).slice(0, 18) || fallback[i]);
}
function normalizeActive(active, fallback) {
const out = Array.isArray(active) ? active.slice(0, 6) : [];
while (out.length < 6) out.push(fallback[out.length] ?? true);
return out.map(Boolean);
}
function normalizeScores(scores, fallback) {
const rows = Array.isArray(scores) ? scores.slice(0, 7) : [];
while (rows.length < 7) rows.push([...fallback[rows.length] ?? Array(6).fill(0)]);
return rows.map((row, ri) => {
const cells = Array.isArray(row) ? row.slice(0, 6) : [];
while (cells.length < 6) cells.push(fallback[ri]?.[cells.length] ?? 0);
return cells.map((v) => clampScore(v));
});
}
function clampScore(v) {
const n = parseInt(String(v).replace(/[^\d-]/g, ''), 10);
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(99, n));
}
function saveState() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
function visibleCount() {
return state.playerCount;
}
function render() {
playerCountEl.value = String(state.playerCount);
headRow.innerHTML = '<th class="corner" scope="col"></th>';
sumRow.innerHTML = '<th class="cat-label" scope="row">Σ</th>';
bodyRows.innerHTML = '';
for (let p = 0; p < visibleCount(); p++) {
const th = document.createElement('th');
th.scope = 'col';
th.innerHTML = `
<div class="player-head${state.active[p] ? '' : ' inactive'}" style="--player-color:${PLAYER_COLORS[p]}">
<input class="player-name" type="text" value="${escapeAttr(state.names[p])}" maxlength="18" aria-label="Name Spieler ${p + 1}" data-player="${p}">
<button type="button" class="toggle-player" data-player="${p}" aria-pressed="${state.active[p] ? 'false' : 'true'}">
${state.active[p] ? 'ausblenden' : 'einblenden'}
</button>
</div>`;
headRow.appendChild(th);
}
state.categories.forEach((cat, row) => {
const tr = document.createElement('tr');
const label = document.createElement('th');
label.className = 'cat-label';
label.scope = 'row';
label.innerHTML = `<input type="text" value="${escapeAttr(cat)}" maxlength="4" aria-label="Kategorie ${row + 1}" data-cat="${row}">`;
tr.appendChild(label);
for (let p = 0; p < visibleCount(); p++) {
const td = document.createElement('td');
td.className = `score-cell${state.active[p] ? '' : ' inactive'}`;
const val = state.scores[row][p];
td.innerHTML = `<input class="score-input${val === 0 ? ' zero' : ''}" type="text" inputmode="numeric" pattern="[0-9]*" value="${val}" aria-label="${escapeAttr(cat)} ${escapeAttr(state.names[p])}" data-row="${row}" data-player="${p}"${state.active[p] ? '' : ' disabled'}>`;
tr.appendChild(td);
}
bodyRows.appendChild(tr);
});
for (let p = 0; p < visibleCount(); p++) {
const td = document.createElement('td');
const sum = columnSum(p);
td.innerHTML = `<span class="sum-value${state.active[p] ? '' : ' inactive'}">${sum}</span>`;
sumRow.appendChild(td);
}
bindEvents();
}
function columnSum(player) {
return state.categories.reduce((acc, _, row) => acc + state.scores[row][player], 0);
}
function updateSums() {
const sums = sumRow.querySelectorAll('.sum-value');
for (let p = 0; p < visibleCount(); p++) {
if (!sums[p]) continue;
const sum = columnSum(p);
sums[p].textContent = sum;
sums[p].classList.toggle('inactive', !state.active[p]);
}
}
function escapeAttr(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;');
}
function bindEvents() {
document.querySelectorAll('.player-name').forEach((el) => {
el.addEventListener('input', (e) => {
const p = Number(e.target.dataset.player);
state.names[p] = e.target.value;
saveState();
});
});
document.querySelectorAll('.toggle-player').forEach((el) => {
el.addEventListener('click', (e) => {
const p = Number(e.target.dataset.player);
state.active[p] = !state.active[p];
saveState();
render();
});
});
document.querySelectorAll('[data-cat]').forEach((el) => {
el.addEventListener('input', (e) => {
const row = Number(e.target.dataset.cat);
state.categories[row] = e.target.value;
saveState();
});
el.addEventListener('blur', (e) => {
const row = Number(e.target.dataset.cat);
if (!state.categories[row].trim()) state.categories[row] = DEFAULT_CATEGORIES[row];
saveState();
render();
});
});
document.querySelectorAll('.score-input').forEach((el) => {
el.addEventListener('focus', (e) => e.target.select());
el.addEventListener('input', (e) => {
const row = Number(e.target.dataset.row);
const p = Number(e.target.dataset.player);
const digits = e.target.value.replace(/[^\d]/g, '');
e.target.value = digits;
const val = digits === '' ? 0 : clampScore(digits);
state.scores[row][p] = val;
e.target.classList.toggle('zero', val === 0);
updateSums();
saveState();
});
el.addEventListener('blur', (e) => {
const row = Number(e.target.dataset.row);
const p = Number(e.target.dataset.player);
const val = clampScore(e.target.value);
state.scores[row][p] = val;
e.target.value = val;
e.target.classList.toggle('zero', val === 0);
saveState();
});
});
}
playerCountEl.addEventListener('change', () => {
state.playerCount = clamp(playerCountEl.value, 4, 6);
saveState();
render();
});
resetBtn.addEventListener('click', () => {
if (!confirm('Alle Punkte und Namen zurücksetzen?')) return;
state = defaultState();
saveState();
render();
});
render();
</script>
</body>
</html>