Compare commits
49 Commits
0166e1a695
...
feat/tts-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d4831349b | ||
|
|
5beced0041 | ||
|
|
fd18d0cd22 | ||
| 455633178c | |||
| 9361cfdee6 | |||
| f558c64886 | |||
|
|
3400b9cc6a | ||
|
|
fa87fd0222 | ||
|
|
a6c885ee33 | ||
|
|
8acb611b9b | ||
|
|
68483c8127 | ||
|
|
781ad18c6a | ||
|
|
a875b20ba2 | ||
|
|
f6b2dceedc | ||
|
|
df95a37ca7 | ||
|
|
96fcb74c80 | ||
|
|
48ae556949 | ||
|
|
9d958c94f1 | ||
|
|
0223cceef8 | ||
| 05166b443b | |||
|
|
76b492606e | ||
| d9edd47a31 | |||
|
|
a67e29b9e4 | ||
|
|
5ef5e6d636 | ||
|
|
77f1ebc1f1 | ||
|
|
2a832257ba | ||
|
|
5ca67d8804 | ||
|
|
8fac847a75 | ||
|
|
36c35ba161 | ||
|
|
e383e54e41 | ||
|
|
9de327bfb3 | ||
|
|
7e9705902e | ||
|
|
1ed3a30340 | ||
|
|
02a60d84cf | ||
|
|
4f0fc68d41 | ||
|
|
3aac843736 | ||
|
|
ed9e8994a9 | ||
|
|
86b0407f82 | ||
|
|
a400ca00ff | ||
|
|
173b13fcfc | ||
|
|
55eb062d2c | ||
|
|
a768783640 | ||
|
|
2b9cc7283c | ||
|
|
ba1b86a053 | ||
|
|
4a04565878 | ||
|
|
6793a54103 | ||
|
|
60d4393bd2 | ||
|
|
a1c68791bf | ||
| e6a089fd61 |
18
.claude/settings.local.json
Normal file
18
.claude/settings.local.json
Normal 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"
|
||||
]
|
||||
}
|
||||
10
.github/workflows/build.yml
vendored
10
.github/workflows/build.yml
vendored
@@ -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
106
.gitignore
vendored
@@ -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
142
API.md
@@ -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).**
|
||||
|
||||
BIN
AquaMaster-Update-Anleitung.pdf
Normal file
BIN
AquaMaster-Update-Anleitung.pdf
Normal file
Binary file not shown.
674
Bedienungsanleitung_NinjaCross_Timer.html
Normal file
674
Bedienungsanleitung_NinjaCross_Timer.html
Normal 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
66
CLAUDE.md
Normal 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
21
LICENSE
Normal 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
245
README.md
@@ -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
|
||||
[](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}}) [](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
78
THIRD_PARTY_LICENSES.md
Normal file
@@ -0,0 +1,78 @@
|
||||
## Third-Party Licenses and Notices
|
||||
|
||||
This project uses third‑party 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 LGPL‑licensed 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 | LGPL‑3.0 | [esp32async/ESPAsyncWebServer](https://github.com/esp32async/ESPAsyncWebServer) | [LICENSE](https://github.com/esp32async/ESPAsyncWebServer/blob/master/LICENSE) |
|
||||
| AsyncTCP (esp32async) | ^3.4.2 | LGPL‑3.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 LGPL‑3.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 LGPL‑3.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 LGPL‑3.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).
|
||||
|
||||
|
||||
3
TODO.md
3
TODO.md
@@ -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!
|
||||
|
||||
@@ -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.
500
data/index.css
500
data/index.css
@@ -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 {
|
||||
|
||||
775
data/index.html
775
data/index.html
@@ -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()">×</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
367
data/leaderboard.css
Normal 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
248
data/leaderboard.html
Normal 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>
|
||||
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
23
data/pictures/logo.svg
Normal file
23
data/pictures/logo.svg
Normal 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 |
358
data/rfid.html
358
data/rfid.html
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
308
data/tts.js
Normal 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
BIN
data/tts/0.mp3
Normal file
Binary file not shown.
BIN
data/tts/1.mp3
Normal file
BIN
data/tts/1.mp3
Normal file
Binary file not shown.
BIN
data/tts/10.mp3
Normal file
BIN
data/tts/10.mp3
Normal file
Binary file not shown.
BIN
data/tts/11.mp3
Normal file
BIN
data/tts/11.mp3
Normal file
Binary file not shown.
BIN
data/tts/12.mp3
Normal file
BIN
data/tts/12.mp3
Normal file
Binary file not shown.
BIN
data/tts/13.mp3
Normal file
BIN
data/tts/13.mp3
Normal file
Binary file not shown.
BIN
data/tts/14.mp3
Normal file
BIN
data/tts/14.mp3
Normal file
Binary file not shown.
BIN
data/tts/15.mp3
Normal file
BIN
data/tts/15.mp3
Normal file
Binary file not shown.
BIN
data/tts/16.mp3
Normal file
BIN
data/tts/16.mp3
Normal file
Binary file not shown.
BIN
data/tts/17.mp3
Normal file
BIN
data/tts/17.mp3
Normal file
Binary file not shown.
BIN
data/tts/18.mp3
Normal file
BIN
data/tts/18.mp3
Normal file
Binary file not shown.
BIN
data/tts/19.mp3
Normal file
BIN
data/tts/19.mp3
Normal file
Binary file not shown.
BIN
data/tts/2.mp3
Normal file
BIN
data/tts/2.mp3
Normal file
Binary file not shown.
BIN
data/tts/20.mp3
Normal file
BIN
data/tts/20.mp3
Normal file
Binary file not shown.
BIN
data/tts/21.mp3
Normal file
BIN
data/tts/21.mp3
Normal file
Binary file not shown.
BIN
data/tts/22.mp3
Normal file
BIN
data/tts/22.mp3
Normal file
Binary file not shown.
BIN
data/tts/23.mp3
Normal file
BIN
data/tts/23.mp3
Normal file
Binary file not shown.
BIN
data/tts/24.mp3
Normal file
BIN
data/tts/24.mp3
Normal file
Binary file not shown.
BIN
data/tts/25.mp3
Normal file
BIN
data/tts/25.mp3
Normal file
Binary file not shown.
BIN
data/tts/26.mp3
Normal file
BIN
data/tts/26.mp3
Normal file
Binary file not shown.
BIN
data/tts/27.mp3
Normal file
BIN
data/tts/27.mp3
Normal file
Binary file not shown.
BIN
data/tts/28.mp3
Normal file
BIN
data/tts/28.mp3
Normal file
Binary file not shown.
BIN
data/tts/29.mp3
Normal file
BIN
data/tts/29.mp3
Normal file
Binary file not shown.
BIN
data/tts/3.mp3
Normal file
BIN
data/tts/3.mp3
Normal file
Binary file not shown.
BIN
data/tts/30.mp3
Normal file
BIN
data/tts/30.mp3
Normal file
Binary file not shown.
BIN
data/tts/31.mp3
Normal file
BIN
data/tts/31.mp3
Normal file
Binary file not shown.
BIN
data/tts/32.mp3
Normal file
BIN
data/tts/32.mp3
Normal file
Binary file not shown.
BIN
data/tts/33.mp3
Normal file
BIN
data/tts/33.mp3
Normal file
Binary file not shown.
BIN
data/tts/34.mp3
Normal file
BIN
data/tts/34.mp3
Normal file
Binary file not shown.
BIN
data/tts/35.mp3
Normal file
BIN
data/tts/35.mp3
Normal file
Binary file not shown.
BIN
data/tts/36.mp3
Normal file
BIN
data/tts/36.mp3
Normal file
Binary file not shown.
BIN
data/tts/37.mp3
Normal file
BIN
data/tts/37.mp3
Normal file
Binary file not shown.
BIN
data/tts/38.mp3
Normal file
BIN
data/tts/38.mp3
Normal file
Binary file not shown.
BIN
data/tts/39.mp3
Normal file
BIN
data/tts/39.mp3
Normal file
Binary file not shown.
BIN
data/tts/4.mp3
Normal file
BIN
data/tts/4.mp3
Normal file
Binary file not shown.
BIN
data/tts/40.mp3
Normal file
BIN
data/tts/40.mp3
Normal file
Binary file not shown.
BIN
data/tts/41.mp3
Normal file
BIN
data/tts/41.mp3
Normal file
Binary file not shown.
BIN
data/tts/42.mp3
Normal file
BIN
data/tts/42.mp3
Normal file
Binary file not shown.
BIN
data/tts/43.mp3
Normal file
BIN
data/tts/43.mp3
Normal file
Binary file not shown.
BIN
data/tts/44.mp3
Normal file
BIN
data/tts/44.mp3
Normal file
Binary file not shown.
BIN
data/tts/45.mp3
Normal file
BIN
data/tts/45.mp3
Normal file
Binary file not shown.
BIN
data/tts/46.mp3
Normal file
BIN
data/tts/46.mp3
Normal file
Binary file not shown.
BIN
data/tts/47.mp3
Normal file
BIN
data/tts/47.mp3
Normal file
Binary file not shown.
BIN
data/tts/48.mp3
Normal file
BIN
data/tts/48.mp3
Normal file
Binary file not shown.
BIN
data/tts/49.mp3
Normal file
BIN
data/tts/49.mp3
Normal file
Binary file not shown.
BIN
data/tts/5.mp3
Normal file
BIN
data/tts/5.mp3
Normal file
Binary file not shown.
BIN
data/tts/50.mp3
Normal file
BIN
data/tts/50.mp3
Normal file
Binary file not shown.
BIN
data/tts/51.mp3
Normal file
BIN
data/tts/51.mp3
Normal file
Binary file not shown.
BIN
data/tts/52.mp3
Normal file
BIN
data/tts/52.mp3
Normal file
Binary file not shown.
BIN
data/tts/53.mp3
Normal file
BIN
data/tts/53.mp3
Normal file
Binary file not shown.
BIN
data/tts/54.mp3
Normal file
BIN
data/tts/54.mp3
Normal file
Binary file not shown.
BIN
data/tts/55.mp3
Normal file
BIN
data/tts/55.mp3
Normal file
Binary file not shown.
BIN
data/tts/56.mp3
Normal file
BIN
data/tts/56.mp3
Normal file
Binary file not shown.
BIN
data/tts/57.mp3
Normal file
BIN
data/tts/57.mp3
Normal file
Binary file not shown.
BIN
data/tts/58.mp3
Normal file
BIN
data/tts/58.mp3
Normal file
Binary file not shown.
BIN
data/tts/59.mp3
Normal file
BIN
data/tts/59.mp3
Normal file
Binary file not shown.
BIN
data/tts/6.mp3
Normal file
BIN
data/tts/6.mp3
Normal file
Binary file not shown.
BIN
data/tts/60.mp3
Normal file
BIN
data/tts/60.mp3
Normal file
Binary file not shown.
BIN
data/tts/61.mp3
Normal file
BIN
data/tts/61.mp3
Normal file
Binary file not shown.
BIN
data/tts/62.mp3
Normal file
BIN
data/tts/62.mp3
Normal file
Binary file not shown.
BIN
data/tts/63.mp3
Normal file
BIN
data/tts/63.mp3
Normal file
Binary file not shown.
BIN
data/tts/64.mp3
Normal file
BIN
data/tts/64.mp3
Normal file
Binary file not shown.
BIN
data/tts/65.mp3
Normal file
BIN
data/tts/65.mp3
Normal file
Binary file not shown.
BIN
data/tts/66.mp3
Normal file
BIN
data/tts/66.mp3
Normal file
Binary file not shown.
BIN
data/tts/67.mp3
Normal file
BIN
data/tts/67.mp3
Normal file
Binary file not shown.
BIN
data/tts/68.mp3
Normal file
BIN
data/tts/68.mp3
Normal file
Binary file not shown.
BIN
data/tts/69.mp3
Normal file
BIN
data/tts/69.mp3
Normal file
Binary file not shown.
BIN
data/tts/7.mp3
Normal file
BIN
data/tts/7.mp3
Normal file
Binary file not shown.
BIN
data/tts/70.mp3
Normal file
BIN
data/tts/70.mp3
Normal file
Binary file not shown.
BIN
data/tts/71.mp3
Normal file
BIN
data/tts/71.mp3
Normal file
Binary file not shown.
BIN
data/tts/72.mp3
Normal file
BIN
data/tts/72.mp3
Normal file
Binary file not shown.
BIN
data/tts/73.mp3
Normal file
BIN
data/tts/73.mp3
Normal file
Binary file not shown.
BIN
data/tts/74.mp3
Normal file
BIN
data/tts/74.mp3
Normal file
Binary file not shown.
BIN
data/tts/75.mp3
Normal file
BIN
data/tts/75.mp3
Normal file
Binary file not shown.
BIN
data/tts/76.mp3
Normal file
BIN
data/tts/76.mp3
Normal file
Binary file not shown.
BIN
data/tts/77.mp3
Normal file
BIN
data/tts/77.mp3
Normal file
Binary file not shown.
BIN
data/tts/78.mp3
Normal file
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
Reference in New Issue
Block a user