Compare commits

...

49 Commits

Author SHA1 Message Date
Carsten Graf
2d4831349b feat(tts): browser-based MP3 announcer for finished times
All checks were successful
/ build (push) Successful in 3m58s
Plays the just-finished time via Web Audio API on index.html and on
new top-1 entries on leaderboard.html. Snippets are pre-rendered as
German neural-TTS MP3s (numbers 0-99 spoken naturally as
"vierzehn", "sechsundneunzig" etc.) and decoded into AudioBuffers
once at page load, then chained gaplessly via start(when, offset,
duration) — leading/trailing silence in each MP3 is detected and
skipped so words flow without pauses. A floating speaker toggle
persists in localStorage and doubles as the user gesture that
unlocks the AudioContext on autoplay-restricted browsers (SmartTV,
iOS Safari).

Hundredths formatting mirrors the ESP's float-truncation via
Math.fround so the announced value always matches the displayed
string, even at hundredths boundaries where double/float rounding
diverges. Preload runs at concurrency 2 with a 2 s start delay so
the 107 MP3 fetches don't starve /api/data and freeze the live
timer.

Regenerator script: tools/generate-tts.py (requires edge-tts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:21:49 +02:00
Carsten Graf
5beced0041 Merge feat/lb-fly-animation: fly-down animation on lap reset
Some checks failed
/ build (push) Has been cancelled
2026-05-03 17:24:05 +02:00
Carsten Graf
fd18d0cd22 feat(leaderboard): fly-down animation on lap reset
All checks were successful
/ build (push) Successful in 4m6s
Beim Übergang finished -> ready (Auto-Reset) fliegt die große
Lauf-Zeit aus #time1/#time2 nach unten in die Leaderboard-Liste.
Die bestehenden Einträge werden dabei nach unten geschoben, um
Platz zu machen.

Auto-Trigger beim Leaderboard-Polling entfernt; Animation läuft
jetzt ausschließlich am Status-Übergang über einen Snapshot, der
vor kickDisplayScheduler() eingefroren wird.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:23:39 +02:00
455633178c .github/workflows/build.yml aktualisiert
All checks were successful
/ build (push) Successful in 4m2s
revert
2026-05-03 16:41:12 +02:00
9361cfdee6 .github/workflows/build.yml aktualisiert
Some checks failed
/ build (push) Failing after 23s
2026-05-03 16:38:13 +02:00
f558c64886 Merge pull request 'feat/rtc-pcf8523' (#3) from feat/rtc-pcf8523 into main
Some checks failed
/ build (push) Failing after 26s
Reviewed-on: #3
2026-05-03 16:28:05 +02:00
Carsten Graf
3400b9cc6a glow für neue Zeit
Some checks failed
/ build (push) Failing after 4m11s
2026-05-03 16:27:27 +02:00
Carsten Graf
fa87fd0222 fix(rtc): address code-review findings (5 fixes)
- Drop extern "C" from weak hooks (UB with C++ reference param)
- syncTimeWithNTP returns bool; syncFromNTP uses it (robust success check)
- Avoid duplicate NTP sync at boot (wificlass already syncs)
- Clamp negative time deltas in 24h timer and JSON status
- Cache rtc.now() in loopRTC to avoid I2C race with PN532
2026-05-03 15:17:19 +02:00
Carsten Graf
a6c885ee33 feat(rtc): wire rtcsync into setup and loop 2026-05-03 15:06:07 +02:00
Carsten Graf
8acb611b9b feat(rtc): add loopRTC and appendTimeStatus hook 2026-05-03 15:01:31 +02:00
Carsten Graf
68483c8127 feat(rtc): add syncFromNTP wrapper that persists to RTC 2026-05-03 15:00:08 +02:00
Carsten Graf
781ad18c6a feat(rtc): wire timesync hooks and add persistSystemTimeToRTC 2026-05-03 14:58:48 +02:00
Carsten Graf
a875b20ba2 feat(rtc): add rtcsync.h with PCF8523 detection and time fallback 2026-05-03 14:55:05 +02:00
Carsten Graf
f6b2dceedc build: add RTClib dependency for PCF8523 RTC support 2026-05-03 14:51:51 +02:00
Carsten Graf
df95a37ca7 docs: add implementation plan for PCF8523 RTC fallback
Ten tasks covering dependency setup, header creation, weak-hook
decoupling from timesync.h, master.cpp wiring, and hardware
verification on real device.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 14:47:37 +02:00
Carsten Graf
96fcb74c80 docs: add design spec for PCF8523 RTC fallback
New header rtcsync.h providing persistent time storage and offline
fallback when NTP is unavailable. Soft-fails when hardware is absent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 14:44:40 +02:00
Carsten Graf
48ae556949 BugFix: Status Unknown
Some checks failed
/ build (push) Has been cancelled
2026-04-12 14:50:33 +02:00
Carsten Graf
9d958c94f1 UHrzeit und Starttick
Some checks failed
/ build (push) Has been cancelled
2026-04-11 23:28:37 +02:00
Carsten Graf
0223cceef8 Button Simmulator, Frontend änderungen
Some checks failed
/ build (push) Has been cancelled
2026-04-11 20:24:39 +02:00
05166b443b Merge pull request 'Kleine änderungen' (#2) from v1 into main
Some checks failed
/ build (push) Failing after 26s
Reviewed-on: #2
2026-03-06 15:00:32 +01:00
Carsten Graf
76b492606e Kleine änderungen
All checks were successful
/ build (push) Successful in 6m43s
2026-02-21 15:37:54 +01:00
d9edd47a31 Merge pull request 'v1' (#1) from v1 into main
Some checks failed
/ build (push) Has been cancelled
Reviewed-on: #1
2026-01-24 15:11:30 +01:00
Carsten Graf
a67e29b9e4 Add DevServer (brokern)
Some checks failed
/ build (push) Has been cancelled
2026-01-24 15:08:14 +01:00
Carsten Graf
5ef5e6d636 Changes to the Statusdisplay 2026-01-24 14:51:33 +01:00
Carsten Graf
77f1ebc1f1 Add Manual 2025-11-05 22:32:16 +01:00
Carsten Graf
2a832257ba Added minTime 2025-10-13 19:17:35 +02:00
Carsten Graf
5ca67d8804 Add Local leaderboard, CSS optimiztion 2025-09-23 20:07:35 +02:00
Carsten Graf
8fac847a75 Change best times to Local leaderboard 2025-09-22 20:51:09 +02:00
Carsten Graf
36c35ba161 leere lokales leaderboard wenn best times zurück gesetzt werden 2025-09-22 20:41:33 +02:00
Carsten Graf
e383e54e41 Add all times to local leaderboard 2025-09-22 20:37:13 +02:00
Carsten Graf
9de327bfb3 Lokal Leaderboard 2025-09-20 19:14:41 +02:00
Carsten Graf
7e9705902e RFID Implementierung 2025-09-20 01:04:00 +02:00
Carsten Graf
1ed3a30340 RFID im master ist back 2025-09-18 23:23:49 +02:00
Carsten Graf
02a60d84cf Update 2025-09-18 23:21:14 +02:00
Carsten Graf
4f0fc68d41 Lane difficulty added 2025-09-11 13:56:07 +02:00
Carsten Graf
3aac843736 RFID erstellung raugeflogen 2025-09-11 11:56:59 +02:00
Carsten Graf
ed9e8994a9 Auch settings aufs neue farbschema 2025-09-11 11:54:42 +02:00
Carsten Graf
86b0407f82 Rename and add logos 2025-09-11 11:50:24 +02:00
Carsten Graf
a400ca00ff NewColors 2025-09-11 10:19:35 +02:00
Carsten Graf
173b13fcfc add settings locations
Some checks failed
/ build (push) Has been cancelled
2025-09-08 22:30:15 +02:00
Carsten Graf
55eb062d2c Move all the preference in seperate h file 2025-08-18 17:57:43 +02:00
Carsten Graf
a768783640 Update API markdown 2025-08-14 10:02:03 +02:00
Carsten Graf
2b9cc7283c Fix: Debugmode. TBD 2025-08-14 09:02:03 +02:00
Carsten Graf
ba1b86a053 Bug fixed, Wettkampfmodus Done. TODO: Zeitstempel der Statusampel im Master verwenden 2025-08-06 22:47:31 +02:00
Carsten Graf
4a04565878 BUG: Comp-mode erstes stoppen zeigt falsche zeit an! Individ mode geht. 2025-08-06 00:46:05 +02:00
Carsten Graf
6793a54103 Refactor for Gamemodes 2025-08-05 21:21:22 +02:00
Carsten Graf
60d4393bd2 Merge branch 'main' of https://git.reptilfpv.de/reptil1990/AquaMasterMQTT 2025-08-02 21:08:25 +02:00
Carsten Graf
a1c68791bf Start competition mode 2025-08-02 20:36:19 +02:00
e6a089fd61 .github/workflows/build.yml aktualisiert
Add Unique ID
2025-08-01 17:04:17 +02:00
155 changed files with 7932 additions and 1465 deletions

View File

@@ -0,0 +1,18 @@
{
"permissions": {
"allow": [
"Bash(pio run:*)",
"Bash(npm install:*)",
"Bash(where pio:*)",
"Read(//c/Users/repti/.platformio/penv/Scripts/**)",
"Bash(/c/Users/repti/.platformio/penv/Scripts/pio.exe run:*)",
"Bash(python -c \"import reportlab\")",
"Read(//c/Program Files/Google/Chrome/Application/**)",
"Read(//c/Program Files \\(x86\\)/Microsoft/Edge/Application/**)"
]
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"proxmox"
]
}

View File

@@ -31,11 +31,17 @@ jobs:
cp .pio/build/esp32thing_CI/firmware.bin firmware.bin
cp .pio/build/esp32thing_CI/spiffs.bin spiffs.bin
- name: Generate tag name
id: tag
run: |
TAG="esp32thing-$(date +'%Y%m%d-%H%M%S')-${GITHUB_SHA::7}"
echo "tag_name=$TAG" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: "esp32thing Firmware ${{ github.ref_name }}"
tag_name: "${{ github.ref_name }}"
name: "esp32thing Firmware ${{ steps.tag.outputs.tag_name }}"
tag_name: "${{ steps.tag.outputs.tag_name }}"
files: |
firmware.bin
spiffs.bin

106
.gitignore vendored
View File

@@ -1,6 +1,104 @@
.pio
# PlatformIO
.pio/
.pioenvs/
.piolibdeps/
.platformio/
platformio.ini.bak
# Build directories
build/
.vscode/
# IDE files
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Node.js (falls du Node.js Tools verwendest)
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
# nyc test coverage
.nyc_output
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
# IDE specific files
.vscode/settings.json
.vscode/launch.json
.vscode/ipch
.vscode/extensions.json
.idea/
*.iml
*.ipr
*.iws
# Temporary files
*.tmp
*.temp
# Compiled files
*.o
*.obj
*.exe
*.dll
*.so
*.dylib
# Firmware files (optional - falls du sie nicht versionieren willst)
# *.bin
# *.hex
# Backup files
*.bak
*.backup
# Archive files
*.zip
*.tar.gz
*.rar
# MCP related files (falls nicht benötigt)
gitea-mcp.exe
gitea-mcp.zip
# Local configuration files
config.local.*

142
API.md
View File

@@ -1,109 +1,93 @@
# API- und Routenbeschreibung für das AquaMaster-Projekt
# API Documentation: AquaMaster Webserver
Diese Datei beschreibt alle HTTP-Routen (API und statische Seiten) für das AquaMaster-Projekt. Sie dient als Referenz für Frontend-Entwickler.
This document describes all available API routes provided by the webserver in `webserverrouter.h`.
All API endpoints return JSON unless otherwise noted.
---
## Statische Seiten
## Static Files
| Route | Methode | Beschreibung | Antwort (Content-Type) |
|-----------------|---------|-------------------------------------|------------------------|
| `/` | GET | Hauptseite (Timer) | HTML |
| `/settings` | GET | Einstellungen-Seite | HTML |
| `/about` | GET | Info-/About-Seite | HTML |
| `/` (static) | GET | Statische Dateien (CSS, Bilder, JS) | entspr. MIME-Type |
| Route | Method | Description | Response Type |
| --------------- | ------ | ---------------------- | ------------- |
| `/` | GET | Main page | HTML |
| `/settings` | GET | Settings page | HTML |
| `/rfid` | GET | RFID page | HTML |
| `/firmware.bin` | GET | Firmware file (SPIFFS) | Binary |
---
## API-Routen
## Timer & Data
### Timer & Daten
| Route | Methode | Beschreibung | Body/Parameter | Antwort (Content-Type) |
|-------------------|---------|-------------------------------------|------------------------|--------------------------------|
| `/api/data` | GET | Aktuelle Timerdaten und Status | | JSON |
**Beispiel-Response:**
```json
{
"time1": 12.34,
"status1": "running",
"time2": 0,
"status2": "ready",
"best1": 10.12,
"best2": 9.87,
"learningMode": false,
"learningButton": "Start Bahn 1"
}
```
| Route | Method | Description | Request Body/Params | Response Example |
| ----------------- | ------ | --------------------------------- | ------------------- | --------------------- |
| `/api/data` | GET | Get current timer and status data | | `{...}` |
| `/api/reset-best` | POST | Reset best times | | `{ "success": true }` |
---
### Bestzeiten
## Button Learning
| Route | Methode | Beschreibung | Body/Parameter | Antwort (Content-Type) |
|----------------------|---------|-------------------------------------|------------------------|--------------------------------|
| `/api/reset-best` | POST | Setzt Bestzeiten zurück | | `{ "success": true }` |
| Route | Method | Description | Request Body/Params | Response Example |
| --------------------- | ------ | --------------------------------- | ------------------- | ------------------------------------------------------- |
| `/api/unlearn-button` | POST | Remove all button assignments | | `{ "success": true }` |
| `/api/start-learning` | POST | Start button learning mode | | `{ "success": true }` |
| `/api/stop-learning` | POST | Stop button learning mode | | `{ "success": true }` |
| `/api/learn/status` | GET | Get learning mode status | | `{ "active": true, "step": 1 }` |
| `/api/buttons/status` | GET | Get button assignment and voltage | | `{ "lane1Start": true, "lane1StartVoltage": 3.3, ... }` |
---
### Button-Lernmodus
## Settings
| Route | Methode | Beschreibung | Body/Parameter | Antwort (Content-Type) |
|--------------------------|---------|-------------------------------------|------------------------|--------------------------------|
| `/api/start-learning` | POST | Startet Lernmodus | | `{ "success": true }` |
| `/api/stop-learning` | POST | Beendet Lernmodus | | `{ "success": true }` |
| `/api/learn/status` | GET | Status des Lernmodus | | `{ "active": true, "step": 1 }`|
| `/api/unlearn-button` | POST | Löscht alle Button-Zuordnungen | | `{ "success": true }` |
| `/api/buttons/status` | GET | Status der Button-Zuordnung | | JSON (siehe unten) |
**Beispiel-Response für `/api/buttons/status`:**
```json
{
"lane1Start": true,
"lane1Stop": false,
"lane2Start": true,
"lane2Stop": false
}
```
| Route | Method | Description | Request Body/Params | Response Example |
| ------------------- | ------ | ------------------------------ | --------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `/api/set-max-time` | POST | Set max timer and display time | `maxTime`, `maxTimeDisplay`, `minTimeForLeaderboard` (form params, seconds) | `{ "success": true }` |
| `/api/get-settings` | GET | Get current timer settings | | `{ "maxTime": 300, "maxTimeDisplay": 20, "minTimeForLeaderboard": 5 }` |
---
### Einstellungen
## WiFi Configuration
| Route | Methode | Beschreibung | Body/Parameter | Antwort (Content-Type) |
|------------------------|---------|-------------------------------------|------------------------|--------------------------------|
| `/api/set-max-time` | POST | Setzt max. Laufzeit & max. Anzeigezeit | `maxTime` (Sekunden, optional), `maxTimeDisplay` (Sekunden, optional) als Form-Parameter | `{ "success": true }` oder `{ "success": false }` |
| `/api/get-settings` | GET | Liefert aktuelle Einstellungen | | `{ "maxTime": 300, "maxTimeDisplay": 20 }` |
| Route | Method | Description | Request Body/Params | Response Example |
| --------------- | ------ | ---------------------------------- | -------------------------------- | -------------------------------------- |
| `/api/set-wifi` | POST | Set WiFi SSID and password | `ssid`, `password` (form params) | `{ "success": true }` |
| `/api/get-wifi` | GET | Get current WiFi SSID and password | | `{ "ssid": "...", "password": "..." }` |
---
### Systeminfo
## Location Configuration
| Route | Methode | Beschreibung | Antwort (Content-Type) |
|-------------------|---------|-------------------------------------|--------------------------------|
| `/api/info` | GET | Systeminfos (IP, MAC, Speicher, Lizenz, verbundene Buttons) | JSON (siehe unten) |
**Beispiel-Response:**
```json
{
"ip": "192.168.4.1",
"channel": 1,
"mac": "AA:BB:CC:DD:EE:FF",
"freeMemory": 123456,
"connectedButtons": 3,
"valid": "Ja",
"tier": 1
}
```
| Route | Method | Description | Request Body/Params | Response Example |
| ------------------- | ------ | ------------------------ | -------------------------- | ------------------------- |
| `/api/set-location` | POST | Set location name and ID | `id`, `name` (form params) | `{ "success": true }` |
| `/api/get-location` | GET | Get current location | | `{ "locationid": "..." }` |
---
## Hinweise
## Button Update & Mode
- **Alle API-Routen liefern JSON zurück.**
- **POST-Requests erwarten ggf. Form-Parameter (kein JSON-Body).**
- **Statische Seiten und Assets werden direkt ausgeliefert.**
- **Kein Authentifizierungsverfahren implementiert.**
| Route | Method | Description | Request Body/Params | Response Example |
| -------------------- | ------ | ------------------------------- | ------------------------------------------------ | -------------------------- |
| `/api/updateButtons` | GET | Trigger MQTT update for buttons | | `{ "success": true }` |
| `/api/set-mode` | POST | Set operational mode | `mode` (form param: "individual" or "wettkampf") | `{ "success": true }` |
| `/api/get-mode` | GET | Get current operational mode | | `{ "mode": "individual" }` |
---
---
## System Info
| Route | Method | Description | Request Body/Params | Response Example |
| ----------- | ------ | ------------------------------------------------ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `/api/info` | GET | Get system info (IP, MAC, memory, license, etc.) | | `{ "ip": "...", "ipSTA": "...", "channel": 1, "mac": "...", "freeMemory": 123456, "connectedButtons": 3, "isOnline": true, "valid": "Ja", "tier": 1 }` |
---
## WebSocket
| Route | Description |
| ----- | ----------------------------------- |
| `/ws` | WebSocket endpoint for live updates |
---
**All API endpoints return JSON unless otherwise noted. POST requests expect form parameters (not JSON body).**

Binary file not shown.

View File

@@ -0,0 +1,674 @@
<!DOCTYPE html>
<html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:w='urn:schemas-microsoft-com:office:word' xmlns='http://www.w3.org/TR/REC-html40'>
<head>
<meta charset="utf-8">
<title>NinjaCross Timer - Bedienungsanleitung</title>
<!--[if gte mso 9]>
<xml>
<w:WordDocument>
<w:View>Print</w:View>
<w:Zoom>90</w:Zoom>
<w:DoNotOptimizeForBrowser/>
</w:WordDocument>
</xml>
<![endif]-->
<style>
body { font-family: Arial, sans-serif; }
h1 { color: #2c3e50; border-bottom: 3px solid #3498db; padding-bottom: 10px; }
h2 { color: #34495e; margin-top: 30px; }
h3 { color: #555; }
ul { line-height: 1.8; }
ol { line-height: 1.8; }
code { background-color: #f4f4f4; padding: 2px 6px; border-radius: 3px; }
.warning { background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 15px 0; }
.info { background-color: #d1ecf1; border-left: 4px solid #0dcaf0; padding: 15px; margin: 15px 0; }
.success { background-color: #d4edda; border-left: 4px solid #28a745; padding: 15px; margin: 15px 0; }
table { border-collapse: collapse; width: 100%; margin: 15px 0; }
table th, table td { border: 1px solid #ddd; padding: 12px; text-align: left; }
table th { background-color: #3498db; color: white; }
table tr:nth-child(even) { background-color: #f2f2f2; }
</style>
</head>
<body>
<h1>NinjaCross Timer - Bedienungsanleitung</h1>
<div class="info">
<p><strong>Version:</strong> 1.0</p>
<p><strong>Hersteller:</strong> AquaMaster MQTT</p>
<p><strong>Datum:</strong> 2024</p>
</div>
<h2>1. Einleitung</h2>
<p>Der NinjaCross Timer ist ein professionelles Zeitmessgerät für Ninjacross-Wettkämpfe. Das System ermöglicht die präzise Zeitmessung für bis zu zwei Bahnen gleichzeitig und bietet zahlreiche Features wie RFID-Erkennung, lokales Leaderboard und Internet-Konnektivität über WiFi und MQTT.</p>
<h2>2. Systemübersicht</h2>
<h3>2.1 Komponenten</h3>
<ul>
<li><strong>ESP32 Master</strong>: Hauptprozessor mit Web-Interface</li>
<li><strong>4 Wireless-Buttons</strong>: Start/Stop Buttons für 2 Bahnen</li>
<li><strong>RFID-Reader</strong>: Optional - für Nutzeridentifikation</li>
<li><strong>Internet-Verbindung</strong>: Über WiFi für Cloud-Synchronisation</li>
</ul>
<h3>2.2 Anzeigen und Status</h3>
<table>
<tr>
<th>Komponente</th>
<th>Beschreibung</th>
</tr>
<tr>
<td>Heartbeat-Indikatoren</td>
<td>4 grüne/rote Punkte zeigen die Verbindung der Buttons an (Start1, Stop1, Start2, Stop2)</td>
</tr>
<tr>
<td>Timer-Anzeige</td>
<td>Live-Zeit für beide Bahnen</td>
</tr>
<tr>
<td>Status-Anzeige</td>
<td>Bereit, Läuft, Geschafft, Standby</td>
</tr>
<tr>
<td>Leaderboard</td>
<td>Top 6 Zeiten lokal gespeichert</td>
</tr>
<tr>
<td>Batterie-Warnung</td>
<td>Banner bei niedriger Batterie der Buttons</td>
</tr>
</table>
<h2>3. Erste Inbetriebnahme</h2>
<h3>3.1 Einschalten und Netzwerkverbindung</h3>
<ol>
<li><strong>Einschalten</strong>: Master einschalten</li>
<li><strong>Access Point finden</strong>: Suchen Sie nach dem WiFi-Netzwerk mit dem Namen <code>NinjaCross-XXXXX</code> (die letzten Zeichen sind eindeutig für Ihr Gerät)</li>
<li><strong>Verbinden</strong>: Das Netzwerk ist standardmäßig ohne Passwort</li>
<li><strong>IP-Adresse</strong>: Das Gerät hat die feste IP <code>192.168.10.1</code></li>
<li><strong>Alternative</strong>: Sie können auch <code>ninjacross.local</code> im Browser verwenden (mDNS)</li>
</ol>
<div class="warning">
<p><strong>Wichtig:</strong> Der Access Point benötigt kein Passwort.</p>
</div>
<h3>3.2 Web-Interface öffnen</h3>
<p>Öffnen Sie Ihren Webbrowser und geben Sie eine der folgenden Adressen ein:</p>
<ul>
<li><code>http://192.168.10.1</code> (direkte IP)</li>
<li><code>http://ninjacross.local</code> (falls mDNS unterstützt wird)</li>
</ul>
<h2>4. Hauptoberfläche</h2>
<h3>4.1 Timer-Ansicht</h3>
<p>Die Hauptseite zeigt:</p>
<ul>
<li><strong>Bahn 1</strong>: Links - Timer und Status</li>
<li><strong>Bahn 2</strong>: Rechts - Timer und Status</li>
<li><strong>Heartbeat-Indikatoren</strong>: Oben - Verbindungsstatus der Buttons</li>
<li><strong>Leaderboard</strong>: Unten - Top 6 lokale Zeiten</li>
<li><strong>Navigation</strong>:
<ul>
<li>🏆 = Leaderboard (Volansicht)</li>
<li>⚙️ = Einstellungen</li>
</ul>
</li>
</ul>
<h3>4.2 Timer-Bedienung</h3>
<ol>
<li><strong>Standby</strong>: "Drücke beide Buttons einmal" - Buttons initialisieren</li>
<li><strong>Bereit</strong>: Beide Buttons sind verbunden (grüne Heartbeats)</li>
<li><strong>Armiert</strong>: Startbutton gedrückt - Timer startet bei freigegebenem Button</li>
<li><strong>Läuft</strong>: Timer läuft - Zeit wird live angezeigt</li>
<li><strong>Geschafft</strong>: Stop-Button gedrückt - Zeit wird gespeichert</li>
</ol>
<div class="info">
<p><strong>Tipp:</strong> Die Anzeige blendet automatisch die Schwimmer-Namen ein, wenn sie via RFID erkannt werden.</p>
</div>
<h2>5. Button-Konfiguration</h2>
<h3>5.1 Anlernmodus</h3>
<p>Der erste Schritt ist das Anlernen Ihrer Wireless-Buttons:</p>
<ol>
<li>Öffnen Sie die <strong>Einstellungen</strong> (⚙️)</li>
<li>Scrollen Sie zu <strong>"Button-Konfiguration"</strong></li>
<li>Klicken Sie auf <strong>"🎯 Anlernmodus starten"</strong></li>
<li>Folgen Sie den Anweisungen:
<ol>
<li>Drücken Sie den Button für <strong>Bahn 1 Start</strong></li>
<li>Drücken Sie den Button für <strong>Bahn 1 Stop</strong></li>
<li>Drücken Sie den Button für <strong>Bahn 2 Start</strong></li>
<li>Drücken Sie den Button für <strong>Bahn 2 Stop</strong></li>
</ol>
</li>
<li>Die Anzeige zeigt automatisch an, welchen Button Sie drücken müssen</li>
<li>Nach erfolgreicher Konfiguration erhalten Sie eine Bestätigung</li>
</ol>
<div class="success">
<p><strong>Erfolg:</strong> Nach dem Anlernen sollten alle 4 Heartbeat-Indikatoren grün leuchten.</p>
</div>
<h3>5.2 Buttons verlernen</h3>
<p>Um alle Button-Zuweisungen zu löschen:</p>
<ol>
<li>Einstellungen öffnen</li>
<li>"❌ Buttons verlernen" klicken</li>
<li>Bestätigung erfordert</li>
</ol>
<h3>5.3 Button-Status anzeigen</h3>
<p>Klicken Sie auf <strong>"📊 Button-Status anzeigen"</strong> um zu sehen:</p>
<ul>
<li>Welche Buttons konfiguriert sind</li>
<li>Batteriestand jedes Buttons in Prozent</li>
</ul>
<h2>6. RFID-Benutzerverwaltung</h2>
<h3>6.1 RFID-Karte registrieren</h3>
<p>Die RFID-Funktion ermöglicht die automatische Zuordnung von Zeiten zu Nutzern:</p>
<ol>
<li>Öffnen Sie <strong>"RFID"</strong> (🏷️) aus dem Einstellungsmenü</li>
<li>Klicken Sie auf <strong>"📡 Read Chip"</strong></li>
<li>Halten Sie die RFID-Karte an den Reader des Masters</li>
<li>Die UID wird automatisch eingefügt</li>
<li>Geben Sie den <strong>Namen</strong> ein</li>
<li>Klicken Sie auf <strong>"💾 Speichern"</strong></li>
</ol>
<div class="info">
<p><strong>Funktionsweise:</strong> Beim nächsten Scannen der RFID-Karte an einem Button wird automatisch der Name angezeigt und die Zeit diesem Nutzer zugeordnet.</p>
</div>
<h3>6.2 Kontinuierliches Lesen</h3>
<p>Der "Read Chip" Button startet einen kontinuierlichen Lesemodus:</p>
<ul>
<li>Statusleiste zeigt: "RFID Lesen gestartet - Karte auflegen!"</li>
<li>Alle erkannten Karten werden automatisch übernommen</li>
<li>Nach erfolgreichem Lesen wird die Eingabe fokussiert</li>
</ul>
<h2>7. Einstellungen</h2>
<h3>7.1 Datum & Uhrzeit</h3>
<p>Die Uhrzeit kann manuell oder automatisch gesetzt werden:</p>
<ul>
<li><strong>Manuell</strong>: Datum und Uhrzeit eingeben, dann "🕐 Uhrzeit setzen"</li>
<li><strong>Automatisch</strong>: "💻 Browser-Zeit übernehmen" verwendet die Zeit Ihres Computers</li>
</ul>
<h3>7.2 Modus</h3>
<table>
<tr>
<th>Modus</th>
<th>Beschreibung</th>
</tr>
<tr>
<td>👤 Individual</td>
<td>Beide Bahnen arbeiten unabhängig - ideale für Training</td>
</tr>
<tr>
<td>🏆 Wettkampf</td>
<td>Beide Bahnen starten synchron - für Wettkämpfe</td>
</tr>
</table>
<h3>7.3 Lane-Konfiguration</h3>
<p>Die Bahnen können identisch oder unterschiedlich konfiguriert werden:</p>
<ul>
<li><strong>⚖️ Identische Lanes</strong>: Beide Bahnen sind gleich</li>
<li><strong>⚡ Unterschiedliche Lanes</strong>: Bahnen mit unterschiedlichen Schwierigkeiten
<ul>
<li>🟢 Leicht: Standard-Konfiguration</li>
<li>🔴 Schwer: Anspruchsvollere Hindernisse</li>
</ul>
</li>
</ul>
<h3>7.4 Grundeinstellungen</h3>
<table>
<tr>
<th>Einstellung</th>
<th>Standard</th>
<th>Beschreibung</th>
</tr>
<tr>
<td>Maximale Zeit</td>
<td>300 Sekunden</td>
<td>Nach dieser Zeit wird eine Bahn automatisch zurückgesetzt</td>
</tr>
<tr>
<td>Anzeigedauer</td>
<td>20 Sekunden</td>
<td>Wie lange die letzte Zeit angezeigt bleibt</td>
</tr>
<tr>
<td>Min. Zeit Leaderboard</td>
<td>5 Sekunden</td>
<td>Zeiten unter diesem Wert werden nicht gespeichert (Missbrauchsschutz)</td>
</tr>
</table>
<h3>7.5 WLAN-Konfiguration (Lizenz Level 3 erforderlich)</h3>
<div class="warning">
<p><strong>Wichtig:</strong> Um das System mit einem bestehenden WLAN zu verbinden wird eine Lizenz Level 3 oder höher.</p>
</div>
<p>Zur Konfiguration:</p>
<ol>
<li>WLAN Name (SSID) eingeben</li>
<li>WLAN Passwort eingeben</li>
<li>Aktueller STA IP-Status wird angezeigt</li>
<li>Nach dem Speichern startet das Gerät neu</li>
</ol>
<div class="info">
<p><strong>Dual-Mode:</strong> Das Gerät kann gleichzeitig Access Point (für direkte Verbindung) und WiFi Station (für Internet) betreiben.</p>
</div>
<h3>7.6 Standort (Lizenz Level 3 erforderlich)</h3>
<p>Wählen Sie Ihren Standort aus einem Dropdown-Menü:</p>
<ul>
<li>Beim Eingeben einer gültigen Lizenz werden verfügbare Standorte aus der API geladen</li>
<li>Ohne Lizenz werden Fallback-Standorte angezeigt</li>
<li>Der gewählte Standort wird lokal gespeichert</li>
</ul>
<h3>7.7 OTA Update (Lizenz Level 2 erforderlich)</h3>
<div class="warning">
<p><strong>Lizenz erforderlich:</strong> OTA-Updates benötigen Lizenz Level 2 oder höher.</p>
</div>
<ol>
<li>Klicken Sie auf <strong>"🔄 Update durchführen"</strong></li>
<li>Bestätigen Sie die Abfrage</li>
<li>Das Gerät lädt die neueste Firmware herunter und installiert sie automatisch</li>
<li>Während des Updates darf der Strom nicht unterbrochen werden!</li>
</ol>
<h3>7.8 Buttons Updaten</h3>
<p>Sendet eine Update-Nachricht über MQTT an alle konfigurierten Buttons:</p>
<ol>
<li>Klicken Sie auf <strong>"📡 Buttons Updaten"</strong></li>
<li>Die Buttons erhalten die aktuelle Konfiguration</li>
<li>Nutzen Sie dies nach Button-Wartung oder Konfigurationsänderungen</li>
</ol>
<h2>8. Leaderboard</h2>
<h3>8.1 Lokales Leaderboard</h3>
<p>Die Hauptseite zeigt die Top 6 Zeiten:</p>
<ul>
<li>🏆 Gold für Platz 1</li>
<li>🥈 Silber für Platz 2</li>
<li>🥉 Bronze für Platz 3</li>
<li>Platz 4-6 in Standard-Darstellung</li>
</ul>
<h3>8.2 Volle Leaderboard-Ansicht</h3>
<p>Öffnen Sie die Leaderboard-Seite (🏆):</p>
<ul>
<li>Zeigt alle erfassten Zeiten</li>
<li>Gruppiert in 2 Zeilen zu je 5 Einträgen</li>
<li>Wird alle 5 Sekunden automatisch aktualisiert</li>
</ul>
<h3>8.3 Beste Zeiten zurücksetzen</h3>
<p>Einstellungen → "🏆 Zeiten verwalten" → "🔄 Beste Zeiten zurücksetzen"</p>
<div class="warning">
<p><strong>Achtung:</strong> Diese Aktion kann nicht rückgängig gemacht werden!</p>
</div>
<h2>9. System-Information</h2>
<p>Die Einstellungsseite zeigt folgende Systemdaten:</p>
<table>
<tr>
<th>Information</th>
<th>Beschreibung</th>
</tr>
<tr>
<td>IP-Adresse</td>
<td>Access Point IP (meist 192.168.10.1)</td>
</tr>
<tr>
<td>Kanal</td>
<td>WiFi-Kanal</td>
</tr>
<tr>
<td>MAC-Adresse</td>
<td>Eindeutige Geräte-ID</td>
</tr>
<tr>
<td>Internet</td>
<td>Ja/Nein - Verbindung zum Internet</td>
</tr>
<tr>
<td>Freier Speicher</td>
<td>Verfügbarer RAM in Bytes</td>
</tr>
<tr>
<td>Verbundene Buttons</td>
<td>Anzahl konfigurierter Buttons (0-4)</td>
</tr>
<tr>
<td>Lizenz gültig</td>
<td>Status der Lizenz</td>
</tr>
<tr>
<td>Lizenz Level</td>
<td>0-3 - Bestimmt verfügbare Features</td>
</tr>
</table>
<h2>10. Lizenz-System</h2>
<h3>10.1 Lizenz-Level</h3>
<table>
<tr>
<th>Level</th>
<th>Features</th>
</tr>
<tr>
<td>0 (Basis)</td>
<td>Standard-Timer, lokales Leaderboard, RFID</td>
</tr>
<tr>
<td>1</td>
<td>Alle Level 0 Features</td>
</tr>
<tr>
<td>2</td>
<td>Level 1 + OTA Updates</td>
</tr>
<tr>
<td>3</td>
<td>Level 2 + WLAN-Station Mode, Standort-Konfiguration</td>
</tr>
</table>
<h3>10.2 Lizenz eingeben</h3>
<ol>
<li>Einstellungen → "🔧 Lizenz"</li>
<li>Lizenzschlüssel eingeben</li>
<li>"💾 Lizenz speichern" klicken</li>
<li>System-Information aktualisiert sich automatisch</li>
</ol>
<h2>11. Batterie-Überwachung</h2>
<p>Das System überwacht kontinuierlich die Batteriestände der Wireless-Buttons:</p>
<ul>
<li><strong>Warnung</strong>: Bei Batteriestand ≤ 15% erscheint ein Banner</li>
<li><strong>Anzeige</strong>: "⚠️ Niedrige Batterie erkannt!" mit Geräteliste</li>
<li><strong>Detailliert</strong>: Über Button-Status-Anzeige werden alle Batteriestände angezeigt</li>
</ul>
<div class="info">
<p><strong>Tipp:</strong> Der Banner blendet automatisch aus, sobald alle Batterien wieder über 15% sind.</p>
</div>
<h2>12. API & Technische Details</h2>
<h3>12.1 API-Endpunkte</h3>
<table>
<tr>
<th>Endpoint</th>
<th>Method</th>
<th>Funktion</th>
</tr>
<tr>
<td>/api/data</td>
<td>GET</td>
<td>Timer und Status abrufen</td>
</tr>
<tr>
<td>/api/reset-best</td>
<td>POST</td>
<td>Beste Zeiten zurücksetzen</td>
</tr>
<tr>
<td>/api/start-learning</td>
<td>POST</td>
<td>Anlernmodus starten</td>
</tr>
<tr>
<td>/api/learn/status</td>
<td>GET</td>
<td>Anlern-Status abrufen</td>
</tr>
<tr>
<td>/api/buttons/status</td>
<td>GET</td>
<td>Button-Konfiguration und Batterie</td>
</tr>
<tr>
<td>/api/set-max-time</td>
<td>POST</td>
<td>Timer-Einstellungen setzen</td>
</tr>
<tr>
<td>/api/get-settings</td>
<td>GET</td>
<td>Einstellungen abrufen</td>
</tr>
<tr>
<td>/api/set-wifi</td>
<td>POST</td>
<td>WiFi konfigurieren</td>
</tr>
<tr>
<td>/api/set-mode</td>
<td>POST</td>
<td>Modus setzen (Individual/Wettkampf)</td>
</tr>
<tr>
<td>/api/info</td>
<td>GET</td>
<td>System-Informationen</td>
</tr>
<tr>
<td>/ws</td>
<td>WebSocket</td>
<td>Live-Updates für Timer</td>
</tr>
</table>
<h3>12.2 WebSocket-Daten</h3>
<p>Der WebSocket liefert Echtzeit-Updates:</p>
<ul>
<li>Button-Status und Heartbeats</li>
<li>Timer-Daten (live)</li>
<li>RFID-Erkennung</li>
<li>Batterie-Status</li>
</ul>
<h2>13. Troubleshooting</h2>
<h3>13.1 Buttons verbinden sich nicht</h3>
<ul>
<li><strong>Heartbeat rot</strong>: Button außerhalb der Reichweite oder Batterie leer</li>
<li><strong>Lösung</strong>: Batterien prüfen, Button näher zum Master bringen</li>
<li><strong>Neu anlernen</strong>: Einstellungen → Buttons verlernen → Anlernmodus starten</li>
</ul>
<h3>13.2 WiFi-Verbindung funktioniert nicht</h3>
<ul>
<li>Standard: Nutzen Sie den Access Point <code>NinjaCross-XXXXX</code></li>
<li>Mit Lizenz Level 3: Konfigurieren Sie das WLAN in den Einstellungen</li>
<li>Falls Netzwerk nicht gefunden wird: Gerät neustarten</li>
</ul>
<h3>13.3 IP-Adresse unbekannt</h3>
<ul>
<li><code>192.168.10.1</code> ist die Standard IP</li>
<li>Alternative: <code>ninjacross.local</code></li>
<li>Router-Konfiguration: DHCP-Range darf 192.168.10.1 nicht blocken</li>
</ul>
<h3>13.4 Timer startet nicht</h3>
<ul>
<li>Prüfen Sie alle 4 Heartbeat-Indikatoren (müssen grün sein)</li>
<li>Start-Button muss vor dem Drücken des Stop-Buttons gedrückt werden</li>
<li>Bahn muss "Bereit" Status zeigen</li>
</ul>
<h3>13.5 RFID wird nicht erkannt</h3>
<ul>
<li>RFID-Lesemodus aktivieren: "📡 Read Chip" klicken</li>
<li>Karte langsam über den Reader führen</li>
<li>Neu versuchen wenn nach 5 Sekunden nichts passiert</li>
</ul>
<div class="warning">
<p><strong>Wichtig:</strong> Bei andauernden Problemen Gerät neustarten oder Support kontaktieren.</p>
</div>
<h2>14. Wartung</h2>
<h3>14.1 Regelmäßige Wartung</h3>
<ul>
<li><strong>Täglich</strong>: Batteriestände prüfen</li>
<li><strong>Wöchentlich</strong>: Leaderboard zurücksetzen (falls gewünscht)</li>
<li><strong>Monatlich</strong>: OTA Update prüfen</li>
<li><strong>Jährlich</strong>: Firmware aktualisieren</li>
</ul>
<h3>14.2 Firmware-Updates</h3>
<ol>
<li>Lizenz Level 2+ erforderlich</li>
<li>Einstellungen → OTA Update</li>
<li>Keine Unterbrechung während des Updates</li>
<li>Update dauert ca. 1-2 Minuten</li>
</ol>
<h2>15. Support & Kontakt</h2>
<p>Bei Fragen oder Problemen:</p>
<ul>
<li>Dokumentation prüfen</li>
<li>Troubleshooting-Abschnitt beachten</li>
<li>System-Informationen für Support bereitstellen</li>
</ul>
<div class="info">
<p><strong>Hinweis:</strong> Diese Anleitung basiert auf der aktuellen Firmware-Version. Neuere Versionen könnten abweichende Features haben.</p>
</div>
<h2>16. Anhang</h2>
<h3>16.1 Tastenkombinationen im Web-Interface</h3>
<ul>
<li><code>Enter</code> in UID-Feld: Sprung zum Namensfeld</li>
<li>Browser-Refresh: Aktualisiert alle Daten</li>
</ul>
<h3>16.2 Unterstützte Browser</h3>
<ul>
<li>Chrome/Edge (empfohlen)</li>
<li>Firefox</li>
<li>Safari</li>
<li>Mobile Browser (iOS/Android)</li>
</ul>
<h3>16.3 Technische Spezifikationen</h3>
<table>
<tr>
<th>Komponente</th>
<th>Spezifikation</th>
</tr>
<tr>
<td>ESP32 Version</td>
<td>ESP32-WROOM oder kompatibel</td>
</tr>
<tr>
<td>WiFi</td>
<td>2.4 GHz, WPA2</td>
</tr>
<tr>
<td>Protokoll</td>
<td>MQTT für Kommunikation</td>
</tr>
<tr>
<td>RFID</td>
<td>13.56 MHz, NFC-kompatibel</td>
</tr>
<tr>
<td>Timer-Genauigkeit</td>
<td>Millisekunden</td>
</tr>
</table>
<hr>
<p style="text-align: center; color: #888; margin-top: 50px;">
<strong>Ende der Bedienungsanleitung</strong><br>
NinjaCross Timer v1.0
</p>
</body>
</html>

66
CLAUDE.md Normal file
View File

@@ -0,0 +1,66 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Projektüberblick
**AquaMaster MQTT** ist die ESP32-Firmware für die Master-Einheit eines "Aquacross / NinjaCross"-Sport-Timers (zwei Bahnen, Start/Stopp-Taster). Der Master stellt einen WiFi-AP und/oder STA bereit, hostet einen MQTT-Broker, einen Async-Webserver mit WebSocket-Live-Updates und kommuniziert mit batteriebetriebenen Funktastern. Die README.md im Repo-Root ist **veraltet/falsch** (Gitea-MCP-Text) — als Quelle für Projektkontext stattdessen `API.md`, `TODO.md`, `Bedienungsanleitung_NinjaCross_Timer.html` und den Code selbst nutzen.
## Build & Flash (PlatformIO)
Default-Environment ist `esp32thing_CI` (siehe `platformio.ini`). Weitere Envs: `wemos_d1_mini32`, `esp32thing`, `esp32thing_OTA` (OTA an `192.168.1.96`), `um_feathers3`, `um_feathers3_debug`.
```bash
pio run # Default-Env bauen
pio run -e esp32thing # Spezifisches Env
pio run -e esp32thing -t upload # Flashen
pio run -e esp32thing -t buildfs # SPIFFS-Image aus data/ bauen
pio run -e esp32thing -t uploadfs # SPIFFS flashen (data/ → ESP)
pio device monitor -b 115200 # Serieller Monitor
pio run -e esp32thing_OTA -t upload # OTA-Upload (Ziel-IP in platformio.ini)
```
Tests gibt es nicht — `test/` enthält nur ein leeres README.
## CI
`.github/workflows/build.yml` baut bei jedem Push `firmware.bin` und `spiffs.bin` mit `pio run -e esp32thing_CI` und erzeugt automatisch ein GitHub-Release mit Tag `esp32thing-<datum>-<sha7>`. Wenn der Build lokal funktioniert, aber CI nicht, ist `esp32thing_CI` (board=esp32dev, platform=espressif32) die maßgebliche Konfiguration.
## Architektur (das Wesentliche)
### Header-only-Pattern (wichtig!)
Es gibt nur **eine** `.cpp`-Datei: `src/master.cpp`. Alle anderen Module unter `src/*.h` enthalten sowohl Deklarationen *als auch* Implementierungen und definieren teilweise **globale Objekte** (z. B. `AsyncWebServer server(80)` in `webserverrouter.h`, `Preferences preferences` in `licenceing.h`, `PicoMQTT::Server mqtt` in `communication.h`). Konsequenzen:
- Jeder dieser Header darf **nur in `master.cpp`** inkludiert werden, sonst gibt es Multiple-Definition-Linkerfehler.
- Header inkludieren sich gegenseitig (`master.h``webserverrouter.h``communication.h`). Beim Hinzufügen neuer Header die bestehende Include-Reihenfolge in `master.cpp` beibehalten.
- Globale Timer-/Button-State-Variablen (`timerData1`, `timerData2`, `buttonConfigs`, `localTimes`, `learningMode`, `gamemode`, …) leben in `src/master.h` und werden überall direkt referenziert.
Wer eine neue Datei anlegt: entweder als weiteren Header dem Pattern folgen und in `master.cpp` einklinken, oder bewusst eine echte `.cpp` mit `extern`-Deklarationen erstellen.
### Laufzeit-Module
- **`master.cpp`** — `setup()`/`loop()`. Reihenfolge in `setup()` ist relevant (SPIFFS → API-Setups → `load*()` aus Preferences → WiFi → OTA → Routes → WebSocket → MQTT → RFID). `loop()` priorisiert MQTT vor WebSocket vor RFID.
- **`communication.h`** — PicoMQTT-Broker. Tasten publishen auf `aquacross/button/<MAC>`; `readButtonJSON()` parst, ordnet die MAC einer der vier Rollen (`start1`/`stop1`/`start2`/`stop2`) zu und triggert die Timerlogik. Hält pro MAC `TimestampData` für Drift-Berechnung.
- **`webserverrouter.h`** — `ESPAsyncWebServer` auf Port 80 + WebSocket `/ws`. Liefert statische Seiten aus SPIFFS (`/`, `/settings`, `/leaderboard`, `/rfid`) und alle `/api/...`-Endpunkte. Vollständige Routenliste in `API.md`.
- **`wificlass.h`** — AP-Modus auf `192.168.10.1` (eindeutiger SSID-Suffix), STA-Fallback wenn gespeicherte Credentials vorhanden. Bindet `PrettyOTA` (lokale Bibliothek unter `lib/PrettyOTA/`) und mDNS ein.
- **`preferencemanager.h`** — Persistierung in NVS (`Preferences`). Namespaces u. a. `buttons`, `leaderboard`, plus WiFi-/Location-/Settings-Slots. Beim Ändern persistierter Strukturen (z. B. `ButtonConfigs`) auf Größenkompatibilität achten — `loadButtonConfig()` lädt nur, wenn `getBytesLength == sizeof(buttonConfigs)`.
- **`licenceing.h`** — HMAC-SHA256 (`mbedtls`) gegen `secret` über die STA-MAC; bestimmt Tier/Online-Funktionen. Lizenz wird zusammen mit jeder Backend-Anfrage als `Authorization: Bearer …` gesendet.
- **`databasebackend.h`** — HTTPS-Client gegen `https://ninja.reptilfpv.de` (Locations, Leaderboard-Upload, Health). Funktioniert nur bei verbundenem STA + gültiger Lizenz.
- **`rfid.h`** — Adafruit PN532 (I²C/SPI). Liest UIDs nur, wenn `isRFIDReadingActive()`; UID landet in `TimerData*::RFIDUID` und wird mit Namen aus `localUsers`/Backend verknüpft.
- **`gamemodes.h`** — Modus `0=individual`, `1=wettkampf`; steuert, wann Timer als „bereit/armiert/laufend" gilt und wie Bestzeiten abgelegt werden (lokales `localTimes`-Vektor + optional Backend).
- **`timesync.h`/`debug.h`/`statusled.h`/`battery.h`/`buttonassigh.h`/`helper.h`** — Hilfsmodule (NTP/Zeitzone, Debug-API, Status-LED, Akku, Lerne-Mode für Tasten-Zuordnung).
### Web-Frontend
`data/` enthält `index.html`, `settings.html`, `leaderboard.html`, `rfid.html` plus zugehörige CSS und ein `pictures/`-Verzeichnis. Diese Dateien werden via `pio run -t uploadfs` ins SPIFFS geschrieben und vom Webserver direkt ausgeliefert. **Frontend-Änderungen erfordern ein erneutes `uploadfs`** — ein normaler Firmware-Upload aktualisiert sie nicht.
`data/firmware.bin` wird unter `/firmware.bin` ausgeliefert (Buttons können sich darüber selbst aktualisieren).
## API
Vollständige HTTP-/WebSocket-API in `API.md` (autoritativ; `apientpoints` ist eine ältere Kurzversion). Alle POST-Routen erwarten **Form-Parameter, kein JSON-Body**. Antworten sind JSON, außer bei statischen Dateien.
## Sprache
Code-Kommentare und einige Variablennamen sind deutsch (`bahn`, `wettkampf`, „Anlernmodus"). Beim Erweitern bei der vorhandenen Sprache bleiben statt halb zu übersetzen.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 The Gitea Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

245
README.md
View File

@@ -1,28 +1,233 @@
# Ninjacross Timer ⏱️
# Gitea MCP Server
Ein präziser, drahtloser Timer für Ninjacross- und Schwimmwettbewerbe. Entwickelt für Trainings- und Wettkampfumgebungen, bei denen Geschwindigkeit, Zuverlässigkeit und Benutzerfreundlichkeit entscheidend sind.
[繁體中文](README.zh-tw.md) | [简体中文](README.zh-cn.md)
## 🔧 Funktionen
**Gitea MCP Server** is an integration plugin designed to connect Gitea with Model Context Protocol (MCP) systems. This allows for seamless command execution and repository management through an MCP-compatible chat interface.
- **0.1 s Genauigkeit** bei der Zeitmessung
- **Drahtlose Kommunikation** über ESP-NOW oder Wi-Fi Mesh
- **Mehrere Timer-Zonen** (z.B. Start/Stop für zwei Bahnen)
- **Visualisierung in Echtzeit** auf einem zentralen Raspberry Pi Dashboard
- **Großanzeige** per 7-Segment-Display oder Browseranzeige
- **Einfache Bedienung** über robuste Hardware-Taster
- **Erweiterbar** für mehrere Bahnen und Disziplinen
[![Install with Docker in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=gitea&inputs=[{%22id%22:%22gitea_token%22,%22type%22:%22promptString%22,%22description%22:%22Gitea%20Personal%20Access%20Token%22,%22password%22:true}]&config={%22command%22:%22docker%22,%22args%22:[%22run%22,%22-i%22,%22--rm%22,%22-e%22,%22GITEA_ACCESS_TOKEN%22,%22docker.gitea.com/gitea-mcp-server%22],%22env%22:{%22GITEA_ACCESS_TOKEN%22:%22${input:gitea_token}%22}}) [![Install with Docker in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=gitea&inputs=[{%22id%22:%22gitea_token%22,%22type%22:%22promptString%22,%22description%22:%22Gitea%20Personal%20Access%20Token%22,%22password%22:true}]&config={%22command%22:%22docker%22,%22args%22:[%22run%22,%22-i%22,%22--rm%22,%22-e%22,%22GITEA_ACCESS_TOKEN%22,%22docker.gitea.com/gitea-mcp-server%22],%22env%22:{%22GITEA_ACCESS_TOKEN%22:%22${input:gitea_token}%22}}&quality=insiders)
## 🛠️ Hardware-Komponenten
## Table of Contents
- ESP32 Mikrocontroller (pro Button oder Sensor ein Gerät)
- ESP32 Master mit MQTT Broker (zentrale Steuerung und Webserver)
- Taster oder Lichtschranken
- Optional: 7-Segment-Displays oder HDMI-Display
- Stabile WLAN-Verbindung (z.B. Wi-Fi Mesh)
- [Gitea MCP Server](#gitea-mcp-server)
- [Table of Contents](#table-of-contents)
- [What is Gitea?](#what-is-gitea)
- [What is MCP?](#what-is-mcp)
- [🚧 Installation](#-installation)
- [Usage with VS Code](#usage-with-vs-code)
- [📥 Download the official binary release](#-download-the-official-binary-release)
- [🔧 Build from Source](#-build-from-source)
- [📁 Add to PATH](#-add-to-path)
- [🚀 Usage](#-usage)
- [✅ Available Tools](#-available-tools)
- [🐛 Debugging](#-debugging)
- [🛠 Troubleshooting](#-troubleshooting)
## 📦 Aufbau
## What is Gitea?
Gitea is a community-managed lightweight code hosting solution written in Go. It is published under the MIT license. Gitea provides Git hosting including a repository viewer, issue tracking, pull requests, and more.
## What is MCP?
Model Context Protocol (MCP) is a protocol that allows for the integration of various tools and systems through a chat interface. It enables seamless command execution and management of repositories, users, and other resources.
## 🚧 Installation
### Usage with VS Code
For quick installation, use one of the one-click install buttons at the top of this README.
For manual installation, add the following JSON block to your User Settings (JSON) file in VS Code. You can do this by pressing `Ctrl + Shift + P` and typing `Preferences: Open User Settings (JSON)`.
Optionally, you can add it to a file called `.vscode/mcp.json` in your workspace. This will allow you to share the configuration with others.
> Note that the `mcp` key is not needed in the `.vscode/mcp.json` file.
```json
{
"mcp": {
"inputs": [
{
"type": "promptString",
"id": "gitea_token",
"description": "Gitea Personal Access Token",
"password": true
}
],
"servers": {
"gitea-mcp": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-e",
"GITEA_ACCESS_TOKEN",
"docker.gitea.com/gitea-mcp-server"
],
"env": {
"GITEA_ACCESS_TOKEN": "${input:gitea_token}"
}
}
}
}
}
```
### 📥 Download the official binary release
You can download the official release from [official Gitea MCP binary releases](https://gitea.com/gitea/gitea-mcp/releases).
### 🔧 Build from Source
You can download the source code by cloning the repository using Git:
```bash
git clone https://gitea.com/gitea/gitea-mcp.git
```
Before building, make sure you have the following installed:
- make
- Golang (Go 1.24 or later recommended)
Then run:
```bash
make install
```
### 📁 Add to PATH
After installing, copy the binary gitea-mcp to a directory included in your system's PATH. For example:
```bash
cp gitea-mcp /usr/local/bin/
```
## 🚀 Usage
This example is for Cursor, you can also use plugins in VSCode.
To configure the MCP server for Gitea, add the following to your MCP configuration file:
- **stdio mode**
```json
{
"mcpServers": {
"gitea": {
"command": "gitea-mcp",
"args": [
"-t",
"stdio",
"--host",
"https://gitea.com"
// "--token", "<your personal access token>"
],
"env": {
// "GITEA_HOST": "https://gitea.com",
// "GITEA_INSECURE": "true",
"GITEA_ACCESS_TOKEN": "<your personal access token>"
}
}
}
}
```
- **sse mode**
```json
{
"mcpServers": {
"gitea": {
"url": "http://localhost:8080/sse"
}
}
}
```
- **http mode**
```json
{
"mcpServers": {
"gitea": {
"url": "http://localhost:8080/mcp"
}
}
}
```
**Default log path**: `$HOME/.gitea-mcp/gitea-mcp.log`
> [!NOTE]
> You can provide your Gitea host and access token either as command-line arguments or environment variables.
> Command-line arguments have the highest priority
Once everything is set up, try typing the following in your MCP-compatible chatbox:
```text
[ESP32-Startbutton] --\
---> WLAN --> [ESP32 Master] --> [Browseranzeige / Display]
[ESP32-Stopbutton ] --/
list all my repositories
```
## ✅ Available Tools
The Gitea MCP Server supports the following tools:
| Tool | Scope | Description |
| :--------------------------: | :----------: | :------------------------------------------------------: |
| get_my_user_info | User | Get the information of the authenticated user |
| get_user_orgs | User | Get organizations associated with the authenticated user |
| create_repo | Repository | Create a new repository |
| fork_repo | Repository | Fork a repository |
| list_my_repos | Repository | List all repositories owned by the authenticated user |
| create_branch | Branch | Create a new branch |
| delete_branch | Branch | Delete a branch |
| list_branches | Branch | List all branches in a repository |
| create_release | Release | Create a new release in a repository |
| delete_release | Release | Delete a release from a repository |
| get_release | Release | Get a release |
| get_latest_release | Release | Get the latest release in a repository |
| list_releases | Release | List all releases in a repository |
| create_tag | Tag | Create a new tag |
| delete_tag | Tag | Delete a tag |
| get_tag | Tag | Get a tag |
| list_tags | Tag | List all tags in a repository |
| list_repo_commits | Commit | List all commits in a repository |
| get_file_content | File | Get the content and metadata of a file |
| get_dir_content | File | Get a list of entries in a directory |
| create_file | File | Create a new file |
| update_file | File | Update an existing file |
| delete_file | File | Delete a file |
| get_issue_by_index | Issue | Get an issue by its index |
| list_repo_issues | Issue | List all issues in a repository |
| create_issue | Issue | Create a new issue |
| create_issue_comment | Issue | Create a comment on an issue |
| edit_issue | Issue | Edit a issue |
| edit_issue_comment | Issue | Edit a comment on an issue |
| get_issue_comments_by_index | Issue | Get comments of an issue by its index |
| get_pull_request_by_index | Pull Request | Get a pull request by its index |
| list_repo_pull_requests | Pull Request | List all pull requests in a repository |
| create_pull_request | Pull Request | Create a new pull request |
| search_users | User | Search for users |
| search_org_teams | Organization | Search for teams in an organization |
| search_repos | Repository | Search for repositories |
| get_gitea_mcp_server_version | Server | Get the version of the Gitea MCP Server |
## 🐛 Debugging
To enable debug mode, add the `-d` flag when running the Gitea MCP Server with sse mode:
```sh
./gitea-mcp -t sse [--port 8080] --token <your personal access token> -d
```
## 🛠 Troubleshooting
If you encounter any issues, here are some common troubleshooting steps:
1. **Check your PATH**: Ensure that the `gitea-mcp` binary is in a directory included in your system's PATH.
2. **Verify dependencies**: Make sure you have all the required dependencies installed, such as `make` and `Golang`.
3. **Review configuration**: Double-check your MCP configuration file for any errors or missing information.
4. **Consult logs**: Check the logs for any error messages or warnings that can provide more information about the issue.
Enjoy exploring and managing your Gitea repositories via chat!

78
THIRD_PARTY_LICENSES.md Normal file
View File

@@ -0,0 +1,78 @@
## Third-Party Licenses and Notices
This project uses thirdparty libraries. Below is a summary of their licenses and the key obligations when you distribute firmware/binaries that include them.
### Summary of obligations
- Keep original copyright and license notices.
- Include this file (or equivalent notices) with any distribution.
- For LGPLlicensed components (ESPAsyncWebServer, AsyncTCP):
- You are not required to publish your entire application.
- If you distribute binaries, you must provide a way for users to relink the application with a modified version of the LGPL library (e.g., provide object files of your application or the full source code), publish any changes you made to the LGPL libs, and include the LGPL license text.
- PrettyOTA has a custom permissive license with attribution and no rebranding without a commercial license.
---
### Dependency overview
| Library | Version (as configured) | License | Project | License Text |
|---|---|---|---|---|
| ArduinoJson | ^7.4.1 | MIT | [bblanchon/ArduinoJson](https://github.com/bblanchon/ArduinoJson) | [LICENSE](https://github.com/bblanchon/ArduinoJson/blob/v7.4.1/LICENSE.md) |
| ESPAsyncWebServer (esp32async) | ^3.7.7 | LGPL3.0 | [esp32async/ESPAsyncWebServer](https://github.com/esp32async/ESPAsyncWebServer) | [LICENSE](https://github.com/esp32async/ESPAsyncWebServer/blob/master/LICENSE) |
| AsyncTCP (esp32async) | ^3.4.2 | LGPL3.0 | [esp32async/AsyncTCP](https://github.com/esp32async/AsyncTCP) | [LICENSE](https://github.com/esp32async/AsyncTCP/blob/master/LICENSE) |
| PicoMQTT | ^1.3.0 | MIT | [mlesniew/PicoMQTT](https://github.com/mlesniew/PicoMQTT) | [LICENSE](https://github.com/mlesniew/PicoMQTT/blob/master/LICENSE) |
| MFRC522 | ^1.4.12 | MIT | [miguelbalboa/rfid](https://github.com/miguelbalboa/rfid) | [LICENSE](https://github.com/miguelbalboa/rfid/blob/master/LICENSE) |
| RTClib | ^2.1.4 | MIT | [adafruit/RTClib](https://github.com/adafruit/RTClib) | [LICENSE](https://github.com/adafruit/RTClib/blob/master/license.txt) |
| PrettyOTA (vendored) | included | Custom (see below) | Included in `lib/PrettyOTA` | See “PrettyOTA License” below |
---
### Notes on LGPL3.0 components (ESPAsyncWebServer, AsyncTCP)
If you distribute firmware that statically links these libraries (typical on microcontrollers):
- Provide a method for relinking your application with a modified version of the LGPL library. Practically, either:
- Provide object files (.o/.a) of your application so users can relink against a modified LGPL lib, or
- Publish your full application source code (voluntary but simpler), or
- Replace these libraries with permissive alternatives to avoid LGPL obligations.
- Publish any changes you made to the LGPL libraries themselves.
- Include the full text of the LGPL3.0 license with your distribution.
For many teams, the simplest path is to provide application object files or to switch to a permissively licensed HTTP/WebServer.
---
### PrettyOTA License (full text)
The PrettyOTA component is included under the following license. This license imposes attribution requirements and restricts removing/replacing the name/logo without a separate commercial license.
```
# License
Copyright (c) 2025 Marc Schöndorf
Permission is granted to anyone to use this software for private and commercial applications, to alter it and redistribute it, subject to the following conditions:
1. The origin of this software must not be misrepresented. You must not
claim that you wrote the original software. If you use this Software in a product, acknowledgment in the product documentation or credits is required.
2. Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software.
3. You are not permitted to modify, replace or remove the name "PrettyOTA" or the original logo displayed within the Software's default user interface (if applicable), unless you have obtained a separate commercial license granting you such rights. This restriction applies even when redistributing modified versions of the source code.
4. This license notice must not be removed or altered from any source code distribution.
**Disclaimer:**
The software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose and non-infringement. In no event shall the authors or copyright holders be liable for any claim, damages or other liability, whether in an action of contract, tort or otherwise, arising from, out of or in connection with the software or the use or other dealings in the software.
## Commercial Licensing
A separate commercial license is required for specific rights not granted herein, particularly for white-labeling or rebranding (using a different name or logo). Please refer to the README file or contact the copyright holder for details on obtaining a commercial license.
```
---
### Recommended distribution checklist
- Include this `THIRD_PARTY_LICENSES.md` with your firmware or product documentation.
- Include the full LGPL3.0 license text if you distribute firmware using ESPAsyncWebServer/AsyncTCP.
- Provide either application object files for relinking or publish source (to satisfy LGPL).
- Keep all copyright and license notices intact in UI/docs where applicable (e.g., PrettyOTA attribution).

View File

@@ -15,3 +15,6 @@ v2.0
- ADD option point for location (read from online table and select the location via dropdown) DONE
- ADD option to enter a name, age DONE
- ADD upload to a Online Database () DONE
- Redo Database Backend -> New SQL Server and deploy backend to edge functions?! Maybe host evrythin myself in a VM!

View File

@@ -9,7 +9,7 @@ POST /api/unlearn-button
→ Verlernt alle Button-Zuordnungen
POST /api/set-max-time
→ Setzt die maximale Zeit und maxTimeDisplay
→ Setzt die maximale Zeit, maxTimeDisplay und minTimeForLeaderboard
GET /api/get-settings
→ Gibt die aktuellen Einstellungen zurück

Binary file not shown.

View File

@@ -11,8 +11,8 @@ html {
}
body {
font-family: "Arial", sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
font-family: "Segoe UI", Arial, sans-serif;
background: linear-gradient(0deg, #0d1733 0%, #223c83 100%);
height: 100vh;
width: 100vw;
display: flex;
@@ -27,10 +27,12 @@ body {
.logo {
position: fixed;
top: 20px;
/* Vertikal zentriert im 60px-Header-Bereich (top:20px, height:60px → Mitte 50px) */
top: 50px;
left: 20px;
width: auto;
height: auto;
transform: translateY(-50%);
z-index: 1000;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
@@ -38,12 +40,12 @@ body {
text-decoration: none;
display: block;
cursor: pointer;
padding-left: 5px;
padding-right: 5px;
padding: 5px;
background:rgba(255, 255, 255, 0.6);
}
.logo:hover {
transform: scale(1.1);
transform: translateY(-50%) scale(1.1);
}
.logo img {
@@ -53,6 +55,32 @@ body {
border-radius: 10px;
}
.leaderboard-btn {
position: fixed;
top: 20px;
right: 90px;
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 15px;
border-radius: 50%;
text-decoration: none;
font-size: 1.5rem;
transition: all 0.3s ease;
z-index: 1000;
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
.leaderboard-btn:hover {
background: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.5);
transform: scale(1.1);
}
.settings-btn {
position: fixed;
top: 20px;
@@ -79,25 +107,114 @@ body {
transform: scale(1.1);
}
.heartbeat-indicators {
.live-clock {
position: fixed;
top: 20px;
right: 90px;
left: 25%;
transform: translateX(-50%);
height: 60px;
min-width: 150px;
display: flex;
gap: 15px;
align-items: center;
justify-content: center;
padding: 0 24px;
z-index: 1000;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-radius: 25px;
padding: 10px 20px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 30px;
font-family: "Consolas", "Menlo", "Courier New", monospace;
font-size: 1.6rem;
font-weight: 600;
letter-spacing: 2px;
color: rgba(255, 255, 255, 0.95);
font-variant-numeric: tabular-nums;
}
.heartbeat-indicators {
position: fixed;
top: 20px;
right: 160px;
height: 60px;
display: flex;
align-items: flex-end;
gap: 18px;
z-index: 1000;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-radius: 30px;
padding: 0 24px 10px 24px;
border: 1px solid rgba(255, 255, 255, 0.2);
}
@media (max-width: 768px) {
.logo {
width: 40px;
height: 40px;
/* Mobile: Header-Band top:15px height:60px → Mitte 45px */
top: 45px;
left: 15px;
padding: 3px;
}
.leaderboard-btn {
top: 15px;
right: 60px;
padding: 10px;
font-size: 1.2rem;
}
.settings-btn {
top: 15px;
right: 15px;
padding: 10px;
font-size: 1.2rem;
}
.live-clock {
top: 15px;
height: 40px;
min-width: 100px;
padding: 0 14px;
font-size: 1rem;
letter-spacing: 1px;
border-radius: 20px;
}
.heartbeat-indicators {
top: 15px;
right: 90px;
height: 60px;
gap: 12px;
padding: 0 16px 10px 16px;
font-size: 0.8rem;
border-radius: 30px;
}
.heartbeat-indicator {
width: 18px;
height: 18px;
}
.heartbeat-indicator::before {
font-size: 8px;
top: -14px;
}
.header h1 {
font-size: clamp(1.2rem, 3vw, 1.8rem);
}
.header p {
font-size: clamp(0.7rem, 1.5vw, 0.9rem);
}
}
.heartbeat-indicator {
width: 20px;
height: 20px;
width: 26px;
height: 26px;
border-radius: 50%;
background: #e74c3c;
background: #f50f0f;
transition: all 0.3s ease;
position: relative;
}
@@ -105,7 +222,7 @@ body {
.heartbeat-indicator::before {
content: attr(data-label);
position: absolute;
top: -25px;
top: -18px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
@@ -115,8 +232,8 @@ body {
}
.heartbeat-indicator.active {
background: #2ecc71;
box-shadow: 0 0 10px rgba(46, 204, 113, 0.5);
background: #00ff15;
box-shadow: 0 0 10px rgba(73, 186, 228, 0.5);
}
/* Batterie-Banner Styling */
@@ -125,7 +242,7 @@ body {
top: -100px;
left: 0;
width: 100%;
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
background: linear-gradient(135deg, #f59d0f 0%, #e67e22 100%);
color: white;
padding: 15px 20px;
text-align: center;
@@ -261,6 +378,9 @@ body {
font-size: clamp(1.8rem, 4vw, 2.5rem);
margin-bottom: 0.5vh;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
font-weight: bold;
text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif;
}
.header p {
@@ -297,15 +417,20 @@ body {
transition: transform 0.3s ease;
display: flex;
flex-direction: column;
justify-content: center;
justify-content: flex-start;
height: 100%;
overflow: hidden;
position: relative;
}
.lane h2 {
font-size: clamp(1.2rem, 2.5vw, 1.8rem);
margin-bottom: clamp(10px, 1vh, 15px);
color: #fff;
font-weight: bold;
text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif;
flex-shrink: 0;
}
.swimmer-name {
@@ -338,37 +463,84 @@ body {
}
.time-display {
font-size: clamp(3rem, 9vw, 10rem);
font-size: clamp(3rem, 13vw, 13rem);
font-weight: bold;
margin: clamp(10px, 1vh, 15px) 0;
font-family: "Courier New", monospace;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
line-height: 1;
position: relative;
z-index: 1;
flex-shrink: 0;
order: 1;
}
.status {
font-size: clamp(3rem, 1.8vw, 1.2rem);
font-size: clamp(1.5rem, 4vw, 5rem);
margin: clamp(8px, 1vh, 12px) 0;
padding: clamp(6px, 1vh, 10px) clamp(12px, 2vw, 18px);
border-radius: 20px;
display: inline-block;
font-weight: 600;
position: relative;
z-index: 2;
}
.status.ready {
background-color: rgba(52, 152, 219, 0.3);
border: 2px solid #3498db;
.status:not(.large-status) {
position: relative;
order: 2;
margin-top: auto;
}
.status.running {
background-color: rgba(46, 204, 113, 0.3);
border: 2px solid #2ecc71;
animation: pulse 1s infinite;
.status.large-status {
font-size: clamp(1.8rem, 5vw, 5rem);
position: absolute;
left: 50%;
transform: translateX(-50%);
z-index: 10;
margin: 0 !important;
padding: clamp(8px, 1.5vh, 15px) clamp(15px, 3vw, 30px);
white-space: normal;
pointer-events: none;
text-align: center;
background-color: rgba(0, 0, 0, 0.85) !important;
backdrop-filter: blur(5px);
width: calc(100% - 40px);
max-width: calc(100% - 40px);
word-wrap: break-word;
line-height: 1.3;
overflow: visible;
}
.status.large-status.ready {
display: flex !important;
align-items: center !important;
justify-content: center !important;
white-space: nowrap;
line-height: 1;
padding: 8px 12px !important;
overflow: hidden;
}
.status.finished {
background-color: rgba(231, 76, 60, 0.3);
border: 2px solid #e74c3c;
background-color: rgba(73, 186, 228, 0.3);
border: 2px solid #49bae4;
}
.status.ready {
background-color: rgb(0 165 3 / 54%);
border: 2px solid #06ff00;
}
.status.armed {
background-color: rgba(245, 157, 15, 0.3);
border: 2px solid #f59d0f;
animation: pulse 1s infinite;
}
.status.running {
background-color: rgb(255 91 0 / 65%);
border: 2px solid #f59d0f;
}
@keyframes pulse {
@@ -384,8 +556,8 @@ body {
}
.status.standby {
background-color: rgba(255, 193, 7, 0.3);
border: 2px solid #ffc107;
background-color: rgba(220, 242, 250, 0.3);
border: 2px solid #DCF2FA;
animation: standbyBlink 2s infinite;
}
@@ -410,23 +582,60 @@ body {
}
}
.leaderboards-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: clamp(15px, 2vw, 30px);
width: 100%;
max-width: 100%;
padding: 0 2vw;
margin-top: 0.5vh;
box-sizing: border-box;
flex-shrink: 0;
}
@media (max-width: 768px) {
.leaderboards-row {
grid-template-columns: 1fr;
gap: clamp(15px, 3vw, 30px);
padding: 0 15px;
}
}
.best-times {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
border-radius: 15px;
padding: clamp(10px, 1.5vh, 15px);
margin: 1vh 0 0 0;
width: 50%;
max-width: 50%;
border-radius: 12px;
padding: clamp(6px, 1vh, 10px);
text-align: center;
border: 1px solid rgba(255, 255, 255, 0.2);
flex-shrink: 0;
align-self: center;
display: flex;
flex-direction: column;
align-items: stretch;
gap: clamp(4px, 0.8vh, 8px);
box-sizing: border-box;
min-width: 0;
}
.best-times--full {
grid-column: 1 / -1;
}
.leaderboard-list {
text-align: left;
display: flex;
flex-direction: column;
gap: clamp(4px, 0.8vh, 8px);
width: 100%;
}
.best-times h3 {
font-size: clamp(0.9rem, 1.8vw, 1.1rem);
margin-bottom: clamp(5px, 0.5vh, 8px);
font-size: clamp(0.7rem, 1.2vw, 0.85rem);
margin: 0 auto;
font-weight: bold;
text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif;
text-align: center;
}
.best-time-row {
@@ -440,9 +649,209 @@ body {
border-radius: 8px;
}
/* Leaderboard Styles */
.leaderboard-entry {
display: flex;
justify-content: space-between;
align-items: center;
margin: 0;
font-size: clamp(0.7rem, 1.1vw, 0.9rem);
font-weight: 600;
background: rgba(255, 255, 255, 0.15);
padding: clamp(4px, 0.8vh, 7px) clamp(8px, 1.2vw, 12px);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.3);
transition: all 0.3s ease;
min-height: 0;
width: 100%;
box-sizing: border-box;
gap: 8px;
}
.leaderboard-entry:hover {
background: rgba(255, 255, 255, 0.25);
transform: translateY(-1px);
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
}
.leaderboard-entry.latest {
border: 2px solid #00ff88;
animation: latest-pulse 1.6s ease-in-out infinite;
position: relative;
z-index: 1;
}
.leaderboard-entry.latest .name {
color: #ffffff;
font-weight: 800;
text-shadow: 0 0 8px rgba(0, 255, 136, 0.7);
}
.leaderboard-entry.latest .time {
color: #ffffff;
animation: latest-time-flash 1.6s ease-in-out infinite;
}
.latest-badge {
display: inline-block;
background: #00ff88;
color: #0d1733;
font-weight: 900;
font-size: clamp(0.6rem, 1vw, 0.85rem);
letter-spacing: 1px;
padding: 3px 8px;
border-radius: 5px;
flex-shrink: 0;
text-transform: uppercase;
animation: latest-badge-pulse 1.6s ease-in-out infinite;
}
@keyframes latest-pulse {
0%,
100% {
background: linear-gradient(
135deg,
rgba(0, 255, 136, 0.28) 0%,
rgba(0, 200, 110, 0.18) 100%
);
box-shadow: 0 0 8px rgba(0, 255, 136, 0.35),
inset 0 0 6px rgba(0, 255, 136, 0.18);
border-color: #00ff88;
}
50% {
background: linear-gradient(
135deg,
rgba(0, 255, 136, 0.5) 0%,
rgba(0, 230, 120, 0.32) 100%
);
box-shadow: 0 0 16px rgba(0, 255, 136, 0.6),
0 0 32px rgba(0, 255, 136, 0.3),
inset 0 0 10px rgba(255, 255, 255, 0.25);
border-color: #ffffff;
}
}
@keyframes latest-badge-pulse {
0%,
100% {
background: #00ff88;
color: #0d1733;
box-shadow: 0 0 5px rgba(0, 255, 136, 0.5);
}
50% {
background: #ffffff;
color: #006a3a;
box-shadow: 0 0 10px rgba(255, 255, 255, 0.7),
0 0 16px rgba(0, 255, 136, 0.55);
}
}
@keyframes latest-time-flash {
0%,
100% {
text-shadow: 0 0 6px rgba(0, 255, 136, 0.55);
}
50% {
text-shadow: 0 0 8px #ffffff, 0 0 14px rgba(0, 255, 136, 0.7);
}
}
.leaderboard-entry .rank {
color: #ffd700;
font-weight: bold;
min-width: 20px;
font-size: clamp(0.75rem, 1.2vw, 0.95rem);
flex-shrink: 0;
}
.leaderboard-entry .name {
flex: 1;
margin: 0;
color: #ffffff;
font-weight: 500;
font-size: clamp(0.7rem, 1.1vw, 0.9rem);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.leaderboard-entry .time {
color: #00ff88;
font-weight: bold;
font-family: 'Courier New', monospace;
min-width: 70px;
text-align: right;
font-size: clamp(1rem, 1.8vw, 1.3rem);
flex-shrink: 0;
}
.leaderboard-entry.gold {
background: linear-gradient(135deg, #ffd700 0%, #ffed4e 100%);
border-color: #ffd700;
color: #b8860b;
font-weight: bold;
box-shadow: 0 4px 15px rgba(255, 215, 0, 0.3);
}
.leaderboard-entry.gold .rank {
color: #7a4d00;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.6);
}
.leaderboard-entry.gold .time {
color: #0f5132;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.5);
}
.leaderboard-entry.silver {
background: linear-gradient(135deg, #c0c0c0 0%, #e8e8e8 100%);
border-color: #c0c0c0;
color: #696969;
font-weight: bold;
box-shadow: 0 4px 15px rgba(192, 192, 192, 0.3);
}
.leaderboard-entry.silver .rank {
color: #4b5563;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.6);
}
.leaderboard-entry.silver .time {
color: #0f5132;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.5);
}
.leaderboard-entry.bronze {
background: linear-gradient(135deg, #cd7f32 0%, #e6a85c 100%);
border-color: #cd7f32;
color: #8b4513;
font-weight: bold;
box-shadow: 0 4px 15px rgba(205, 127, 50, 0.3);
}
.leaderboard-entry.bronze .rank {
color: #7a3410;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.6);
}
.leaderboard-entry.bronze .time {
color: #0f5132;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.5);
}
.no-times {
text-align: center;
color: rgba(255, 255, 255, 0.7);
font-style: italic;
font-size: clamp(0.9rem, 1.8vw, 1.1rem);
padding: 20px;
}
.learning-mode {
background: rgba(255, 193, 7, 0.2);
border: 2px solid #ffc107;
background: rgba(245, 157, 15, 0.2);
border: 2px solid #f59d0f;
border-radius: 15px;
padding: clamp(15px, 2vh, 20px);
margin: 2vh 0;
@@ -457,9 +866,12 @@ body {
}
.learning-mode h3 {
color: #ffc107;
color: #f59d0f;
margin-bottom: 10px;
font-size: clamp(1rem, 2vw, 1.3rem);
font-weight: bold;
text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif;
}
.learning-mode p {

View File

@@ -15,15 +15,19 @@
<div>
<div class="banner-text">⚠️ Niedrige Batterie erkannt!</div>
<div class="banner-devices" id="battery-devices">
Geräte mit niedriger Batterie: <span id="low-battery-list"></span>
Deine Geräte mit niedriger Batterie:
<span id="low-battery-list"></span>
</div>
</div>
</div>
<button class="close-btn" onclick="closeBatteryBanner()">&times;</button>
</div>
<img src="/pictures/logo.png" class="logo" alt="NinjaCross Logo" />
<img src="/pictures/erlebniss.png" class="logo" alt="NinjaCross Logo" />
<div id="live-clock" class="live-clock">--:--:--</div>
<a href="/leaderboard.html" class="leaderboard-btn">🏆</a>
<a href="/settings" class="settings-btn">⚙️</a>
<script src="/tts.js" defer></script>
<div class="heartbeat-indicators">
<div
@@ -42,45 +46,41 @@
<div class="header">
<h1>🏊‍♀️ NinjaCross Timer</h1>
<p>Professioneller Zeitmesser für Ninjacross Wettkämpfe</p>
</div>
<div id="learning-display" class="learning-mode" style="display: none">
<h3>📚 Lernmodus aktiv</h3>
<p>
Bitte drücken Sie den Button für: <span id="learning-button"></span>
</p>
<p>Drücke jetzt den Button für: <span id="learning-button"></span></p>
</div>
<div class="timer-container">
<div class="lane">
<div id="name1" class="swimmer-name" style="display: none"></div>
<h2>🏊‍♀️ Bahn 1</h2>
<div id="time1" class="time-display">00.00</div>
<div id="status1" class="status standby">
Standby: Bitte beide 1x betätigen
Standby: Drücke beide Buttons einmal
</div>
<div id="time1" class="time-display">00.00</div>
</div>
<div class="lane">
<div id="name2" class="swimmer-name" style="display: none"></div>
<h2>🏊‍♂️ Bahn 2</h2>
<div id="time2" class="time-display">00.00</div>
<div id="status2" class="status standby">
Standby: Bitte beide 1x betätigen
Standby: Drücke beide Buttons einmal
</div>
<div id="time2" class="time-display">00.00</div>
</div>
</div>
<div class="best-times">
<h3>🏆 Beste Zeiten des Tages</h3>
<div class="best-time-row">
<span>Bahn 1:</span>
<span id="best1">--.-</span>
<div class="leaderboards-row">
<div class="best-times" id="best-times-1">
<h3 id="lb-title-1">🏊‍♀️ Bahn 1 — Letzte Zeiten</h3>
<div id="leaderboard-container-1" class="leaderboard-list"></div>
</div>
<div class="best-time-row">
<span>Bahn 2:</span>
<span id="best2">--.-</span>
<div class="best-times" id="best-times-2">
<h3 id="lb-title-2">🏊‍♂️ Bahn 2 — Letzte Zeiten</h3>
<div id="leaderboard-container-2" class="leaderboard-list"></div>
</div>
</div>
@@ -97,6 +97,12 @@
let learningButton = "";
let name1 = "";
let name2 = "";
let leaderboardData = null;
// Lane Configuration
let laneConfigType = 0; // 0=Identical, 1=Different
let lane1DifficultyType = 0; // 0=Light, 1=Heavy
let lane2DifficultyType = 0; // 0=Light, 1=Heavy
// Batterie-Banner State
let lowBatteryDevices = new Set();
@@ -163,6 +169,12 @@
document.getElementById(indicatorId).classList.remove("active");
}
}
// Hinweis: Heartbeats und echte Tastendrücke kommen im WebSocket
// identisch als {button, mac, active: true} an. Eine optimistische
// Status-Übernahme (z. B. running→finished bei stop1) führte daher
// zu kurzem „Geschafft!"-Aufblitzen während des Laufs, sobald der
// Stop-Button einen periodischen Heartbeat sendete. Der Status
// kommt jetzt ausschließlich über syncFromBackend (1 s-Polling).
}
try {
@@ -188,24 +200,18 @@
}
// Namen-Handling
if (
(data.firstname == "" || data.lastname == "") &&
data.lane == "start1"
) {
if ((data.name == "" || !data.name) && data.lane == "start1") {
name1 = "";
}
if (
(data.firstname == "" || data.lastname == "") &&
data.lane == "start2"
) {
if ((data.name == "" || !data.name) && data.lane == "start2") {
name2 = "";
}
if (data.firstname && data.lastname && data.lane) {
if (data.name && data.lane) {
if (data.lane === "start1") {
name1 = `${data.firstname} ${data.lastname}`;
name1 = data.name;
} else if (data.lane === "start2") {
name2 = `${data.firstname} ${data.lastname}`;
name2 = data.name;
}
updateDisplay();
}
@@ -324,21 +330,372 @@
function getButtonDisplayName(button) {
switch (button) {
case "start1":
return "Start Bahn 1";
return "Start Button Bahn 1";
case "stop1":
return "Stop Bahn 1";
return "Stop Button Bahn 1";
case "start2":
return "Start Bahn 2";
return "Start Button Bahn 2";
case "stop2":
return "Stop Bahn 2";
return "Stop Button Bahn 2";
default:
return button;
}
}
// Passt "Bereit" so an, dass es die Status-Box maximal ausfüllt.
// Nutzt echte DOM-Messung via unsichtbarem Span → berechnet den
// Skalierungsfaktor und setzt font-size pixelgenau.
const fitReadyCache = { 1: { w: 0, h: 0, fs: 0 }, 2: { w: 0, h: 0, fs: 0 } };
function fitReadyText(statusEl, laneEl, laneNum) {
// Wir messen die Status-Box selbst — sie wurde vorher bereits
// positioniert (top/bottom/width gesetzt), hat also ihre finale Größe.
const sw = statusEl.clientWidth;
const sh = statusEl.clientHeight;
if (!sw || !sh) return;
const cache = fitReadyCache[laneNum];
if (cache.w === sw && cache.h === sh) {
statusEl.style.fontSize = cache.fs + "px";
return;
}
// Innenraum der Status-Box (nach Padding)
const cs = window.getComputedStyle(statusEl);
const pL = parseFloat(cs.paddingLeft) || 0;
const pR = parseFloat(cs.paddingRight) || 0;
const pT = parseFloat(cs.paddingTop) || 0;
const pB = parseFloat(cs.paddingBottom) || 0;
const availW = sw - pL - pR - 6;
const availH = sh - pT - pB - 6;
if (availW <= 0 || availH <= 0) return;
// Unsichtbarer Messspan im selben Font
let m = fitReadyText.m;
if (!m) {
m = document.createElement("span");
m.style.cssText =
"position:absolute;visibility:hidden;white-space:nowrap;" +
"left:-99999px;top:0;font-family:'Segoe UI',Arial,sans-serif;" +
"font-weight:600;line-height:1;padding:0;margin:0";
m.textContent = "Bereit";
document.body.appendChild(m);
fitReadyText.m = m;
}
const refSize = 200;
m.style.fontSize = refSize + "px";
const textW = m.offsetWidth || 1;
const textH = m.offsetHeight || 1;
// Skalierungsfaktor so wählen, dass Breite UND Höhe passen
const scale = Math.min(availW / textW, availH / textH);
const finalFs = Math.max(20, Math.floor(refSize * scale));
cache.w = sw;
cache.h = sh;
cache.fs = finalFs;
statusEl.style.fontSize = finalFs + "px";
}
// Passt die Timer-Zeit (Courier-Monospace) so an, dass sie den Platz
// zwischen h2 und Status maximal ausnutzt.
const fitTimeCache = {
1: { len: 0, lw: 0, lh: 0, fs: 0 },
2: { len: 0, lw: 0, lh: 0, fs: 0 },
};
function fitTimeText(timeEl, laneEl, laneNum) {
const text = timeEl.textContent;
const len = text.length;
const lw = laneEl.clientWidth;
const lh = laneEl.clientHeight;
if (!lw || !lh) return;
const cache = fitTimeCache[laneNum];
if (cache.len === len && cache.lw === lw && cache.lh === lh) {
timeEl.style.fontSize = cache.fs + "px";
return;
}
let m = fitTimeText.m;
if (!m) {
m = document.createElement("span");
m.style.cssText =
"position:absolute;visibility:hidden;white-space:nowrap;" +
"left:-99999px;top:0;font-family:'Courier New',monospace;" +
"font-weight:bold;line-height:1;padding:0;margin:0";
document.body.appendChild(m);
fitTimeText.m = m;
}
const refSize = 200;
m.style.fontSize = refSize + "px";
m.textContent = text;
const textW = m.offsetWidth || 1;
const textH = m.offsetHeight || 1;
// Aggressiv: 92% Breite, 62% Höhe (h2 oben + Status unten reserviert)
const availW = lw * 0.92;
const availH = lh * 0.62;
const scale = Math.min(availW / textW, availH / textH);
const fs = Math.max(30, Math.floor(refSize * scale));
cache.len = len;
cache.lw = lw;
cache.lh = lh;
cache.fs = fs;
timeEl.style.fontSize = fs + "px";
}
function formatTime(seconds) {
if (seconds === 0) return "00.00";
return seconds.toFixed(2);
const totalSeconds = Math.floor(seconds);
const minutes = Math.floor(totalSeconds / 60);
const remainingSeconds = totalSeconds % 60;
const milliseconds = Math.floor((seconds - totalSeconds) * 100);
// Zeige Minuten nur wenn über 60 Sekunden
if (totalSeconds >= 60) {
return `${minutes.toString().padStart(2, "0")}:${remainingSeconds
.toString()
.padStart(2, "0")}.${milliseconds.toString().padStart(2, "0")}`;
} else {
return `${remainingSeconds.toString().padStart(2, "0")}.${milliseconds
.toString()
.padStart(2, "0")}`;
}
}
// Leaderboard Funktionen
async function loadLeaderboard() {
try {
const response = await fetch("/api/leaderboard");
leaderboardData = await response.json();
updateLeaderboardDisplay();
} catch (error) {
console.error("Fehler beim Laden des Leaderboards:", error);
}
}
function formatEndTime(epochSeconds) {
if (!epochSeconds || epochSeconds < 1577836800) return ""; // < 2020 = kein NTP-Sync
const d = new Date(epochSeconds * 1000);
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
const ss = String(d.getSeconds()).padStart(2, "0");
return `${hh}:${mm}:${ss}`;
}
function createEntryElement(entry, isLatest) {
const div = document.createElement("div");
div.className = "leaderboard-entry";
if (isLatest) div.classList.add("latest");
if (isLatest) {
const badge = document.createElement("span");
badge.className = "latest-badge";
badge.textContent = "NEU";
div.appendChild(badge);
}
const nameSpan = document.createElement("span");
nameSpan.className = "name";
let label = entry.name || "Unbekannt";
// Bei "Lauf N"-Einträgen die Endzeit in Klammern anhängen
if (/^Lauf\s+\d+$/.test(label)) {
const endTime = formatEndTime(entry.endEpoch);
if (endTime) label += ` (${endTime})`;
}
nameSpan.textContent = label;
const timeSpan = document.createElement("span");
timeSpan.className = "time";
timeSpan.textContent = entry.timeFormatted;
div.appendChild(nameSpan);
div.appendChild(timeSpan);
return div;
}
function fillLeaderboardContainer(container, entries) {
container.innerHTML = "";
if (!entries || entries.length === 0) {
const empty = document.createElement("div");
empty.className = "no-times";
empty.textContent = "Noch keine Zeiten";
container.appendChild(empty);
return;
}
entries.forEach((e, i) =>
container.appendChild(createEntryElement(e, i === 0))
);
}
// -------- Fly-down Animation --------
// Wird ausgelöst beim Status-Übergang finished -> ready (kurz vor dem
// Auto-Reset des Backends). Damit bleibt die große Zeit oben sichtbar
// bis der Backend resettet, und fliegt dann erst nach unten.
// Snapshot der Quelle einfrieren, BEVOR sie versteckt wird.
function captureSourceSnapshot(el) {
if (!el) return null;
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return null;
const cs = window.getComputedStyle(el);
return {
rect,
fontSize: cs.fontSize,
fontFamily: cs.fontFamily,
fontWeight: cs.fontWeight,
color: cs.color,
text: el.textContent,
};
}
function flyDownFromSnapshot(srcSnap, destEl) {
if (!srcSnap || !destEl) return;
const dstRect = destEl.getBoundingClientRect();
if (dstRect.width === 0 || dstRect.height === 0) return;
// ---- Phase 1: bestehende Einträge nach unten "schieben" ----
// Wir verstecken den (bereits gerenderten) neuen Top-Eintrag und
// setzen die Geschwister visuell an die Position, die sie VOR
// dem neuen Eintrag hatten (eine Slot-Höhe nach oben). Dann
// gleiten sie animiert in ihre natürliche Position herunter.
const container = destEl.parentElement;
const siblings = container
? Array.from(
container.querySelectorAll(".leaderboard-entry")
).filter((e) => e !== destEl)
: [];
let shiftPx = dstRect.height;
if (container) {
const cs = window.getComputedStyle(container);
const gap =
parseFloat(cs.rowGap) || parseFloat(cs.gap) || 0;
shiftPx += gap;
}
// Dest sofort verstecken (Layout-Slot bleibt erhalten)
destEl.style.visibility = "hidden";
// Geschwister hochsetzen (instant, ohne Transition)
siblings.forEach((s) => {
s.style.transition = "none";
s.style.transform = `translateY(-${shiftPx}px)`;
});
// Reflow, damit der "instant"-Setup wirkt
if (siblings.length > 0) siblings[0].getBoundingClientRect();
// Slide nach unten zur natürlichen Position
const slideMs = 280;
siblings.forEach((s) => {
s.style.transition = `transform ${slideMs}ms cubic-bezier(0.4, 0, 0.2, 1)`;
s.style.transform = "translateY(0)";
});
// ---- Phase 2: nach dem Slide den Ghost einfliegen lassen ----
const flyMs = 800;
setTimeout(() => {
const ghost = document.createElement("div");
ghost.textContent = srcSnap.text;
ghost.style.position = "fixed";
ghost.style.left = srcSnap.rect.left + "px";
ghost.style.top = srcSnap.rect.top + "px";
ghost.style.width = srcSnap.rect.width + "px";
ghost.style.height = srcSnap.rect.height + "px";
ghost.style.margin = "0";
ghost.style.padding = "0";
ghost.style.zIndex = "9999";
ghost.style.pointerEvents = "none";
ghost.style.color = srcSnap.color;
ghost.style.fontFamily = srcSnap.fontFamily;
ghost.style.fontWeight = srcSnap.fontWeight;
ghost.style.fontSize = srcSnap.fontSize;
ghost.style.lineHeight = "1";
ghost.style.display = "flex";
ghost.style.alignItems = "center";
ghost.style.justifyContent = "center";
ghost.style.textShadow = "0 0 12px rgba(0, 255, 136, 0.8)";
ghost.style.borderRadius = "8px";
ghost.style.transition =
`left ${flyMs}ms cubic-bezier(0.55, 0, 0.3, 1),` +
`top ${flyMs}ms cubic-bezier(0.55, 0.05, 0.3, 1.1),` +
`width ${flyMs}ms cubic-bezier(0.55, 0, 0.3, 1),` +
`height ${flyMs}ms cubic-bezier(0.55, 0, 0.3, 1),` +
`font-size ${flyMs}ms cubic-bezier(0.55, 0, 0.3, 1),` +
`color ${flyMs}ms ease-out,` +
`text-shadow ${flyMs}ms ease-out`;
document.body.appendChild(ghost);
// Reflow erzwingen, damit die Anfangsposition wirkt
ghost.getBoundingClientRect();
// Endwerte: aktuelle Position des .time-Spans im Eintrag
const destTimeSpan = destEl.querySelector(".time") || destEl;
const destTimeRect = destTimeSpan.getBoundingClientRect();
const destStyle = window.getComputedStyle(destTimeSpan);
ghost.style.left = destTimeRect.left + "px";
ghost.style.top = destTimeRect.top + "px";
ghost.style.width = destTimeRect.width + "px";
ghost.style.height = destTimeRect.height + "px";
ghost.style.fontSize = destStyle.fontSize;
ghost.style.color = destStyle.color;
ghost.style.textShadow = "0 0 6px rgba(0, 255, 136, 0.55)";
setTimeout(() => {
ghost.remove();
destEl.style.visibility = "";
// Sibling-Transforms aufräumen (sie sind eh schon bei 0)
siblings.forEach((s) => {
s.style.transition = "";
s.style.transform = "";
});
}, flyMs + 20);
}, slideMs);
}
// Bestimmt das Ziel-Element (erster Leaderboard-Eintrag) für eine Lane.
function findFlyDest(lane) {
const containerId =
leaderboardData && leaderboardData.mode === "different"
? "leaderboard-container-" + lane
: "leaderboard-container-1";
const container = document.getElementById(containerId);
if (!container) return null;
return container.querySelector(".leaderboard-entry");
}
function updateLeaderboardDisplay() {
const box1 = document.getElementById("best-times-1");
const box2 = document.getElementById("best-times-2");
const container1 = document.getElementById("leaderboard-container-1");
const container2 = document.getElementById("leaderboard-container-2");
const title1 = document.getElementById("lb-title-1");
const title2 = document.getElementById("lb-title-2");
if (!leaderboardData) {
return;
}
// Reset Layout-Klassen
box1.classList.remove("best-times--full");
box2.style.display = "";
if (leaderboardData.mode === "different") {
// Unterschiedliche Lanes: eigene History pro Bahn unter jeder Lane
title1.textContent = "🏊‍♀️ Bahn 1 — Letzte Zeiten";
title2.textContent = "🏊‍♂️ Bahn 2 — Letzte Zeiten";
fillLeaderboardContainer(container1, leaderboardData.lane1);
fillLeaderboardContainer(container2, leaderboardData.lane2);
} else {
// Identische Lanes: ein gemeinsames Leaderboard über beide Spalten
title1.textContent = "🏆 Letzte Zeiten";
box1.classList.add("best-times--full");
box2.style.display = "none";
fillLeaderboardContainer(container1, leaderboardData.entries);
}
}
function updateDisplay() {
@@ -363,38 +720,213 @@
document.getElementById("time1").textContent = formatTime(display1);
const time1Element = document.getElementById("time1");
const lane1Element = time1Element.closest(".lane");
const h2_1 = lane1Element.querySelector("h2");
if (!lane1Connected) {
s1.className = "status standby";
s1.textContent = "Standby: Bitte beide Buttons 1x betätigen";
s1.className = "status standby large-status";
s1.textContent = "Standby: Drücke beide Buttons einmal";
time1Element.style.display = "none";
// Position über time-display, aber innerhalb des Containers
if (s1.classList.contains("large-status")) {
const lane1Rect = lane1Element.getBoundingClientRect();
const h2Rect = h2_1.getBoundingClientRect();
const h2Bottom = h2Rect.bottom - lane1Rect.top;
const startTop = h2Bottom + 10;
s1.style.top = startTop + "px";
s1.style.left = "50%";
s1.style.transform = "translateX(-50%)";
s1.style.bottom = "20px";
s1.style.width = "calc(100% - 40px)";
s1.style.display = "flex";
s1.style.alignItems = "center";
s1.style.justifyContent = "center";
}
} else {
s1.className = `status ${status1}`;
s1.textContent =
status1 === "ready"
? "Bereit"
: status1 === "running"
? "Läuft..."
: "Beendet";
// Wenn status "ready" ist, verstecke Zeit und mache Status groß
if (status1 === "ready") {
s1.classList.add("large-status");
time1Element.style.display = "none";
const lane1Rect = lane1Element.getBoundingClientRect();
const h2Rect = h2_1.getBoundingClientRect();
const h2Bottom = h2Rect.bottom - lane1Rect.top;
const startTop = h2Bottom + 10;
s1.style.top = startTop + "px";
s1.style.left = "50%";
s1.style.transform = "translateX(-50%)";
s1.style.bottom = "20px";
s1.style.width = "calc(100% - 40px)";
s1.style.display = "flex";
s1.style.alignItems = "center";
s1.style.justifyContent = "center";
fitReadyText(s1, lane1Element, 1);
} else {
// Bei anderen Status (running, finished, etc.) zeige Zeit wieder an
time1Element.style.display = "";
if (status1 !== "running" && status1 !== "finished") {
s1.classList.add("large-status");
const time1Rect = time1Element.getBoundingClientRect();
const lane1Rect = lane1Element.getBoundingClientRect();
const h2Rect = h2_1.getBoundingClientRect();
const time1Center = time1Rect.top - lane1Rect.top + time1Rect.height / 2;
const h2Bottom = h2Rect.bottom - lane1Rect.top;
const startTop = h2Bottom + 10;
const statusHeight = s1.offsetHeight || 200;
const targetTop = Math.max(startTop, time1Center - statusHeight / 2);
s1.style.top = targetTop + "px";
s1.style.transform = "translateX(-50%)";
s1.style.height = "";
s1.style.width = "";
s1.style.display = "";
s1.style.alignItems = "";
s1.style.justifyContent = "";
s1.style.fontSize = "";
const maxHeight = lane1Rect.height - targetTop - 30;
s1.style.maxHeight = maxHeight + "px";
s1.style.overflow = "auto";
} else {
s1.classList.remove("large-status");
s1.style.top = "";
s1.style.transform = "";
s1.style.maxHeight = "";
s1.style.height = "";
s1.style.width = "";
s1.style.display = "";
s1.style.alignItems = "";
s1.style.justifyContent = "";
s1.style.fontSize = "";
s1.style.left = "";
s1.style.bottom = "";
fitTimeText(time1Element, lane1Element, 1);
}
}
switch (status1) {
case "ready":
s1.textContent = "Bereit";
break;
case "running":
s1.textContent = "Läuft - Gib alles!";
break;
case "finished":
s1.textContent = "Geschafft!";
break;
case "armed":
s1.textContent = "Bereit zum Start!";
break;
default:
s1.textContent = "Status unbekannt";
}
}
document.getElementById("time2").textContent = formatTime(display2);
const time2Element = document.getElementById("time2");
const lane2Element = time2Element.closest(".lane");
const h2_2 = lane2Element.querySelector("h2");
if (!lane2Connected) {
s2.className = "status standby";
s2.textContent = "Standby: Bitte beide 1x betätigen";
s2.className = "status standby large-status";
s2.textContent = "Standby: Drücke beide Buttons einmal";
time2Element.style.display = "none";
// Position über time-display, aber innerhalb des Containers
if (s2.classList.contains("large-status")) {
const lane2Rect = lane2Element.getBoundingClientRect();
const h2Rect = h2_2.getBoundingClientRect();
const h2Bottom = h2Rect.bottom - lane2Rect.top;
const startTop = h2Bottom + 10;
s2.style.top = startTop + "px";
s2.style.left = "50%";
s2.style.transform = "translateX(-50%)";
s2.style.bottom = "20px";
s2.style.width = "calc(100% - 40px)";
s2.style.display = "flex";
s2.style.alignItems = "center";
s2.style.justifyContent = "center";
}
} else {
s2.className = `status ${status2}`;
s2.textContent =
status2 === "ready"
? "Bereit"
: status2 === "running"
? "Läuft..."
: "Beendet";
// Wenn status "ready" ist, verstecke Zeit und mache Status groß
if (status2 === "ready") {
s2.classList.add("large-status");
time2Element.style.display = "none";
const lane2Rect = lane2Element.getBoundingClientRect();
const h2Rect = h2_2.getBoundingClientRect();
const h2Bottom = h2Rect.bottom - lane2Rect.top;
const startTop = h2Bottom + 10;
s2.style.top = startTop + "px";
s2.style.left = "50%";
s2.style.transform = "translateX(-50%)";
s2.style.bottom = "20px";
s2.style.width = "calc(100% - 40px)";
s2.style.display = "flex";
s2.style.alignItems = "center";
s2.style.justifyContent = "center";
fitReadyText(s2, lane2Element, 2);
} else {
// Bei anderen Status (running, finished, etc.) zeige Zeit wieder an
time2Element.style.display = "";
if (status2 !== "running" && status2 !== "finished") {
s2.classList.add("large-status");
const time2Rect = time2Element.getBoundingClientRect();
const lane2Rect = lane2Element.getBoundingClientRect();
const h2Rect = h2_2.getBoundingClientRect();
const time2Center = time2Rect.top - lane2Rect.top + time2Rect.height / 2;
const h2Bottom = h2Rect.bottom - lane2Rect.top;
const startTop = h2Bottom + 10;
const statusHeight = s2.offsetHeight || 200;
const targetTop = Math.max(startTop, time2Center - statusHeight / 2);
s2.style.top = targetTop + "px";
s2.style.transform = "translateX(-50%)";
s2.style.height = "";
s2.style.width = "";
s2.style.display = "";
s2.style.alignItems = "";
s2.style.justifyContent = "";
s2.style.fontSize = "";
const maxHeight = lane2Rect.height - targetTop - 30;
s2.style.maxHeight = maxHeight + "px";
s2.style.overflow = "auto";
} else {
s2.classList.remove("large-status");
s2.style.top = "";
s2.style.transform = "";
s2.style.maxHeight = "";
s2.style.height = "";
s2.style.width = "";
s2.style.display = "";
s2.style.alignItems = "";
s2.style.justifyContent = "";
s2.style.fontSize = "";
s2.style.left = "";
s2.style.bottom = "";
fitTimeText(time2Element, lane2Element, 2);
}
}
switch (status2) {
case "ready":
s2.textContent = "Bereit";
break;
case "running":
s2.textContent = "Läuft - Gib alles!";
break;
case "finished":
s2.textContent = "Geschafft!";
break;
case "armed":
s2.textContent = "Bereit zum Start!";
break;
default:
s2.textContent = "Status unbekannt";
}
}
document.getElementById("best1").textContent =
best1 > 0 ? formatTime(best1) + "s" : "--.-";
document.getElementById("best2").textContent =
best2 > 0 ? formatTime(best2) + "s" : "--.-";
// Leaderboard wird separat geladen
// Namen anzeigen/verstecken
const name1Element = document.getElementById("name1");
@@ -425,31 +957,129 @@
}
}
const validStatuses = ["ready", "running", "finished", "armed"];
function syncFromBackend() {
fetch("/api/data")
.then((response) => response.json())
.then((data) => {
timer1 = data.time1;
timer2 = data.time2;
status1 = data.status1;
status2 = data.status2;
// Alte Status-Werte sichern, BEVOR sie überschrieben werden
const oldStatus1 = status1;
const oldStatus2 = status2;
// Status nur übernehmen, wenn der Wert gültig ist.
// Bei unvollständiger ESP-Response (Last) bleibt der
// bisherige Status erhalten statt "Status unbekannt".
if (validStatuses.includes(data.status1)) status1 = data.status1;
if (validStatuses.includes(data.status2)) status2 = data.status2;
best1 = data.best1;
best2 = data.best2;
// TTS: bei Übergang running -> finished die Endzeit ansagen
// ("Neue Zeit: ..."). Bahn-/Status-Phrasen werden bewusst
// weggelassen.
if (window.tts && tts.isEnabled()) {
if (oldStatus1 === 'running' && status1 === 'finished' && data.time1 > 0) {
tts.sayTime(data.time1);
}
if (oldStatus2 === 'running' && status2 === 'finished' && data.time2 > 0) {
tts.sayTime(data.time2);
}
}
learningMode = data.learningMode;
learningButton = data.learningButton || "";
lastSync = Date.now();
updateDisplay();
// Übergang finished -> ready erkennen.
// Snapshot der großen Zeit JETZT einfrieren, bevor
// kickDisplayScheduler/updateDisplay sie versteckt.
const fly1 =
oldStatus1 === "finished" && status1 === "ready"
? captureSourceSnapshot(document.getElementById("time1"))
: null;
const fly2 =
oldStatus2 === "finished" && status2 === "ready"
? captureSourceSnapshot(document.getElementById("time2"))
: null;
kickDisplayScheduler();
// Animation auf nächsten Frame, wenn updateDisplay durch ist
if (fly1 || fly2) {
requestAnimationFrame(() => {
if (fly1) flyDownFromSnapshot(fly1, findFlyDest(1));
if (fly2) flyDownFromSnapshot(fly2, findFlyDest(2));
});
}
})
.catch((error) =>
console.error("Fehler beim Laden der Daten:", error)
console.error("Fehler beim Laden deiner Daten:", error)
);
}
function loadLaneConfig() {
fetch("/api/get-lane-config")
.then((response) => response.json())
.then((data) => {
laneConfigType = data.type === "different" ? 1 : 0;
lane1DifficultyType = data.lane1Difficulty === "heavy" ? 1 : 0;
lane2DifficultyType = data.lane2Difficulty === "heavy" ? 1 : 0;
updateLaneDisplay();
})
.catch((error) =>
console.error(
"Fehler beim Laden der Lane-Schwierigkeits-Konfiguration:",
error
)
);
}
function updateLaneDisplay() {
const lane1Title = document.querySelector(".lane h2");
const lane2Title = document.querySelectorAll(".lane h2")[1];
if (laneConfigType === 0) {
// Identische Lanes
lane1Title.textContent = "🏊‍♀️ Bahn 1";
lane2Title.textContent = "🏊‍♂️ Bahn 2";
} else {
// Unterschiedliche Lanes
const lane1Icon = lane1DifficultyType === 0 ? "🟢" : "🔴";
const lane2Icon = lane2DifficultyType === 0 ? "🟢" : "🔴";
const lane1Difficulty =
lane1DifficultyType === 0 ? "Leicht" : "Schwer";
const lane2Difficulty =
lane2DifficultyType === 0 ? "Leicht" : "Schwer";
lane1Title.textContent = `${lane1Icon} Bahn 1 (${lane1Difficulty})`;
lane2Title.textContent = `${lane2Icon} Bahn 2 (${lane2Difficulty})`;
}
}
// Sync with backend every 1 second
setInterval(syncFromBackend, 1000);
// Smooth update every 50ms
setInterval(updateDisplay, 50);
// Adaptive Update-Rate: 50 ms wenn mindestens eine Bahn läuft,
// sonst 500 ms. Über kickDisplayScheduler() kann der Zyklus sofort
// neu gestartet werden (WebSocket-Start-Event, frische Sync-Daten),
// damit beim Übergang Stand→Lauf nichts springt.
let displayTimer = null;
function scheduleDisplayUpdate() {
updateDisplay();
const anyRunning = status1 === "running" || status2 === "running";
displayTimer = setTimeout(scheduleDisplayUpdate, anyRunning ? 50 : 500);
}
function kickDisplayScheduler() {
if (displayTimer !== null) {
clearTimeout(displayTimer);
displayTimer = null;
}
scheduleDisplayUpdate();
}
scheduleDisplayUpdate();
// Heartbeat timeout check (every second)
setInterval(() => {
@@ -466,8 +1096,33 @@
});
}, 1000);
window.addEventListener("resize", () => {
fitReadyCache[1].w = 0;
fitReadyCache[2].w = 0;
fitTimeCache[1].lw = 0;
fitTimeCache[2].lw = 0;
updateDisplay();
});
// Initial load
syncFromBackend();
loadLaneConfig();
loadLeaderboard();
// Leaderboard alle 5 Sekunden aktualisieren
setInterval(loadLeaderboard, 5000);
// Live-Uhr im Header (HH:mm:ss, Browser-Lokalzeit)
function updateLiveClock() {
const now = new Date();
const hh = String(now.getHours()).padStart(2, "0");
const mm = String(now.getMinutes()).padStart(2, "0");
const ss = String(now.getSeconds()).padStart(2, "0");
const el = document.getElementById("live-clock");
if (el) el.textContent = `${hh}:${mm}:${ss}`;
}
updateLiveClock();
setInterval(updateLiveClock, 1000);
</script>
</body>
</html>

367
data/leaderboard.css Normal file
View File

@@ -0,0 +1,367 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Segoe UI", Arial, sans-serif;
background: linear-gradient(0deg, #0d1733 0%, #223c83 100%);
min-height: 100vh;
padding: 20px;
}
.back-btn {
position: fixed;
top: 20px;
left: 20px;
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 15px;
border-radius: 50%;
text-decoration: none;
font-size: 1.5rem;
transition: all 0.3s ease;
z-index: 1000;
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
.back-btn:hover {
background: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.5);
transform: scale(1.1);
}
.container {
max-width: 800px;
margin: 0 auto;
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
overflow: visible;
backdrop-filter: blur(10px);
}
.header {
background: linear-gradient(135deg, #49bae4 0%, #223c83 100%);
color: white;
padding: 30px;
text-align: center;
position: relative;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
position: relative;
z-index: 1;
font-weight: bold;
text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif;
}
.content {
padding: 30px;
}
.leaderboard-container {
background: white;
border-radius: 12px;
padding: 20px;
border: 2px solid #e9ecef;
min-height: 150px;
max-height: none;
overflow: visible;
}
.leaderboard-row {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 20px;
}
.leaderboard-row:last-child {
margin-bottom: 0;
}
@media (min-width: 768px) {
.leaderboard-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
align-items: start;
grid-auto-rows: min-content;
}
.leaderboard-row {
margin-bottom: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
}
.leaderboard-entry {
display: flex;
justify-content: space-between;
align-items: center;
margin: 15px 0;
font-size: 1.1em;
font-weight: 600;
background: #f8f9fa;
padding: 15px 20px;
border-radius: 10px;
border: 2px solid #e9ecef;
transition: all 0.3s ease;
}
.leaderboard-entry:hover {
background: #e9ecef;
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}
.leaderboard-entry.gold {
background: linear-gradient(135deg, #ffd700 0%, #ffed4e 100%);
border-color: #ffd700;
color: #b8860b;
font-weight: bold;
box-shadow: 0 4px 15px rgba(255, 215, 0, 0.3);
}
.leaderboard-entry.silver {
background: linear-gradient(135deg, #c0c0c0 0%, #e8e8e8 100%);
border-color: #c0c0c0;
color: #696969;
font-weight: bold;
box-shadow: 0 4px 15px rgba(192, 192, 192, 0.3);
}
.leaderboard-entry.bronze {
background: linear-gradient(135deg, #cd7f32 0%, #e6a85c 100%);
border-color: #cd7f32;
color: #8b4513;
font-weight: bold;
box-shadow: 0 4px 15px rgba(205, 127, 50, 0.3);
}
.leaderboard-entry .rank {
font-weight: bold;
min-width: 40px;
font-size: 1.2em;
text-align: center;
}
.leaderboard-entry .name {
flex: 1;
margin: 0 20px;
font-weight: 600;
}
.leaderboard-entry .time {
font-weight: bold;
font-family: 'Courier New', monospace;
min-width: 100px;
text-align: right;
font-size: 1.1em;
}
.no-entries {
text-align: center;
color: #6c757d;
font-style: italic;
font-size: 1.1em;
padding: 40px;
}
.loading {
text-align: center;
color: #49bae4;
font-size: 1.1em;
padding: 40px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
/* Modern Notification Toast */
.notification-toast {
position: fixed;
top: 24px;
right: 24px;
min-width: 320px;
max-width: 400px;
background: rgba(255, 255, 255, 0.98);
border-radius: 16px;
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04),
0 0 0 1px rgba(0, 0, 0, 0.05);
backdrop-filter: blur(20px);
z-index: 99999;
display: none;
align-items: flex-start;
gap: 12px;
padding: 16px;
transform: translateX(100%);
opacity: 0;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
pointer-events: auto;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.notification-toast.show {
transform: translateX(0);
opacity: 1;
}
.notification-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
color: white;
background: linear-gradient(135deg, #10b981, #059669);
}
.notification-body {
flex: 1;
min-width: 0;
}
.notification-title {
font-size: 14px;
font-weight: 600;
color: #111827;
margin-bottom: 4px;
line-height: 1.2;
}
.notification-message {
font-size: 13px;
color: #6b7280;
line-height: 1.4;
word-wrap: break-word;
}
.notification-close {
flex-shrink: 0;
width: 32px;
height: 32px;
border: none;
background: none;
color: #9ca3af;
cursor: pointer;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
margin-top: -4px;
margin-right: -4px;
}
.notification-close:hover {
background: rgba(0, 0, 0, 0.05);
color: #374151;
}
.notification-close:active {
transform: scale(0.95);
}
/* Toast Types */
.notification-toast.success .notification-icon {
background: linear-gradient(135deg, #10b981, #059669);
}
.notification-toast.error .notification-icon {
background: linear-gradient(135deg, #ef4444, #dc2626);
}
.notification-toast.info .notification-icon {
background: linear-gradient(135deg, #3b82f6, #2563eb);
}
.notification-toast.warning .notification-icon {
background: linear-gradient(135deg, #f59e0b, #d97706);
}
/* Mobile Responsiveness */
@media (max-width: 768px) {
.container {
margin: 10px;
border-radius: 15px;
}
.content {
padding: 20px;
}
.leaderboard-entry {
flex-direction: column;
gap: 10px;
text-align: center;
}
.leaderboard-entry .name {
margin: 0;
order: 1;
}
.leaderboard-entry .rank {
order: 2;
}
.leaderboard-entry .time {
order: 3;
}
/* Mobile notification adjustments */
.notification-toast {
top: 10px;
right: 10px;
left: 10px;
max-width: none;
font-size: 14px;
padding: 12px 16px;
}
}
@media (max-width: 480px) {
.header h1 {
font-size: 2em;
}
.leaderboard-entry {
padding: 12px 15px;
font-size: 1em;
}
.leaderboard-entry .rank {
font-size: 1.1em;
}
.leaderboard-entry .time {
font-size: 1em;
}
}

248
data/leaderboard.html Normal file
View File

@@ -0,0 +1,248 @@
<!DOCTYPE html>
<html lang="de">
<head>
<!-- Meta Tags -->
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" href="/pictures/favicon.ico" />
<!-- Stylesheets -->
<link rel="stylesheet" href="leaderboard.css" />
<title>Ninjacross Timer - Leaderboard</title>
<script src="/tts.js" defer></script>
</head>
<body>
<!-- Modern Notification Toast -->
<div
id="notificationBubble"
class="notification-toast"
style="display: none"
>
<div class="notification-icon">
<span id="notificationIcon"></span>
</div>
<div class="notification-body">
<div class="notification-title" id="notificationTitle">Erfolg</div>
<div class="notification-message" id="notificationText">Bereit</div>
</div>
<button class="notification-close" onclick="hideNotification()">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"
/>
</svg>
</button>
</div>
<!-- Zurück Button -->
<a href="/" class="back-btn">🏠</a>
<div class="container">
<!-- Header Section -->
<div class="header">
<h1>🏆 Leaderboard</h1>
</div>
<div class="content">
<!-- Leaderboard Section -->
<div id="leaderboard-container" class="leaderboard-container">
<div class="loading">Lade Leaderboard...</div>
</div>
</div>
</div>
<!-- JavaScript Code -->
<script>
let leaderboardData = [];
let lastUpdateTime = null;
// Identität (name + Zeit) des aktuellen Top-1-Eintrags. Wechselt
// diese, sagen wir „neue Bestzeit + Zeit" an. Initial null →
// beim allerersten Poll wird nur registriert, nicht angesagt.
let lastTopId = null;
// Seite laden
window.onload = function () {
loadLeaderboard();
// Leaderboard alle 5 Sekunden aktualisieren
setInterval(loadLeaderboard, 5000);
};
// Leaderboard laden
async function loadLeaderboard() {
try {
const response = await fetch("/api/leaderboard-full");
const data = await response.json();
const wasFirstLoad = leaderboardData.length === 0 && lastTopId === null;
leaderboardData = data.leaderboard || [];
lastUpdateTime = new Date();
updateLeaderboardDisplay();
announceTopIfChanged(wasFirstLoad);
} catch (error) {
console.error("Fehler beim Laden des Leaderboards:", error);
showMessage("Fehler beim Laden des Leaderboards", "error");
}
}
// Wechselt der Top-1-Eintrag, kommt eine TTS-Ansage ("Neue Zeit
// + Zeit"). Beim allerersten Laden nur den Stand merken, sonst
// würde jeder Seitenaufruf die aktuelle Bestzeit erneut ansagen.
function announceTopIfChanged(isFirstLoad) {
const top = leaderboardData[0];
const newId = top ? `${top.name}::${top.timeFormatted}` : null;
if (lastTopId !== newId) {
if (!isFirstLoad && newId && window.tts && tts.isEnabled()) {
tts.sayTime(tts.parseFormattedTime(top.timeFormatted));
}
lastTopId = newId;
}
}
// Leaderboard anzeigen
function updateLeaderboardDisplay() {
const container = document.getElementById("leaderboard-container");
container.innerHTML = "";
if (leaderboardData.length === 0) {
container.innerHTML =
'<div class="no-entries">Noch keine Zeiten erfasst</div>';
return;
}
// Alle Einträge anzeigen
const displayData = leaderboardData;
// Erstelle zwei Reihen
const row1 = document.createElement("div");
row1.className = "leaderboard-row";
const row2 = document.createElement("div");
row2.className = "leaderboard-row";
displayData.forEach((entry, index) => {
const entryDiv = document.createElement("div");
entryDiv.className = "leaderboard-entry";
// Podium-Plätze hervorheben
if (index === 0) {
entryDiv.classList.add("gold");
} else if (index === 1) {
entryDiv.classList.add("silver");
} else if (index === 2) {
entryDiv.classList.add("bronze");
}
const rankSpan = document.createElement("span");
rankSpan.className = "rank";
rankSpan.textContent = entry.rank + ".";
const nameSpan = document.createElement("span");
nameSpan.className = "name";
nameSpan.textContent = entry.name;
const timeSpan = document.createElement("span");
timeSpan.className = "time";
timeSpan.textContent = entry.timeFormatted;
entryDiv.appendChild(rankSpan);
entryDiv.appendChild(nameSpan);
entryDiv.appendChild(timeSpan);
// Erste 5 Einträge in die erste Reihe, nächste 5 in die zweite Reihe
if (index < 5) {
row1.appendChild(entryDiv);
} else {
row2.appendChild(entryDiv);
}
});
container.appendChild(row1);
if (displayData.length > 5) {
container.appendChild(row2);
}
}
// Moderne Notification anzeigen
function showMessage(message, type = "info") {
console.log("showMessage called:", message, type);
const toast = document.getElementById("notificationBubble");
const icon = document.getElementById("notificationIcon");
const title = document.getElementById("notificationTitle");
const text = document.getElementById("notificationText");
if (!toast || !icon || !title || !text) {
console.error("Notification elements not found!");
return;
}
// Clear any existing timeout
if (window.notificationTimeout) {
clearTimeout(window.notificationTimeout);
}
// Set content
text.textContent = message;
// Set type-specific styling and content
toast.className = "notification-toast";
switch (type) {
case "success":
toast.classList.add("success");
icon.textContent = "✓";
title.textContent = "Erfolg";
break;
case "error":
toast.classList.add("error");
icon.textContent = "✕";
title.textContent = "Fehler";
break;
case "info":
toast.classList.add("info");
icon.textContent = "";
title.textContent = "Information";
break;
case "warning":
toast.classList.add("warning");
icon.textContent = "⚠";
title.textContent = "Warnung";
break;
default:
toast.classList.add("info");
icon.textContent = "";
title.textContent = "Information";
}
// Show toast with animation
toast.style.display = "flex";
// Force reflow
toast.offsetHeight;
// Add show class after a small delay to ensure display is set
setTimeout(() => {
toast.classList.add("show");
}, 10);
// Auto-hide after 5 seconds
window.notificationTimeout = setTimeout(() => {
hideNotification();
}, 5000);
}
// Notification verstecken mit Animation
function hideNotification() {
const toast = document.getElementById("notificationBubble");
if (!toast) return;
// Clear timeout if exists
if (window.notificationTimeout) {
clearTimeout(window.notificationTimeout);
}
// Remove show class for animation
toast.classList.remove("show");
// Hide after animation completes
setTimeout(() => {
toast.style.display = "none";
}, 400); // Match CSS transition duration
}
</script>
</body>
</html>

View File

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

23
data/pictures/logo.svg Normal file
View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Logo" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 132.7 76.6">
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
<g id="Schriftzug">
<g id="Schriftzug1">
<path d="M13.3.7l10.2,19.9h0V.7h6.7v35.8h-5.3L14,15.6h0v20.9h-6.6V.7h5.9,0Z" fill="#223c83"/>
<path d="M42.7,36.4h-6.9V.6h6.9v35.8Z" fill="#223c83"/>
<path d="M54,.7l10.2,19.9h0V.7h6.7v35.8h-5.3l-10.9-20.9h0v20.9h-6.6V.7h5.9,0Z" fill="#223c83"/>
<path d="M80.5,36.4c-4.6,0-6.2-2.7-6.2-6v-7l6.7-1.3v8.2h5.8V.5h7v29.8c0,3.3-1.5,6-6.2,6h-7.2l.1.1Z" fill="#223c83"/>
<path d="M113.2,29.3h-8.8l-1.8,7.1h-6.6L105.8.6h6.3l10,35.8h-7.3l-1.8-7.1h.2ZM110,15.9l-1-4.7h0l-1.1,4.7-1.8,7.5h5.8l-1.8-7.5h-.1Z" fill="#223c83"/>
<path d="M0,46.6c0-3.3,1.5-6,6.2-6h9.5c4.6,0,6.1,2.7,6.1,6v5l-6.7,1.4v-6.4H7v23.8h8.1v-7.3l6.7,1.3v6.1c0,3.3-1.5,6-6.1,6H6.2c-4.6,0-6.2-2.7-6.2-6v-23.9Z" fill="#223c83"/>
<path d="M41.6,40.7c4.6,0,6.2,2.7,6.2,6v10c0,3.3-1.5,5.8-5.8,5.9l7.7,14h-8l-7.2-14h-1.4v14h-6.9v-35.8h15.5l-.1-.1ZM33,56.4h6.2c.9,0,1.6-.8,1.6-1.7v-6.5c0-.9-.8-1.7-1.6-1.7h-6.2v9.9Z" fill="#223c83"/>
<path d="M75.9,70.5c0,3.3-1.5,6-6.1,6h-11.5c-4.6,0-6.2-2.7-6.2-6v-23.9c0-3.3,1.5-6,6.2-6h11.5c4.6,0,6.1,2.7,6.1,6v23.9ZM69,46.7h-9.9v23.8h9.9v-23.8Z" fill="#223c83"/>
<path d="M87.5,52.6c0,1.7.6,2.2,2.3,2.4l6,.2c4.5.2,6.1,2.7,6.1,6v9.3c0,3.3-1.5,6-6.1,6h-10c-4.5,0-6.1-2.7-6.1-6v-3.6l6.8-1.3v3.2c0,.9.8,1.7,1.6,1.7h5.3c.9,0,1.6-.8,1.6-1.7v-5c0-1.7-.5-2.2-2.3-2.2l-6-.3c-4.6-.2-6.2-2.6-6.2-6v-8.7c0-3.3,1.5-6,6.2-6h8.8c4.6,0,6.1,2.7,6.1,6v3.3l-6.8,1.4v-3c0-.9-.7-1.7-1.5-1.7h-4.3c-.9,0-1.6.8-1.6,1.7v4.3h.1Z" fill="#223c83"/>
<path d="M112.9,52.6c0,1.7.6,2.2,2.3,2.4l6,.2c4.5.2,6.1,2.7,6.1,6v9.3c0,3.3-1.5,6-6.1,6h-10c-4.5,0-6.1-2.7-6.1-6v-3.6l6.8-1.3v3.2c0,.9.8,1.7,1.6,1.7h5.3c.9,0,1.6-.8,1.6-1.7v-5c0-1.7-.5-2.2-2.3-2.2l-6-.3c-4.6-.2-6.2-2.6-6.2-6v-8.7c0-3.3,1.5-6,6.2-6h8.9c4.6,0,6.1,2.7,6.1,6v3.3l-6.8,1.4v-3c0-.9-.7-1.7-1.5-1.7h-4.3c-.9,0-1.6.8-1.6,1.7v4.3h0Z" fill="#223c83"/>
</g>
</g>
<g id="Registered_Symbol">
<g id="Registered_Symbol1">
<path d="M126.7,0c3.7,0,6,2.8,6,6.3s-2.3,6.3-6,6.3-6-2.8-6-6.3,2.2-6.3,6-6.3ZM126.7,11.3c2.8,0,4.4-2.2,4.4-5s-1.6-5-4.4-5-4.4,2.2-4.4,5,1.6,5,4.4,5ZM126.1,7.2v2.8h-1.8V2.5h2.5c2.2,0,2.7,1.1,2.7,2.4h0c0,.8-.3,1.5-1,1.9l1.2,3.1h-2l-.9-2.8h-.6l-.1.1ZM126.1,5.7h.6c.8,0,1-.3,1-.8h0c0-.5-.3-.9-1-.9h-.6v1.7h0Z" fill="#223c83"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -1,4 +1,4 @@
<!doctype html>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
@@ -62,7 +62,7 @@
type="button"
id="readUidBtn"
class="read-uid-btn"
onclick="readRFIDUID()"
onclick="toggleRFIDReading()"
>
📡 Read Chip
</button>
@@ -70,47 +70,16 @@
</div>
<div class="form-group">
<label for="vorname">Vorname <span class="required">*</span></label>
<label for="name">Name <span class="required">*</span></label>
<input
type="text"
id="vorname"
name="vorname"
placeholder="Vorname eingeben"
id="name"
name="name"
placeholder="Name eingeben"
required
/>
</div>
<div class="form-group">
<label for="nachname">Nachname <span class="required">*</span></label>
<input
type="text"
id="nachname"
name="nachname"
placeholder="Nachname eingeben"
required
/>
</div>
<div class="form-group">
<label for="geburtsdatum"
>Geburtsdatum <span class="required">*</span></label
>
<div class="date-input-group">
<input
type="date"
id="geburtsdatum"
name="geburtsdatum"
required
max=""
/>
<div
id="ageDisplay"
class="age-display"
style="display: none"
></div>
</div>
</div>
<div class="btn-container">
<button type="submit" class="btn btn-primary">💾 Speichern</button>
<button type="button" class="btn btn-secondary" onclick="clearForm()">
@@ -124,60 +93,8 @@
// Globale Variablen
let rfidData = [];
let isLoading = false;
let DBUrl = "db.reptilfpv.de:3000";
var APIKey;
// Maximales Datum auf heute setzen
document.addEventListener("DOMContentLoaded", function () {
const today = new Date().toISOString().split("T")[0];
document.getElementById("geburtsdatum").setAttribute("max", today);
});
// Alter berechnen und anzeigen
function calculateAge(birthDate) {
const today = new Date();
const birth = new Date(birthDate);
let age = today.getFullYear() - birth.getFullYear();
const monthDiff = today.getMonth() - birth.getMonth();
if (
monthDiff < 0 ||
(monthDiff === 0 && today.getDate() < birth.getDate())
) {
age--;
}
return age;
}
// Geburtsdatum Change Event
document
.getElementById("geburtsdatum")
.addEventListener("change", function (e) {
const birthDate = e.target.value;
const ageDisplay = document.getElementById("ageDisplay");
if (birthDate) {
const age = calculateAge(birthDate);
if (age >= 0 && age <= 150) {
ageDisplay.textContent = `${age} Jahre`;
ageDisplay.style.display = "block";
} else {
ageDisplay.style.display = "none";
if (age < 0) {
showErrorMessage(
"Das Geburtsdatum kann nicht in der Zukunft liegen!",
);
e.target.value = "";
} else {
showErrorMessage("Bitte überprüfen Sie das Geburtsdatum!");
e.target.value = "";
}
}
} else {
ageDisplay.style.display = "none";
}
});
// Lokale Benutzer-Speicherung (geht bei Neustart verloren)
let localUsers = [];
// Form Submit Handler
document
@@ -189,45 +106,40 @@
// Daten aus dem Formular holen
const uid = document.getElementById("uid").value.trim();
const vorname = document.getElementById("vorname").value.trim();
const nachname = document.getElementById("nachname").value.trim();
const geburtsdatum = document.getElementById("geburtsdatum").value;
const name = document.getElementById("name").value.trim();
// Validierung
if (!uid || !vorname || !nachname || !geburtsdatum) {
if (!uid || !name) {
showErrorMessage("Bitte füllen Sie alle Pflichtfelder aus!");
return;
}
// Alter berechnen
const alter = calculateAge(geburtsdatum);
if (alter < 0) {
showErrorMessage(
"Das Geburtsdatum kann nicht in der Zukunft liegen!",
);
return;
}
// Loading State
setLoadingState(true);
try {
// API Aufruf zum Erstellen des Benutzers
// API Aufruf zum Erstellen des Benutzers (lokal)
const requestData = {
uid: uid,
name: name,
};
console.log("Sende Daten:", requestData);
console.log("JSON String:", JSON.stringify(requestData));
const response = await fetch(`/api/users/insert`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
uid: uid,
vorname: vorname,
nachname: nachname,
geburtsdatum: geburtsdatum,
alter: alter, // Berechnetes Alter wird mit gesendet
}),
body: JSON.stringify(requestData),
});
console.log("Response Status:", response.status);
console.log("Response Headers:", response.headers);
const result = await response.json();
console.log("Response Result:", result);
if (result.success) {
// Erfolg anzeigen
@@ -243,13 +155,13 @@
} else {
// Fehler anzeigen
showErrorMessage(
result.error || "Fehler beim Speichern der Daten",
result.error || "Fehler beim Speichern der Daten"
);
}
} catch (error) {
console.error("Fehler beim Speichern:", error);
showErrorMessage(
"Verbindungsfehler zum Server. Bitte versuchen Sie es später erneut.",
"Verbindungsfehler zum Server. Bitte versuchen Sie es später erneut."
);
} finally {
setLoadingState(false);
@@ -312,7 +224,6 @@
function clearForm() {
document.getElementById("rfidForm").reset();
document.getElementById("ageDisplay").style.display = "none";
document.getElementById("uid").focus();
}
@@ -320,14 +231,13 @@
window.addEventListener("load", function () {
document.getElementById("uid").focus();
checkServerStatus();
loadLicence();
});
// Enter-Taste in UID Feld zum nächsten Feld springen
document.getElementById("uid").addEventListener("keydown", function (e) {
if (e.key === "Enter") {
e.preventDefault();
document.getElementById("vorname").focus();
document.getElementById("name").focus();
}
});
@@ -339,18 +249,159 @@
e.target.value = value;
});
// RFID UID lesen
let rfidReadingMode = false;
let statusInterval = null;
// Toggle RFID Reading Mode
async function toggleRFIDReading() {
const readBtn = document.getElementById("readUidBtn");
try {
const response = await fetch(`/api/rfid/toggle`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
const result = await response.json();
if (result.success) {
rfidReadingMode = result.reading_mode;
if (rfidReadingMode) {
// RFID Reading gestartet
readBtn.innerHTML = "🛑 Stop Reading";
readBtn.className = "read-uid-btn reading";
showSuccessMessage("RFID Lesen gestartet - Karte auflegen!");
// Status Polling starten
startStatusPolling();
} else {
// RFID Reading gestoppt
readBtn.innerHTML = "📡 Read Chip";
readBtn.className = "read-uid-btn";
showSuccessMessage("RFID Lesen gestoppt");
// Status Polling stoppen
stopStatusPolling();
}
} else {
showErrorMessage("Fehler beim Toggle RFID: " + result.message);
}
} catch (error) {
console.error("Toggle RFID Error:", error);
showErrorMessage("Fehler beim Toggle RFID");
}
}
// Status Polling für kontinuierliches Lesen
function startStatusPolling() {
if (statusInterval) {
clearInterval(statusInterval);
}
statusInterval = setInterval(async () => {
try {
const response = await fetch(`/api/rfid/status`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const result = await response.json();
if (result.success && result.last_uid && result.last_uid !== "") {
// Neue UID gelesen - automatisch stoppen
const uidInput = document.getElementById("uid");
uidInput.value = result.last_uid;
// Visuelles Feedback
uidInput.style.borderColor = "#28a745";
setTimeout(() => {
uidInput.style.borderColor = "#e1e5e9";
}, 2000);
showSuccessMessage("UID gelesen: " + result.last_uid);
// Automatisch zum nächsten Feld springen
setTimeout(() => {
document.getElementById("name").focus();
}, 500);
// RFID Lesen automatisch stoppen
stopRFIDReading();
// UID im Backend zurücksetzen
clearBackendUID();
}
} catch (error) {
console.error("Status Poll Error:", error);
}
}, 500); // Alle 500ms prüfen
}
// Status Polling stoppen
function stopStatusPolling() {
if (statusInterval) {
clearInterval(statusInterval);
statusInterval = null;
}
}
// RFID Reading komplett stoppen (Frontend + Backend)
async function stopRFIDReading() {
// Status Polling stoppen
stopStatusPolling();
// Backend stoppen
try {
const response = await fetch(`/api/rfid/toggle`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
const result = await response.json();
if (result.success && !result.reading_mode) {
rfidReadingMode = false;
// Button zurücksetzen
const readBtn = document.getElementById("readUidBtn");
readBtn.innerHTML = "📡 Read Chip";
readBtn.className = "read-uid-btn";
}
} catch (error) {
console.error("Stop RFID Error:", error);
}
}
// UID im Backend zurücksetzen
async function clearBackendUID() {
try {
await fetch(`/api/rfid/clear`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
} catch (error) {
console.error("Clear UID Error:", error);
}
}
// Einzelnes Lesen (für Kompatibilität)
async function readRFIDUID() {
const readBtn = document.getElementById("readUidBtn");
const uidInput = document.getElementById("uid");
// Button Status ändern
readBtn.disabled = true;
readBtn.className = "read-uid-btn reading";
readBtn.innerHTML = "📡 Lese UID...";
readBtn.innerHTML = "📡 Lese...";
try {
// API Aufruf zum RFID Reader
const response = await fetch(`/api/rfid/read`, {
method: "GET",
headers: {
@@ -361,11 +412,7 @@
const result = await response.json();
if (result.success && result.uid) {
// UID in das Eingabefeld setzen
uidInput.value = result.uid
.match(/.{1,2}/g)
.join(":")
.toUpperCase();
uidInput.value = result.uid;
uidInput.focus();
// Visuelles Feedback
@@ -374,50 +421,34 @@
uidInput.style.borderColor = "#e1e5e9";
}, 2000);
showSuccessMessage("UID erfolgreich gelesen!");
showSuccessMessage("UID gelesen: " + result.uid);
// Automatisch zum nächsten Feld springen
setTimeout(() => {
document.getElementById("vorname").focus();
document.getElementById("name").focus();
}, 500);
} else {
// Fehler beim Lesen
const errorMsg = result.error || "Keine UID gefunden";
showErrorMessage(`RFID Fehler: ${errorMsg}`);
// UID Feld rot markieren
uidInput.style.borderColor = "#dc3545";
setTimeout(() => {
uidInput.style.borderColor = "#e1e5e9";
}, 10000);
showErrorMessage("Keine Karte erkannt");
}
} catch (error) {
console.error("Fehler beim Lesen der UID:", error);
showErrorMessage(
"Verbindungsfehler zum RFID Reader. Bitte prüfen Sie die Verbindung.",
);
// UID Feld rot markieren
uidInput.style.borderColor = "#dc3545";
setTimeout(() => {
uidInput.style.borderColor = "#e1e5e9";
}, 3000);
console.error("RFID Read Error:", error);
showErrorMessage("Fehler beim Lesen");
} finally {
// Button Status zurücksetzen
readBtn.disabled = false;
readBtn.className = "read-uid-btn";
readBtn.innerHTML = "📡 Read Chip";
}
}
async function checkServerStatus() {
try {
const response = await fetch("/api/health");
const response = await fetch("/api/health", {
headers: {},
});
const data = await response.json();
if (!data.status || data.status !== "connected") {
showErrorMessage(
"Server nicht verbunden. Einige Funktionen könnten eingeschränkt sein.",
"Server nicht verbunden. Einige Funktionen könnten eingeschränkt sein."
);
return false;
}
@@ -430,16 +461,19 @@
}
}
function loadLicence() {
fetch("/api/get-licence")
.then((response) => response.json())
.then((data) => {
APIKey = data.licence || "";
})
.catch((error) =>
showMessage("Fehler beim Laden der Lizenz", "error"),
);
}
// Seite laden - RFID Status initialisieren
document.addEventListener("DOMContentLoaded", function () {
// Status Polling stoppen falls aktiv
stopStatusPolling();
// Server Status prüfen
checkServerStatus();
});
// Seite verlassen - RFID Reading komplett stoppen
window.addEventListener("beforeunload", function () {
stopRFIDReading();
});
</script>
</body>
</html>

View File

@@ -1,416 +1,568 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
body {
font-family: "Segoe UI", Arial, sans-serif;
background: linear-gradient(0deg, #0d1733 0%, #223c83 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 600px;
margin: 0 auto;
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
overflow: hidden;
backdrop-filter: blur(10px);
}
.container {
max-width: 600px;
margin: 0 auto;
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
overflow: hidden;
backdrop-filter: blur(10px);
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
position: relative;
}
.header {
background: linear-gradient(135deg, #49bae4 0%, #223c83 100%);
color: white;
padding: 30px;
text-align: center;
position: relative;
}
.header::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 20"><defs><radialGradient id="a" cx="50%" cy="40%" r="50%"><stop offset="0%" stop-color="white" stop-opacity="0.1"/><stop offset="100%" stop-color="white" stop-opacity="0"/></radialGradient></defs><rect width="100" height="20" fill="url(%23a)"/></svg>');
opacity: 0.3;
}
.header::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 20"><defs><radialGradient id="a" cx="50%" cy="40%" r="50%"><stop offset="0%" stop-color="white" stop-opacity="0.1"/><stop offset="100%" stop-color="white" stop-opacity="0"/></radialGradient></defs><rect width="100" height="20" fill="url(%23a)"/></svg>');
opacity: 0.3;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
position: relative;
z-index: 1;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
position: relative;
z-index: 1;
font-weight: bold;
text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif;
}
.header p {
font-size: 1.1em;
opacity: 0.9;
position: relative;
z-index: 1;
}
.header p {
font-size: 1.1em;
opacity: 0.9;
position: relative;
z-index: 1;
}
.content {
padding: 40px;
}
.content {
padding: 40px;
}
.nav-buttons {
display: flex;
gap: 15px;
margin-bottom: 30px;
}
.nav-buttons {
display: flex;
gap: 15px;
margin-bottom: 30px;
}
.nav-button {
flex: 1;
padding: 12px 20px;
background: #f8f9fa;
border: 2px solid #e9ecef;
border-radius: 10px;
color: #495057;
text-decoration: none;
text-align: center;
font-weight: 600;
transition: all 0.3s ease;
}
.nav-button {
flex: 1;
padding: 12px 20px;
background: #f8f9fa;
border: 2px solid #e9ecef;
border-radius: 10px;
color: #495057;
text-decoration: none;
text-align: center;
font-weight: 600;
transition: all 0.3s ease;
}
.nav-button:hover {
background: #667eea;
color: white;
border-color: #667eea;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
}
.nav-button:hover {
background: #49bae4;
color: white;
border-color: #49bae4;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(73, 186, 228, 0.3);
}
.section {
margin-bottom: 30px;
background: #f8f9fa;
border-radius: 15px;
padding: 25px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05);
}
.section {
margin-bottom: 30px;
background: #f8f9fa;
border-radius: 15px;
padding: 25px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05);
}
.section h2 {
color: #495057;
margin-bottom: 20px;
font-size: 1.4em;
display: flex;
align-items: center;
gap: 10px;
}
.section h2 {
color: #495057;
margin-bottom: 20px;
font-size: 1.4em;
display: flex;
align-items: center;
gap: 10px;
font-weight: bold;
text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif;
}
.section h2::before {
content: "";
width: 4px;
height: 25px;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 2px;
}
.section h2::before {
content: "";
width: 4px;
height: 25px;
background: linear-gradient(135deg, #49bae4, #223c83);
border-radius: 2px;
}
.form-group {
margin-bottom: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #495057;
font-weight: 600;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #495057;
font-weight: 600;
}
.form-group input {
width: 100%;
padding: 12px 15px;
border: 2px solid #e9ecef;
border-radius: 10px;
font-size: 16px;
transition: all 0.3s ease;
}
.form-group input {
width: 100%;
padding: 12px 15px;
border: 2px solid #e9ecef;
border-radius: 10px;
font-size: 16px;
transition: all 0.3s ease;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-group input:focus {
outline: none;
border-color: #49bae4;
box-shadow: 0 0 0 3px rgba(73, 186, 228, 0.1);
}
.time-row {
display: flex;
gap: 15px;
align-items: end;
}
.time-row {
display: flex;
gap: 15px;
align-items: end;
}
.time-input {
flex: 1;
}
.time-input {
flex: 1;
}
.current-time {
background: white;
padding: 15px;
border-radius: 10px;
text-align: center;
font-family: monospace;
font-size: 18px;
color: #495057;
border: 2px solid #e9ecef;
margin-bottom: 15px;
}
.current-time {
background: white;
padding: 15px;
border-radius: 10px;
text-align: center;
font-family: monospace;
font-size: 18px;
color: #495057;
border: 2px solid #e9ecef;
margin-bottom: 15px;
}
.button-group {
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.button-group {
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.btn {
padding: 15px 25px;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
text-align: center;
min-width: 150px;
}
.btn {
padding: 15px 25px;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
text-align: center;
min-width: 150px;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary {
background: linear-gradient(135deg, #49bae4 0%, #223c83 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.3);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(73, 186, 228, 0.3);
}
.btn-secondary {
background: linear-gradient(135deg, #74b9ff 0%, #0984e3 100%);
color: white;
}
.btn-secondary {
background: linear-gradient(135deg, #DCF2FA 0%, #49bae4 100%);
color: #223c83;
}
.btn-secondary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(116, 185, 255, 0.3);
}
.btn-secondary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(220, 242, 250, 0.3);
}
.btn-warning {
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);
color: #d84315;
}
.btn-warning {
background: linear-gradient(135deg, #f59d0f 0%, #e67e22 100%);
color: white;
}
.btn-warning:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(252, 182, 159, 0.3);
}
.btn-warning:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(245, 157, 15, 0.3);
}
.btn-danger {
background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%);
color: #c62828;
}
.btn-danger {
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
color: white;
}
.btn-danger:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(255, 154, 158, 0.3);
}
.btn-danger:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(231, 76, 60, 0.3);
}
.btn-disabled {
background: #e9ecef !important;
color: #6c757d !important;
cursor: not-allowed !important;
transform: none !important;
box-shadow: none !important;
}
.btn-disabled {
background: #e9ecef !important;
color: #6c757d !important;
cursor: not-allowed !important;
transform: none !important;
box-shadow: none !important;
}
.btn-disabled:hover {
transform: none !important;
box-shadow: none !important;
}
.btn-disabled:hover {
transform: none !important;
box-shadow: none !important;
}
.restriction-notice {
background: #fff3cd;
color: #856404;
padding: 15px;
border-radius: 10px;
border: 2px solid #ffeaa7;
margin-bottom: 15px;
font-weight: 600;
text-align: center;
}
/* Toggle Buttons für Modus-Auswahl */
.mode-toggle {
display: flex;
gap: 0;
border: 2px solid #e9ecef;
border-radius: 12px;
overflow: hidden;
background: white;
}
.status-message {
margin-top: 20px;
padding: 15px;
border-radius: 10px;
font-weight: 600;
text-align: center;
display: none;
}
.mode-button {
flex: 1;
padding: 15px 25px;
border: none;
background: white;
color: #495057;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
}
.status-success {
background: #d4edda;
color: #155724;
border: 2px solid #c3e6cb;
}
.mode-button.active {
background: linear-gradient(135deg, #49bae4 0%, #223c83 100%);
color: white;
}
.status-error {
background: #f8d7da;
color: #721c24;
border: 2px solid #f5c6cb;
}
.mode-button:not(.active):hover {
background: #f8f9fa;
color: #49bae4;
}
.status-info {
background: #cce7ff;
color: #004085;
border: 2px solid #b3d9ff;
}
.mode-button:first-child {
border-right: 1px solid #e9ecef;
}
.learning-mode {
display: none;
text-align: center;
padding: 30px;
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);
border-radius: 15px;
margin-top: 20px;
}
/* Lane Difficulty Selection Styles */
.lane-difficulty-selection {
margin-top: 20px;
padding: 20px;
background: rgba(73, 186, 228, 0.1);
border-radius: 12px;
border: 2px solid rgba(73, 186, 228, 0.2);
}
.learning-mode h3 {
color: #d84315;
font-size: 1.5em;
margin-bottom: 15px;
}
.lane-difficulty-selection .form-group {
margin-bottom: 25px;
}
.learning-mode p {
color: #bf360c;
font-size: 1.2em;
margin-bottom: 20px;
}
.lane-difficulty-selection .form-group:last-child {
margin-bottom: 0;
}
.pulse {
animation: pulse 2s infinite;
}
.lane-difficulty-selection label {
font-weight: 600;
color: #223c83;
margin-bottom: 12px;
display: block;
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
.restriction-notice {
background: #fff3cd;
color: #856404;
padding: 15px;
border-radius: 10px;
border: 2px solid #ffeaa7;
margin-bottom: 15px;
font-weight: 600;
text-align: center;
}
@media (max-width: 600px) {
.container {
margin: 10px;
border-radius: 15px;
}
/* Modern Notification Toast */
.notification-toast {
position: fixed;
top: 24px;
right: 24px;
min-width: 320px;
max-width: 400px;
background: rgba(255, 255, 255, 0.98);
border-radius: 16px;
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04),
0 0 0 1px rgba(0, 0, 0, 0.05);
backdrop-filter: blur(20px);
z-index: 99999;
display: none;
align-items: flex-start;
gap: 12px;
padding: 16px;
transform: translateX(100%);
opacity: 0;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
pointer-events: auto;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.content {
padding: 20px;
}
.notification-toast.show {
transform: translateX(0);
opacity: 1;
}
.nav-buttons {
flex-direction: column;
}
.notification-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
color: white;
background: linear-gradient(135deg, #10b981, #059669);
}
.button-group {
flex-direction: column;
}
.notification-body {
flex: 1;
min-width: 0;
}
.btn {
width: 100%;
}
.notification-title {
font-size: 14px;
font-weight: 600;
color: #111827;
margin-bottom: 4px;
line-height: 1.2;
}
.time-row {
flex-direction: column;
gap: 10px;
}
}
.notification-message {
font-size: 13px;
color: #6b7280;
line-height: 1.4;
word-wrap: break-word;
}
.section select {
width: 100%;
padding: 12px 16px;
font-size: 16px;
font-family: inherit;
border: 2px solid #e1e5e9;
border-radius: 8px;
background-color: white;
background-image: url("data:image/svg+xml;charset=US-ASCII,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'><path fill='%23666' d='M2 0L0 2h4zm0 5L0 3h4z'/></svg>");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 12px;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
cursor: pointer;
transition: all 0.3s ease;
}
.notification-close {
flex-shrink: 0;
width: 32px;
height: 32px;
border: none;
background: none;
color: #9ca3af;
cursor: pointer;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
margin-top: -4px;
margin-right: -4px;
}
.section select:hover {
border-color: #007bff;
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.1);
}
.notification-close:hover {
background: rgba(0, 0, 0, 0.05);
color: #374151;
}
.section select:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
}
.notification-close:active {
transform: scale(0.95);
}
.section select:disabled {
background-color: #f8f9fa;
color: #6c757d;
cursor: not-allowed;
opacity: 0.6;
border-color: #dee2e6;
}
/* Toast Types */
.notification-toast.success .notification-icon {
background: linear-gradient(135deg, #10b981, #059669);
}
.section select:disabled:hover {
border-color: #dee2e6;
box-shadow: none;
}
.notification-toast.error .notification-icon {
background: linear-gradient(135deg, #ef4444, #dc2626);
}
/* Option Styling */
.section select option {
padding: 8px;
font-size: 16px;
background-color: white;
color: #333;
}
.notification-toast.info .notification-icon {
background: linear-gradient(135deg, #3b82f6, #2563eb);
}
.section select option:hover {
background-color: #f8f9fa;
}
.notification-toast.warning .notification-icon {
background: linear-gradient(135deg, #f59e0b, #d97706);
}
.section select option:disabled {
color: #6c757d;
background-color: #f8f9fa;
}
/* Animation */
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Form Group für bessere Abstände */
.section .form-group {
margin-bottom: 20px;
}
@keyframes slideOutRight {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
.section .form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #333;
font-size: 14px;
}
/* Responsive Design für kleinere Bildschirme */
@media (max-width: 768px) {
.section select {
font-size: 16px; /* Verhindert Zoom auf iOS */
padding: 14px 16px;
}
}
.learning-mode {
display: none;
text-align: center;
padding: 30px;
background: linear-gradient(135deg, #f59d0f 0%, #e67e22 100%);
border-radius: 15px;
margin-top: 20px;
}
.learning-mode h3 {
color: white;
font-size: 1.5em;
margin-bottom: 15px;
font-weight: bold;
text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif;
}
.learning-mode p {
color: white;
font-size: 1.2em;
margin-bottom: 20px;
}
.pulse {
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
.section select {
width: 100%;
padding: 12px 16px;
font-size: 16px;
font-family: inherit;
border: 2px solid #e1e5e9;
border-radius: 8px;
background-color: white;
background-image: url("data:image/svg+xml;charset=US-ASCII,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'><path fill='%23666' d='M2 0L0 2h4zm0 5L0 3h4z'/></svg>");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 12px;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
cursor: pointer;
transition: all 0.3s ease;
}
.section select:hover {
border-color: #007bff;
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.1);
}
.section select:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
}
.section select:disabled {
background-color: #f8f9fa;
color: #6c757d;
cursor: not-allowed;
opacity: 0.6;
border-color: #dee2e6;
}
@media (max-width: 600px) {
.container {
margin: 10px;
border-radius: 15px;
}
.content {
padding: 20px;
}
.nav-buttons {
flex-direction: column;
}
.button-group {
flex-direction: column;
}
.btn {
width: 100%;
}
.time-row {
flex-direction: column;
gap: 10px;
}
.mode-toggle {
flex-direction: column;
gap: 0;
}
.mode-button:first-child {
border-right: none;
border-bottom: 1px solid #e9ecef;
}
/* Mobile notification bubble adjustments */
.notification-bubble {
top: 10px;
right: 10px;
left: 10px;
max-width: none;
font-size: 14px;
padding: 12px 16px;
}
}

View File

@@ -11,6 +11,22 @@
<title>Ninjacross Timer - Einstellungen</title>
</head>
<body>
<!-- Modern Notification Toast -->
<div id="notificationBubble" class="notification-toast" style="display: none;">
<div class="notification-icon">
<span id="notificationIcon"></span>
</div>
<div class="notification-body">
<div class="notification-title" id="notificationTitle">Erfolg</div>
<div class="notification-message" id="notificationText">Bereit</div>
</div>
<button class="notification-close" onclick="hideNotification()">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"/>
</svg>
</button>
</div>
<div class="container">
<!-- Header Section -->
<div class="header">
@@ -22,12 +38,9 @@
<!-- Navigation Buttons -->
<div class="nav-buttons">
<a href="/" class="nav-button">🏠 Hauptseite</a>
<a href="/rfid" class="nav-button">📡 RFID</a>
<a href="/rfid.html" class="nav-button">🏷️ RFID</a>
</div>
<!-- Status Message Container -->
<div id="statusMessage" class="status-message"></div>
<!-- Date & Time Section -->
<div class="section">
<h2>🕐 Datum & Uhrzeit</h2>
@@ -66,6 +79,79 @@
</form>
</div>
<!-- Mode Selection Section -->
<div class="section">
<h2>🎯 Modus</h2>
<form id="modeForm">
<div class="form-group">
<label>Modus auswählen:</label>
<div class="mode-toggle">
<button type="button" class="mode-button active" data-mode="individual" onclick="selectMode('individual')">
👤 Individual
</button>
<button type="button" class="mode-button" data-mode="wettkampf" onclick="selectMode('wettkampf')">
🏆 Wettkampf
</button>
</div>
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary">
💾 Modus speichern
</button>
</div>
</form>
</div>
<!-- Lane Configuration Section -->
<div class="section">
<h2>🏊‍♀️ Lane-Konfiguration</h2>
<form id="laneForm">
<div class="form-group">
<label>Lane-Typ auswählen:</label>
<div class="mode-toggle">
<button type="button" class="mode-button active" data-lane-type="identical" onclick="selectLaneType('identical')">
⚖️ Identische Lanes
</button>
<button type="button" class="mode-button" data-lane-type="different" onclick="selectLaneType('different')">
⚡ Unterschiedliche Lanes
</button>
</div>
</div>
<div id="laneDifficultySelection" class="lane-difficulty-selection" style="display: none;">
<div class="form-group">
<label>Lane 1 Schwierigkeit:</label>
<div class="mode-toggle">
<button type="button" class="mode-button active" data-lane="1" data-difficulty="light" onclick="selectLaneDifficulty(1, 'light')">
🟢 Leicht
</button>
<button type="button" class="mode-button" data-lane="1" data-difficulty="heavy" onclick="selectLaneDifficulty(1, 'heavy')">
🔴 Schwer
</button>
</div>
</div>
<div class="form-group">
<label>Lane 2 Schwierigkeit:</label>
<div class="mode-toggle">
<button type="button" class="mode-button active" data-lane="2" data-difficulty="light" onclick="selectLaneDifficulty(2, 'light')">
🟢 Leicht
</button>
<button type="button" class="mode-button" data-lane="2" data-difficulty="heavy" onclick="selectLaneDifficulty(2, 'heavy')">
🔴 Schwer
</button>
</div>
</div>
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary">
💾 Lane-Konfiguration speichern
</button>
</div>
</form>
</div>
<!-- Basic Settings Section -->
<div class="section">
<h2>🔧 Grundeinstellungen</h2>
@@ -96,6 +182,18 @@
title="Zeit nach der die angezeigte Zeit zurückgesetzt wird"
/>
</div>
<div class="form-group">
<label for="minTimeForLeaderboard">Minimale Zeit für Leaderboard (Sekunden):</label>
<input
type="number"
id="minTimeForLeaderboard"
name="minTimeForLeaderboard"
min="1"
max="300"
value="5"
title="Zeiten unter diesem Wert werden nicht ins lokale Leaderboard eingetragen (Missbrauchsschutz)"
/>
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary">
💾 Einstellungen speichern
@@ -132,7 +230,7 @@
<div id="learningMode" class="learning-mode">
<h3>🎯 Anlernmodus aktiv</h3>
<p id="learningInstruction" class="pulse">
Drücken Sie jetzt den Button für: <strong>Bahn 1 Start</strong>
Drücke jetzt den Button für: <strong>Bahn 1 Start</strong>
</p>
<button onclick="cancelLearningMode()" class="btn btn-danger">
❌ Abbrechen
@@ -320,6 +418,8 @@
loadCurrentTime();
updateCurrentTimeDisplay();
loadWifiSettings();
loadMode();
loadLaneConfig();
};
// Aktuelle Zeit anzeigen (Live-Update)
@@ -386,7 +486,7 @@
document.getElementById("currentTimeInput").value = now
.toTimeString()
.split(" ")[0];
showMessage("Browser-Zeit übernommen", "info");
showMessage("Deine Browser-Zeit wurde übernommen", "info");
// Jetzt auch direkt an den Server senden:
const dateValue = document.getElementById("currentDate").value;
@@ -430,7 +530,7 @@
const timeValue = document.getElementById("currentTimeInput").value;
if (!dateValue || !timeValue) {
showMessage("Bitte Datum und Uhrzeit eingeben", "error");
showMessage("Bitte gib Datum und Uhrzeit ein", "error");
return;
}
@@ -447,7 +547,7 @@
.then((response) => response.json())
.then((data) => {
if (data.success) {
showMessage("Uhrzeit erfolgreich gesetzt!", "success");
showMessage("Die Uhrzeit wurde erfolgreich gesetzt!", "success");
} else {
showMessage("Fehler beim Setzen der Uhrzeit", "error");
}
@@ -464,6 +564,164 @@
saveLicence();
});
// Mode selection function
// Remove active class from all mode buttons
function selectMode(mode) {
document.querySelectorAll('.mode-button').forEach(button => {
button.classList.remove('active');
});
// Add active class to selected button
document.querySelector(`[data-mode="${mode}"]`).classList.add('active');
}
// Mode form handler
document.getElementById('modeForm').addEventListener('submit', function(e) {
e.preventDefault();
const activeButton = document.querySelector('.mode-button.active');
const selectedMode = activeButton ? activeButton.getAttribute('data-mode') : 'individual';
fetch('/api/set-mode', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'mode=' + encodeURIComponent(selectedMode)
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage('Modus erfolgreich gespeichert!', 'success');
} else {
showMessage('Fehler beim Speichern des Modus', 'error');
}
})
.catch(error => showMessage('Verbindungsfehler', 'error'));
});
function loadMode() {
fetch("/api/get-mode")
.then((response) => response.json())
.then((data) => {
const mode = data.mode || "individual";
document.querySelectorAll('.mode-button').forEach(button => {
button.classList.remove('active');
});
const btn = document.querySelector(`.mode-button[data-mode="${mode}"]`);
if (btn) btn.classList.add('active');
})
.catch((error) => {
showMessage("Fehler beim Laden des Modus", "error");
});
}
// Lane Type selection function
function selectLaneType(type) {
// Remove active class from all lane type buttons
document.querySelectorAll('[data-lane-type]').forEach(button => {
button.classList.remove('active');
});
// Add active class to selected button
document.querySelector(`[data-lane-type="${type}"]`).classList.add('active');
// Show/hide lane difficulty selection
const difficultySelection = document.getElementById('laneDifficultySelection');
if (type === 'different') {
difficultySelection.style.display = 'block';
} else {
difficultySelection.style.display = 'none';
}
}
// Lane Difficulty selection function
function selectLaneDifficulty(lane, difficulty) {
// Remove active class from all buttons for this lane
document.querySelectorAll(`[data-lane="${lane}"]`).forEach(button => {
button.classList.remove('active');
});
// Add active class to selected button
document.querySelector(`[data-lane="${lane}"][data-difficulty="${difficulty}"]`).classList.add('active');
}
// Lane form handler
document.getElementById('laneForm').addEventListener('submit', function(e) {
e.preventDefault();
const activeLaneTypeButton = document.querySelector('[data-lane-type].active');
const laneType = activeLaneTypeButton ? activeLaneTypeButton.getAttribute('data-lane-type') : 'identical';
let laneConfig = {
type: laneType
};
if (laneType === 'different') {
const lane1Difficulty = document.querySelector('[data-lane="1"].active')?.getAttribute('data-difficulty') || 'light';
const lane2Difficulty = document.querySelector('[data-lane="2"].active')?.getAttribute('data-difficulty') || 'light';
laneConfig.lane1Difficulty = lane1Difficulty;
laneConfig.lane2Difficulty = lane2Difficulty;
}
fetch('/api/set-lane-config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(laneConfig)
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage('Lane-Schwierigkeits-Konfiguration erfolgreich gespeichert!', 'success');
} else {
showMessage('Fehler beim Speichern der Lane-Schwierigkeits-Konfiguration', 'error');
}
})
.catch(error => showMessage('Verbindungsfehler', 'error'));
});
function loadLaneConfig() {
fetch("/api/get-lane-config")
.then((response) => response.json())
.then((data) => {
const laneType = data.type || "identical";
const lane1Difficulty = data.lane1Difficulty || "light";
const lane2Difficulty = data.lane2Difficulty || "light";
// Set lane type
document.querySelectorAll('[data-lane-type]').forEach(button => {
button.classList.remove('active');
});
const laneTypeBtn = document.querySelector(`[data-lane-type="${laneType}"]`);
if (laneTypeBtn) laneTypeBtn.classList.add('active');
// Set lane difficulties
document.querySelectorAll('[data-lane]').forEach(button => {
button.classList.remove('active');
});
const lane1Btn = document.querySelector(`[data-lane="1"][data-difficulty="${lane1Difficulty}"]`);
const lane2Btn = document.querySelector(`[data-lane="2"][data-difficulty="${lane2Difficulty}"]`);
if (lane1Btn) lane1Btn.classList.add('active');
if (lane2Btn) lane2Btn.classList.add('active');
// Show/hide difficulty selection
const difficultySelection = document.getElementById('laneDifficultySelection');
if (laneType === 'different') {
difficultySelection.style.display = 'block';
} else {
difficultySelection.style.display = 'none';
}
})
.catch((error) => {
showMessage("Fehler beim Laden der Lane-Schwierigkeits-Konfiguration", "error");
});
}
// Einstellungen laden
function loadSettings() {
fetch("/api/get-settings")
@@ -472,6 +730,8 @@
document.getElementById("maxTime").value = data.maxTime || 300;
document.getElementById("maxTimeDisplay").value =
data.maxTimeDisplay || 20;
document.getElementById("minTimeForLeaderboard").value =
data.minTimeForLeaderboard || 5;
})
.catch((error) =>
showMessage("Fehler beim Laden der Einstellungen", "error")
@@ -516,7 +776,7 @@
.then((response) => response.json())
.then((data) => {
document.getElementById("licencekey").value = data.licence || "";
loadLocations();
loadLocationsFromBackend();
})
.catch((error) =>
showMessage("Fehler beim Laden der Lizenz", "error")
@@ -725,6 +985,9 @@
const maxTimeDisplay = parseInt(
document.getElementById("maxTimeDisplay").value
);
const minTimeForLeaderboard = parseInt(
document.getElementById("minTimeForLeaderboard").value
);
fetch("/api/set-max-time", {
method: "POST",
@@ -735,7 +998,9 @@
"maxTime=" +
encodeURIComponent(maxTime) +
"&maxTimeDisplay=" +
encodeURIComponent(maxTimeDisplay),
encodeURIComponent(maxTimeDisplay) +
"&minTimeForLeaderboard=" +
encodeURIComponent(minTimeForLeaderboard),
})
.then((response) => response.json())
.then((data) => {
@@ -925,44 +1190,84 @@
//location functions
// Locations laden und Dropdown befüllen
function loadLocations() {
const licence = document.getElementById("licencekey").value; // Get the licence key from the input field
fetch("http://db.reptilfpv.de:3000/api/location/", {
method: "GET",
headers: {
Authorization: `Bearer ${licence}`, // Add Bearer token using licenkey
},
})
.then((response) => response.json())
.then((data) => {
const select = document.getElementById("locationSelect");
function loadLocationsFromBackend() {
const select = document.getElementById("locationSelect");
// Vorhandene Optionen löschen (außer der ersten "Bitte wählen...")
while (select.children.length > 1) {
select.removeChild(select.lastChild);
}
// Vorhandene Optionen löschen (außer der ersten "Bitte wählen...")
while (select.children.length > 1) {
select.removeChild(select.lastChild);
}
// Neue Optionen aus Backend-Response hinzufügen
// Neue Optionen aus Backend-Response hinzufügen
data.forEach((location) => {
const option = document.createElement("option");
option.value = location.id;
option.textContent = location.name;
select.appendChild(option);
});
// Aktuell gespeicherten Standort laden
loadCurrentLocation();
// Fallback: Statische Standorte falls API nicht verfügbar ist
const staticLocations = [
{ id: "1", name: "Hauptstandort" },
{ id: "2", name: "Standort A" },
{ id: "3", name: "Standort B" },
{ id: "4", name: "Teststandort" }
];
// Versuche zuerst die echte API zu verwenden
const licence = document.getElementById("licencekey").value;
if (licence && licence.trim() !== "") {
fetch("https://ninja.reptilfpv.de/api/v1/private/locations", {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${licence}`,
},
})
.catch((error) => {
console.log("Locations konnten nicht geladen werden:", error);
showMessage("Fehler beim Laden der Locations", "error");
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
})
.then((data) => {
if (data.success && data.data) {
// API erfolgreich - verwende echte Daten
data.data.forEach((location) => {
const option = document.createElement("option");
option.value = location.id;
option.textContent = location.name;
select.appendChild(option);
});
showMessage("Standorte erfolgreich von API geladen", "success");
} else {
throw new Error("Ungültige API-Response");
}
// Aktuell gespeicherten Standort laden
loadSavedLocation();
})
.catch((error) => {
console.log("API nicht verfügbar, verwende statische Daten:", error);
// API fehlgeschlagen - verwende statische Daten als Fallback
staticLocations.forEach((location) => {
const option = document.createElement("option");
option.value = location.id;
option.textContent = location.name;
select.appendChild(option);
});
showMessage("Standorte geladen (statische Daten - API nicht verfügbar)", "warning");
// Aktuell gespeicherten Standort laden
loadSavedLocation();
});
} else {
// Kein Lizenz-Key - verwende statische Daten
staticLocations.forEach((location) => {
const option = document.createElement("option");
option.value = location.id;
option.textContent = location.name;
select.appendChild(option);
});
showMessage("Standorte geladen (statische Daten - kein Lizenz-Key)", "warning");
// Aktuell gespeicherten Standort laden
loadSavedLocation();
}
}
// Aktuell gespeicherten Standort laden
function loadCurrentLocation() {
fetch("/api/get-location")
function loadSavedLocation() {
fetch("/api/get-local-location")
.then((response) => response.json())
.then((data) => {
if (data.locationId) {
@@ -1035,7 +1340,7 @@
}
// Standort an Backend senden
fetch("/api/set-location", {
fetch("/api/set-local-location", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
@@ -1053,18 +1358,90 @@
.catch((error) => showMessage("Verbindungsfehler", "error"));
});
// Status-Nachricht anzeigen
function showMessage(message, type) {
const statusDiv = document.getElementById("statusMessage");
statusDiv.textContent = message;
statusDiv.className = `status-message status-${type}`;
statusDiv.style.display = "block";
// Moderne Notification anzeigen
function showMessage(message, type = 'info') {
console.log("showMessage called:", message, type);
const toast = document.getElementById("notificationBubble");
const icon = document.getElementById("notificationIcon");
const title = document.getElementById("notificationTitle");
const text = document.getElementById("notificationText");
if (!toast || !icon || !title || !text) {
console.error("Notification elements not found!");
return;
}
// Clear any existing timeout
if (window.notificationTimeout) {
clearTimeout(window.notificationTimeout);
}
// Set content
text.textContent = message;
// Set type-specific styling and content
toast.className = "notification-toast";
switch(type) {
case 'success':
toast.classList.add('success');
icon.textContent = '✓';
title.textContent = 'Erfolg';
break;
case 'error':
toast.classList.add('error');
icon.textContent = '✕';
title.textContent = 'Fehler';
break;
case 'info':
toast.classList.add('info');
icon.textContent = '';
title.textContent = 'Information';
break;
case 'warning':
toast.classList.add('warning');
icon.textContent = '⚠';
title.textContent = 'Warnung';
break;
default:
toast.classList.add('info');
icon.textContent = '';
title.textContent = 'Information';
}
// Show toast with animation
toast.style.display = "flex";
// Force reflow
toast.offsetHeight;
// Add show class after a small delay to ensure display is set
setTimeout(() => {
statusDiv.style.display = "none";
toast.classList.add('show');
}, 10);
// Auto-hide after 5 seconds
window.notificationTimeout = setTimeout(() => {
hideNotification();
}, 5000);
}
// Notification verstecken mit Animation
function hideNotification() {
const toast = document.getElementById("notificationBubble");
if (!toast) return;
// Clear timeout if exists
if (window.notificationTimeout) {
clearTimeout(window.notificationTimeout);
}
// Remove show class for animation
toast.classList.remove('show');
// Hide after animation completes
setTimeout(() => {
toast.style.display = "none";
}, 400); // Match CSS transition duration
}
// System-Info alle 30 Sekunden aktualisieren
setInterval(loadSystemInfo, 30000);
</script>

308
data/tts.js Normal file
View File

@@ -0,0 +1,308 @@
// tts.js — Plays pre-rendered MP3 snippets from /tts/ gaplessly.
// Uses the Web Audio API: each MP3 is decoded once into an AudioBuffer,
// then chained playback is scheduled with start(when) so there is no
// JS-callback gap between snippets. Persists enable-state in
// localStorage. The toggle button doubles as the user gesture that
// unlocks the AudioContext on browsers with autoplay restrictions
// (iOS Safari, many SmartTV browsers).
(() => {
'use strict';
const BASE = '/tts/';
const STORAGE_KEY = 'aqm_tts_enabled';
// All snippets shipped in /tts/. Listed explicitly so we can
// preload (and decode) them upfront before the first announcement.
// Numbers 0-99 are spoken as natural German words ("vierzehn",
// "sechsundneunzig"), produced by tools/generate-tts.py.
const FILES = [
'bereit', 'komma', 'minute', 'minuten',
'neue_zeit', 'sekunden', 'und',
];
for (let i = 0; i < 100; i++) FILES.push(String(i));
let enabled = localStorage.getItem(STORAGE_KEY) === '1';
let audioCtx = null;
// For each name we cache { buffer, offset, duration } where offset
// and duration mark the non-silent region of the decoded buffer.
// Edge-TTS pads each MP3 with ~50-150 ms of leading/trailing
// silence, which would otherwise stack up between snippets.
const buffers = Object.create(null);
let scheduled = [];
let preloadPromise = null;
let btn = null;
// Linear amplitude threshold below which a sample counts as silence
// (~ -46 dBFS). Slightly higher than typical decoder noise floor.
const SILENCE_THRESHOLD = 0.005;
// Tiny grace at the start so we don't chop a soft consonant attack.
const LEAD_GRACE_S = 0.01;
// Scans both channels of an AudioBuffer to find the first and last
// sample whose absolute value exceeds SILENCE_THRESHOLD, returning
// {offset, duration} in seconds for use with start(when, offset,
// duration). Falls back to the full buffer if everything looks
// silent (shouldn't happen for our snippets, but be safe).
function findNonSilentRange(buffer) {
const channels = buffer.numberOfChannels;
const len = buffer.length;
const sampleRate = buffer.sampleRate;
let firstHit = len;
let lastHit = -1;
for (let c = 0; c < channels; c++) {
const data = buffer.getChannelData(c);
for (let i = 0; i < firstHit; i++) {
if (Math.abs(data[i]) >= SILENCE_THRESHOLD) {
firstHit = i;
break;
}
}
for (let i = len - 1; i > lastHit; i--) {
if (Math.abs(data[i]) >= SILENCE_THRESHOLD) {
lastHit = i;
break;
}
}
}
if (lastHit < 0 || firstHit >= len) {
return { offset: 0, duration: buffer.duration };
}
const grace = Math.floor(LEAD_GRACE_S * sampleRate);
const start = Math.max(0, firstHit - grace);
const end = Math.min(len, lastHit + 1);
return {
offset: start / sampleRate,
duration: (end - start) / sampleRate,
};
}
// Builds the spoken-snippet sequence for a duration in seconds.
// Examples (all sound natural in German):
// 14.96 -> ["14","komma","96","sekunden"] "vierzehn Komma sechsundneunzig Sekunden"
// 14.05 -> ["14","komma","0","5","sekunden"] "vierzehn Komma null fünf Sekunden"
// 14.00 -> ["14","sekunden"] "vierzehn Sekunden"
// 65.96 -> ["minute","und","5","komma","96","sekunden"]
// "eine Minute und fünf Komma sechsundneunzig Sekunden"
// 125.50 -> ["2","minuten","und","5","komma","50","sekunden"]
//
// Note on hundredths < 10: the leading zero is spoken digit-by-
// digit ("null fünf" for .05) so .05 stays distinguishable from
// .50 ("fünfzig"). For >= 10 the value is spoken as a single
// German word ("sechsundneunzig" for .96).
function timeToSeq(seconds) {
const total = Math.max(0, Number(seconds) || 0);
// Replicate the server's exact float-based formatting so the
// announcement always matches what the user sees on screen.
// The ESP (databasebackend.h, gamemodes.h) does:
// float s = timeMs / 1000.0;
// int totalSec = (int)s;
// int hundredths = (int)((s - totalSec) * 100);
// C++ `float` is single precision (24-bit mantissa); JS Number
// is double precision. For times near a hundredths boundary
// (e.g. 14.090) the two give different floor results — server
// says "14.08" but a naive double calculation announces "14.09".
// Math.fround forces single-precision rounding at each step so
// the chain matches the server bit-for-bit.
const sFloat = Math.fround(total);
const totalSec = Math.trunc(sFloat);
const minutes = Math.floor(totalSec / 60);
const remSec = totalSec % 60;
const diffFloat = Math.fround(sFloat - totalSec);
const scaledFloat = Math.fround(diffFloat * 100);
let hundredths = Math.trunc(scaledFloat);
if (hundredths < 0) hundredths = 0;
if (hundredths > 99) hundredths = 99;
const out = [];
if (minutes > 0) {
if (minutes === 1) {
out.push('minute'); // "eine Minute"
} else {
out.push(String(minutes));
out.push('minuten');
}
if (remSec > 0 || hundredths > 0) out.push('und');
}
if (remSec > 0 || hundredths > 0 || minutes === 0) {
out.push(String(remSec));
if (hundredths > 0) {
out.push('komma');
if (hundredths < 10) {
out.push('0');
out.push(String(hundredths));
} else {
out.push(String(hundredths));
}
}
out.push('sekunden');
}
return out;
}
// "12.34" or "01:23.45" -> seconds. timeToSeq() then re-rounds
// the value through Math.fround to match the server's float math,
// so the parseFloat drift is harmless here.
function parseFormattedTime(str) {
if (!str) return 0;
if (str.includes(':')) {
const [mm, rest] = str.split(':');
return parseInt(mm, 10) * 60 + parseFloat(rest);
}
return parseFloat(str) || 0;
}
function ensureContext() {
if (audioCtx) return;
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) {
console.warn('TTS: Web Audio API not supported');
return;
}
audioCtx = new Ctx();
}
// Limit concurrent fetches: the ESP's async web server only serves
// a handful of requests well in parallel, and the browser's 6-per-
// host pool would otherwise starve the 1 s /api/data poll while
// 107 MP3s come in. Two parallel fetches finishes the preload in
// ~2 s without holding up the live timer.
const PRELOAD_CONCURRENCY = 2;
async function fetchAndStore(name) {
const res = await fetch(BASE + name + '.mp3');
const arr = await res.arrayBuffer();
// Older Safari only supports the callback form of decodeAudioData.
const buffer = await new Promise((resolve, reject) => {
const p = audioCtx.decodeAudioData(arr, resolve, reject);
if (p && typeof p.then === 'function') p.then(resolve, reject);
});
const { offset, duration } = findNonSilentRange(buffer);
buffers[name] = { buffer, offset, duration };
}
function preload() {
if (preloadPromise) return preloadPromise;
ensureContext();
if (!audioCtx) return Promise.resolve();
let i = 0;
const worker = async () => {
while (i < FILES.length) {
const name = FILES[i++];
try {
await fetchAndStore(name);
} catch (e) {
console.warn('TTS preload failed:', name, e);
}
}
};
const workers = [];
for (let n = 0; n < PRELOAD_CONCURRENCY; n++) workers.push(worker());
preloadPromise = Promise.all(workers);
return preloadPromise;
}
function stop() {
scheduled.forEach(s => { try { s.stop(); } catch (_) {} });
scheduled = [];
}
function play(seq) {
if (!enabled || !seq || !seq.length) return;
ensureContext();
if (!audioCtx) return;
// Browsers may suspend the context until a user gesture. resume()
// is a no-op if already running.
if (audioCtx.state === 'suspended') {
audioCtx.resume().catch(() => {});
}
stop();
let when = audioCtx.currentTime;
seq.forEach((name) => {
const entry = buffers[name];
if (!entry) {
console.warn('TTS buffer missing:', name);
return;
}
const src = audioCtx.createBufferSource();
src.buffer = entry.buffer;
src.connect(audioCtx.destination);
// start(when, offset, duration) plays only the non-silent slice
// we identified during preload, so trailing silence in each
// snippet doesn't stack up between words.
src.start(when, entry.offset, entry.duration);
scheduled.push(src);
when += entry.duration;
});
}
function setEnabled(on) {
enabled = !!on;
localStorage.setItem(STORAGE_KEY, enabled ? '1' : '0');
if (!enabled) stop();
updateToggleUI();
}
function updateToggleUI() {
if (!btn) return;
btn.textContent = enabled ? '🔊' : '🔇';
btn.title = enabled ? 'Ansagen deaktivieren' : 'Ansagen aktivieren';
btn.setAttribute('aria-pressed', enabled ? 'true' : 'false');
}
function injectToggle() {
if (btn || !document.body) return;
btn = document.createElement('button');
btn.id = 'tts-toggle';
btn.style.cssText =
'position:fixed;bottom:14px;right:14px;z-index:9998;' +
'width:54px;height:54px;border-radius:50%;border:none;' +
'background:rgba(0,0,0,0.55);color:#fff;font-size:24px;' +
'cursor:pointer;display:flex;align-items:center;justify-content:center;' +
'box-shadow:0 2px 10px rgba(0,0,0,0.35);';
btn.addEventListener('click', async () => {
const next = !enabled;
setEnabled(next);
if (next) {
// The click itself is the user gesture that lets us create
// and resume the AudioContext. Preload then play a short ack.
await preload();
play(['bereit']);
}
});
document.body.appendChild(btn);
updateToggleUI();
}
// Decode buffers in the background. On a fresh page load the
// AudioContext is created in suspended state (no audio yet, just
// decoding — allowed without a gesture), so the first real
// announcement after the user clicks anywhere is already gapless.
// Defer the start so the initial render and the first /api/data
// poll go through unimpeded — otherwise the ESP's web server is
// busy serving MP3s and the live timer freezes for several seconds.
function eagerPreload() {
if (!enabled) return;
setTimeout(preload, 2000);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
injectToggle();
eagerPreload();
});
} else {
injectToggle();
eagerPreload();
}
window.tts = {
isEnabled: () => enabled,
setEnabled,
play,
stop,
timeToSeq,
parseFormattedTime,
sayTime: (sec) => play(['neue_zeit', ...timeToSeq(sec)]),
};
})();

BIN
data/tts/0.mp3 Normal file

Binary file not shown.

BIN
data/tts/1.mp3 Normal file

Binary file not shown.

BIN
data/tts/10.mp3 Normal file

Binary file not shown.

BIN
data/tts/11.mp3 Normal file

Binary file not shown.

BIN
data/tts/12.mp3 Normal file

Binary file not shown.

BIN
data/tts/13.mp3 Normal file

Binary file not shown.

BIN
data/tts/14.mp3 Normal file

Binary file not shown.

BIN
data/tts/15.mp3 Normal file

Binary file not shown.

BIN
data/tts/16.mp3 Normal file

Binary file not shown.

BIN
data/tts/17.mp3 Normal file

Binary file not shown.

BIN
data/tts/18.mp3 Normal file

Binary file not shown.

BIN
data/tts/19.mp3 Normal file

Binary file not shown.

BIN
data/tts/2.mp3 Normal file

Binary file not shown.

BIN
data/tts/20.mp3 Normal file

Binary file not shown.

BIN
data/tts/21.mp3 Normal file

Binary file not shown.

BIN
data/tts/22.mp3 Normal file

Binary file not shown.

BIN
data/tts/23.mp3 Normal file

Binary file not shown.

BIN
data/tts/24.mp3 Normal file

Binary file not shown.

BIN
data/tts/25.mp3 Normal file

Binary file not shown.

BIN
data/tts/26.mp3 Normal file

Binary file not shown.

BIN
data/tts/27.mp3 Normal file

Binary file not shown.

BIN
data/tts/28.mp3 Normal file

Binary file not shown.

BIN
data/tts/29.mp3 Normal file

Binary file not shown.

BIN
data/tts/3.mp3 Normal file

Binary file not shown.

BIN
data/tts/30.mp3 Normal file

Binary file not shown.

BIN
data/tts/31.mp3 Normal file

Binary file not shown.

BIN
data/tts/32.mp3 Normal file

Binary file not shown.

BIN
data/tts/33.mp3 Normal file

Binary file not shown.

BIN
data/tts/34.mp3 Normal file

Binary file not shown.

BIN
data/tts/35.mp3 Normal file

Binary file not shown.

BIN
data/tts/36.mp3 Normal file

Binary file not shown.

BIN
data/tts/37.mp3 Normal file

Binary file not shown.

BIN
data/tts/38.mp3 Normal file

Binary file not shown.

BIN
data/tts/39.mp3 Normal file

Binary file not shown.

BIN
data/tts/4.mp3 Normal file

Binary file not shown.

BIN
data/tts/40.mp3 Normal file

Binary file not shown.

BIN
data/tts/41.mp3 Normal file

Binary file not shown.

BIN
data/tts/42.mp3 Normal file

Binary file not shown.

BIN
data/tts/43.mp3 Normal file

Binary file not shown.

BIN
data/tts/44.mp3 Normal file

Binary file not shown.

BIN
data/tts/45.mp3 Normal file

Binary file not shown.

BIN
data/tts/46.mp3 Normal file

Binary file not shown.

BIN
data/tts/47.mp3 Normal file

Binary file not shown.

BIN
data/tts/48.mp3 Normal file

Binary file not shown.

BIN
data/tts/49.mp3 Normal file

Binary file not shown.

BIN
data/tts/5.mp3 Normal file

Binary file not shown.

BIN
data/tts/50.mp3 Normal file

Binary file not shown.

BIN
data/tts/51.mp3 Normal file

Binary file not shown.

BIN
data/tts/52.mp3 Normal file

Binary file not shown.

BIN
data/tts/53.mp3 Normal file

Binary file not shown.

BIN
data/tts/54.mp3 Normal file

Binary file not shown.

BIN
data/tts/55.mp3 Normal file

Binary file not shown.

BIN
data/tts/56.mp3 Normal file

Binary file not shown.

BIN
data/tts/57.mp3 Normal file

Binary file not shown.

BIN
data/tts/58.mp3 Normal file

Binary file not shown.

BIN
data/tts/59.mp3 Normal file

Binary file not shown.

BIN
data/tts/6.mp3 Normal file

Binary file not shown.

BIN
data/tts/60.mp3 Normal file

Binary file not shown.

BIN
data/tts/61.mp3 Normal file

Binary file not shown.

BIN
data/tts/62.mp3 Normal file

Binary file not shown.

BIN
data/tts/63.mp3 Normal file

Binary file not shown.

BIN
data/tts/64.mp3 Normal file

Binary file not shown.

BIN
data/tts/65.mp3 Normal file

Binary file not shown.

BIN
data/tts/66.mp3 Normal file

Binary file not shown.

BIN
data/tts/67.mp3 Normal file

Binary file not shown.

BIN
data/tts/68.mp3 Normal file

Binary file not shown.

BIN
data/tts/69.mp3 Normal file

Binary file not shown.

BIN
data/tts/7.mp3 Normal file

Binary file not shown.

BIN
data/tts/70.mp3 Normal file

Binary file not shown.

BIN
data/tts/71.mp3 Normal file

Binary file not shown.

BIN
data/tts/72.mp3 Normal file

Binary file not shown.

BIN
data/tts/73.mp3 Normal file

Binary file not shown.

BIN
data/tts/74.mp3 Normal file

Binary file not shown.

BIN
data/tts/75.mp3 Normal file

Binary file not shown.

BIN
data/tts/76.mp3 Normal file

Binary file not shown.

BIN
data/tts/77.mp3 Normal file

Binary file not shown.

BIN
data/tts/78.mp3 Normal file

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More