Files
cursor 26c87b24cf 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>
2026-07-17 19:45:34 +00:00

580 lines
16 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>