Usa fallback a 127.0.0.1 quando l'hostname è localhost/::1 nei client websocket display/controller. Instrada il proxy del controller dev verso Vite tramite DEV_PROXY_HOST (default 127.0.0.1). Mostra gli URL locali del server con 127.0.0.1 per una diagnostica coerente su Raspberry/Linux.
862 lines
23 KiB
Vue
862 lines
23 KiB
Vue
<template>
|
|
<section class="controller-page">
|
|
<!-- Barra di stato connessione -->
|
|
<div class="conn-bar" :class="{ connected: wsConnected }">
|
|
<span class="dot"></span>
|
|
{{ wsConnected ? 'Connesso' : 'Connessione...' }}
|
|
</div>
|
|
|
|
<!-- Anteprima punteggio -->
|
|
<div class="score-preview">
|
|
<div class="team-score home-bg" @click="sendAction({ type: 'incPunt', team: 'home' })">
|
|
<div class="team-name">{{ state.sp.nomi.home }}</div>
|
|
<div class="team-pts">{{ state.sp.punt.home }}</div>
|
|
<div class="team-set">SET {{ state.sp.set.home }}</div>
|
|
<img v-show="state.sp.servHome" src="/serv.png" class="serv-icon" />
|
|
</div>
|
|
<div class="team-score guest-bg" @click="sendAction({ type: 'incPunt', team: 'guest' })">
|
|
<div class="team-name">{{ state.sp.nomi.guest }}</div>
|
|
<div class="team-pts">{{ state.sp.punt.guest }}</div>
|
|
<div class="team-set">SET {{ state.sp.set.guest }}</div>
|
|
<img v-show="!state.sp.servHome" src="/serv.png" class="serv-icon" />
|
|
</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
|
|
</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>
|
|
|
|
<!-- Finestra conferma reset -->
|
|
<div class="overlay" v-if="confirmReset" @click.self="confirmReset = false">
|
|
<div class="dialog">
|
|
<div class="dialog-title">Azzero punteggio?</div>
|
|
<div class="dialog-buttons">
|
|
<button class="btn btn-cancel" @click="confirmReset = false">NO</button>
|
|
<button class="btn btn-confirm" @click="doReset()">SI</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Finestra configurazione -->
|
|
<div class="overlay" v-if="showConfig" @click.self="showConfig = false">
|
|
<div class="dialog dialog-config">
|
|
<div class="dialog-title">Configurazione</div>
|
|
|
|
<div class="form-group">
|
|
<label>Nome Home</label>
|
|
<input type="text" v-model="configData.nomeHome" class="input-field" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Nome Guest</label>
|
|
<input type="text" v-model="configData.nomeGuest" class="input-field" />
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Modalità partita</label>
|
|
<div class="mode-buttons">
|
|
<button :class="['btn', 'btn-mode', configData.modalita === '2/3' ? 'active' : '']"
|
|
@click="configData.modalita = '2/3'">2/3</button>
|
|
<button :class="['btn', 'btn-mode', configData.modalita === '3/5' ? 'active' : '']"
|
|
@click="configData.modalita = '3/5'">3/5</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Formazione Home</label>
|
|
<div class="form-grid">
|
|
<div class="form-row">
|
|
<input type="text" v-model="configData.formHome[3]" class="input-num" />
|
|
<input type="text" v-model="configData.formHome[2]" class="input-num" />
|
|
<input type="text" v-model="configData.formHome[1]" class="input-num" />
|
|
</div>
|
|
<div class="form-line"></div>
|
|
<div class="form-row">
|
|
<input type="text" v-model="configData.formHome[4]" class="input-num" />
|
|
<input type="text" v-model="configData.formHome[5]" class="input-num" />
|
|
<input type="text" v-model="configData.formHome[0]" class="input-num" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Formazione Guest</label>
|
|
<div class="form-grid">
|
|
<div class="form-row">
|
|
<input type="text" v-model="configData.formGuest[3]" class="input-num" />
|
|
<input type="text" v-model="configData.formGuest[2]" class="input-num" />
|
|
<input type="text" v-model="configData.formGuest[1]" class="input-num" />
|
|
</div>
|
|
<div class="form-line"></div>
|
|
<div class="form-row">
|
|
<input type="text" v-model="configData.formGuest[4]" class="input-num" />
|
|
<input type="text" v-model="configData.formGuest[5]" class="input-num" />
|
|
<input type="text" v-model="configData.formGuest[0]" class="input-num" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="dialog-buttons">
|
|
<button class="btn btn-cancel" @click="showConfig = false">Annulla</button>
|
|
<button class="btn btn-confirm" @click="saveConfig()">OK</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Selezione squadra per cambi -->
|
|
<div class="overlay" v-if="showCambiTeam" @click.self="showCambiTeam = false">
|
|
<div class="dialog">
|
|
<div class="dialog-title">Scegli squadra</div>
|
|
<div class="dialog-buttons">
|
|
<button class="btn btn-set home-bg" @click="openCambi('home')">{{ state.sp.nomi.home }}</button>
|
|
<button class="btn btn-set guest-bg" @click="openCambi('guest')">{{ state.sp.nomi.guest }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Finestra gestione cambi -->
|
|
<div class="overlay" v-if="showCambi" @click.self="closeCambi()">
|
|
<div class="dialog">
|
|
<div class="dialog-title">{{ state.sp.nomi[cambiTeam] }}: CAMBIO</div>
|
|
<div class="cambi-container">
|
|
<div class="cambio-row" v-for="(c, i) in cambiData" :key="i">
|
|
<input type="text" v-model="c.in" placeholder="IN" class="input-num cambi-in-field" />
|
|
<span class="cambio-arrow">→</span>
|
|
<input type="text" v-model="c.out" placeholder="OUT" class="input-num cambi-out-field" />
|
|
</div>
|
|
</div>
|
|
<div class="dialog-buttons">
|
|
<button class="btn btn-cancel" @click="closeCambi()">Annulla</button>
|
|
<button class="btn btn-confirm" :disabled="!cambiValid" @click="confermaCambi()">CONFERMA</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<script>
|
|
export default {
|
|
name: "ControllerPage",
|
|
data() {
|
|
return {
|
|
ws: null,
|
|
wsConnected: false,
|
|
isConnecting: false,
|
|
reconnectTimeout: null,
|
|
reconnectAttempts: 0,
|
|
maxReconnectDelay: 30000,
|
|
confirmReset: false,
|
|
showConfig: false,
|
|
showCambiTeam: false,
|
|
showCambi: false,
|
|
cambiTeam: "home",
|
|
cambiData: [
|
|
{ in: "", out: "" },
|
|
{ in: "", out: "" },
|
|
],
|
|
configData: {
|
|
nomeHome: "",
|
|
nomeGuest: "",
|
|
modalita: "3/5",
|
|
formHome: ["1", "2", "3", "4", "5", "6"],
|
|
formGuest: ["1", "2", "3", "4", "5", "6"],
|
|
},
|
|
state: {
|
|
order: true,
|
|
visuForm: false,
|
|
visuStriscia: true,
|
|
modalitaPartita: "3/5",
|
|
sp: {
|
|
striscia: { home: [0], guest: [0] },
|
|
servHome: true,
|
|
punt: { home: 0, guest: 0 },
|
|
set: { home: 0, guest: 0 },
|
|
nomi: { home: "Antoniana", guest: "Guest" },
|
|
form: {
|
|
home: ["1", "2", "3", "4", "5", "6"],
|
|
guest: ["1", "2", "3", "4", "5", "6"],
|
|
},
|
|
storicoServizio: [],
|
|
},
|
|
},
|
|
}
|
|
},
|
|
computed: {
|
|
isPunteggioZeroZero() {
|
|
return this.state.sp.punt.home === 0 && this.state.sp.punt.guest === 0
|
|
},
|
|
cambiValid() {
|
|
let hasComplete = false
|
|
let allValid = true
|
|
this.cambiData.forEach((c) => {
|
|
const cin = (c.in || "").trim()
|
|
const cout = (c.out || "").trim()
|
|
if (!cin && !cout) return
|
|
if (!cin || !cout) { allValid = false; return }
|
|
hasComplete = true
|
|
})
|
|
return allValid && hasComplete
|
|
}
|
|
},
|
|
mounted() {
|
|
this.connectWebSocket()
|
|
|
|
// Gestisce l'HMR di Vite evitando riconnessioni durante la ricarica a caldo.
|
|
if (import.meta.hot) {
|
|
import.meta.hot.on('vite:beforeUpdate', () => {
|
|
// Annulla eventuali tentativi di riconnessione pianificati.
|
|
if (this.reconnectTimeout) {
|
|
clearTimeout(this.reconnectTimeout)
|
|
this.reconnectTimeout = null
|
|
}
|
|
})
|
|
}
|
|
},
|
|
beforeUnmount() {
|
|
// Pulisce il timeout di riconnessione.
|
|
if (this.reconnectTimeout) {
|
|
clearTimeout(this.reconnectTimeout)
|
|
this.reconnectTimeout = null
|
|
}
|
|
|
|
// Chiude il WebSocket con il codice di chiusura appropriato.
|
|
if (this.ws) {
|
|
this.ws.onclose = null // Rimuove il listener per evitare nuove riconnessioni pianificate.
|
|
this.ws.onerror = null
|
|
this.ws.onmessage = null
|
|
this.ws.onopen = null
|
|
|
|
// Usa il codice 1000 (chiusura normale) se la connessione e aperta.
|
|
try {
|
|
if (this.ws.readyState === WebSocket.OPEN) {
|
|
this.ws.close(1000, 'Component unmounting')
|
|
} else if (this.ws.readyState === WebSocket.CONNECTING) {
|
|
// Se la connessione e ancora in fase di apertura, chiude direttamente.
|
|
this.ws.close()
|
|
}
|
|
} catch (err) {
|
|
console.error('[Controller] Error closing WebSocket:', err)
|
|
}
|
|
this.ws = null
|
|
}
|
|
},
|
|
methods: {
|
|
connectWebSocket() {
|
|
// Evita connessioni simultanee multiple.
|
|
if (this.isConnecting) {
|
|
console.log('[Controller] Already connecting, skipping...')
|
|
return
|
|
}
|
|
|
|
// Chiude la connessione precedente, se presente.
|
|
if (this.ws) {
|
|
this.ws.onclose = null
|
|
this.ws.onerror = null
|
|
this.ws.onmessage = null
|
|
this.ws.onopen = null
|
|
try {
|
|
if (this.ws.readyState === WebSocket.OPEN) {
|
|
this.ws.close(1000, 'Reconnecting')
|
|
} else if (this.ws.readyState === WebSocket.CONNECTING) {
|
|
this.ws.close()
|
|
}
|
|
} catch (err) {
|
|
console.error('[Controller] Error closing previous WebSocket:', err)
|
|
}
|
|
this.ws = null
|
|
}
|
|
|
|
this.isConnecting = true
|
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
|
|
// Permette di specificare un host WebSocket alternativo via query parameter
|
|
// Utile per scenari WSL2 o development remoto: ?wsHost=192.168.1.100:3001
|
|
const params = new URLSearchParams(location.search)
|
|
const defaultHost = (
|
|
location.hostname === 'localhost' || location.hostname === '::1'
|
|
)
|
|
? `127.0.0.1${location.port ? `:${location.port}` : ''}`
|
|
: location.host
|
|
const wsHost = params.get('wsHost') || defaultHost
|
|
const wsUrl = `${protocol}//${wsHost}/ws`
|
|
|
|
console.log('[Controller] Connecting to WebSocket:', wsUrl)
|
|
|
|
try {
|
|
this.ws = new WebSocket(wsUrl)
|
|
} catch (err) {
|
|
console.error('[Controller] Failed to create WebSocket:', err)
|
|
this.isConnecting = false
|
|
this.scheduleReconnect()
|
|
return
|
|
}
|
|
|
|
this.ws.onopen = () => {
|
|
this.isConnecting = false
|
|
this.wsConnected = true
|
|
this.reconnectAttempts = 0
|
|
console.log('[Controller] Connected to server')
|
|
|
|
// Invia la registrazione solo se la connessione e realmente aperta.
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
try {
|
|
this.ws.send(JSON.stringify({ type: 'register', role: 'controller' }))
|
|
} catch (err) {
|
|
console.error('[Controller] Failed to register:', err)
|
|
}
|
|
}
|
|
}
|
|
|
|
this.ws.onmessage = (event) => {
|
|
try {
|
|
const msg = JSON.parse(event.data)
|
|
|
|
if (msg.type === 'state') {
|
|
this.state = msg.state
|
|
} else if (msg.type === 'error') {
|
|
console.error('[Controller] Server error:', msg.message)
|
|
// Fornisce feedback di errore all'utente.
|
|
this.showErrorFeedback(msg.message)
|
|
}
|
|
} catch (e) {
|
|
console.error('[Controller] Parse error:', e)
|
|
}
|
|
}
|
|
|
|
this.ws.onclose = (event) => {
|
|
this.isConnecting = false
|
|
this.wsConnected = false
|
|
console.log('[Controller] Disconnected from server', event.code, event.reason)
|
|
|
|
// Non riconnette durante HMR (codice 1001, "going away")
|
|
// ne in caso di chiusura pulita (codice 1000).
|
|
if (event.code === 1000 || event.code === 1001) {
|
|
console.log('[Controller] Clean close, not reconnecting')
|
|
return
|
|
}
|
|
|
|
this.scheduleReconnect()
|
|
}
|
|
|
|
this.ws.onerror = (err) => {
|
|
console.error('[Controller] WebSocket error:', err)
|
|
this.isConnecting = false
|
|
this.wsConnected = false
|
|
}
|
|
},
|
|
|
|
scheduleReconnect() {
|
|
// Evita pianificazioni multiple della riconnessione.
|
|
if (this.reconnectTimeout) {
|
|
return
|
|
}
|
|
|
|
// Applica backoff esponenziale: 1s, 2s, 4s, 8s, 16s, fino a 30s.
|
|
const delay = Math.min(
|
|
1000 * Math.pow(2, this.reconnectAttempts),
|
|
this.maxReconnectDelay
|
|
)
|
|
this.reconnectAttempts++
|
|
|
|
console.log(`[Controller] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`)
|
|
|
|
this.reconnectTimeout = setTimeout(() => {
|
|
this.reconnectTimeout = null
|
|
this.connectWebSocket()
|
|
}, delay)
|
|
},
|
|
|
|
sendAction(action) {
|
|
if (!this.wsConnected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
console.warn('[Controller] Cannot send action: not connected')
|
|
this.showErrorFeedback('Non connesso al server')
|
|
return
|
|
}
|
|
|
|
// Valida l'azione prima dell'invio.
|
|
if (!action || !action.type) {
|
|
console.error('[Controller] Invalid action format:', action)
|
|
return
|
|
}
|
|
|
|
try {
|
|
this.ws.send(JSON.stringify({ type: 'action', action }))
|
|
} catch (err) {
|
|
console.error('[Controller] Failed to send action:', err)
|
|
this.showErrorFeedback('Errore invio comando')
|
|
}
|
|
},
|
|
|
|
showErrorFeedback(message) {
|
|
// Feedback visivo degli errori: attualmente solo log su console.
|
|
// In futuro puo essere esteso con notifiche a comparsa (toast).
|
|
console.error('[Controller] Error:', message)
|
|
},
|
|
|
|
doReset() {
|
|
this.sendAction({ type: 'resetta' })
|
|
this.confirmReset = false
|
|
},
|
|
|
|
openConfig() {
|
|
this.configData.nomeHome = this.state.sp.nomi.home
|
|
this.configData.nomeGuest = this.state.sp.nomi.guest
|
|
this.configData.modalita = this.state.modalitaPartita
|
|
this.configData.formHome = [...this.state.sp.form.home]
|
|
this.configData.formGuest = [...this.state.sp.form.guest]
|
|
this.showConfig = true
|
|
},
|
|
|
|
saveConfig() {
|
|
this.sendAction({ type: 'setNomi', home: this.configData.nomeHome, guest: this.configData.nomeGuest })
|
|
this.sendAction({ type: 'setModalita', modalita: this.configData.modalita })
|
|
this.sendAction({ type: 'setFormazione', team: 'home', form: this.configData.formHome })
|
|
this.sendAction({ type: 'setFormazione', team: 'guest', form: this.configData.formGuest })
|
|
this.showConfig = false
|
|
},
|
|
|
|
openCambiTeam() {
|
|
this.showCambiTeam = true
|
|
},
|
|
|
|
openCambi(team) {
|
|
this.showCambiTeam = false
|
|
this.cambiTeam = team
|
|
this.cambiData = [{ in: "", out: "" }, { in: "", out: "" }]
|
|
this.showCambi = true
|
|
},
|
|
|
|
closeCambi() {
|
|
this.showCambi = false
|
|
this.cambiData = [{ in: "", out: "" }, { in: "", out: "" }]
|
|
},
|
|
|
|
confermaCambi() {
|
|
if (!this.cambiValid) return
|
|
const cambi = this.cambiData
|
|
.filter(c => (c.in || "").trim() && (c.out || "").trim())
|
|
.map(c => ({ in: c.in.trim(), out: c.out.trim() }))
|
|
this.sendAction({ type: 'confermaCambi', team: this.cambiTeam, cambi })
|
|
this.closeCambi()
|
|
},
|
|
|
|
speak() {
|
|
const msg = new SpeechSynthesisUtterance()
|
|
if (this.state.sp.punt.home + this.state.sp.punt.guest === 0) {
|
|
msg.text = "zero a zero"
|
|
} else if (this.state.sp.punt.home === this.state.sp.punt.guest) {
|
|
msg.text = this.state.sp.punt.home + " pari"
|
|
} else {
|
|
if (this.state.sp.servHome) {
|
|
msg.text = this.state.sp.punt.home + " a " + this.state.sp.punt.guest
|
|
} else {
|
|
msg.text = this.state.sp.punt.guest + " a " + this.state.sp.punt.home
|
|
}
|
|
}
|
|
const voices = window.speechSynthesis.getVoices()
|
|
msg.voice = voices.find(v => v.name === 'Google italiano')
|
|
window.speechSynthesis.speak(msg)
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.controller-page {
|
|
min-height: 100vh;
|
|
background: #111;
|
|
color: #fff;
|
|
padding: 8px;
|
|
padding-top: 36px;
|
|
font-family: 'Inter', system-ui, sans-serif;
|
|
-webkit-user-select: none;
|
|
user-select: none;
|
|
}
|
|
|
|
/* Barra stato connessione */
|
|
.conn-bar {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 28px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
background: #c62828;
|
|
color: white;
|
|
z-index: 200;
|
|
transition: background 0.3s;
|
|
}
|
|
.conn-bar.connected {
|
|
background: #2e7d32;
|
|
}
|
|
.dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: white;
|
|
}
|
|
|
|
/* Anteprima punteggio */
|
|
.score-preview {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-bottom: 8px;
|
|
}
|
|
.team-score {
|
|
flex: 1;
|
|
border-radius: 16px;
|
|
padding: 16px 12px;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
position: relative;
|
|
transition: transform 0.1s;
|
|
min-height: 120px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
.team-score:active {
|
|
transform: scale(0.97);
|
|
}
|
|
.home-bg {
|
|
background: linear-gradient(145deg, #1a1a1a, #333);
|
|
border: 2px solid #fdd835;
|
|
color: #fdd835;
|
|
}
|
|
.guest-bg {
|
|
background: linear-gradient(145deg, #0d47a1, #1565c0);
|
|
border: 2px solid #64b5f6;
|
|
color: #fff;
|
|
}
|
|
.team-name {
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
margin-bottom: 4px;
|
|
}
|
|
.team-pts {
|
|
font-size: 56px;
|
|
font-weight: 900;
|
|
line-height: 1;
|
|
}
|
|
.team-set {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
opacity: 0.75;
|
|
margin-top: 4px;
|
|
}
|
|
.serv-icon {
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 8px;
|
|
width: 20px;
|
|
height: 20px;
|
|
}
|
|
|
|
/* Riga annulla punto */
|
|
.undo-row {
|
|
margin-bottom: 8px;
|
|
}
|
|
.btn-undo {
|
|
width: 100%;
|
|
background: rgba(255,255,255,0.08);
|
|
border: 1px solid rgba(255,255,255,0.2);
|
|
color: #ffab91;
|
|
padding: 10px;
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
border-radius: 12px;
|
|
cursor: pointer;
|
|
}
|
|
.btn-undo:active {
|
|
background: rgba(255,100,50,0.2);
|
|
}
|
|
|
|
/* Pulsanti set */
|
|
.action-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-bottom: 8px;
|
|
}
|
|
.btn-set {
|
|
flex: 1;
|
|
padding: 12px;
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
border-radius: 12px;
|
|
cursor: pointer;
|
|
border: none;
|
|
text-transform: uppercase;
|
|
}
|
|
.btn-set:active {
|
|
transform: scale(0.97);
|
|
}
|
|
|
|
/* Griglia controlli */
|
|
.controls {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: 8px;
|
|
}
|
|
|
|
.btn {
|
|
border: none;
|
|
font-family: inherit;
|
|
cursor: pointer;
|
|
-webkit-tap-highlight-color: transparent;
|
|
}
|
|
|
|
.btn-ctrl {
|
|
background: rgba(255,255,255,0.08);
|
|
border: 1px solid rgba(255,255,255,0.15);
|
|
color: #e0e0e0;
|
|
padding: 14px 8px;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
border-radius: 12px;
|
|
transition: background 0.15s;
|
|
}
|
|
.btn-ctrl:active {
|
|
background: rgba(255,255,255,0.18);
|
|
}
|
|
.btn-ctrl:disabled {
|
|
opacity: 0.35;
|
|
}
|
|
|
|
.btn-danger {
|
|
background: rgba(198, 40, 40, 0.25);
|
|
border: 1px solid rgba(239, 83, 80, 0.4);
|
|
color: #ef5350;
|
|
padding: 14px 8px;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
border-radius: 12px;
|
|
}
|
|
.btn-danger:active {
|
|
background: rgba(198, 40, 40, 0.45);
|
|
}
|
|
|
|
/* Overlay e finestre modali */
|
|
.overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0,0,0,0.75);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 300;
|
|
padding: 16px;
|
|
}
|
|
|
|
.dialog {
|
|
background: #1e1e1e;
|
|
border-radius: 20px;
|
|
padding: 24px;
|
|
width: 100%;
|
|
max-width: 400px;
|
|
border: 1px solid rgba(255,255,255,0.12);
|
|
}
|
|
|
|
.dialog-config {
|
|
max-height: 85vh;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.dialog-title {
|
|
font-size: 18px;
|
|
font-weight: 800;
|
|
text-align: center;
|
|
margin-bottom: 20px;
|
|
padding-bottom: 12px;
|
|
border-bottom: 1px solid rgba(255,255,255,0.12);
|
|
}
|
|
|
|
.dialog-buttons {
|
|
display: flex;
|
|
gap: 12px;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.btn-cancel {
|
|
flex: 1;
|
|
background: rgba(255,255,255,0.08);
|
|
color: #aaa;
|
|
padding: 12px;
|
|
border-radius: 12px;
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.btn-confirm {
|
|
flex: 1;
|
|
background: #2e7d32;
|
|
color: white;
|
|
padding: 12px;
|
|
border-radius: 12px;
|
|
font-size: 15px;
|
|
font-weight: 700;
|
|
}
|
|
.btn-confirm:disabled {
|
|
opacity: 0.4;
|
|
}
|
|
|
|
/* Gruppi form */
|
|
.form-group {
|
|
margin-bottom: 16px;
|
|
}
|
|
.form-group label {
|
|
display: block;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: #aaa;
|
|
margin-bottom: 6px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
.input-field {
|
|
width: 100%;
|
|
background: rgba(255,255,255,0.08);
|
|
border: 1px solid rgba(255,255,255,0.2);
|
|
color: white;
|
|
padding: 10px 14px;
|
|
border-radius: 10px;
|
|
font-size: 16px;
|
|
box-sizing: border-box;
|
|
}
|
|
.input-field:focus {
|
|
outline: none;
|
|
border-color: #64b5f6;
|
|
}
|
|
|
|
.input-num {
|
|
width: 52px;
|
|
background: rgba(255,255,255,0.08);
|
|
border: 1px solid rgba(255,255,255,0.2);
|
|
color: white;
|
|
padding: 8px;
|
|
border-radius: 8px;
|
|
font-size: 18px;
|
|
text-align: center;
|
|
box-sizing: border-box;
|
|
}
|
|
.input-num:focus {
|
|
outline: none;
|
|
border-color: #64b5f6;
|
|
}
|
|
|
|
/* Griglia formazione */
|
|
.form-grid {
|
|
background: rgba(205, 133, 63, 0.15);
|
|
border: 2px solid rgba(255,255,255,0.15);
|
|
border-radius: 12px;
|
|
padding: 12px;
|
|
}
|
|
.form-row {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 10px;
|
|
padding: 8px 0;
|
|
}
|
|
.form-line {
|
|
border-top: 1px dashed rgba(255,255,255,0.3);
|
|
margin: 4px 0;
|
|
}
|
|
|
|
/* Pulsanti modalita */
|
|
.mode-buttons {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
.btn-mode {
|
|
flex: 1;
|
|
padding: 10px;
|
|
background: rgba(255,255,255,0.08);
|
|
color: #aaa;
|
|
border-radius: 10px;
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
transition: all 0.15s;
|
|
}
|
|
.btn-mode.active {
|
|
background: #2e7d32;
|
|
color: white;
|
|
}
|
|
|
|
/* Sezione cambi */
|
|
.cambi-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 14px;
|
|
padding: 8px 0;
|
|
}
|
|
.cambio-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 12px;
|
|
}
|
|
.cambio-arrow {
|
|
font-size: 20px;
|
|
font-weight: 700;
|
|
color: #aaa;
|
|
}
|
|
.cambi-in-field {
|
|
background: rgba(120, 200, 120, 0.2) !important;
|
|
border-color: rgba(120, 200, 120, 0.4) !important;
|
|
}
|
|
.cambi-out-field {
|
|
background: rgba(200, 120, 120, 0.2) !important;
|
|
border-color: rgba(200, 120, 120, 0.4) !important;
|
|
}
|
|
</style>
|