7 Commits
v1.0.0 ... apk

Author SHA1 Message Date
2c0bcdb833 Aggiorna dipendenze e Node.js 20 con fix permessi dist
- Aggiorna Vite: 4.3.9 → 5.4.10
- Aggiorna Vue: 3.2.47 → 3.4.38
- Aggiorna @vitejs/plugin-vue: 4.1.0 → 5.1.4
- Aggiorna vite-plugin-pwa: 0.16.0 → 0.20.5
- Aggiorna Capacitor: 6.0.0 → 6.2.0
- Aggiorna wave-ui: 3.3.0 → 3.17.0
- Aggiorna Node.js nel Dockerfile: 18.x → 20.x (LTS)
- Aggiunge fix automatico permessi dist dopo build Docker
2026-01-24 18:43:21 +01:00
5e9377c681 Unifica output APK nella cartella /dist/android invece di /output 2026-01-24 18:41:11 +01:00
b174e66c8b Corregge encoding README.md (caratteri accentati) 2026-01-24 18:02:57 +01:00
3394113ea0 Aggiunge sensor landscape e tenta fix audio mobile (WIP)
- Cambia orientamento da landscape a sensorLandscape (rotazione 180°)
- Aggiorna Java 17 -> 21 per plugin TTS
- Aggiunge @capacitor-community/text-to-speech per audio nativo
- Implementa fallback Web API su desktop, plugin nativo su mobile
- Corregge lang 'it_IT' -> 'it-IT', aggiunge log debug

Nota: audio mobile ancora non funziona, richiede debug
2026-01-24 16:29:11 +01:00
1f71846e23 Ottimizza layout mobile e setup icone APK Android
- Forza orientamento landscape e blocca scroll su mobile
- Aggiunge media queries responsive per schermi <768px
- Usa 100dvh e position:fixed per layout fullscreen
- Crea setup-android-icons.sh per generazione automatica icone Android
- Configura Capacitor per landscape, disabilita splash screen
- Refactoring Dockerfile con ImageMagick per icone multi-densità
2026-01-24 16:29:11 +01:00
9346117603 Aggiunge integrazione Capacitor per build APK Android 2026-01-24 16:29:11 +01:00
2e956aef75 Aggiorna README.md 2026-01-24 10:10:00 +01:00
16 changed files with 4798 additions and 3252 deletions

14
.gitignore vendored
View File

