540 lines
16 KiB
HTML
540 lines
16 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="it">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>🎾 Tennis Roots – Controller</title>
|
||
<style>
|
||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body {
|
||
font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
||
background: #0f0f1a;
|
||
color: #e0e0e0;
|
||
padding: 24px;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
h1 {
|
||
font-size: 28px;
|
||
font-weight: 700;
|
||
color: #facc15;
|
||
margin-bottom: 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
h1 small {
|
||
font-size: 14px;
|
||
font-weight: 400;
|
||
color: rgba(255,255,255,0.4);
|
||
}
|
||
|
||
.grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 20px;
|
||
max-width: 1100px;
|
||
}
|
||
|
||
.card {
|
||
background: linear-gradient(160deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
border-radius: 12px;
|
||
padding: 18px 20px;
|
||
}
|
||
.card.full { grid-column: 1 / -1; }
|
||
.card h2 {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
color: rgba(255,255,255,0.4);
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.field {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.field label {
|
||
min-width: 80px;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: rgba(255,255,255,0.6);
|
||
}
|
||
.field input,
|
||
.field select {
|
||
flex: 1;
|
||
background: rgba(255,255,255,0.08);
|
||
border: 1px solid rgba(255,255,255,0.12);
|
||
border-radius: 6px;
|
||
padding: 8px 12px;
|
||
font-size: 15px;
|
||
color: #fff;
|
||
transition: border-color 0.2s;
|
||
}
|
||
.field input:focus,
|
||
.field select:focus {
|
||
outline: none;
|
||
border-color: #facc15;
|
||
}
|
||
.field input::placeholder { color: rgba(255,255,255,0.25); }
|
||
|
||
.field input.short { flex: 0 0 70px; text-align: center; }
|
||
.field .flag-select {
|
||
flex: 0 0 70px;
|
||
text-align: center;
|
||
font-size: 20px;
|
||
}
|
||
|
||
.btn-group {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-top: 6px;
|
||
}
|
||
.btn {
|
||
padding: 8px 16px;
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.btn:hover { filter: brightness(1.15); transform: translateY(-1px); }
|
||
.btn:active { transform: translateY(0); }
|
||
|
||
.btn-primary { background: #3b82f6; color: #fff; }
|
||
.btn-success { background: #22c55e; color: #fff; }
|
||
.btn-warning { background: #facc15; color: #000; }
|
||
.btn-danger { background: #ef4444; color: #fff; }
|
||
.btn-ghost { background: rgba(255,255,255,0.08); color: #e0e0e0; }
|
||
.btn-sm { padding: 5px 10px; font-size: 12px; }
|
||
|
||
.score-display {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
gap: 24px;
|
||
padding: 12px 0;
|
||
font-size: 32px;
|
||
font-weight: 700;
|
||
}
|
||
.score-display .vs {
|
||
font-size: 18px;
|
||
font-weight: 400;
|
||
color: rgba(255,255,255,0.25);
|
||
}
|
||
.score-display .p-score {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 2px;
|
||
}
|
||
.score-display .p-name {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: rgba(255,255,255,0.7);
|
||
}
|
||
.score-display .p-points {
|
||
font-size: 36px;
|
||
font-weight: 800;
|
||
color: #facc15;
|
||
}
|
||
.score-display .p-points.adv { color: #4ade80; }
|
||
.score-display .playing-now {
|
||
font-size: 12px;
|
||
color: #facc15;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
.sets-display {
|
||
display: flex;
|
||
justify-content: center;
|
||
gap: 32px;
|
||
font-size: 15px;
|
||
color: rgba(255,255,255,0.5);
|
||
margin-bottom: 8px;
|
||
}
|
||
.sets-display span {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
.sets-display .set-score {
|
||
font-weight: 700;
|
||
font-size: 18px;
|
||
color: #fff;
|
||
}
|
||
.sets-display .set-label {
|
||
font-size: 12px;
|
||
text-transform: uppercase;
|
||
color: rgba(255,255,255,0.3);
|
||
}
|
||
|
||
.status-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
padding: 8px 0;
|
||
font-size: 14px;
|
||
color: rgba(255,255,255,0.5);
|
||
}
|
||
.status-bar .dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background: #22c55e;
|
||
}
|
||
.status-bar .dot.disconnected { background: #ef4444; }
|
||
.status-bar .status-text {
|
||
color: #fff;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.connection-status {
|
||
position: fixed;
|
||
top: 24px;
|
||
right: 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
color: rgba(255,255,255,0.5);
|
||
}
|
||
.connection-status .led {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
background: #ef4444;
|
||
transition: background 0.3s;
|
||
}
|
||
.connection-status .led.connected { background: #22c55e; box-shadow: 0 0 8px rgba(34,197,94,0.5); }
|
||
|
||
@media (max-width: 700px) {
|
||
.grid { grid-template-columns: 1fr; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="connection-status" id="connStatus">
|
||
<span class="led" id="connLed"></span>
|
||
<span id="connLabel">Disconnesso</span>
|
||
</div>
|
||
|
||
<h1>
|
||
🎾 Tennis Roots
|
||
<small>Controller</small>
|
||
</h1>
|
||
|
||
<div class="grid">
|
||
|
||
<!-- STATO IN TEMPO REALE -->
|
||
<div class="card full">
|
||
<h2>📊 Stato corrente</h2>
|
||
<div class="sets-display" id="setsDisplay"></div>
|
||
<div class="score-display">
|
||
<div class="p-score" id="p1score">
|
||
<span class="p-name" id="liveP1Name">P1</span>
|
||
<span class="playing-now" id="liveP1Serving"></span>
|
||
<span class="p-points" id="liveP1Points">0</span>
|
||
</div>
|
||
<span class="vs">VS</span>
|
||
<div class="p-score" id="p2score">
|
||
<span class="p-name" id="liveP2Name">P2</span>
|
||
<span class="playing-now" id="liveP2Serving"></span>
|
||
<span class="p-points" id="liveP2Points">0</span>
|
||
</div>
|
||
</div>
|
||
<div class="status-bar">
|
||
<span class="dot" id="statusDot"></span>
|
||
<span class="status-text" id="liveStatus">In attesa...</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- PUNTEGGIO -->
|
||
<div class="card">
|
||
<h2>🎾 Punteggio</h2>
|
||
<div class="btn-group">
|
||
<button class="btn btn-success" onclick="addPoint(1)">🎾 Punto P1</button>
|
||
<button class="btn btn-success" onclick="addPoint(2)">🎾 Punto P2</button>
|
||
</div>
|
||
<div class="btn-group" style="margin-top:8px">
|
||
<button class="btn btn-warning" onclick="setDeuce()">⚖️ Deuce</button>
|
||
<button class="btn btn-ghost" onclick="resetGame()">🔄 Reset Game</button>
|
||
</div>
|
||
<div class="btn-group" style="margin-top:8px">
|
||
<button class="btn btn-danger" onclick="resetMatch()">🗑️ Reset Match</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SERVIZIO -->
|
||
<div class="card">
|
||
<h2>🎾 Servizio</h2>
|
||
<div class="field">
|
||
<label>Battuta:</label>
|
||
<select id="serverSelect">
|
||
<option value="1">P1</option>
|
||
<option value="2">P2</option>
|
||
</select>
|
||
<button class="btn btn-primary btn-sm" onclick="setServer()">Imposta</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- GIOCATORI -->
|
||
<div class="card">
|
||
<h2>👤 Giocatori</h2>
|
||
<div class="field">
|
||
<label>P1 Nome:</label>
|
||
<input type="text" id="p1Name" value="J. Sinner" />
|
||
</div>
|
||
<div class="field">
|
||
<label>P1 Bandiera:</label>
|
||
<select class="flag-select" id="p1Flag">
|
||
<option value="it">🇮🇹</option>
|
||
<option value="es">🇪🇸</option>
|
||
<option value="us">🇺🇸</option>
|
||
<option value="fr">🇫🇷</option>
|
||
<option value="gb">🇬🇧</option>
|
||
<option value="de">🇩🇪</option>
|
||
<option value="au">🇦🇺</option>
|
||
<option value="ch">🇨🇭</option>
|
||
<option value="rs">🇷🇸</option>
|
||
<option value="gr">🇬🇷</option>
|
||
<option value="br">🇧🇷</option>
|
||
<option value="ar">🇦🇷</option>
|
||
</select>
|
||
</div>
|
||
<div class="field">
|
||
<label>P2 Nome:</label>
|
||
<input type="text" id="p2Name" value="C. Alcaraz" />
|
||
</div>
|
||
<div class="field">
|
||
<label>P2 Bandiera:</label>
|
||
<select class="flag-select" id="p2Flag">
|
||
<option value="it">🇮🇹</option>
|
||
<option value="es" selected>🇪🇸</option>
|
||
<option value="us">🇺🇸</option>
|
||
<option value="fr">🇫🇷</option>
|
||
<option value="gb">🇬🇧</option>
|
||
<option value="de">🇩🇪</option>
|
||
<option value="au">🇦🇺</option>
|
||
<option value="ch">🇨🇭</option>
|
||
<option value="rs">🇷🇸</option>
|
||
<option value="gr">🇬🇷</option>
|
||
<option value="br">🇧🇷</option>
|
||
<option value="ar">🇦🇷</option>
|
||
</select>
|
||
</div>
|
||
<div class="btn-group" style="margin-top:6px">
|
||
<button class="btn btn-primary" onclick="updatePlayers()">💾 Salva Giocatori</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- MATCH INFO -->
|
||
<div class="card">
|
||
<h2>📋 Info Match</h2>
|
||
<div class="field">
|
||
<label>Torneo:</label>
|
||
<input type="text" id="tournament" value="Wimbledon" />
|
||
</div>
|
||
<div class="field">
|
||
<label>Round:</label>
|
||
<input type="text" id="round" value="Finale · Uomini · Singolare" />
|
||
</div>
|
||
<div class="btn-group" style="margin-top:6px">
|
||
<button class="btn btn-primary" onclick="updateMatchInfo()">💾 Salva Info</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SCORCIATOIE -->
|
||
<div class="card">
|
||
<h2>⌨️ Scorciatoie</h2>
|
||
<div style="font-size:13px; color:rgba(255,255,255,0.5); line-height:1.8">
|
||
<kbd style="background:rgba(255,255,255,0.1); padding:2px 6px; border-radius:3px;">1</kbd> Punto P1
|
||
<kbd style="background:rgba(255,255,255,0.1); padding:2px 6px; border-radius:3px;">2</kbd> Punto P2
|
||
<kbd style="background:rgba(255,255,255,0.1); padding:2px 6px; border-radius:3px;">D</kbd> Deuce
|
||
<kbd style="background:rgba(255,255,255,0.1); padding:2px 6px; border-radius:3px;">R</kbd> Reset Game
|
||
<kbd style="background:rgba(255,255,255,0.1); padding:2px 6px; border-radius:3px;">M</kbd> Reset Match
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
// ---- WebSocket ----
|
||
const WS_URL = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws`;
|
||
let ws = null;
|
||
let currentState = {};
|
||
let reconnectTimer = null;
|
||
|
||
function connect() {
|
||
if (ws && (ws.readyState === 0 || ws.readyState === 1)) return;
|
||
ws = new WebSocket(WS_URL);
|
||
|
||
ws.onopen = () => {
|
||
document.getElementById('connLed').className = 'led connected';
|
||
document.getElementById('connLabel').textContent = 'Connesso';
|
||
};
|
||
|
||
ws.onmessage = (event) => {
|
||
try {
|
||
const msg = JSON.parse(event.data);
|
||
if (msg.type === 'STATE_UPDATE') {
|
||
currentState = msg.state;
|
||
updateUI();
|
||
}
|
||
} catch (e) {}
|
||
};
|
||
|
||
ws.onclose = () => {
|
||
document.getElementById('connLed').className = 'led';
|
||
document.getElementById('connLabel').textContent = 'Disconnesso';
|
||
scheduleReconnect();
|
||
};
|
||
|
||
ws.onerror = () => ws.close();
|
||
}
|
||
|
||
function scheduleReconnect() {
|
||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||
reconnectTimer = setTimeout(connect, 2000);
|
||
}
|
||
|
||
function send(msg) {
|
||
if (ws && ws.readyState === 1) {
|
||
ws.send(JSON.stringify(msg));
|
||
}
|
||
}
|
||
|
||
// ---- UI Update ----
|
||
function pointLabel(state) {
|
||
if (state.tiebreak) {
|
||
return [String(state.tiebreakPoints[0]), String(state.tiebreakPoints[1])];
|
||
}
|
||
const p1 = state.points[0];
|
||
const p2 = state.points[1];
|
||
if (state.deuce) {
|
||
if (state.advantage === 1) return ['AD', '40'];
|
||
if (state.advantage === 2) return ['40', 'AD'];
|
||
return ['40', '40'];
|
||
}
|
||
const map = ['0', '15', '30', '40'];
|
||
return [map[p1] || '40', map[p2] || '40'];
|
||
}
|
||
|
||
function updateUI() {
|
||
const s = currentState;
|
||
if (!s || !s.player1) return;
|
||
|
||
// Giocatori
|
||
document.getElementById('liveP1Name').textContent = s.player1;
|
||
document.getElementById('liveP2Name').textContent = s.player2;
|
||
document.getElementById('p1Name').value = s.player1;
|
||
document.getElementById('p2Name').value = s.player2;
|
||
document.getElementById('p1Flag').value = s.flag1 || 'it';
|
||
document.getElementById('p2Flag').value = s.flag2 || 'es';
|
||
document.getElementById('serverSelect').value = s.server || 1;
|
||
|
||
// Punti
|
||
const labels = pointLabel(s);
|
||
const p1el = document.getElementById('liveP1Points');
|
||
const p2el = document.getElementById('liveP2Points');
|
||
p1el.textContent = labels[0];
|
||
p2el.textContent = labels[1];
|
||
p1el.className = 'p-points' + (s.advantage === 1 ? ' adv' : '');
|
||
p2el.className = 'p-points' + (s.advantage === 2 ? ' adv' : '');
|
||
|
||
// Servizio
|
||
document.getElementById('liveP1Serving').textContent = s.server === 1 ? '● Serve' : '';
|
||
document.getElementById('liveP2Serving').textContent = s.server === 2 ? '● Serve' : '';
|
||
|
||
// Set
|
||
const setsEl = document.getElementById('setsDisplay');
|
||
let setsHtml = '';
|
||
const setLabels = ['S1', 'S2', 'S3'];
|
||
for (let i = 0; i < 3; i++) {
|
||
const ss = s.sets[i] || [0, 0];
|
||
const active = i === s.currentSet ? ' style="color:#facc15"' : '';
|
||
setsHtml += `<span><span class="set-label">${setLabels[i]}</span><span class="set-score"${active}>${ss[0]}–${ss[1]}</span></span>`;
|
||
}
|
||
setsEl.innerHTML = setsHtml;
|
||
|
||
// Stato
|
||
document.getElementById('liveStatus').textContent = s.matchStatus || 'In corso';
|
||
document.getElementById('statusDot').style.background = s.matchOver ? '#22c55e' : '#facc15';
|
||
|
||
// Info match
|
||
document.getElementById('tournament').value = s.tournament || '';
|
||
document.getElementById('round').value = s.round || '';
|
||
}
|
||
|
||
// ---- Comandi ----
|
||
function addPoint(player) {
|
||
send({ type: 'COMMAND', command: 'ADD_POINT', player });
|
||
}
|
||
|
||
function resetGame() {
|
||
send({ type: 'COMMAND', command: 'RESET_GAME' });
|
||
}
|
||
|
||
function resetMatch() {
|
||
if (confirm('Reset totale del match?')) {
|
||
send({ type: 'COMMAND', command: 'RESET_MATCH' });
|
||
}
|
||
}
|
||
|
||
function setDeuce() {
|
||
send({ type: 'COMMAND', command: 'SET_DEUCE' });
|
||
}
|
||
|
||
function setServer() {
|
||
const val = parseInt(document.getElementById('serverSelect').value);
|
||
send({ type: 'COMMAND', command: 'SET_SERVER', player: val });
|
||
}
|
||
|
||
function updatePlayers() {
|
||
send({
|
||
type: 'STATE_UPDATE',
|
||
state: {
|
||
player1: document.getElementById('p1Name').value || 'P1',
|
||
player2: document.getElementById('p2Name').value || 'P2',
|
||
flag1: document.getElementById('p1Flag').value,
|
||
flag2: document.getElementById('p2Flag').value,
|
||
}
|
||
});
|
||
}
|
||
|
||
function updateMatchInfo() {
|
||
send({
|
||
type: 'STATE_UPDATE',
|
||
state: {
|
||
tournament: document.getElementById('tournament').value || 'Torneo',
|
||
round: document.getElementById('round').value || '',
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---- Keyboard shortcuts ----
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
|
||
if (e.key === '1') addPoint(1);
|
||
if (e.key === '2') addPoint(2);
|
||
if (e.key === 'd' || e.key === 'D') setDeuce();
|
||
if (e.key === 'r') resetGame();
|
||
if (e.key === 'm' || e.key === 'M') resetMatch();
|
||
});
|
||
|
||
// ---- Init ----
|
||
connect();
|
||
</script>
|
||
</body>
|
||
</html>
|