feat: modalità estesa controller con dashboard landscape

Aggiunge una seconda modalità dashboard per schermi orizzontali,
rilevata automaticamente dall'orientamento (landscape → estesa,
portrait → mobile) tramite resize/orientationchange listener.

Layout estesa: due pannelli affiancati con stripe colorata in cima
(giallo/blu per identità squadra), score grande come elemento eroe,
diagramma campo in verde scuro, SET button a colori pieni in fondo.
Barra azioni compatta (40px) con tutti i controlli secondari.

Il pannello usa position:fixed ancorato al viewport per impedire
qualsiasi scroll, con @wheel.prevent e @touchmove.prevent.
This commit is contained in:
2026-06-20 23:29:50 +02:00
parent 7e6d51ce58
commit c7d0ec6215
2 changed files with 352 additions and 66 deletions
+350 -65
View File
@@ -6,66 +6,147 @@
{{ wsConnected ? 'Connesso' : 'Connessione...' }}
</div>
<!-- Anteprima punteggio -->
<div class="score-preview">
<div class="team-score" :class="primaSquadra + '-bg'" @click="sendAction({ type: 'incPunt', team: primaSquadra })">
<div class="team-name">{{ state.sp.nomi[primaSquadra] }}</div>
<div class="team-pts">{{ punt[primaSquadra] }}</div>
<div class="team-set">SET {{ set[primaSquadra] }}</div>
<img v-show="primaSquadra === 'home' ? servHome : !servHome" src="/serv.png" class="serv-icon" alt="Servizio" />
<!-- MODALITÀ MOBILE -->
<template v-if="!isEstesa">
<!-- Anteprima punteggio -->
<div class="score-preview">
<div class="team-score" :class="primaSquadra + '-bg'" @click="sendAction({ type: 'incPunt', team: primaSquadra })">
<div class="team-name">{{ state.sp.nomi[primaSquadra] }}</div>
<div class="team-pts">{{ punt[primaSquadra] }}</div>
<div class="team-set">SET {{ set[primaSquadra] }}</div>
<img v-show="primaSquadra === 'home' ? servHome : !servHome" src="/serv.png" class="serv-icon" alt="Servizio" />
</div>
<div class="team-score" :class="secondaSquadra + '-bg'" @click="sendAction({ type: 'incPunt', team: secondaSquadra })">
<div class="team-name">{{ state.sp.nomi[secondaSquadra] }}</div>
<div class="team-pts">{{ punt[secondaSquadra] }}</div>
<div class="team-set">SET {{ set[secondaSquadra] }}</div>
<img v-show="secondaSquadra === 'home' ? servHome : !servHome" src="/serv.png" class="serv-icon" alt="Servizio" />
</div>
</div>
<div class="team-score" :class="secondaSquadra + '-bg'" @click="sendAction({ type: 'incPunt', team: secondaSquadra })">
<div class="team-name">{{ state.sp.nomi[secondaSquadra] }}</div>
<div class="team-pts">{{ punt[secondaSquadra] }}</div>
<div class="team-set">SET {{ set[secondaSquadra] }}</div>
<img v-show="secondaSquadra === 'home' ? servHome : !servHome" src="/serv.png" class="serv-icon" alt="Servizio" />
<!-- Riga annulla punto -->
<div class="undo-row">
<button class="btn btn-undo" @click="sendAction({ type: 'decPunt' })">
ANNULLA PUNTO
</button>
</div>
<!-- Pulsanti set -->
<div class="action-row">
<button class="btn btn-set home-bg" @click="sendAction({ type: 'incSet', team: 'home' })">
SET {{ state.sp.nomi.home }}
</button>
<button class="btn btn-set guest-bg" @click="sendAction({ type: 'incSet', team: 'guest' })">
SET {{ state.sp.nomi.guest }}
</button>
</div>
<!-- Controlli principali -->
<div class="controls">
<button class="btn btn-ctrl" @click="sendAction({ type: 'cambiaPalla' })" :disabled="!isPunteggioZeroZero">
Cambio Palla
</button>
<button class="btn btn-ctrl" @click="sendAction({ type: 'toggleFormazione' })">
{{ state.visuForm ? 'Punteggio' : 'Formazioni' }}
</button>
<button class="btn btn-ctrl" @click="sendAction({ type: 'toggleStriscia' })">
Striscia {{ state.visuStriscia ? 'ON' : 'OFF' }}
</button>
<button class="btn btn-ctrl" @click="sendAction({ type: 'toggleOrder' })">
Inverti
</button>
<button class="btn btn-ctrl" @click="speak()">
Voce
</button>
<button class="btn btn-ctrl" @click="openConfig()">
Config
</button>
<button class="btn btn-ctrl" @click="openCambiTeam()">
Cambi
</button>
<button class="btn btn-danger" @click="confirmReset = true">
Reset
</button>
</div>
</template>
<!-- MODALITÀ ESTESA -->
<div v-else class="e-dash" @wheel.prevent @touchmove.prevent>
<div class="e-panels">
<!-- Prima squadra -->
<div class="e-panel" :class="primaSquadra === 'home' ? 'e-panel--home' : 'e-panel--guest'">
<div class="e-panel__stripe"></div>
<div class="e-panel__body">
<header class="e-panel__head">
<span class="e-panel__name">{{ state.sp.nomi[primaSquadra] }}</span>
<span class="e-panel__meta">
<img v-show="primaSquadra === 'home' ? servHome : !servHome" src="/serv.png" class="e-panel__serv" />
{{ set[primaSquadra] }} <span class="e-label">SET</span>
</span>
</header>
<div class="e-panel__score" @click="sendAction({ type: 'incPunt', team: primaSquadra })">
{{ punt[primaSquadra] }}
</div>
<div class="e-panel__court">
<div class="e-court__row">
<div v-for="x in [3,2,1]" :key="'pf1'+x" class="e-court__cell">{{ state.sp.form[primaSquadra][x] }}</div>
</div>
<div class="e-court__net"></div>
<div class="e-court__row">
<div v-for="x in [4,5,0]" :key="'pf2'+x" class="e-court__cell">{{ state.sp.form[primaSquadra][x] }}</div>
</div>
</div>
<button class="btn e-panel__setbtn" @click="sendAction({ type: 'incSet', team: primaSquadra })">
SET {{ state.sp.nomi[primaSquadra] }}
</button>
</div>
</div>
<!-- Seconda squadra -->
<div class="e-panel" :class="secondaSquadra === 'home' ? 'e-panel--home' : 'e-panel--guest'">
<div class="e-panel__stripe"></div>
<div class="e-panel__body">
<header class="e-panel__head">
<span class="e-panel__name">{{ state.sp.nomi[secondaSquadra] }}</span>
<span class="e-panel__meta">
<img v-show="secondaSquadra === 'home' ? servHome : !servHome" src="/serv.png" class="e-panel__serv" />
{{ set[secondaSquadra] }} <span class="e-label">SET</span>
</span>
</header>
<div class="e-panel__score" @click="sendAction({ type: 'incPunt', team: secondaSquadra })">
{{ punt[secondaSquadra] }}
</div>
<div class="e-panel__court">
<div class="e-court__row">
<div v-for="x in [3,2,1]" :key="'sf1'+x" class="e-court__cell">{{ state.sp.form[secondaSquadra][x] }}</div>
</div>
<div class="e-court__net"></div>
<div class="e-court__row">
<div v-for="x in [4,5,0]" :key="'sf2'+x" class="e-court__cell">{{ state.sp.form[secondaSquadra][x] }}</div>
</div>
</div>
<button class="btn e-panel__setbtn" @click="sendAction({ type: 'incSet', team: secondaSquadra })">
SET {{ state.sp.nomi[secondaSquadra] }}
</button>
</div>
</div>
</div>
<!-- Barra azioni secondarie -->
<div class="e-actions">
<button class="btn e-act e-act--undo" @click="sendAction({ type: 'decPunt' })"> Annulla</button>
<button class="btn e-act" :disabled="!isPunteggioZeroZero" @click="sendAction({ type: 'cambiaPalla' })">Cambio Palla</button>
<button class="btn e-act" @click="openCambiTeam()">Cambi</button>
<button class="btn e-act" @click="sendAction({ type: 'toggleOrder' })">Inverti</button>
<button class="btn e-act" @click="sendAction({ type: 'toggleStriscia' })">Striscia {{ state.visuStriscia ? 'ON' : 'OFF' }}</button>
<button class="btn e-act" @click="speak()">Voce</button>
<button class="btn e-act" @click="openConfig()">Config</button>
<button class="btn e-act e-act--danger" @click="confirmReset = true">Reset</button>
</div>
</div>
<!-- Riga annulla punto -->
<div class="undo-row">
<button class="btn btn-undo" @click="sendAction({ type: 'decPunt' })">
ANNULLA PUNTO
</button>
</div>
<!-- Pulsanti set -->
<div class="action-row">
<button class="btn btn-set home-bg" @click="sendAction({ type: 'incSet', team: 'home' })">
SET {{ state.sp.nomi.home }}
</button>
<button class="btn btn-set guest-bg" @click="sendAction({ type: 'incSet', team: 'guest' })">
SET {{ state.sp.nomi.guest }}
</button>
</div>
<!-- Controlli principali -->
<div class="controls">
<button class="btn btn-ctrl" @click="sendAction({ type: 'cambiaPalla' })" :disabled="!isPunteggioZeroZero">
Cambio Palla
</button>
<button class="btn btn-ctrl" @click="sendAction({ type: 'toggleFormazione' })">
{{ state.visuForm ? 'Punteggio' : 'Formazioni' }}
</button>
<button class="btn btn-ctrl" @click="sendAction({ type: 'toggleStriscia' })">
Striscia {{ state.visuStriscia ? 'ON' : 'OFF' }}
</button>
<button class="btn btn-ctrl" @click="sendAction({ type: 'toggleOrder' })">
Inverti
</button>
<button class="btn btn-ctrl" @click="speak()">
Voce
</button>
<button class="btn btn-ctrl" @click="openConfig()">
Config
</button>
<button class="btn btn-ctrl" @click="openCambiTeam()">
Cambi
</button>
<button class="btn btn-danger" @click="confirmReset = true">
Reset
</button>
</div>
<!-- DIALOGS (condivisi) -->
<!-- Finestra conferma reset -->
<div class="overlay" v-if="confirmReset" @click.self="confirmReset = false">
@@ -213,9 +294,11 @@ export default {
formHome: ["1", "2", "3", "4", "5", "6"],
formGuest: ["1", "2", "3", "4", "5", "6"],
},
isLandscape: window.innerWidth > window.innerHeight,
}
},
computed: {
isEstesa() { return this.isLandscape },
primaSquadra() { return this.state.order ? 'home' : 'guest' },
secondaSquadra() { return this.state.order ? 'guest' : 'home' },
isPunteggioZeroZero() {
@@ -255,6 +338,15 @@ export default {
}
},
},
mounted() {
this._resizeHandler = () => { this.isLandscape = window.innerWidth > window.innerHeight }
window.addEventListener('resize', this._resizeHandler)
window.addEventListener('orientationchange', this._resizeHandler)
},
beforeUnmount() {
window.removeEventListener('resize', this._resizeHandler)
window.removeEventListener('orientationchange', this._resizeHandler)
},
methods: {
onWsMessage(msg) {
if (msg.type === 'error') this.showErrorFeedback(msg.message)
@@ -370,6 +462,7 @@ export default {
font-family: 'Inter', system-ui, sans-serif;
-webkit-user-select: none;
user-select: none;
overflow-x: hidden;
}
/* Barra stato connessione */
@@ -400,7 +493,8 @@ export default {
background: white;
}
/* Anteprima punteggio */
/* ── MODALITÀ MOBILE ── */
.score-preview {
display: flex;
gap: 8px;
@@ -459,7 +553,6 @@ export default {
height: 20px;
}
/* Riga annulla punto */
.undo-row {
margin-bottom: 8px;
}
@@ -478,7 +571,6 @@ export default {
background: rgba(255,100,50,0.2);
}
/* Pulsanti set */
.action-row {
display: flex;
gap: 8px;
@@ -498,7 +590,6 @@ export default {
transform: scale(0.97);
}
/* Griglia controlli */
.controls {
display: grid;
grid-template-columns: repeat(2, 1fr);
@@ -529,6 +620,7 @@ export default {
opacity: 0.35;
}
.btn-danger {
background: rgba(198, 40, 40, 0.25);
border: 1px solid rgba(239, 83, 80, 0.4);
@@ -542,7 +634,195 @@ export default {
background: rgba(198, 40, 40, 0.45);
}
/* Overlay e finestre modali */
/* ── MODALITÀ ESTESA ── */
.e-dash {
position: fixed;
top: 28px; /* altezza conn-bar */
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
padding: 10px;
gap: 8px;
box-sizing: border-box;
overflow: hidden;
background: #111;
}
.e-panels {
display: flex;
flex: 1;
gap: 10px;
min-height: 0;
}
/* Pannello squadra */
.e-panel {
flex: 1;
display: flex;
flex-direction: column;
border-radius: 16px;
overflow: hidden;
background: #161618;
border: 1px solid rgba(255,255,255,0.07);
min-height: 0;
}
/* Barra colorata in cima — identità squadra */
.e-panel__stripe {
height: 5px;
flex-shrink: 0;
}
.e-panel--home .e-panel__stripe { background: #f5c518; }
.e-panel--guest .e-panel__stripe { background: #2196f3; }
.e-panel__body {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 14px 16px 14px;
min-height: 0;
}
/* Header */
.e-panel__head {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.e-panel__name {
font-size: 15px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 1.5px;
color: #f0f0f0;
}
.e-panel__meta {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-weight: 700;
}
.e-panel--home .e-panel__meta { color: #f5c518; }
.e-panel--guest .e-panel__meta { color: #2196f3; }
.e-label {
font-size: 9px;
letter-spacing: 1px;
opacity: 0.6;
font-weight: 600;
}
.e-panel__serv { width: 14px; height: 14px; }
/* Score — l'elemento eroe */
.e-panel__score {
font-size: clamp(60px, 14vh, 110px);
font-weight: 900;
line-height: 1;
text-align: center;
cursor: pointer;
border-radius: 12px;
padding: 6px 0;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
transition: opacity 0.1s, transform 0.1s;
user-select: none;
}
.e-panel__score:active { opacity: 0.65; transform: scale(0.96); }
.e-panel--home .e-panel__score { color: #f5c518; }
.e-panel--guest .e-panel__score { color: #2196f3; }
/* Campo formazione — diagramma del campo */
.e-panel__court {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0;
background: rgba(34, 80, 34, 0.18);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 10px;
padding: 8px 10px;
height: clamp(120px, 28vh, 200px);
}
.e-court__row {
flex: 1;
display: flex;
gap: 6px;
min-height: 0;
}
.e-court__cell {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: clamp(13px, 2.4vh, 22px);
font-weight: 700;
color: #ddd;
background: rgba(255,255,255,0.07);
border-radius: 6px;
min-height: 0;
}
.e-court__net {
height: 2px;
background: rgba(255,255,255,0.18);
margin: 5px 0;
border-radius: 1px;
flex-shrink: 0;
}
/* SET button — pieno colore, fondo pannello */
.e-panel__setbtn {
flex-shrink: 0;
width: 100%;
padding: 9px 0;
font-size: 11px;
font-weight: 800;
letter-spacing: 1.2px;
text-transform: uppercase;
border: none;
border-radius: 8px;
cursor: pointer;
transition: opacity 0.1s;
}
.e-panel__setbtn:active { opacity: 0.75; transform: scale(0.98); }
.e-panel--home .e-panel__setbtn { background: #f5c518; color: #111; }
.e-panel--guest .e-panel__setbtn { background: #2196f3; color: #fff; }
/* Barra azioni secondarie */
.e-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
height: 40px;
}
.e-act {
flex: 1;
height: 100%;
font-family: inherit;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.2px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.1);
background: rgba(255,255,255,0.07);
color: #bbb;
cursor: pointer;
white-space: nowrap;
-webkit-tap-highlight-color: transparent;
transition: background 0.12s;
}
.e-act:active { background: rgba(255,255,255,0.16); }
.e-act:disabled { opacity: 0.28; cursor: not-allowed; }
.e-act--undo { color: #ffab91; border-color: rgba(255,171,145,0.2); }
.e-act--danger { background: rgba(180,30,30,0.2); border-color: rgba(239,83,80,0.3); color: #ff6b6b; }
.e-act--danger:active { background: rgba(198,40,40,0.4); }
/* ── OVERLAY E DIALOGS ── */
.overlay {
position: fixed;
top: 0;
@@ -608,6 +888,9 @@ export default {
border-radius: 12px;
font-size: 15px;
font-weight: 600;
border: none;
cursor: pointer;
font-family: inherit;
}
.btn-confirm {
@@ -618,12 +901,14 @@ export default {
border-radius: 12px;
font-size: 15px;
font-weight: 700;
border: none;
cursor: pointer;
font-family: inherit;
}
.btn-confirm:disabled {
opacity: 0.4;
}
/* Gruppi form */
.form-group {
margin-bottom: 16px;
}
@@ -667,7 +952,6 @@ export default {
border-color: #64b5f6;
}
/* Griglia formazione */
.form-grid {
background: rgba(205, 133, 63, 0.15);
border: 2px solid rgba(255,255,255,0.15);
@@ -685,7 +969,6 @@ export default {
margin: 4px 0;
}
/* Pulsanti modalita */
.mode-buttons {
display: flex;
gap: 8px;
@@ -699,13 +982,15 @@ export default {
font-size: 16px;
font-weight: 700;
transition: all 0.15s;
border: none;
cursor: pointer;
font-family: inherit;
}
.btn-mode.active {
background: #2e7d32;
color: white;
}
/* Sezione cambi */
.cambi-container {
display: flex;
flex-direction: column;