@@ -11,7 +11,8 @@ lerna-debug.log*
currentCommit.txt currentCommit.txt
node_modules node_modules
dist dist/*
!dist/android/
dist-ssr dist-ssr
*.local *.local
@@ -25,3 +26,14 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Capacitor
android/
ios/
.capacitor/
# Build output
output/
*.apk
!dist/android/*.apk
*.aab

View File

@@ -1,282 +0,0 @@
# Changelog
Tutte le modifiche significative a questo progetto sono documentate in questo file.
Il formato si basa su [Keep a Changelog](https://keepachangelog.com/it/1.0.0/),
e questo progetto aderisce al [Versionamento Semantico](https://semver.org/lang/it/).
---
## [1.0.0] - 2026-02-10
Rilascio iniziale di **Segnapunti Anto**, un'applicazione web Progressive Web App (PWA) professionale per il tracciamento in tempo reale dei punteggi durante partite di pallavolo.
### Funzionalità Principali
#### Gestione Partite e Punteggi
- **Tracciamento punti in tempo reale** per entrambe le squadre (casa e ospite)
- **Conteggio automatico dei set** con supporto per modalità al meglio di 3 o al meglio di 5
- **Logica regolamentare completa**:
- Set regolari: primo a 25 punti con almeno 2 di vantaggio
- Set decisivo (tie-break): primo a 15 punti con almeno 2 di vantaggio
- Blocco automatico assegnazione punti al raggiungimento della vittoria
- **Indicatore visivo del servizio** per identificare quale squadra è al servizio
- **Cronologia punti** con striscia visiva per seguire l'andamento della partita
- Possibilità di **incrementare e decrementare punti** e **set**
- **Annullamento punti** con ripristino automatico del servizio precedente
#### Formazioni e Rotazioni
- **Visualizzazione interattiva** della formazione in campo con 6 giocatori per squadra
- **Rotazione automatica regolamentare** quando la squadra conquista il servizio (cambio palla)
- **Configurazione manuale dei numeri di maglia** per ogni giocatore
- **Sistema di cambi giocatori**:
- Dialog dedicato per effettuare i cambi
- Supporto per cambi singoli o multipli
- Tabella IN/OUT con validazione degli input
- Verifica che i numeri di maglia siano numerici
- Scorciatoie da tastiera dedicate per squadra casa e ospite
- **Limitazione cambio palla manuale** solo a inizio set (0-0) per prevenire errori nella rotazione
#### Interfaccia Utente e Personalizzazione
- **Interfaccia fullscreen touch-friendly** ottimizzata per tablet e smartphone
- **Layout responsive** con media queries per schermi piccoli (<768px)
- **Modalità di visualizzazione**:
- Punteggio semplice
- Formazioni complete con posizioni giocatori
- Toggle tra le due modalità con scorciatoia da tastiera
- **Personalizzazione squadre**:
- Configurazione dinamica dei nomi squadre
- Inversione layout orizzontale (scambio posizione casa/ospite)
- Configurazione numeri di maglia giocatori
- **Controlli nascondibili**: possibilità di nascondere/mostrare barra pulsanti e cronologia
- **Stabilizzazione UI**: dimensioni fisse per riquadri punteggio per evitare spostamenti durante gli aggiornamenti
#### Controlli e Accessibilità
- **Controlli da tastiera completi** con scorciatoie dedicate:
- **Squadra Casa**: `Ctrl + ↑/↓` (punti), `Ctrl + →` (set), `Ctrl + C` (cambi)
- **Squadra Ospite**: `Shift + ↑/↓` (punti), `Shift + →` (set), `Shift + C` (cambi)
- **Comandi globali**:
- `Ctrl + ←` (cambio palla, solo a 0-0)
- `Ctrl + M` (configurazione)
- `Ctrl + B` (toggle barra pulsanti)
- `Ctrl + F` (fullscreen)
- `Ctrl + S` (annuncio vocale)
- `Ctrl + Z` (switch visualizzazione)
- **Sintesi vocale** per annunci punteggio in italiano usando Web Speech API
- **Sistema di alert professionale** usando Wave UI per notifiche e conferme
#### Progressive Web App (PWA)
- **Installabile** come app nativa su smartphone, tablet e desktop
- **Funzionamento offline completo** grazie ai Service Worker
- **Auto-update automatico** per ricevere nuovi aggiornamenti senza reinstallazione
- **Display fullscreen** per massimizzare lo spazio visivo
- **Orientamento landscape ottimizzato** per utilizzo su tablet
- **Orientamento sensor landscape** con supporto rotazione 180°
- **Icone PWA personalizzate** (192x192 e 512x512)
- **Prevenzione scroll indesiderato** su mobile (overscroll-behavior)
- **Supporto 100dvh** e position:fixed per layout mobile stabile
- **Blocco scroll** per evitare ricariche accidentali con swipe-down
#### Build e Deployment
- **Build APK Android** tramite Capacitor per distribuzione nativa
- **Setup automatico icone Android** con script dedicato per multi-densità (ldpi, mdpi, hdpi, xhdpi, xxhdpi, xxxhdpi)
- **Configurazione Capacitor** ottimizzata per landscape senza splash screen
- **Output unificato** in `/dist/android` per build Android
- **Base path configurabile** (`/segnap`) per deployment su sottocartelle
### Tecnologie e Dipendenze
#### Stack Tecnologico
- **Vue 3.4.38** - Framework JavaScript reattivo
- **Vite 5.4.10** - Build tool e dev server veloce
- **Wave UI 3.17.0** - Libreria UI components per alert e dialogs
- **NoSleep.js 0.12.0** - Prevenzione standby durante le partite
- **vite-plugin-pwa 0.20.5** - Plugin per generazione PWA
- **Capacitor 6.2.0** - Framework per build app native Android/iOS
#### Ambiente di Sviluppo
- **Node.js 20.x LTS** (consigliato v20.2.0)
- **npm 9.0.0+** per gestione dipendenze
- **NVM support** per gestione versioni Node.js
- **Hot Module Replacement (HMR)** in modalità sviluppo
- **Source maps** per debugging
- **Vue DevTools** supportato
#### Browser Supportati
- **Chrome/Chromium 90+** - Supporto completo (consigliato)
- **Firefox 88+** - Supporto completo
### Build e Comandi
#### Comandi Disponibili
- `npm run dev` - Server di sviluppo con hot-reload su http://localhost:5173
- `npm run build` - Build di produzione ottimizzata in `/dist`
- `npm run preview` - Anteprima locale della build di produzione
#### Output Build
- File statici ottimizzati e minificati
- Service Worker generato automaticamente
- PWA manifest configurato
- Assets con hash per cache busting
- Permessi automatici per cartella dist in ambiente Docker
### Documentazione
- **README.md completo** con:
- Panoramica funzionalità
- Requisiti di sistema dettagliati
- Istruzioni di installazione e setup con NVM
- Guida ai comandi per sviluppo e build
- Tabella completa shortcuts tastiera
- Configurazione PWA documentata
- Spiegazione logica regolamentare pallavolo
- Browser testati e supportati
- **Encoding corretto** per caratteri accentati italiani
### Miglioramenti Architetturali
- **Separazione componenti**: HomePage estratta in componente dedicato
- **Rimozione codice legacy**: eliminati file non utilizzati (HelloWorld.vue)
- **Refactoring CSS**: file style.css organizzato e ottimizzato
- **Gestione servizio migliorata**: variabile booleana `servHome` per gestione cambio servizio
- **Validazione input**: controlli su numeri di maglia e cambi giocatori
- **Gestione errori**: prevenzione incrementi a set concluso senza notifiche spam
### Note di Sviluppo
- **Orientamento sensor landscape**: permette rotazione 180° del dispositivo
- **Fix tentati audio mobile**: implementati ma richiedono ancora debug (WIP)
- Aggiunto supporto @capacitor-community/text-to-speech per audio nativo
- Implementato fallback Web API su desktop, plugin nativo su mobile
- Correzione lingua da 'it_IT' a 'it-IT'
- **Java 17 → 21**: aggiornamento per compatibilità plugin TTS
- **ImageMagick in Dockerfile**: per generazione automatica icone Android
### Configurazione
- **Base path**: `/segnap` (configurabile in vite.config.js)
- **Tema PWA**: background `#eee`, theme color `#ffffff`
- **Display**: fullscreen landscape
- **Manifest name**: app_segnap
- **Short name**: segnap
---
## Architettura e Funzionamento Interno
### Come Funziona l'Applicazione Web
L'applicazione è una **Single Page Application (SPA)** completamente **client-side**:
- **Server**: Serve solo file statici (nessun backend, nessun database)
- **Esecuzione**: Tutto gira nel browser dell'utente
- **Stato**: Memorizzato solo nella RAM del browser (si perde al refresh)
- **Offline**: Funziona completamente offline dopo la prima visita (Service Worker)
#### Build e Deployment
```bash
npm run build
```
1. Vite compila il codice Vue/JavaScript
2. Ottimizza e minifica JavaScript e CSS
3. Genera Service Worker e manifest PWA
4. Output in `/dist` con file statici pronti
**Deploy**: Copia `/dist` su web server statico (nginx, Apache, Vercel, Netlify). Non serve Node.js, PHP o database sul server.
#### Funzionamento Runtime
**Prima visita:**
1. Browser scarica index.html dal server
2. Carica bundle JavaScript e CSS
3. Vue.js inizializza l'applicazione
4. Service Worker cachea tutti gli asset
5. App pronta (nessuna ulteriore chiamata al server)
**Visite successive:**
1. Service Worker serve i file dalla cache locale
2. App si avvia istantaneamente anche senza internet
3. In background verifica se esistono aggiornamenti
4. Aggiornamenti applicati al prossimo refresh
#### Gestione Stato
Tutto lo stato della partita vive nella memoria del browser:
```javascript
data() {
return {
sp: {
punt: { home: 0, guest: 0 },
set: { home: 0, guest: 0 },
servHome: true,
form: { home: ["1","2","3","4","5","6"], guest: ["1","2","3","4","5","6"] }
}
}
}
```
**Limitazioni:**
- Refresh della pagina azzera tutto lo stato
- Nessuna sincronizzazione tra dispositivi
- Nessuno storico partite
**Vantaggi:**
- Privacy totale (nessun dato esce dal dispositivo)
- Velocità massima (nessuna latenza di rete)
- Funzionamento offline completo
**Possibili evoluzioni:**
- Persistenza con localStorage
- Backend con database per multi-dispositivo
- WebSocket per sincronizzazione real-time
### Note Tecniche
**Problemi noti:**
- Audio mobile: sintesi vocale non funziona su alcuni dispositivi Android
- Persistenza: lo stato si perde al refresh della pagina
- Codice legacy: presenza di file HomePage duplicati
**Architettura futura:**
- Stato centralizzato con Pinia/Vuex
- Architettura client-server con WebSocket per display remoto
- Persistenza con localStorage o database
- Testing con Vitest e Cypress
---
## Informazioni sul Progetto
**Nome progetto**: Segnapunti Anto
**Descrizione**: Applicazione web PWA per tracciare i punteggi di partite di pallavolo in tempo reale
**Sviluppato per**: Team Antoniana
**Licenza**: Privata
**Repository**: https://github.com/[username]/segnapunti
---
### Come Leggere Questo Changelog
- **Funzionalità Principali**: nuove caratteristiche aggiunte all'applicazione
- **Tecnologie e Dipendenze**: stack tecnologico e versioni utilizzate
- **Build e Comandi**: istruzioni per compilare ed eseguire il progetto
- **Documentazione**: miglioramenti alla documentazione utente e sviluppatore
- **Miglioramenti Architetturali**: refactoring e ottimizzazioni del codice
- **Note di Sviluppo**: work in progress e problemi noti
---
### Supporto e Contributi
Per segnalare bug o richiedere funzionalità, aprire una issue nel repository del progetto.
Per contribuire al progetto:
1. Fork del repository
2. Creare un branch per la feature (`git checkout -b feature/nome-feature`)
3. Commit delle modifiche (`git commit -m 'Aggiunge nome-feature'`)
4. Push al branch (`git push origin feature/nome-feature`)
5. Aprire una Pull Request
---
**Data primo commit**: 2024
**Data rilascio 1.0.0**: 2026-02-10

83
Dockerfile Normal file
View File

@@ -0,0 +1,83 @@
# Dockerfile per build APK Android con Capacitor
FROM ubuntu:22.04
# Evita prompt interattivi
ENV DEBIAN_FRONTEND=noninteractive
# Installa dipendenze base
RUN apt-get update && apt-get install -y \
curl \
git \
unzip \
wget \
openjdk-21-jdk \
build-essential \
imagemagick \
&& rm -rf /var/lib/apt/lists/*
# Installa Node.js 20.x (LTS)
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs
# Installa Gradle 8.5
ENV GRADLE_VERSION=8.5
RUN wget https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip -P /tmp \
&& unzip -d /opt/gradle /tmp/gradle-${GRADLE_VERSION}-bin.zip \
&& ln -s /opt/gradle/gradle-${GRADLE_VERSION}/bin/gradle /usr/bin/gradle \
&& rm /tmp/gradle-${GRADLE_VERSION}-bin.zip
# Installa Android SDK
ENV ANDROID_SDK_ROOT=/opt/android-sdk
ENV ANDROID_HOME=${ANDROID_SDK_ROOT}
ENV PATH=${PATH}:${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${ANDROID_SDK_ROOT}/platform-tools
RUN mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools \
&& wget https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip -P /tmp \
&& unzip -d ${ANDROID_SDK_ROOT}/cmdline-tools /tmp/commandlinetools-linux-9477386_latest.zip \
&& mv ${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools ${ANDROID_SDK_ROOT}/cmdline-tools/latest \
&& rm /tmp/commandlinetools-linux-9477386_latest.zip
# Accetta licenze Android SDK
RUN yes | sdkmanager --licenses || true
# Installa componenti Android necessari
RUN sdkmanager "platform-tools" \
"platforms;android-34" \
"build-tools;34.0.0" \
"extras;google;google_play_services" \
"extras;android;m2repository" \
"extras;google;m2repository"
# Imposta JAVA_HOME in base all'architettura
RUN ARCH=$(dpkg --print-architecture) && \
echo "export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-${ARCH}" >> /etc/profile && \
ln -sf /usr/lib/jvm/java-21-openjdk-${ARCH} /usr/lib/jvm/default-java
ENV JAVA_HOME=/usr/lib/jvm/default-java
# Directory di lavoro
WORKDIR /app
# Copia package files per cache layer
COPY package*.json ./
# Installa dipendenze npm
RUN npm ci --legacy-peer-deps || npm install --legacy-peer-deps
# Copia il resto del progetto
COPY . .
# Build script
CMD ["bash", "-c", "\
npm install --legacy-peer-deps && \
npm run build && \
npx cap add android && \
npx cap sync && \
sed -i 's/android:configChanges=\"\\([^\"]*\\)\"/android:configChanges=\"\\1\" android:screenOrientation=\"sensorLandscape\"/g' android/app/src/main/AndroidManifest.xml && \
bash setup-android-icons.sh && \
cd android && ./gradlew assembleDebug --no-daemon && \
mkdir -p /app/dist/android && \
cp app/build/outputs/apk/debug/app-debug.apk /app/dist/android/segnapunti-debug.apk && \
echo '' && \
echo 'APK generato: /app/dist/android/segnapunti-debug.apk'\
"]

546
README.md
View File

@@ -1,255 +1,437 @@
# Segnapunti Anto # Segnapunti - Anto
Applicazione web **Progressive Web App (PWA)** per tracciare i punteggi di partite di pallavolo in tempo reale. Un'applicazione web segnapunti per pallavolo con funzionalità di sintesi vocale, modalità formazione e supporto PWA (Progressive Web App).
--- ## Cos'è
## Panoramica Segnapunti è un'applicazione standalone per tenere il punteggio durante le partite di pallavolo. L'applicazione è ottimizzata per dispositivi mobili in modalità landscape (orizzontale) e può essere installata come app nativa grazie al supporto PWA.
**Segnapunti Anto** è un'applicazione digitale per il tracciamento dei punteggi durante partite di pallavolo, ottimizzata per l'uso su tablet e smartphone. Sviluppata per il team Antoniana, l'app fornisce un'interfaccia fullscreen touch-friendly con supporto offline e controlli da tastiera. ### Caratteristiche principali
### Funzionalità Principali - **Segnapunti digitale**: Visualizzazione chiara del punteggio per le due squadre (Home e Guest)
- **Contatore set**: Traccia i set vinti da ciascuna squadra (fino a 3 set)
- **Indicatore battuta**: Mostra quale squadra ha il servizio
- **Modalità formazione**: Visualizza la rotazione dei giocatori in campo (posizioni 1-6)
- **Sintesi vocale**: Annuncia il punteggio in italiano
- **PWA**: Installabile come app standalone con funzionamento offline
- **Controllo da tastiera**: Scorciatoie per gestire rapidamente il punteggio
- **No Sleep**: Impedisce allo schermo di spegnersi durante l'uso
- **Fullscreen automatico**: Su dispositivi mobili si avvia a schermo intero
- **Gestione Completa Partite** ## Come funziona
- Tracciamento punti in tempo reale per entrambe le squadre
- Conteggio automatico dei set (modalità 2/3 o 3/5)
- Indicatore visivo del servizio
- Blocco incremento punti a set concluso
- Cronologia punti con striscia visiva
- **Formazioni Squadra** ### Interfaccia
- Visualizzazione interattiva dei 6 giocatori in campo
- Rotazione automatica regolamentare al cambio palla
- Configurazione manuale dei numeri di maglia
- Dialog cambi con uno o due cambi (IN → OUT) e validazioni
- Supporto logica pallavolo ufficiale (25 punti + 2 di vantaggio, tie-break a 15 nel set decisivo)
- **Controlli Multimodali** L'applicazione divide lo schermo in due metà:
- Scorciatoie da tastiera complete (vedi sezione [Shortcuts](#shortcuts))
- Sintesi vocale per annunci punteggio in italiano (Web Speech API)
- **Personalizzazione** - **Metà sinistra (gialla su sfondo nero)**: Squadra Home - Antoniana
- Configurazione dinamica nomi squadre - **Metà destra (bianca su sfondo blu)**: Squadra Guest
- Selettore modalità partita: al meglio di 3 o al meglio di 5
- Toggle layout orizzontale (inverti home/guest)
- Modalità visualizzazione: punteggio semplice o formazioni complete
- Nascondi/mostra controlli e cronologia
--- #### Visualizzazioni
## Requisiti 1. **Modalità Punteggio** (default):
- Mostra il punteggio corrente in caratteri molto grandi
- Cliccando sul punteggio si incrementa di 1
- Cliccando sul nome della squadra si decrementa di 1
### Requisiti di Sistema 2. **Modalità Formazione**:
- Mostra le posizioni dei giocatori in campo (1-6)
- La formazione ruota automaticamente quando si incrementa il punteggio
- L'ordine visualizzato è: [3, 2, 1, 4, 5, 0] (ordine inverso rispetto alla rotazione standard)
#### Per Sviluppo #### Funzionalità
- **Sistema Operativo**: Linux, macOS, Windows (WSL2 consigliato)
- **Node.js**: v20.2.0 o superiore (LTS consigliato)
- **npm**: v9.0.0 o superiore (incluso con Node.js)
- **RAM**: Minimo 2GB, consigliato 4GB
- **Spazio Disco**: ~500MB per dipendenze e build
#### Per Deployment - **Reset**: Azzera punteggio e formazioni, mantiene i nomi delle squadre
- **Server Web**: Qualsiasi server statico (nginx, Apache, Vercel, Netlify) - **Cambio servizio**: Toggle manuale del servizio tra le squadre
- **HTTPS**: Obbligatorio per Service Worker e PWA (eccetto localhost) - **Configurazione nomi**: Dialog per modificare i nomi delle squadre
- **Connessione Internet**: Solo per primo caricamento (poi funziona offline) - **Sintesi vocale**:
- Pronuncia il punteggio in italiano
- Se 0-0 dice "zero a zero"
- Se pari dice "[punteggio] pari"
- Altrimenti annuncia prima il punteggio della squadra al servizio
- **Incremento set**: Click sul contatore set per aumentarlo (si azzera dopo 2)
### Requisiti Browser (Utente Finale) ### Controlli da tastiera
| Requisito | Dettaglio | Necessità | - **Ctrl + M**: Apri dialog configurazione nomi
|-----------|-----------|-----------| - **Ctrl + B**: Mostra/nascondi barra pulsanti in basso
| **JavaScript ES6+** | Supporto moduli, arrow functions, async/await | Obbligatorio | - **Ctrl + F**: Attiva modalità fullscreen
| **Service Worker API** | Per funzionalità offline PWA | Obbligatorio | - **Ctrl + S**: Pronuncia il punteggio
| **Fullscreen API** | Per modalità schermo intero | Consigliato | - **Ctrl + Z**: Alterna tra modalità punteggio e formazione
| **Web Speech API** | Per sintesi vocale punteggi | Opzionale | - **Ctrl + ↑**: Incrementa punteggio Home
| **Local Storage** | Per persistenza configurazioni | Consigliato | - **Ctrl + ↓**: Decrementa punteggio Home
- **Ctrl + →**: Incrementa set Home
- **Shift + ↑**: Incrementa punteggio Guest
- **Shift + ↓**: Decrementa punteggio Guest
- **Shift + →**: Incrementa set Guest
- **Ctrl + ←**: Cambia servizio
### Browser Testati e Supportati ### Comportamento su mobile
| Browser | Versione Minima | Supporto | Note | Su dispositivi mobili l'applicazione:
|---------|-----------------|----------|------| 1. Attiva automaticamente NoSleep per impedire lo spegnimento dello schermo
| Chrome/Chromium | 90+ | ✅ Completo | Consigliato per tutte le features | 2. Si avvia in modalità fullscreen
| Firefox | 88+ | ✅ Completo | Supporto completo PWA e Speech API | 3. Esegue un test di sintesi vocale al caricamento
4. Mostra il pulsante "Esci" per chiudere l'app
--- ## Stack tecnologico
## Installazione e Setup ### Framework e librerie
- **Vue 3** (^3.2.47): Framework JavaScript progressivo per l'interfaccia utente
- **Vite** (^4.3.9): Build tool moderno e veloce
- **Wave UI** (^3.3.0): Libreria di componenti UI per Vue
- **NoSleep.js** (^0.12.0): Previene lo spegnimento automatico dello schermo
- **vite-plugin-pwa** (^0.16.0): Plugin per generare la PWA
### API Web utilizzate
- **Web Speech API**: Per la sintesi vocale (Text-to-Speech)
- **Fullscreen API**: Per la modalità schermo intero
- **Service Worker**: Per il funzionamento offline (PWA)
## Come svilupparlo
### Prerequisiti ### Prerequisiti
- **Node.js** v20.2.0 (consigliato) - **Node.js** (versione 14 o superiore, raccomandata v20.2.0)
- **npm** o **yarn** - **npm** o **yarn**
### Installazione con NVM (consigliato) ### Installazione
1. Clona il repository:
2. Installa le dipendenze:
```bash ```bash
# Installa la versione corretta di Node.js
nvm install v20.2.0
nvm use v20.2.0
# Clona il repository
git clone <repository-url>
cd segnapunti
# Installa le dipendenze
npm install npm install
``` ```
--- ### Comandi di sviluppo
## Comandi per Sviluppo #### Avviare il server di sviluppo
```bash
npm run dev
```
Questo comando avvia Vite in modalità development. L'applicazione sarà disponibile su `http://localhost:5173` (o altra porta se già occupata).
### Dev Server #### Build per produzione
```bash
npm run build
```
Genera i file ottimizzati per la produzione nella cartella `dist/`. Include:
- Minificazione del codice
- Tree-shaking per rimuovere codice non utilizzato
- Generazione del Service Worker per la PWA
- Generazione del manifest per l'installazione
Avvia il server di sviluppo con hot-reload: #### Anteprima build di produzione
```bash
npm run preview
```
Avvia un server locale per testare la build di produzione.
La configurazione PWA si trova in [vite.config.js](vite.config.js:10-32):
- **Display**: fullscreen
- **Orientamento**: landscape (orizzontale)
- **Colore tema**: bianco
- **Base path**: `/segnap` in produzione, `/` in sviluppo
### Modifiche comuni
#### Cambiare i colori delle squadre
Modifica il file [src/style.css](src/style.css:101-108):
```css
.home {
background-color: black;
color: yellow;
}
.guest {
background-color: blue;
color: white;
}
```
#### Modificare i nomi predefiniti
Nel file [src/components/HomePage.vue](src/components/HomePage.vue:20), modifica:
```javascript
nomi: { home: "Antoniana", guest: "Guest" }
```
#### Cambiare la voce della sintesi vocale
Nel file [src/components/HomePage.vue](src/components/HomePage.vue:104), modifica:
```javascript
msg.voice = voices.find(voice => voice.name === 'Google italiano');
```
#### Modificare l'ordine di visualizzazione delle formazioni
Nel template del componente [src/components/HomePage.vue](src/components/HomePage.vue:181), l'ordine di visualizzazione delle posizioni è controllato dall'array nell'attributo `v-for`. La sequenza `[3, 2, 1, 4, 5, 0]` rappresenta l'ordine in cui vengono mostrate le posizioni dei giocatori sulla griglia 2x3 del campo.
Per modificare l'ordine di visualizzazione, modifica questo array:
```vue
<div class="formdiv" v-for="x in [3, 2, 1, 4, 5, 0]">
{{ sp.form.home[x] }}
</div>
```
L'ordine attuale mostra le posizioni in questo schema:
```
3 2 1
4 5 0
```
Dove 0 corrisponde alla posizione 1 nel pallavolo, 1 alla posizione 2, e così via.
## Testing e Deploy
### Testing su dispositivi mobili
Per testare l'applicazione su dispositivi mobili nella stessa rete locale:
1. Avvia il server di sviluppo:
```bash ```bash
npm run dev npm run dev
``` ```
L'applicazione sarà disponibile su [http://localhost:5173](http://localhost:5173) 2. Vite mostrerà l'indirizzo locale e di rete. Se non viene mostrato, trova l'indirizzo IP del tuo computer:
```bash
# Su Linux/Mac
hostname -I
# oppure
ifconfig | grep "inet "
### Modalità Sviluppo # Su Windows
- Hot Module Replacement (HMR) attivo ipconfig
- Source maps per debugging ```
- Vue DevTools supportato
- Errori e warnings in console
--- 3. Dal dispositivo mobile, connesso alla stessa rete Wi-Fi, naviga su:
```
http://[tuo-ip]:5173
```
## Comandi per Build 4. Accetta eventuali permessi richiesti dal browser per:
- Modalità fullscreen
- Sintesi vocale
- Wake Lock (prevenzione spegnimento schermo)
### Build Produzione ### Build e Deploy
Genera i file ottimizzati per il deployment: #### Build per produzione
Genera i file ottimizzati per la produzione:
```bash ```bash
npm run build npm run build
``` ```
**Output:** Questo comando crea una cartella `dist/` contenente:
- Cartella `/dist` con file statici ottimizzati - HTML, CSS e JavaScript minificati
- Service Worker generato automaticamente - Service Worker per il funzionamento offline
- PWA manifest configurato - Manifest PWA per l'installazione come app
- Assets minificati e con hash per cache busting - Asset ottimizzati
- Base path: `/segnap` (modificabile in `vite.config.js`)
### Preview Build #### Anteprima della build
Anteprima locale della build di produzione:
Prima del deploy, puoi testare la build in locale:
```bash ```bash
npm run preview npm run preview
``` ```
Serve i file dalla cartella `/dist` per testare la build prima del deploy. #### Deploy su GitHub Pages
--- 1. Nel file [vite.config.js](vite.config.js:7), verifica che il `base` path corrisponda al nome del repository:
```javascript
base: process.env.NODE_ENV === 'production' ? '/nome-repository' : '/',
```
## Shortcuts 2. Esegui la build:
```bash
npm run build
```
### Controlli Tastiera Squadra Home 3. Publica il contenuto della cartella `dist/` su GitHub Pages usando uno di questi metodi:
- Manualmente tramite l'interfaccia GitHub
- Usando `gh-pages`:
```bash
npm install -D gh-pages
npx gh-pages -d dist
```
- Tramite GitHub Actions (configurando un workflow)
| Scorciatoia | Azione | #### Deploy su server web
|-------------|--------|
| `Ctrl + ↑` | Incrementa punti |
| `Ctrl + ↓` | Decrementa punti |
| `Ctrl + →` | Incrementa set |
| `Ctrl + C` | Apri dialog cambi |
### Controlli Tastiera Squadra Guest Il contenuto della cartella `dist/` può essere servito da qualsiasi server web statico:
| Scorciatoia | Azione | **Nginx:**
|-------------|--------| ```nginx
| `Shift + ↑` | Incrementa punti | server {
| `Shift + ↓` | Decrementa punti | listen 80;
| `Shift + →` | Incrementa set | server_name tuo-dominio.com;
| `Shift + C` | Apri dialog cambi | root /percorso/a/dist;
index index.html;
### Comandi Globali location / {
try_files $uri $uri/ /index.html;
}
}
```
| Scorciatoia | Azione | **Apache (.htaccess):**
|-------------|--------| ```apache
| `Ctrl + ←` | Cambio palla (servizio) - **solo a 0-0** | <IfModule mod_rewrite.c>
| `Ctrl + M` | Apri configurazione nomi squadre e formazioni | RewriteEngine On
| `Ctrl + B` | Toggle visibilità barra pulsanti | RewriteBase /
| `Ctrl + F` | Attiva/disattiva fullscreen | RewriteRule ^index\.html$ - [L]
| `Ctrl + S` | Annuncio vocale punteggio corrente | RewriteCond %{REQUEST_FILENAME} !-f
| `Ctrl + Z` | Switch tra visualizzazione formazioni e punteggio | RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule>
```
--- #### Installazione come PWA
## Configurazione PWA Dopo il deploy, gli utenti possono installare l'app:
L'applicazione è configurata come **Progressive Web App** nel file [vite.config.js](vite.config.js): **Su Android/Chrome:**
1. Visita il sito
2. Tocca il menu (⋮) > "Aggiungi a schermata Home"
3. L'app si avvierà in modalità fullscreen standalone
**Su iOS/Safari:**
1. Visita il sito
2. Tocca il pulsante Condividi
3. Seleziona "Aggiungi a Home"
## Dettagli tecnici
### Architettura del componente
L'applicazione è strutturata come Single Page Application (SPA) con un unico componente principale `HomePage`. Questo design semplice è ideale per un'app focalizzata su un'unica funzionalità.
### Gestione dello stato
Lo stato dell'applicazione è gestito localmente nel componente tramite `data()`:
- `punt`: punteggio corrente delle due squadre
- `set`: numero di set vinti
- `servHome`: boolean che indica quale squadra ha il servizio
- `form`: array con le posizioni dei giocatori (1-6)
- `nomi`: nomi personalizzabili delle squadre
- `visuForm`: toggle tra visualizzazione punteggio/formazione
- `visuButt`: visibilità della barra dei pulsanti
### Gestione della rotazione
La rotazione dei giocatori segue le regole del pallavolo:
**Incremento punteggio** ([HomePage.vue:74-78](src/components/HomePage.vue:74-78)):
```javascript
incPunt(team) {
this.sp.punt[team]++;
this.sp.servHome = (team == "home");
this.sp.form[team].push(this.sp.form[team].shift());
}
```
- Il servizio passa alla squadra che ha segnato
- La rotazione avviene con `push(shift())`: il primo elemento va in fondo
- Simula la rotazione oraria dei giocatori
**Decremento punteggio** ([HomePage.vue:79-85](src/components/HomePage.vue:79-85)):
```javascript
decPunt(team) {
if (this.sp.punt[team] > 0) {
this.sp.punt[team]--;
this.sp.form[team].unshift(this.sp.form[team].pop());
}
}
```
- La rotazione inversa avviene con `unshift(pop())`: l'ultimo elemento va all'inizio
- Permette di annullare errori di punteggio
### Sintesi vocale
La funzione `speak()` ([HomePage.vue:86-112](src/components/HomePage.vue:86-112)) utilizza la Web Speech API:
```javascript ```javascript
VitePWA({ const msg = new SpeechSynthesisUtterance();
registerType: 'autoUpdate', msg.lang = 'it_IT';
manifest: { msg.voice = voices.find(voice => voice.name === 'Google italiano');
name: "app_segnap",
short_name: "segnap",
description: "Segnapunti standalone.",
background_color: "#eee",
theme_color: '#ffffff',
display: "fullscreen",
orientation: "landscape",
icons: [
{ src: 'segnap-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: 'segnap-512x512.png', sizes: '512x512', type: 'image/png' }
]
}
})
``` ```
### Caratteristiche PWA Logica di annuncio:
- "zero a zero" se il punteggio è 0-0
- "[numero] pari" se il punteggio è uguale
- "[servizio] a [ricezione]" annunciando prima chi ha il servizio
- **Display**: Fullscreen per massimizzare lo spazio visivo ### Prevenzione spegnimento schermo
- **Orientamento**: Landscape (orizzontale) ottimizzato per tablet
- **Auto-update**: Service Worker con aggiornamento automatico
- **Offline**: Funzionamento completo senza connessione internet
- **Installabile**: Aggiungibile alla home screen come app nativa
### Installazione PWA NoSleep.js ([HomePage.vue:32-33](src/components/HomePage.vue:32-33)) viene attivato al mount su dispositivi mobili:
```javascript
**Android/Desktop (Chrome):** var noSleep = new NoSleep();
- Menu → "Installa app" o icona (⊕) nella barra degli indirizzi noSleep.enable();
**iOS (Safari):**
- Share (□↑) → "Aggiungi a Home"
---
## Logica Regolamentare Pallavolo
### Vittoria Set
- **Set regolari (1-4)**: Primo a 25 punti con almeno 2 di vantaggio
- **Set decisivo**:
- Modalità 2/3: 3° set a 15 punti con almeno 2 di vantaggio
- Modalità 3/5: 5° set a 15 punti con almeno 2 di vantaggio
- **Blocco automatico**: Non consente assegnare punti oltre la vittoria
### Rotazione Formazione
La rotazione avviene **automaticamente** quando:
1. La squadra **conquista il servizio** (cambio palla)
2. Il punteggio è diverso da 0-0
**Limitazione cambio palla manuale:**
- Il cambio manuale del servizio (`Ctrl + ←`) è consentito **solo a 0-0**
- Questa limitazione previene errori nella rotazione delle formazioni
### Formazione in Campo
Visualizzazione a 6 posizioni standard:
```
Rete
┌─────┬─────┬─────┐
│ 4 │ 3 │ 2 │ ← Fila anteriore
├─────┼─────┼─────┤
│ 5 │ 6 │ 1 │ ← Fila posteriore
└─────┴─────┴─────┘
``` ```
La rotazione avviene in senso orario: 1→6→5→4→3→2→1 Questo impedisce allo schermo di spegnersi durante le partite, funzionalità essenziale per un segnapunti.
### Controlli da tastiera
Gli eventi tastiera sono gestiti tramite listener globale ([HomePage.vue:121-150](src/components/HomePage.vue:121-150)):
```javascript
window.addEventListener("keydown", this.funzioneTastiSpeciali);
```
I listener vengono temporaneamente disabilitati quando si aprono dialog per permettere l'inserimento di testo.
### Progressive Web App
La configurazione PWA in [vite.config.js](vite.config.js:10-33) definisce:
- **Manifest**: metadati dell'app (nome, icone, orientamento)
- **Service Worker**: strategia di cache per funzionamento offline
- **Display mode**: fullscreen per esperienza app-like
- **Orientamento forzato**: landscape per massimizzare spazio visibile
## Setup IDE raccomandato
- [VS Code](https://code.visualstudio.com/) - Editor consigliato
- [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) - Supporto Vue 3 (disabilita Vetur se installato)
- [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) - IntelliSense migliorato
### Estensioni VS Code utili
File [.vscode/extensions.json](.vscode/extensions.json) suggerisce:
- Vue Language Features (Volar)
- TypeScript Vue Plugin
## Risoluzione problemi comuni
### La sintesi vocale non funziona
- Verifica che il browser supporti la Web Speech API (Chrome/Edge consigliati)
- Su alcuni browser è necessaria l'interazione utente prima di utilizzare la sintesi vocale
- Controlla che la voce "Google italiano" sia disponibile nel sistema
### L'app non si installa come PWA
- Verifica che il sito sia servito tramite HTTPS (richiesto per Service Workers)
- Controlla che le icone in `public/` siano presenti e accessibili
- Ispeziona la console del browser per errori del Service Worker
### Lo schermo si spegne comunque
- NoSleep.js richiede un'interazione utente per attivarsi
- Su iOS, la prevenzione dello spegnimento ha limitazioni del sistema operativo
- Verifica che non ci siano impostazioni di risparmio energetico aggressive
### La rotazione non funziona correttamente
- Le formazioni si azzerano con il RESET
- Verifica che gli array `form` abbiano sempre 6 elementi
- La visualizzazione può essere invertita ma la logica di rotazione rimane corretta
## Licenza
Progetto privato. Tutti i diritti riservati.

41
build-apk.sh Executable file
View File

@@ -0,0 +1,41 @@
#!/bin/bash
set -e
echo "========================================="
echo " Building APK for Segnapunti Android "
echo "========================================="
echo ""
# Rileva architettura
ARCH=$(uname -m)
echo "Architettura rilevata: $ARCH"
# Crea directory dist/android se non esiste
mkdir -p dist/android
echo "Building Docker image..."
docker build --platform linux/$(uname -m) -t segnapunti-android-builder .
echo ""
echo "Building APK inside Docker container..."
docker run --rm \
-v "$(pwd)/dist:/app/dist" \
segnapunti-android-builder
echo ""
echo "Fixing permissions on dist folder..."
sudo chown -R $USER:$USER dist
echo ""
echo "========================================="
echo "Build completed successfully!"
echo "========================================="
echo ""
echo "APK location: $(pwd)/dist/android/segnapunti-debug.apk"
echo ""
echo "Per installare su dispositivo Android:"
echo " adb install dist/android/segnapunti-debug.apk"
echo ""
echo "Oppure trasferisci il file sul dispositivo"
echo "e installalo manualmente."
echo ""

21
capacitor.config.json Normal file
View File

@@ -0,0 +1,21 @@
{
"appId": "com.antoniana.segnapunti",
"appName": "Segnapunti",
"webDir": "dist",
"bundledWebRuntime": false,
"server": {
"androidScheme": "https"
},
"android": {
"allowMixedContent": true,
"webContentsDebuggingEnabled": true
},
"plugins": {
"SplashScreen": {
"launchShowDuration": 0
}
},
"preferences": {
"orientation": "landscape"
}
}

42
generate-icons.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/bin/bash
# Script per generare automaticamente le icone Android da segnap-512x512.png
SOURCE="./public/segnap-512x512.png"
BASE_DIR="./android/app/src/main/res"
# Verifica che ImageMagick sia installato
if ! command -v convert &> /dev/null; then
echo "Errore: ImageMagick non è installato."
echo "Installa con: sudo apt-get install imagemagick"
exit 1
fi
# Verifica che il file sorgente esista
if [ ! -f "$SOURCE" ]; then
echo "Errore: File sorgente $SOURCE non trovato"
exit 1
fi
# Verifica che la directory android esista
if [ ! -d "./android" ]; then
echo "Errore: Directory android/ non trovata."
echo "Esegui prima: npx cap add android"
exit 1
fi
echo "Generazione icone Android da $SOURCE..."
# Crea le directory se non esistono e genera le icone
for size in "mdpi:48" "hdpi:72" "xhdpi:96" "xxhdpi:144" "xxxhdpi:192"; do
density="${size%:*}"
pixels="${size#*:}"
dir="$BASE_DIR/mipmap-$density"
mkdir -p "$dir"
convert "$SOURCE" -resize ${pixels}x${pixels} "$dir/ic_launcher.png"
echo " ✓ Creata icona $density (${pixels}x${pixels})"
done
echo "Icone generate con successo!"
echo ""
echo "Prossimo passo: npx cap sync"

View File

@@ -3,7 +3,10 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>Segnapunti - Anto</title> <title>Segnapunti - Anto</title>
</head> </head>
<body> <body>

6205
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,16 +6,25 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"cap:sync": "npm run build && cap sync",
"cap:open": "cap open android",
"android:build:debug": "cd android && ./gradlew assembleDebug",
"docker:build": "./build-apk.sh"
}, },
"dependencies": { "dependencies": {
"@capacitor-community/text-to-speech": "^6.0.0",
"@capacitor/android": "^6.2.0",
"@capacitor/app": "^6.0.1",
"@capacitor/core": "^6.2.0",
"nosleep.js": "^0.12.0", "nosleep.js": "^0.12.0",
"vue": "^3.2.47", "vue": "^3.4.38",
"wave-ui": "^3.3.0" "wave-ui": "^3.17.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^4.1.0", "@capacitor/cli": "^6.2.0",
"vite": "^4.3.9", "@vitejs/plugin-vue": "^5.1.4",
"vite-plugin-pwa": "^0.16.0" "vite": "^5.4.10",
"vite-plugin-pwa": "^0.20.5"
} }
} }

40
setup-android-icons.sh Normal file
View File

@@ -0,0 +1,40 @@
#!/bin/bash
set -e
echo "Configurazione icone Android..."
# Rimuove icone default di Capacitor
rm -rf android/app/src/main/res/mipmap-*
# Crea directory per tutte le densità
for density in mdpi hdpi xhdpi xxhdpi xxxhdpi; do
mkdir -p android/app/src/main/res/mipmap-$density
done
# Mappa densità -> dimensioni
declare -A sizes=(
["mdpi"]="48"
["hdpi"]="72"
["xhdpi"]="96"
["xxhdpi"]="144"
["xxxhdpi"]="192"
)
# Genera icone per ogni densità
for density in "${!sizes[@]}"; do
size="${sizes[$density]}"
echo " Generando icone ${size}x${size} per $density..."
convert public/segnap-512x512.png -resize ${size}x${size} \
android/app/src/main/res/mipmap-$density/ic_launcher.png
convert public/segnap-512x512.png -resize ${size}x${size} \
android/app/src/main/res/mipmap-$density/ic_launcher_round.png
done
# Icona per il file APK (visibile da PC)
mkdir -p android/app/src/main/res/drawable
convert public/segnap-512x512.png -resize 512x512 \
android/app/src/main/res/drawable/icon.png
echo "Icone configurate con successo!"

View File

@@ -1,5 +1,9 @@
<script> <script>
import NoSleep from "nosleep.js"; import NoSleep from "nosleep.js";
import { Capacitor } from '@capacitor/core';
import { App } from '@capacitor/app';
import { TextToSpeech } from '@capacitor-community/text-to-speech';
export default { export default {
name: "HomePage", name: "HomePage",
components: {}, components: {},
@@ -26,9 +30,17 @@ export default {
} }
}, },
mounted() { mounted() {
// Carica le voci (necessario su alcuni browser)
this.voices = window.speechSynthesis.getVoices(); this.voices = window.speechSynthesis.getVoices();
// Ascolta l'evento voiceschanged per Android/Chrome
if (speechSynthesis.onvoiceschanged !== undefined) {
speechSynthesis.onvoiceschanged = () => {
this.voices = window.speechSynthesis.getVoices();
};
}
if (this.isMobile()) { if (this.isMobile()) {
this.speak();
var noSleep = new NoSleep(); var noSleep = new NoSleep();
noSleep.enable(); noSleep.enable();
document.documentElement.requestFullscreen(); document.documentElement.requestFullscreen();
@@ -83,32 +95,64 @@ export default {
this.sp.form[team].unshift(this.sp.form[team].pop()); this.sp.form[team].unshift(this.sp.form[team].pop());
} }
}, },
speak() { async speak() {
const msg = new SpeechSynthesisUtterance(); // Prepara il testo da pronunciare
let text = "";
if (this.sp.punt.home + this.sp.punt.guest == 0) { if (this.sp.punt.home + this.sp.punt.guest == 0) {
msg.text = "zero a zero"; text = "zero a zero";
} else if (this.sp.punt.home == this.sp.punt.guest) { } else if (this.sp.punt.home == this.sp.punt.guest) {
msg.text = this.sp.punt.home + " pari"; text = this.sp.punt.home + " pari";
} else { } else {
if (this.sp.servHome) { if (this.sp.servHome) {
msg.text = this.sp.punt.home + " a " + this.sp.punt.guest; text = this.sp.punt.home + " a " + this.sp.punt.guest;
} else { } else {
msg.text = this.sp.punt.guest + " a " + this.sp.punt.home; text = this.sp.punt.guest + " a " + this.sp.punt.home;
} }
} }
// msg.volume = 1.0; // speech volume (default: 1.0)
// msg.pitch = 1.0; // speech pitch (default: 1.0) console.log('Speak called, text:', text);
// msg.rate = 1.0; // speech rate (default: 1.0) console.log('Is native platform:', Capacitor.isNativePlatform());
msg.lang = 'it_IT'; // speech language (default: 'en-US')
const voices = window.speechSynthesis.getVoices(); // Usa plugin nativo su mobile, Web API su desktop
msg.voice = voices.find(voice => voice.name === 'Google italiano'); // voice URI (default: platform-dependent) if (Capacitor.isNativePlatform()) {
// msg.onboundary = function (event) { try {
// console.log('Speech reached a boundary:', event.name); console.log('Using native TTS');
// }; await TextToSpeech.speak({
// msg.onpause = function (event) { text: text,
// console.log('Speech paused:', event.utterance.text.substring(event.charIndex)); lang: 'it-IT',
// }; rate: 0.9,
window.speechSynthesis.speak(msg); pitch: 1.0,
volume: 1.0,
category: 'ambient'
});
console.log('TTS completed successfully');
} catch (error) {
console.error('TTS error:', error);
this.$waveui.notify('Errore TTS: ' + error.message, 'error', 3000);
}
} else {
// Fallback Web API per desktop/browser
console.log('Using web API speechSynthesis');
window.speechSynthesis.cancel();
const msg = new SpeechSynthesisUtterance();
msg.text = text;
msg.volume = 1.0;
msg.rate = 0.9;
msg.pitch = 1.0;
msg.lang = 'it-IT';
const voices = window.speechSynthesis.getVoices();
const italianVoice = voices.find(voice =>
voice.lang.startsWith('it') || voice.name.includes('Italian') || voice.name.includes('italiano')
);
if (italianVoice) {
msg.voice = italianVoice;
}
window.speechSynthesis.speak(msg);
}
}, },
apriDialogConfig() { apriDialogConfig() {
this.disabilitaTastiSpeciali(); this.disabilitaTastiSpeciali();

View File

@@ -1,126 +1,28 @@
<section class="homepage"> <section class="homepage">
<w-dialog v-model="diaNomi.show" :width="600" @close="chiudiDialogConfig()"> <w-dialog v-model="diaNomi.show" :width="500" @close="abilitaTastiSpeciali()">
<w-input v-model="sp.nomi.home" type="text" class="pa3" tabindex="1">Nome Home</w-input> <w-input v-model="sp.nomi.home" type="text" class="pa3">Home</w-input>
<w-input v-model="sp.nomi.guest" type="text" class="pa3" tabindex="2">Nome Guest</w-input> <w-input v-model="sp.nomi.guest" type="text" class="pa3">Guest</w-input>
<w-button @click="order = !order">Inverti ordine</w-button>
<w-flex justify-center align-center class="pa3"> <w-button bg-color="success" @click="diaNomi.show = false">
<span class="mr3">Modalità partita:</span>
<w-button
@click="modalitaPartita = '2/3'"
:bg-color="modalitaPartita === '2/3' ? 'success' : 'grey-light4'"
:dark="modalitaPartita === '2/3'"
class="ma1"
tabindex="-1">
2/3
</w-button>
<w-button
@click="modalitaPartita = '3/5'"
:bg-color="modalitaPartita === '3/5' ? 'success' : 'grey-light4'"
:dark="modalitaPartita === '3/5'"
class="ma1"
tabindex="-1">
3/5
</w-button>
</w-flex>
<w-flex justify-space-around class="pa3">
<div class="campo-config">
<div class="text-bold mb3 text-center">Formazione Home</div>
<div class="campo-pallavolo">
<!-- Fila anteriore - index [3, 2, 1] - VICINO ALLA RETE (prima fila visualizzata) -->
<w-flex justify-center class="fila-anteriore">
<w-input v-model="sp.form.home[3]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="6"></w-input>
<w-input v-model="sp.form.home[2]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="5"></w-input>
<w-input v-model="sp.form.home[1]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="4"></w-input>
</w-flex>
<!-- Linea dei 3 metri -->
<div class="linea-tre-metri"></div>
<!-- Fila posteriore - index [4, 5, 0] - ZONA DIFESA (seconda fila visualizzata) -->
<w-flex justify-center class="fila-posteriore">
<w-input v-model="sp.form.home[4]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="7"></w-input>
<w-input v-model="sp.form.home[5]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="8"></w-input>
<w-input v-model="sp.form.home[0]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="3"></w-input>
</w-flex>
</div>
</div>
<div class="campo-config">
<div class="text-bold mb3 text-center">Formazione Guest</div>
<div class="campo-pallavolo">
<!-- Fila anteriore - index [3, 2, 1] - VICINO ALLA RETE (prima fila visualizzata) -->
<w-flex justify-center class="fila-anteriore">
<w-input v-model="sp.form.guest[3]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="12"></w-input>
<w-input v-model="sp.form.guest[2]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="11"></w-input>
<w-input v-model="sp.form.guest[1]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="10"></w-input>
</w-flex>
<!-- Linea dei 3 metri -->
<div class="linea-tre-metri"></div>
<!-- Fila posteriore - index [4, 5, 0] - ZONA DIFESA (seconda fila visualizzata) -->
<w-flex justify-center class="fila-posteriore">
<w-input v-model="sp.form.guest[4]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="13"></w-input>
<w-input v-model="sp.form.guest[5]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="14"></w-input>
<w-input v-model="sp.form.guest[0]" type="text" style="width: 50px; text-align: center;" class="ma1" tabindex="9"></w-input>
</w-flex>
</div>
</div>
</w-flex>
<w-button @click="order = !order" class="ma2" tabindex="-1">Inverti ordine</w-button>
<w-button bg-color="success" @click="diaNomi.show = false" class="ma2" tabindex="-1">
Ok Ok
</w-button> </w-button>
</w-dialog> </w-dialog>
<w-dialog v-model="diaCambiTeam.show" :width="420" @close="abilitaTastiSpeciali()">
<div class="text-bold text-center mb2">Scegli squadra</div>
<w-flex justify-center class="pa3">
<w-button class="ma2" @click="selezionaTeamCambi('home')">{{ sp.nomi.home }}</w-button>
<w-button class="ma2" @click="selezionaTeamCambi('guest')">{{ sp.nomi.guest }}</w-button>
</w-flex>
</w-dialog>
<w-dialog v-model="diaCambi.show" :width="360" @close="chiudiDialogCambi">
<div class="cambi-dialog">
<div class="cambi-title">{{ sp.nomi[diaCambi.team] }}: CAMBIO</div>
<div class="cambi-rows">
<div class="cambi-row">
<w-input v-model="diaCambi[diaCambi.team].cambi[0].in" type="text" class="cambi-input cambi-in"></w-input>
<span class="cambi-arrow"></span>
<w-input v-model="diaCambi[diaCambi.team].cambi[0].out" type="text" class="cambi-input cambi-out"></w-input>
</div>
<div class="cambi-row">
<w-input v-model="diaCambi[diaCambi.team].cambi[1].in" type="text" class="cambi-input cambi-in"></w-input>
<span class="cambi-arrow"></span>
<w-input v-model="diaCambi[diaCambi.team].cambi[1].out" type="text" class="cambi-input cambi-out"></w-input>
</div>
</div>
</div>
<w-flex justify-end class="pa3">
<w-button bg-color="success" :disabled="!cambiConfermabili" @click="confermaCambi">
CONFERMA
</w-button>
</w-flex>
</w-dialog>
<div class="campo"> <div class="campo">
<span v-if="order"> <span v-if="order">
<!-- home guest --> <!-- home guest -->
<div class="hea home"> <div class="hea home">
<span @click="decPunt('home')" :style="{ 'float': 'left' }"> <span @click="decPunt('home')" :style="{ 'float': 'left' }">
{{ sp.nomi.home }} {{ sp.nomi.home }} <img v-if="sp.servHome" src="/serv.png" width="25" />
<span class="serv-slot"> <span v-if="visuForm">{{ sp.punt.home }}</span>
<img v-show="sp.servHome" src="/serv.png" width="25" />
</span>
<span v-if="visuForm" class="score-inline">{{ sp.punt.home }}</span>
</span> </span>
<span @click="incSet('home')" class="mr3" :style="{ 'float': 'right' }">set {{ sp.set.home }}</span> <span @click="incSet('home')" class="mr3" :style="{ 'float': 'right' }">set {{ sp.set.home }}</span>
</div> </div>
<div class="hea guest"> <div class="hea guest">
<span @click="decPunt('guest')" :style="{ 'float': 'right' }"> <span @click="decPunt('guest')" :style="{ 'float': 'right' }">
<span v-if="visuForm" class="score-inline">{{ sp.punt.guest }}</span> <img v-if="!sp.servHome" src="/serv.png" width="25" /> {{ sp.nomi.guest }}
<span class="serv-slot"> <span v-if="visuForm">{{ sp.punt.guest }}</span>
<img v-show="!sp.servHome" src="/serv.png" width="25" />
</span>
{{ sp.nomi.guest }}
</span> </span>
<span @click="incSet('guest')" class="ml3" :style="{ 'float': 'left' }">set {{ sp.set.guest }}</span> <span @click="incSet('guest')" class="ml3" :style="{ 'float': 'left' }">set {{ sp.set.guest }}</span>
</div> </div>
@@ -138,10 +40,8 @@
</div> </div>
</span> </span>
<span v-else> <span v-else>
<w-flex class="punteggio-container"> <div class="col punt home" @click="incPunt('home')">{{ sp.punt.home }}</div>
<w-flex justify-center align-center class="col punt home" @click="incPunt('home')">{{ sp.punt.home }}</w-flex> <div class="col punt guest" @click="incPunt('guest')">{{ sp.punt.guest }}</div>
<w-flex justify-center align-center class="col punt guest" @click="incPunt('guest')">{{ sp.punt.guest }}</w-flex>
</w-flex>
</span> </span>
</span> </span>
@@ -150,22 +50,16 @@
<div class="hea guest"> <div class="hea guest">
<span @click="decPunt('guest')" :style="{ 'float': 'left' }"> <span @click="decPunt('guest')" :style="{ 'float': 'left' }">
{{ sp.nomi.guest }} {{ sp.nomi.guest }} <img v-if="!sp.servHome" src="/serv.png" width="25" />
<span class="serv-slot"> <span v-if="visuForm">{{ sp.punt.guest }}</span>
<img v-show="!sp.servHome" src="/serv.png" width="25" />
</span>
<span v-if="visuForm" class="score-inline">{{ sp.punt.guest }}</span>
</span> </span>
<span @click="incSet('guest')" class="mr3" :style="{ 'float': 'right' }">set {{ sp.set.guest }}</span> <span @click="incSet('guest')" class="mr3" :style="{ 'float': 'right' }">set {{ sp.set.guest }}</span>
</div> </div>
<div class="hea home"> <div class="hea home">
<span @click="decPunt('home')" :style="{ 'float': 'right' }"> <span @click="decPunt('home')" :style="{ 'float': 'right' }">
<span v-if="visuForm" class="score-inline">{{ sp.punt.home }}</span> <img v-if="sp.servHome" src="/serv.png" width="25" /> {{ sp.nomi.home }}
<span class="serv-slot"> <span v-if="visuForm">{{ sp.punt.home }}</span>
<img v-show="sp.servHome" src="/serv.png" width="25" />
</span>
{{ sp.nomi.home }}
</span> </span>
<span @click="incSet('home')" class="ml3" :style="{ 'float': 'left' }">set {{ sp.set.home }}</span> <span @click="incSet('home')" class="ml3" :style="{ 'float': 'left' }">set {{ sp.set.home }}</span>
</div> </div>
@@ -183,10 +77,8 @@
</div> </div>
</span> </span>
<span v-else> <span v-else>
<w-flex class="punteggio-container"> <div class="col punt guest" @click="incPunt('guest')">{{ sp.punt.guest }}</div>
<w-flex justify-center align-center class="col punt guest" @click="incPunt('guest')">{{ sp.punt.guest }}</w-flex> <div class="col punt home" @click="incPunt('home')">{{ sp.punt.home }}</div>
<w-flex justify-center align-center class="col punt home" @click="incPunt('home')">{{ sp.punt.home }}</w-flex>
</w-flex>
</span> </span>
</span> </span>
@@ -214,7 +106,7 @@
<w-button @click="apriDialogConfig()"> <w-button @click="apriDialogConfig()">
<img src="/gear.png" width="25" /> <img src="/gear.png" width="25" />
</w-button> </w-button>
<w-button @click="cambiaPalla" :disabled="!isPunteggioZeroZero"> <w-button @click="sp.servHome = !sp.servHome">
<img src="/serv.png" width="25" /> <img src="/serv.png" width="25" />
</w-button> </w-button>
<w-confirm top left question="Azzero punteggio ?" cancel="NO" confirm="SI" @confirm="resetta"> <w-confirm top left question="Azzero punteggio ?" cancel="NO" confirm="SI" @confirm="resetta">
@@ -224,9 +116,6 @@
<span v-if="visuForm">PUNTEGGIO</span> <span v-if="visuForm">PUNTEGGIO</span>
<span v-if="!visuForm">FORMAZIONI</span> <span v-if="!visuForm">FORMAZIONI</span>
</w-button> </w-button>
<w-button @click="apriDialogCambi">
CAMBI
</w-button>
<w-button @click="visuStriscia = !visuStriscia"> <w-button @click="visuStriscia = !visuStriscia">
STRISCIA STRISCIA
</w-button> </w-button>
@@ -237,4 +126,4 @@
</div> </div>
</div> </div>
</section> </section>

View File

@@ -11,19 +11,9 @@ export default {
home: "", home: "",
guest: "", guest: "",
}, },
diaCambi: {
show: false,
team: "home",
guest: { cambi: [{ in: "", out: "" }, { in: "", out: "" }] },
home: { cambi: [{ in: "", out: "" }, { in: "", out: "" }] },
},
diaCambiTeam: {
show: false,
},
visuForm: false, visuForm: false,
visuButt: true, visuButt: true,
visuStriscia: true, visuStriscia: true,
modalitaPartita: "3/5", // "2/3" o "3/5"
sp: { sp: {
striscia: { home: [0], guest: [0] }, striscia: { home: [0], guest: [0] },
servHome: true, servHome: true,
@@ -34,7 +24,6 @@ export default {
home: ["1", "2", "3", "4", "5", "6"], home: ["1", "2", "3", "4", "5", "6"],
guest: ["1", "2", "3", "4", "5", "6"], guest: ["1", "2", "3", "4", "5", "6"],
}, },
storicoServizio: [], // Stack per tracciare lo stato del servizio prima di ogni punto
}, },
} }
}, },
@@ -48,33 +37,6 @@ export default {
} }
this.abilitaTastiSpeciali(); this.abilitaTastiSpeciali();
}, },
computed: {
isPunteggioZeroZero() {
return this.sp.punt.home === 0 && this.sp.punt.guest === 0;
},
cambiConfermabili() {
const team = this.diaCambi.team;
const cambi = this.diaCambi[team].cambi || [];
let hasComplete = false;
let allValid = true;
cambi.forEach((cambio) => {
const teamIn = (cambio.in || "").trim();
const teamOut = (cambio.out || "").trim();
if (!teamIn && !teamOut) {
return;
}
if (!teamIn || !teamOut) {
allValid = false;
return;
}
hasComplete = true;
});
return allValid && hasComplete;
}
},
methods: { methods: {
closeApp() { closeApp() {
var win = window.open("", "_self"); var win = window.open("", "_self");
@@ -104,14 +66,6 @@ export default {
guest: ["1", "2", "3", "4", "5", "6"], guest: ["1", "2", "3", "4", "5", "6"],
} }
this.sp.striscia = { home: [0], guest: [0] } this.sp.striscia = { home: [0], guest: [0] }
this.sp.storicoServizio = []
},
cambiaPalla() {
if (!this.isPunteggioZeroZero) {
this.$waveui.notify("Cambio palla consentito solo a inizio set (0-0)", "warning");
return;
}
this.sp.servHome = !this.sp.servHome;
}, },
incSet(team) { incSet(team) {
if (this.sp.set[team] == 2) { if (this.sp.set[team] == 2) {
@@ -121,17 +75,6 @@ export default {
} }
}, },
incPunt(team) { incPunt(team) {
// Se il set è già terminato, evita ulteriori incrementi
if (this.checkVittoria()) {
return;
}
// Salva lo stato del servizio PRIMA di modificarlo
this.sp.storicoServizio.push({
servHome: this.sp.servHome,
cambioPalla: (team == "home" && !this.sp.servHome) || (team == "guest" && this.sp.servHome)
});
this.sp.punt[team]++; this.sp.punt[team]++;
if (team == 'home') { if (team == 'home') {
this.sp.striscia.home.push(this.sp.punt.home) this.sp.striscia.home.push(this.sp.punt.home)
@@ -139,69 +82,21 @@ export default {
} else { } else {
this.sp.striscia.guest.push(this.sp.punt.guest) this.sp.striscia.guest.push(this.sp.punt.guest)
this.sp.striscia.home.push(' ') this.sp.striscia.home.push(' ')
} }
// Ruota la formazione solo se c'è cambio palla (conquista del servizio)
const cambioPalla = (team == "home" && !this.sp.servHome) || (team == "guest" && this.sp.servHome);
if (cambioPalla) {
this.sp.form[team].push(this.sp.form[team].shift());
}
this.sp.servHome = (team == "home"); this.sp.servHome = (team == "home");
}, this.sp.form[team].push(this.sp.form[team].shift());
checkVittoria() {
const puntHome = this.sp.punt.home;
const puntGuest = this.sp.punt.guest;
const setHome = this.sp.set.home;
const setGuest = this.sp.set.guest;
const totSet = setHome + setGuest;
// Determina se siamo nel set decisivo in base alla modalità partita
let isSetDecisivo = false;
if (this.modalitaPartita === "2/3") {
// Tie-break al 3° set (quando totSet >= 2)
isSetDecisivo = totSet >= 2;
} else {
// Tie-break al 5° set (quando totSet >= 4)
isSetDecisivo = totSet >= 4;
}
const punteggioVittoria = isSetDecisivo ? 15 : 25;
// Vittoria con punteggio >= 25 (o 15 per set decisivo) e almeno 2 punti di vantaggio
if (puntHome >= punteggioVittoria && puntHome - puntGuest >= 2) {
return true; // Home ha vinto
}
if (puntGuest >= punteggioVittoria && puntGuest - puntHome >= 2) {
return true; // Guest ha vinto
}
return false;
}, },
decPunt() { decPunt() {
if (this.sp.striscia.home.length > 1 && this.sp.storicoServizio.length > 0) { if (this.sp.striscia.home.length > 1) {
var tmpHome = this.sp.striscia.home.pop() var tmpHome = this.sp.striscia.home.pop()
var tmpGuest = this.sp.striscia.guest.pop() var tmpGuest = this.sp.striscia.guest.pop()
var statoServizio = this.sp.storicoServizio.pop() // Recupera lo stato completo del servizio
if (tmpHome == ' ') { if (tmpHome == ' ') {
this.sp.punt.guest-- this.sp.punt.guest--
// Ruota indietro solo se c'era stato un cambio palla this.sp.form.guest.unshift(this.sp.form.guest.pop());
if (statoServizio.cambioPalla) {
this.sp.form.guest.unshift(this.sp.form.guest.pop());
}
} else { } else {
this.sp.punt.home-- this.sp.punt.home--
// Ruota indietro solo se c'era stato un cambio palla this.sp.form.home.unshift(this.sp.form.home.pop());
if (statoServizio.cambioPalla) {
this.sp.form.home.unshift(this.sp.form.home.pop());
}
} }
// Ripristina il servizio allo stato precedente
this.sp.servHome = statoServizio.servHome;
} }
}, },
// decPunt(team) { // decPunt(team) {
@@ -244,152 +139,6 @@ export default {
apriDialogConfig() { apriDialogConfig() {
this.disabilitaTastiSpeciali(); this.disabilitaTastiSpeciali();
this.diaNomi.show = true; this.diaNomi.show = true;
// Aggiungi gestore Tab per il dialog
this.dialogConfigTabHandler = (e) => {
if (e.key === 'Tab' && this.diaNomi.show) {
e.preventDefault();
e.stopPropagation();
const dialog = document.querySelector('.w-dialog');
if (!dialog) return;
const allInputs = Array.from(dialog.querySelectorAll('input[type="text"]'))
.sort((a, b) => {
const tabA = parseInt(a.closest('[tabindex]')?.getAttribute('tabindex') || '0');
const tabB = parseInt(b.closest('[tabindex]')?.getAttribute('tabindex') || '0');
return tabA - tabB;
});
if (allInputs.length === 0) return;
// Verifica se il focus è già dentro il dialog
const focusInDialog = dialog.contains(document.activeElement);
// Se non è nel dialog o non è in un input, vai al primo
if (!focusInDialog || !allInputs.includes(document.activeElement)) {
allInputs[0].focus();
return;
}
// Navigazione normale tra i campi
const currentIndex = allInputs.indexOf(document.activeElement);
let nextIndex;
if (e.shiftKey) {
nextIndex = currentIndex <= 0 ? allInputs.length - 1 : currentIndex - 1;
} else {
nextIndex = currentIndex >= allInputs.length - 1 ? 0 : currentIndex + 1;
}
if (allInputs[nextIndex]) {
allInputs[nextIndex].focus();
}
}
};
window.addEventListener('keydown', this.dialogConfigTabHandler, true);
// Focus immediato + retry con timeout
this.$nextTick(() => {
const focusFirst = () => {
const dialog = document.querySelector('.w-dialog');
if (dialog) {
const firstInput = dialog.querySelector('input[type="text"]');
if (firstInput) {
firstInput.focus();
firstInput.select();
return true;
}
}
return false;
};
// Prova immediatamente
if (!focusFirst()) {
// Se fallisce, riprova dopo un breve delay
setTimeout(focusFirst, 100);
}
});
},
chiudiDialogConfig() {
if (this.dialogConfigTabHandler) {
window.removeEventListener('keydown', this.dialogConfigTabHandler, true);
this.dialogConfigTabHandler = null;
}
this.abilitaTastiSpeciali();
},
resettaCambi(team) {
const teams = team ? [team] : ["home", "guest"];
teams.forEach((t) => {
this.diaCambi[t].cambi.forEach((cambio) => {
cambio.in = "";
cambio.out = "";
});
});
},
apriDialogCambi() {
this.disabilitaTastiSpeciali();
this.diaCambiTeam.show = true;
},
apriDialogCambiTeam(team) {
this.disabilitaTastiSpeciali();
this.diaCambi.team = team;
this.resettaCambi(team);
this.diaCambi.show = true;
},
selezionaTeamCambi(team) {
this.diaCambiTeam.show = false;
this.apriDialogCambiTeam(team);
},
chiudiDialogCambi() {
this.diaCambi.show = false;
this.resettaCambi(this.diaCambi.team);
this.abilitaTastiSpeciali();
},
confermaCambi() {
if (!this.cambiConfermabili) {
return;
}
const team = this.diaCambi.team;
const cambi = (this.diaCambi[team].cambi || [])
.map((cambio) => ({
team,
in: (cambio.in || "").trim(),
out: (cambio.out || "").trim(),
}))
.filter((cambio) => cambio.in || cambio.out);
const form = this.sp.form[team].map((val) => String(val).trim());
const formAggiornata = [...form];
for (const cambio of cambi) {
if (!/^\d+$/.test(cambio.in) || !/^\d+$/.test(cambio.out)) {
this.$waveui.notify("Inserisci solo numeri nei campi", "warning");
return;
}
if (cambio.in === cambio.out) {
this.$waveui.notify(`Numero IN e OUT uguali per ${cambio.team}`, "warning");
return;
}
if (formAggiornata.includes(cambio.in)) {
this.$waveui.notify(`Numero ${cambio.in} già presente in formazione ${cambio.team}`, "warning");
return;
}
if (!formAggiornata.includes(cambio.out)) {
this.$waveui.notify(`Numero ${cambio.out} non presente in formazione ${cambio.team}`, "warning");
return;
}
const idx = formAggiornata.findIndex((val) => String(val).trim() === cambio.out);
if (idx !== -1) {
formAggiornata.splice(idx, 1, cambio.in);
}
}
this.sp.form[team] = formAggiornata;
this.chiudiDialogCambi();
}, },
disabilitaTastiSpeciali() { disabilitaTastiSpeciali() {
window.removeEventListener("keydown", this.funzioneTastiSpeciali); window.removeEventListener("keydown", this.funzioneTastiSpeciali);
@@ -398,82 +147,32 @@ export default {
window.addEventListener("keydown", this.funzioneTastiSpeciali); window.addEventListener("keydown", this.funzioneTastiSpeciali);
}, },
funzioneTastiSpeciali(e) { funzioneTastiSpeciali(e) {
if (this.diaNomi.show || this.diaCambi.show || this.diaCambiTeam.show) { e.preventDefault();
return;
}
const target = e.target;
const path = typeof e.composedPath === "function" ? e.composedPath() : [];
const elements = [target, ...path].filter(Boolean);
const isTypingField = elements.some((el) => {
if (!el || !el.tagName) {
return false;
}
const tag = String(el.tagName).toLowerCase();
if (tag === "input" || tag === "textarea") {
return true;
}
if (el.isContentEditable) {
return true;
}
if (el.classList && (el.classList.contains("w-input") || el.classList.contains("w-textarea"))) {
return true;
}
const contentEditable = el.getAttribute && el.getAttribute("contenteditable");
return contentEditable === "true";
});
if (isTypingField) {
return;
}
let handled = false;
if (e.ctrlKey && e.key == "m") { if (e.ctrlKey && e.key == "m") {
this.diaNomi.show = true this.diaNomi.show = true
handled = true;
} else if (e.ctrlKey && e.key == "b") { } else if (e.ctrlKey && e.key == "b") {
this.visuButt = !this.visuButt this.visuButt = !this.visuButt
handled = true;
} else if (e.ctrlKey && e.key == "f") { } else if (e.ctrlKey && e.key == "f") {
document.documentElement.requestFullscreen(); document.documentElement.requestFullscreen();
handled = true;
} else if (e.ctrlKey && e.key == "s") { } else if (e.ctrlKey && e.key == "s") {
this.speak(); this.speak();
handled = true;
} else if (e.ctrlKey && e.key == "z") { } else if (e.ctrlKey && e.key == "z") {
this.visuForm = !this.visuForm this.visuForm = !this.visuForm
handled = true;
} else if (e.ctrlKey && e.key == "ArrowUp") { } else if (e.ctrlKey && e.key == "ArrowUp") {
this.incPunt("home") this.incPunt("home")
handled = true;
} else if (e.ctrlKey && e.key == "ArrowDown") { } else if (e.ctrlKey && e.key == "ArrowDown") {
this.decPunt("home") this.decPunt("home")
handled = true;
} else if (e.ctrlKey && e.key == "ArrowRight") { } else if (e.ctrlKey && e.key == "ArrowRight") {
this.incSet("home") this.incSet("home")
handled = true;
} else if (e.shiftKey && e.key == "ArrowUp") { } else if (e.shiftKey && e.key == "ArrowUp") {
this.incPunt("guest") this.incPunt("guest")
handled = true;
} else if (e.shiftKey && e.key == "ArrowDown") { } else if (e.shiftKey && e.key == "ArrowDown") {
this.decPunt("guest") this.decPunt("guest")
handled = true;
} else if (e.shiftKey && e.key == "ArrowRight") { } else if (e.shiftKey && e.key == "ArrowRight") {
this.incSet("guest") this.incSet("guest")
handled = true;
} else if (e.ctrlKey && e.key == "ArrowLeft") { } else if (e.ctrlKey && e.key == "ArrowLeft") {
this.cambiaPalla() this.sp.servHome = !this.sp.servHome
handled = true;
} else if (e.ctrlKey && (e.key == "c" || e.key == "C")) {
this.apriDialogCambiTeam("home")
handled = true;
} else if (e.shiftKey && (e.key == "c" || e.key == "C")) {
this.apriDialogCambiTeam("guest")
handled = true;
} else { return false } } else { return false }
if (handled) {
e.preventDefault();
}
} }
} }
} }

View File

@@ -1,17 +1,33 @@
:root { :root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
touch-action: pan-x pan-y; touch-action: none;
height: 100% height: 100%;
width: 100%;
overflow: hidden;
}
html {
height: 100%;
width: 100%;
overflow: hidden;
position: fixed;
} }
body { body {
overscroll-behavior-y: contain; overscroll-behavior: none;
margin: 0; margin: 0;
padding: 0;
place-items: center; place-items: center;
min-width: 320px;
width: 100%; width: 100%;
min-height: 100vh; height: 100vh;
height: 100dvh; /* Dynamic viewport height per mobile */
background-color: #000; background-color: #000;
overflow: hidden;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
} }
button { button {
@@ -38,8 +54,13 @@ button:focus-visible {
} }
#app { #app {
margin: 0 auto; margin: 0;
padding: 0;
text-align: center; text-align: center;
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
} }
.campo { .campo {
user-select: none; user-select: none;
@@ -58,19 +79,6 @@ button:focus-visible {
padding-right: 10px; padding-right: 10px;
border-radius: 5px; border-radius: 5px;
} }
.score-inline {
display: inline-block;
min-width: 3ch;
text-align: center;
}
.serv-slot {
display: inline-flex;
width: 25px;
height: 25px;
align-items: center;
justify-content: center;
vertical-align: middle;
}
.tal { .tal {
text-align: left; text-align: left;
} }
@@ -93,23 +101,8 @@ button:focus-visible {
float: left; float: left;
width: 50%; width: 50%;
} }
.punteggio-container {
width: 100%;
height: 100%;
display: flex;
}
.punt { .punt {
font-size: 60vh; font-size: 60vh;
flex: 1;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
min-height: 50vh;
min-width: 50vw;
max-width: 50vw;
overflow: hidden;
box-sizing: border-box;
} }
.form { .form {
font-size: 5vh; font-size: 5vh;
@@ -151,114 +144,43 @@ button:focus-visible {
border-radius: 5px; border-radius: 5px;
} }
.campo-config { /* Responsive mobile */
display: flex; @media (max-width: 768px) {
flex-direction: column; .hea {
align-items: center; font-size: 4vw;
} }
.campo-pallavolo { .hea span {
border: 3px solid #999; padding-left: 5px;
background-color: rgba(205, 133, 63, 0.25); padding-right: 5px;
position: relative; }
width: 220px;
height: 220px;
display: flex;
flex-direction: column;
padding: 0;
}
.fila-anteriore { .hea img {
height: 33.33%; width: 18px !important;
display: flex; }
align-items: center;
justify-content: center;
padding: 5px;
}
.fila-posteriore { .punt {
height: 66.67%; font-size: 45vh;
display: flex; }
align-items: center;
justify-content: center;
padding: 5px;
}
.linea-tre-metri { .form {
border-top: 2px solid #666; font-size: 4vh;
width: 100%; border-top: #fff dashed 15px;
height: 0; padding-top: 30px;
margin: 0; }
}
.cambi-rows { .formdiv {
display: flex; font-size: 15vh;
flex-direction: column; }
gap: 12px;
padding: 8px 0;
}
.cambi-dialog { .bot button {
padding: 8px 6px 2px; font-size: 0.7em;
} padding: 0.4em 0.8em;
margin-left: 3px;
margin-right: 3px;
}
.cambi-title { .bot img {
text-align: center; width: 20px !important;
font-weight: 800; }
letter-spacing: 0.5px; }
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
}
.cambi-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.cambi-arrow {
font-weight: 700;
font-size: 18px;
line-height: 1;
padding: 6px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
white-space: nowrap;
}
.cambi-input {
min-width: 48px;
max-width: 64px;
}
.cambi-input input,
.cambi-input .w-input__input,
.cambi-input .w-input__field {
border: 2px solid rgba(255, 255, 255, 0.35);
border-radius: 8px;
padding: 6px 10px;
color: #000;
text-align: center;
box-sizing: border-box;
}
.cambi-in input,
.cambi-in .w-input__input,
.cambi-in .w-input__field {
background: rgba(120, 200, 120, 0.4);
}
.cambi-out input,
.cambi-out .w-input__input,
.cambi-out .w-input__field {
background: rgba(200, 120, 120, 0.4);
}
.cambi-input input:focus,
.cambi-input .w-input__input:focus,
.cambi-input .w-input__field:focus {
border-color: rgba(255, 255, 255, 0.7);
outline: none;
}

View File

@@ -4,7 +4,7 @@ import { VitePWA } from 'vite-plugin-pwa'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
base: process.env.NODE_ENV === 'production' ? '/segnap' : '/', base: '/',
plugins: [ plugins: [
vue(), vue(),
VitePWA({ VitePWA({