Compare commits

..

5 Commits

Author SHA1 Message Date
Hirnwunde
dc3722c803 added theming 2026-05-18 11:54:31 +02:00
9f6b6a8518 release: v0.9.9 2026-05-14 17:23:05 +02:00
5eda8a241f docs: README.md englisch + README.de.md deutsch mit Sprach-Cross-Link 2026-05-12 19:41:16 +02:00
2aaaa5bbbe README.de.md aktualisiert 2026-05-12 17:20:38 +02:00
374457fb07 release: v0.9.8 2026-05-12 15:05:35 +02:00
18 changed files with 6093 additions and 1784 deletions

View File

@@ -1,5 +1,37 @@
# Changelog
## [0.9.9] 2026-05-14
### Fixes
- **„Failed to fetch"-Schleife in der UI (Issue #21):** Wenn die Web-UI über die LAN-IP geöffnet wurde, lieferte `/kx/printers` `bridge_url: http://localhost:7125` zurück. Der Browser machte daraufhin Cross-Origin-Requests von der LAN-IP nach `localhost` — die wurden vom Browser blockiert und produzierten eine Flut aus `TypeError: Failed to fetch`-Poll-Fehlern. Die Bridge liefert jetzt im Einzel-Drucker-Modus eine leere `bridge_url`, sodass das Frontend relative Pfade gegen dieselbe Origin wie die UI nutzt. Im Multi-Printer-Modus werden `localhost`/`127.0.0.1` als Bridge-Hosts herausgefiltert.
- **Windows-EXE crasht beim Start (Issue #21):** Die v0.9.8-`kx-bridge.exe` wurde mit einer veralteten `config_loader.py` aus einem früheren Release gebaut und stürzte mit `AttributeError: module 'config_loader' has no attribute 'list_printers'` ab. `release.sh` synct jetzt `config_loader.py` zusammen mit den anderen Quellen ins Windows-Build-Repository.
---
## [0.9.8] 2026-05-12
### Neu
- **Multi-Printer in einer Bridge-Instanz:** Ein Prozess verwaltet jetzt mehrere Drucker gleichzeitig — N MQTT-Verbindungen + N HTTP-Listener (Ports 7125, 7126, …), geteilte SQLite + GCode-Store. Konfiguration über `[printer_1]`-, `[printer_2]`-… Sektionen in `config.ini`. Einzel-Modus (`[connection]`) funktioniert unverändert weiter. `docker-compose.yml` exposed einen Port-Range `7125-7130`.
- **Drucker per UI hinzufügen:** „+ Drucker hinzufügen"-Button im Drucker-Tab — nur die IP eingeben, Zugangsdaten (Username, Passwort, Device-ID) werden automatisch vom Drucker geholt und entschlüsselt. Weitere Drucker bekommen den nächsten freien Port (7126, 7127, …).
- **Drucker per UI entfernen:** „✕"-Button auf jeder Drucker-Karte mit Bestätigung — entfernt die `[printer_N]`-Sektion und nummeriert die übrigen um. Beim Entfernen des letzten Druckers wird auch `[connection]` geleert (leerer Zustand).
- **GCode Store:** Hochgeladene Dateien werden in SQLite gespeichert, inkl. Thumbnail-Extraktion. Neue `/kx/files`-API.
- **Browser-Tab:** Grid-Ansicht aller hochgeladenen Dateien — Thumbnail, Status-Badge (✓/✗), letzte Druckdauer, plus Suche, Filter und Sortierung.
- **Druckhistorie:** Druckaufträge (Start/Ende/Status) werden in SQLite protokolliert, Status pro Datei im Browser-Tab sichtbar.
- **Filament-Dialog:** Per-Kanal-Remapping vor dem Druckstart — jeder GCode-Farbkanal wird einem physischen AMS-Slot zugewiesen (wie im Anycubic Slicer). Verfügbar im Browser-Tab und im Upload-Banner.
- **MMU-Emulation:** `GET /printer/objects/query?mmu` liefert eine Happy-Hare-kompatible Struktur, damit OrcaSlicers Filament-Sync die AMS-Slots erkennt.
- **Drucker-Tab:** Live-Status aller Drucker-Instanzen, IP auf jeder Karte, „Wechseln →"-Button.
- **Editierbarer Drucker-Name:** Eigener Name in den Einstellungen (gespeichert in `[bridge] printer_name`, hat Vorrang vor dem vom Drucker gemeldeten Namen).
- **Standalone-tauglich:** Linux-Binary / Windows-EXE laufen ohne Docker — `config/` und `data/` liegen neben dem Programm (portabel). Erststart ohne konfigurierten Drucker zeigt den Drucker-Tab mit „+ Drucker hinzufügen" statt des Einstellungs-Dialogs.
- **i18n:** Alle neuen UI-Elemente auf Deutsch und Englisch.
### Fixes
- **CORS:** CORS-Middleware auf allen Endpunkten — Cross-Instance-Fetches in der Multi-Printer-UI funktionieren zuverlässig.
- **Einstellungen / Update-Check** zeigen im Multi-Printer-Modus jetzt die aktive Bridge-Instanz (via `_apiUrl`).
- **Bridge-Neustart:** Config-abhängige Umgebungsvariablen werden vor einem Neustart gelöscht (der Config-Loader cachte sie, wodurch Config-Änderungen erst nach einem Kaltstart sichtbar wurden). Der Neustart ist jetzt plattformabhängig: Docker/systemd → Prozess-Exit (Supervisor startet neu), Linux standalone → `os.execv`, Windows → detachter Subprozess.
- **`--data-dir`-Default** ist jetzt plattformabhängig — der `/app/data`-Default greift nur in Docker (per `ENV` gesetzt), Standalone-Binaries nutzen `<exe-dir>/data`. Behebt einen Startup-Crash beim Ausführen ohne Docker.
---
## [0.9.7] 2026-05-08
### Neu

View File

@@ -1,5 +1,37 @@
# Changelog
## [0.9.9] 2026-05-14
### Fixes
- **"Failed to fetch" loop in the UI (Issue #21):** When the web UI was opened via the LAN IP, `/kx/printers` was returning `bridge_url: http://localhost:7125`, which caused the browser to fire cross-origin requests from the LAN IP to `localhost` — these were silently blocked, producing a flood of `TypeError: Failed to fetch` poll errors. The bridge now sends an empty `bridge_url` in single-printer mode so the frontend uses relative paths against the same origin as the UI. In multi-printer mode, `localhost`/`127.0.0.1` are filtered out as bridge hosts.
- **Windows EXE startup crash (Issue #21):** The v0.9.8 `kx-bridge.exe` was built with a stale `config_loader.py` from an earlier release and crashed on startup with `AttributeError: module 'config_loader' has no attribute 'list_printers'`. `release.sh` now syncs `config_loader.py` into the Windows build repository together with the other source files.
---
## [0.9.8] 2026-05-12
### New
- **Multi-printer in a single bridge instance:** One process now manages multiple printers — N MQTT connections + N HTTP listeners (ports 7125, 7126, …), shared SQLite + GCode store. Configure via `[printer_1]`, `[printer_2]` … sections in `config.ini`. Single-printer mode (`[connection]` only) keeps working unchanged. `docker-compose.yml` exposes a port range `7125-7130`.
- **Add printer from the UI:** "+ Add printer" button in the Printers tab — just enter the printer IP, the credentials (username, password, device ID) are fetched and decrypted from the printer automatically. Adding more printers assigns the next free port (7126, 7127, …).
- **Remove printer from the UI:** "✕" button on each printer card with a confirmation dialog — removes the `[printer_N]` section and renumbers the rest. Removing the last printer clears `[connection]` too, leaving an empty state.
- **GCode Store:** Uploaded files are persisted in SQLite with thumbnail extraction. New `/kx/files` API.
- **Browser tab:** Grid view of all uploaded files — thumbnail, status badge (✓/✗), last print duration, plus search, filter and sort.
- **Print history:** Print jobs (start/end/status) are recorded in SQLite, status shown per file in the Browser tab.
- **Filament dialog:** Per-channel remapping before print start — assign each GCode color channel to a physical AMS slot (like the Anycubic Slicer does). Available in the Browser tab and the upload banner.
- **MMU emulation:** `GET /printer/objects/query?mmu` returns a Happy-Hare-compatible structure so OrcaSlicer's filament sync detects the AMS slots.
- **Printers tab:** Live status of all printer instances, IP shown on each card, "Switch →" button.
- **Editable printer name:** Set a custom name in Settings (stored in `[bridge] printer_name`, takes precedence over the MQTT-reported name).
- **Standalone friendly:** Linux binary / Windows EXE run without Docker — `config/` and `data/` are placed next to the executable (portable). First start with no printer configured shows the Printers tab with "+ Add printer" instead of the settings modal.
- **i18n:** All new UI elements available in German and English.
### Fixes
- **CORS:** CORS middleware added to all endpoints — cross-instance fetches in the multi-printer UI work reliably.
- **Settings / update check** now reflect the active bridge instance in multi-printer mode (via `_apiUrl`).
- **Bridge restart:** Config-dependent environment variables are cleared before a restart (the config loader cached them, which made config changes invisible until the next cold start). Restart is now platform-aware: Docker/systemd → process exit (supervisor restarts), Linux standalone → `os.execv`, Windows → detached subprocess.
- **`--data-dir` default** is now platform-dependent — the `/app/data` default only applies inside Docker (set via `ENV`), standalone binaries use `<exe-dir>/data`. Fixes a startup crash when running the binary without Docker.
---
## [0.9.7] 2026-05-08
### New

View File

@@ -6,6 +6,7 @@ COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY kobrax_moonraker_bridge.py .
COPY web ./web
COPY config_loader.py .
COPY env_loader.py .
COPY kobrax_client.py .
@@ -16,7 +17,12 @@ COPY config/config.ini.example /app/config/config.ini.example
# config/ ist ein Volume-Mountpoint beim Start wird config.ini aus .env migriert
# falls noch keine config.ini vorhanden ist.
RUN mkdir -p /app/config
RUN mkdir -p /app/config && mkdir -p /app/data
# Daten-Verzeichnis fest auf /app/data (sonst würde der Binary-Default <exe-dir>/data greifen)
# und Container-Erkennung für den Bridge-Restart (Supervisor startet neu statt subprocess).
ENV KX_DATA_DIR=/app/data
ENV KX_IN_DOCKER=1
EXPOSE 7125

View File

@@ -1,160 +1,182 @@
<p align="center"><img src="knlogo.png" alt="KX-Bridge Logo" width="180"/></p>
<div align="center">
# KX-Bridge Anycubic Kobra X
<img src="knlogo.png" alt="KX-Bridge" width="160"/>
**Version:** 0.9.7
# KX-Bridge
Steuere deinen **Anycubic Kobra X** mit OrcaSlicer — ohne Klipper, ohne Raspberry Pi.
KX-Bridge ist eine Moonraker-kompatible Bridge die direkt mit dem Drucker kommuniziert.
**Steuere deinen Anycubic Kobra X mit OrcaSlicer — ohne Klipper, ohne Raspberry Pi.**
Eine Moonraker-kompatible Bridge, die direkt mit dem Drucker spricht.
<sub>🇬🇧 <a href="README.md">English version</a></sub>
<br>
[![Ko-fi](https://img.shields.io/badge/Ko--fi-Support%20this%20project-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/viewitde)
&nbsp;
[![Releases](https://img.shields.io/badge/Download-Releases-2EA043?style=for-the-badge&logo=gitea&logoColor=white)](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
&nbsp;
[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=1Ql4wfH27fM)
<sub>Gefällt dir KX-Bridge? Ein Kaffee auf <a href="https://ko-fi.com/viewitde">Ko-fi</a> hält das Projekt am Leben. ☕</sub>
</div>
---
## Schnellstart in 3 Schritten
## ✨ Was kann KX-Bridge?
### Schritt 1 Drucker vorbereiten
| | Feature |
|---|---|
| 🖨️ | **Druckersteuerung** — Start, Pause, Resume, Abbruch, Temperaturen, Druckgeschwindigkeit |
| 📊 | **Live-Status** — Temperatur, Fortschritt, Layer, Restzeit, Kamera-Stream |
| 🎨 | **AMS / Multicolor** — Filament-Slots, Per-Kanal-Remapping, MMU-Emulation für OrcaSlicer Filament-Sync |
| 🗂️ | **GCode-Browser** — hochgeladene Dateien mit Thumbnail, Druckhistorie, Suche & Filter |
| 🧩 | **Multi-Printer** — mehrere Drucker in **einer** Bridge-Instanz, Umschalten per Dropdown |
| | **Drucker hinzufügen per Klick** — nur die IP eingeben, Zugangsdaten werden automatisch importiert |
| 🔄 | **Self-Update** — neue Versionen direkt im Browser installieren |
| 🌐 | **OrcaSlicer** — volles Moonraker-Protokoll (HTTP + WebSocket), DE/EN UI |
Den Kobra X in den LAN-Modus versetzen:
**Drucker-Display → Einstellungen → LAN-Modus einschalten**
---
### Schritt 2 Credentials holen
## 🚀 Schnellstart
Die MQTT-Zugangsdaten sind druckerspezifisch und an die Hardware gebunden.
### 1. Drucker vorbereiten
**Option A fetch_credentials (empfohlen):**
LAN-Modus am Kobra X aktivieren:
**Drucker-Display → Einstellungen → LAN-Modus aktivieren**
### 2. Bridge starten
**Docker (empfohlen):**
```bash
docker compose up -d
```
**Linux-Binary (kein Docker):**
```bash
chmod +x kx-bridge && ./kx-bridge
```
**Windows-EXE (kein Docker):**
```
kx-bridge.exe
```
> `config\` und `data\` werden neben der EXE angelegt — portabel.
> Bei Linux- und Windows-Binary liegen `config/` und `data/` (Einstellungen, SQLite,
> GCode-Store) jeweils neben dem Programm. Einfach den ganzen Ordner kopieren = umziehen.
**Python direkt:**
```bash
pip install -r bridge/requirements.txt
python bridge/kobrax_moonraker_bridge.py
```
### 3. Drucker einrichten
Web-UI öffnen: **`http://BRIDGE-IP:7125`**
Beim Erststart erscheint der **Drucker-Tab** mit *„+ Drucker hinzufügen"* — einfach die
IP-Adresse des Druckers eingeben, der Rest (Username, Passwort, Device-ID) wird automatisch
vom Drucker geholt und entschlüsselt. Fertig.
> Mehrere Drucker? Einfach mehrfach *„+ Drucker hinzufügen"* — jeder bekommt seinen eigenen
> Port (7125, 7126, …) und ist im Header-Dropdown auswählbar.
### 4. OrcaSlicer verbinden
Drucker → Verbindungstyp **Moonraker** → Host: `http://BRIDGE-IP:7125`
> ⚠️ Verbindungstyp muss **Moonraker** sein (nicht „Bambu" oder „Klipper").
> Vollständige URL inkl. `http://` und Port `:7125` im Host-Feld eintragen.
---
## 📺 Video-Tutorial
[![KX-Bridge Setup & Usage](https://img.youtube.com/vi/1Ql4wfH27fM/hqdefault.jpg)](https://www.youtube.com/watch?v=1Ql4wfH27fM)
---
## 🔧 Zugangsdaten manuell ermitteln
Normalerweise nicht nötig — *„+ Drucker hinzufügen"* macht das automatisch. Falls doch:
```bash
fetch_credentials --ip 192.168.x.x --write-config
```
Holt die Zugangsdaten per HTTP direkt vom Drucker und schreibt sie in `config/config.ini`.
Nur die Drucker-IP nötig, kein Slicer.
Holt die Credentials direkt per HTTP vom Drucker und schreibt sie automatisch in `config/config.ini`. Benötigt nur die Drucker-IP — kein Slicer nötig.
Alternativ (wenn die IP unbekannt ist): AnycubicSlicerNext öffnen, Drucker verbinden,
dann `extract_credentials` ausführen → gibt Username, Passwort, Device-ID und IP aus.
**Option B extract_credentials (wenn Drucker-IP unbekannt):**
> **Downloads:** [Releases](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases) → `fetch_credentials` / `extract_credentials` (Linux & Windows)
1. **AnycubicSlicerNext** öffnen und Drucker verbinden (bis Status angezeigt wird)
2. **`extract_credentials`** ausführen — gibt Username, Password, Device-ID und Drucker-IP aus
3. Werte im Web-UI eintragen (⚙-Menü)
---
> **Download:** [gitea.it-drui.de/viewit/KX-Bridge-Release/releases](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases) → `fetch_credentials` / `extract_credentials` (Linux & Windows) im jeweiligen Release-Asset
### Schritt 3 Bridge starten
## ⚙️ Nützliche Befehle
```bash
./start.sh
```
Das Skript baut das Docker-Image automatisch beim ersten Aufruf.
**Web-UI öffnen:** `http://BRIDGE-IP:7125`
→ Das ⚙-Menü öffnet sich beim ersten Start automatisch
→ Bei Option B: Credentials aus Schritt 2 eintragen → **Speichern & Neustart**
**OrcaSlicer verbinden:**
Drucker → Verbindungstyp **Moonraker** → Host: `http://BRIDGE-IP:7125`
> **Wichtig:** Verbindungstyp muss **Moonraker** sein (nicht „Bambu" oder „Klipper").
> Im Host-Feld vollständige URL mit `http://` und Port `:7125` angeben.
---
## 📺 Video Tutorial
[![KX-Bridge Setup & Bedienung](https://img.youtube.com/vi/1Ql4wfH27fM/hqdefault.jpg)](https://www.youtube.com/watch?v=1Ql4wfH27fM)
---
## ⚠️ Update von 0.9.1 oder älter
Ab **0.9.2** speichert KX-Bridge Einstellungen in `config/config.ini` statt in `.env`.
**Migration erfolgt automatisch** — keine manuelle Aktion nötig:
- Beim ersten Start nach dem Update liest die Bridge die vorhandene `.env` und erstellt `config/config.ini` automatisch
- Einstellungen bleiben ab sofort nach `docker-compose restart` und zukünftigen Updates erhalten
- Die `.env`-Datei bleibt read-only gemountet als Migrationsquelle — kann liegen bleiben
- Zum manuellen Anlegen einer `config.ini`: Vorlage unter `config/config.ini.example` kopieren
---
## Was wird unterstützt?
| Funktion | Details |
|----------|---------|
| Druckerstatus | Temperatur, Fortschritt, Zustand, Restzeit |
| Drucksteuerung | Start, Pause, Fortsetzen, Abbrechen |
| Temperaturregelung | Nozzle und Bett während des Drucks |
| Druckgeschwindigkeit | Leise / Normal / Sport |
| AMS-Farbwechsel | Filament einziehen / ausziehen |
| Licht & Lüfter | Drucklicht und Lüfterdrehzahl |
| Web-UI | Dashboard, Achsensteuerung, Kameraansicht |
| Self-Update | Neue Versionen direkt im Browser installieren |
| OrcaSlicer | Moonraker-Protokoll (HTTP + WebSocket) |
---
## Alternativen zu Docker
**Linux Binary** (kein Docker nötig):
```bash
chmod +x kx-bridge
./kx-bridge
```
**Python direkt:**
```bash
pip install aiohttp
python bridge/kobrax_moonraker_bridge.py
```
Web-UI jeweils unter `http://localhost:7125` — ⚙-Menü führt durch die Erstkonfiguration.
---
## Nützliche Befehle
```bash
# Logs anzeigen
docker-compose logs -f
# Bridge stoppen
docker-compose down
# Bridge neu starten (nach Update)
./start.sh
docker compose logs -f # Logs anzeigen
docker compose down # Bridge stoppen
docker compose up -d --build # Bridge neu bauen & starten (nach Update)
```
---
## Fehlerbehebung
## 🩹 Troubleshooting
**„Falsche MQTT-Zugangsdaten"** beim Start:
- `fetch_credentials --ip <Drucker-IP> --write-config` erneut ausführen und Bridge neu starten
- Wenn IP unbekannt: AnycubicSlicerNext neu starten, Drucker verbinden, `extract_credentials` erneut ausführen
- Nur die IP-Adresse ins Feld eintragen, keinen Port (✗ `192.168.1.102:9883` → ✓ `192.168.1.102`)
<details>
<summary><b>"Falsche MQTT-Zugangsdaten" beim Start</b></summary>
**Drucker nicht gefunden / kein LAN-Modus:**
- Am Drucker-Display: Einstellungen → LAN-Modus einschalten
- Drucker über *„+ Drucker hinzufügen"* erneut hinzufügen, oder
`fetch_credentials --ip <ip> --write-config` ausführen und Bridge neu starten
- Nur die IP-Adresse eingeben, ohne Port (✗ `192.168.1.102:9883` → ✓ `192.168.1.102`)
</details>
<details>
<summary><b>Drucker nicht gefunden / kein LAN-Modus</b></summary>
- Am Drucker-Display: Einstellungen → LAN-Modus aktivieren
- Drucker und Bridge müssen im selben Netzwerk sein
</details>
<details>
<summary><b>Docker: Permission denied</b></summary>
**Docker: Permission denied:**
```bash
sudo usermod -aG docker $USER # dann neu einloggen
sudo usermod -aG docker $USER # danach aus- und wieder einloggen
```
</details>
<details>
<summary><b>Upgrade von 0.9.1 oder älter</b></summary>
Ab 0.9.2 speichert KX-Bridge die Einstellungen in `config/config.ini` statt `.env`.
Die Migration läuft automatisch beim ersten Start nach dem Upgrade — keine Aktion nötig.
</details>
---
## Sicherheitshinweise
## 🔒 Sicherheit
- Die Bridge ist im lokalen Netzwerk erreichbar unter `http://<Host-IP>:7125` nicht ins Internet freigeben
- `config/config.ini` enthält Drucker-Credentials — nicht öffentlich teilen
- Credentials haben keinen Zugang zu Anycubic-Cloud-Diensten
- Die Bridge ist im lokalen Netzwerk unter `http://<host-IP>:7125` erreichbar — **nicht** ins Internet exposen
- `config/config.ini` enthält Drucker-Zugangsdaten — nicht öffentlich teilen
- Die Zugangsdaten geben **keinen** Zugriff auf Anycubic-Cloud-Dienste
---
## Lizenz & Rechtliches
## 📄 Lizenz
Interoperabilitätsforschung gem. §69e UrhG — ausschließlich private, nicht-kommerzielle Nutzung.
Interoperabilitätsforschung gem. §69e UrhG — private, nicht-kommerzielle Nutzung.
<p align="center">
<a href="https://ko-fi.com/viewitde">
<img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="Ko-fi Support"/>
</a>
</p>
<div align="center">
<br>
**Wenn dir KX-Bridge hilft, freut sich das Projekt über Unterstützung:**
[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/viewitde)
</div>

207
README.md
View File

@@ -1,47 +1,94 @@
<p align="center"><img src="knlogo.png" alt="KX-Bridge Logo" width="180"/></p>
<div align="center">
# KX-Bridge Anycubic Kobra X
<img src="knlogo.png" alt="KX-Bridge" width="160"/>
**Version:** 0.9.7
# KX-Bridge
Control your **Anycubic Kobra X** with OrcaSlicer — no Klipper, no Raspberry Pi.
KX-Bridge is a Moonraker-compatible bridge that communicates directly with the printer.
**Control your Anycubic Kobra X with OrcaSlicer — no Klipper, no Raspberry Pi.**
A Moonraker-compatible bridge that talks directly to the printer.
<sub>🇩🇪 <a href="README.de.md">Deutsche Version</a></sub>
<br>
[![Ko-fi](https://img.shields.io/badge/Ko--fi-Support%20this%20project-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/viewitde)
&nbsp;
[![Releases](https://img.shields.io/badge/Download-Releases-2EA043?style=for-the-badge&logo=gitea&logoColor=white)](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases)
&nbsp;
[![Video](https://img.shields.io/badge/YouTube-Tutorial-FF0000?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=1Ql4wfH27fM)
<sub>Like KX-Bridge? A coffee on <a href="https://ko-fi.com/viewitde">Ko-fi</a> keeps the project alive. ☕</sub>
</div>
---
## Quick Start in 3 Steps
## ✨ Features
### Step 1 Prepare the printer
| | |
|---|---|
| 🖨️ | **Printer control** — start, pause, resume, cancel, temperatures, print speed |
| 📊 | **Live status** — temperature, progress, layers, remaining time, camera stream |
| 🎨 | **AMS / multicolor** — filament slots, per-channel remapping, MMU emulation for OrcaSlicer filament sync |
| 🗂️ | **GCode browser** — uploaded files with thumbnails, print history, search & filter |
| 🧩 | **Multi-printer** — multiple printers in **one** bridge instance, switch via dropdown |
| | **Add a printer with one click** — just enter the IP, credentials are imported automatically |
| 🔄 | **Self-update** — install new versions directly in the browser |
| 🌐 | **OrcaSlicer** — full Moonraker protocol (HTTP + WebSocket), EN/DE UI |
Enable LAN mode on the Kobra X:
---
## 🚀 Quick Start
### 1. Prepare the printer
Enable LAN mode on the Kobra X:
**Printer display → Settings → Enable LAN mode**
### Step 2 Get credentials
The MQTT credentials are printer-specific. Here's how to get them:
1. Open **AnycubicSlicerNext** and connect the printer (wait until status is shown)
2. Run **`extract_credentials.exe`** (Windows) or **`extract_credentials`** (Linux) — outputs Username, Password, Device ID and printer IP
3. Note / copy the values
> **Download:** [gitea.it-drui.de/viewit/KX-Bridge-Release/releases](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases) → `extract_credentials.exe` (Windows) / `extract_credentials` (Linux) in the release assets
### Step 3 Start the bridge
### 2. Start the bridge
**Docker (recommended):**
```bash
./start.sh
docker compose up -d
```
The script builds the Docker image automatically on first run.
**Linux binary (no Docker):**
```bash
chmod +x kx-bridge && ./kx-bridge
```
**Open Web-UI:** `http://BRIDGE-IP:7125`
→ The ⚙ menu opens automatically on first start
→ Enter credentials from Step 2 → **Save & Restart**
**Windows EXE (no Docker):**
```
kx-bridge.exe
```
> `config\` and `data\` are created next to the EXE — portable.
> With the Linux and Windows binaries, `config/` and `data/` (settings, SQLite, GCode store)
> live next to the program. Copy the whole folder = move the installation.
**Python directly:**
```bash
pip install -r bridge/requirements.txt
python bridge/kobrax_moonraker_bridge.py
```
### 3. Set up the printer
Open the Web UI: **`http://BRIDGE-IP:7125`**
On first start the **Printers tab** shows *"+ Add printer"* — just enter the printer's IP
address, the rest (username, password, device ID) is fetched from the printer and decrypted
automatically. Done.
> More than one printer? Just click *"+ Add printer"* again — each gets its own port
> (7125, 7126, …) and is selectable from the header dropdown.
### 4. Connect OrcaSlicer
**Connect OrcaSlicer:**
Printer → Connection type **Moonraker** → Host: `http://BRIDGE-IP:7125`
> **Important:** Connection type must be **Moonraker** (not "Bambu" or "Klipper").
> ⚠️ Connection type must be **Moonraker** (not "Bambu" or "Klipper").
> Enter the full URL including `http://` and port `:7125` in the host field.
---
@@ -52,98 +99,84 @@ Printer → Connection type **Moonraker** → Host: `http://BRIDGE-IP:7125`
---
## ⚠️ Upgrading from 0.9.1 or earlier
## 🔧 Getting credentials manually
Starting with **0.9.2**, KX-Bridge stores settings in `config/config.ini` instead of `.env`.
Normally not needed — *"+ Add printer"* does this automatically. If you do need it:
**Migration is automatic** — no manual action required:
- On first start after upgrade, the bridge reads your existing `.env` and creates `config/config.ini` automatically
- Settings now survive `docker-compose restart` and future updates
- The `.env` file stays mounted read-only as a migration source — you can keep it in place
- If you want to create a `config.ini` manually: copy `config/config.ini.example`
---
## What's supported?
| Feature | Details |
|---------|---------|
| Printer status | Temperature, progress, state, remaining time |
| Print control | Start, pause, resume, cancel |
| Temperature control | Nozzle and bed during print |
| Print speed | Silent / Normal / Sport |
| AMS filament change | Load / unload filament |
| Light & fan | Print light and fan speed |
| Web-UI | Dashboard, motion control, camera view |
| Self-update | Install new versions directly in the browser |
| OrcaSlicer | Moonraker protocol (HTTP + WebSocket) |
---
## Alternatives to Docker
**Linux binary** (no Docker needed):
```bash
chmod +x kx-bridge
./kx-bridge
fetch_credentials --ip 192.168.x.x --write-config
```
Fetches the credentials directly from the printer via HTTP and writes them to `config/config.ini`.
Only the printer IP is required, no slicer.
**Python directly:**
```bash
pip install aiohttp
python bridge/kobrax_moonraker_bridge.py
```
Alternatively (if the IP is unknown): open AnycubicSlicerNext, connect the printer, then run
`extract_credentials` → outputs username, password, device ID and the printer IP.
Web-UI available at `http://localhost:7125` — the ⚙ menu guides through initial setup.
> **Downloads:** [Releases](https://gitea.it-drui.de/viewit/KX-Bridge-Release/releases) → `fetch_credentials` / `extract_credentials` (Linux & Windows)
---
## Useful commands
## ⚙️ Useful commands
```bash
# Show logs
docker-compose logs -f
# Stop bridge
docker-compose down
# Restart bridge (after update)
./start.sh
docker compose logs -f # show logs
docker compose down # stop the bridge
docker compose up -d --build # rebuild & start (after an update)
```
---
## Troubleshooting
## 🩹 Troubleshooting
**"Wrong MQTT credentials"** on start:
- Restart AnycubicSlicerNext, reconnect the printer, run `extract_credentials` again
<details>
<summary><b>"Wrong MQTT credentials" on start</b></summary>
- Re-add the printer via *"+ Add printer"*, or run
`fetch_credentials --ip <ip> --write-config` and restart the bridge
- Enter only the IP address, no port (✗ `192.168.1.102:9883` → ✓ `192.168.1.102`)
</details>
<details>
<summary><b>Printer not found / no LAN mode</b></summary>
**Printer not found / no LAN mode:**
- On the printer display: Settings → Enable LAN mode
- Printer and bridge must be on the same network
</details>
<details>
<summary><b>Docker: Permission denied</b></summary>
**Docker: Permission denied:**
```bash
sudo usermod -aG docker $USER # then log out and back in
sudo usermod -aG docker $USER # then log out and back in
```
</details>
<details>
<summary><b>Upgrading from 0.9.1 or earlier</b></summary>
Starting with 0.9.2, KX-Bridge stores settings in `config/config.ini` instead of `.env`.
Migration runs automatically on first start after the upgrade — no action required.
</details>
---
## Security
## 🔒 Security
- The bridge is accessible on the local network at `http://<host-IP>:7125` — do not expose to the internet
- The bridge is reachable on the local network at `http://<host-IP>:7125`**do not** expose it to the internet
- `config/config.ini` contains printer credentials — do not share publicly
- Credentials do not grant access to Anycubic cloud services
- The credentials do **not** grant access to Anycubic cloud services
---
## License
## 📄 License
Interoperability research under §69e UrhG — private, non-commercial use only.
<p align="center">
<a href="https://ko-fi.com/viewitde">
<img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="Ko-fi Support"/>
</a>
</p>
<div align="center">
<br>
**If KX-Bridge helps you, the project appreciates your support:**
[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/viewitde)
</div>

View File

@@ -1 +1 @@
0.9.7
0.9.9

36
config/config.ini.example Normal file
View File

@@ -0,0 +1,36 @@
# KX-Bridge Konfigurationsdatei
# Kopiere diese Datei nach config.ini und trage deine Werte ein:
# cp config.ini.example config.ini
#
# Credentials automatisch eintragen:
# python3 tools/fetch_credentials.py --ip 192.168.x.x --write-config
# Alternativ (Windows, ohne Drucker-IP bekannt):
# extract_credentials.exe --write-env (liest aus laufendem AnycubicSlicerNext)
[connection]
# IP-Adresse des Druckers im lokalen Netzwerk
printer_ip = 192.168.x.x
# MQTT-Port (Anycubic Kobra X Standard: 9883)
mqtt_port = 9883
# MQTT-Zugangsdaten (druckerspezifisch, beginnt mit "user")
username = userXXXXXXXXXX
password = XXXXXXXXXXXXXXX
# Geräte-ID (32-stelliger Hex-String, druckerspezifisch)
device_id = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Modell-ID (Kobra X Standard: 20030)
mode_id = 20030
[print]
# Standard-AMS-Slot für Einfarbdruck (auto = alle belegten Slots, 0-3 = fixer Slot)
default_ams_slot = auto
# Auto-Leveling vor jedem Druck (1 = an, 0 = aus)
auto_leveling = 1
[bridge]
# Poll-Intervall in Sekunden
poll_interval = 3

View File

@@ -57,8 +57,9 @@ def _load_config_file(path: pathlib.Path):
"MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"),
"MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"),
"DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"),
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
"BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"),
}
for env_key, (section, option) in mapping.items():
if env_key not in os.environ:
@@ -128,6 +129,39 @@ elif _env_path:
_config_path = _target
def list_printers() -> list[dict]:
"""Liest alle [printer_N]-Sektionen aus config.ini.
Jede Sektion kann folgende Keys haben:
name, printer_ip, mqtt_port, username, password, mode_id, device_id,
bridge_url, default_ams_slot, auto_leveling
Gibt eine leere Liste zurück wenn keine [printer_N]-Sektionen vorhanden sind
(Single-Printer-Betrieb via [connection]).
"""
path = _find_config_file()
if not path:
return []
cfg = configparser.ConfigParser()
cfg.read(path, encoding="utf-8")
printers: list[dict] = []
idx = 1
while True:
section = f"printer_{idx}"
if not cfg.has_section(section):
break
p = dict(cfg[section])
p.setdefault("id", str(idx))
if "mqtt_port" in p:
try:
p["mqtt_port"] = int(p["mqtt_port"])
except ValueError:
p["mqtt_port"] = 9883
printers.append(p)
idx += 1
return printers
def get(key: str, default: str = "") -> str:
return os.environ.get(key, default)

View File

@@ -4,9 +4,10 @@ services:
build: .
volumes:
- ./config:/app/config
- ./data:/app/data
- ./.env:/app/.env:ro
ports:
- "7125:7125"
- "7125-7130:7125-7130"
restart: unless-stopped
logging:
driver: json-file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,338 @@
# KX-Bridge Theme Element-ID-Checkliste
Alle IDs, die `default/app.js` erwartet oder nutzt. Beim Umbau eines Themes (`new1`, …) müssen diese **IDs beibehalten** werden, sonst brechen Features still (null-Check) oder mit Fehler (ohne null-Check).
**Legende**
| Symbol | Bedeutung |
|--------|-----------|
| **Pflicht** | Fehlt → Laufzeitfehler oder zentraler Ablauf bricht |
| **Empfohlen** | Fehlt → Feature funktioniert nicht (meist mit `if(el)` abgesichert) |
| **Optional** | Nur i18n, Dekoration oder rein HTML-seitig |
**Quelle:** automatisch aus `default/app.js` extrahiert + Abgleich mit `default/index.html`
---
## Navigation & Panels
| ID | Pflicht | Verwendung in app.js |
|----|---------|----------------------|
| `panel-dashboard` | **Pflicht** | `showPanel('dashboard')` |
| `panel-printers` | **Pflicht** | `showPanel('printers')` |
| `panel-store` | **Pflicht** | `showPanel('store')` |
| `panel-console` | **Pflicht** | `showPanel('console')` |
| `nb-dashboard` | Empfohlen | Sidebar active + i18n `.nav-text` |
| `nb-printers` | Empfohlen | Sidebar active + i18n |
| `nb-store` | Empfohlen | Sidebar active + i18n |
| `nb-console` | Empfohlen | Sidebar active + i18n + `#log-badge` |
| `bnb-dashboard` | Empfohlen | Bottom-Nav active + i18n |
| `bnb-printers` | Empfohlen | Bottom-Nav |
| `bnb-store` | Empfohlen | Bottom-Nav |
| `bnb-console` | Empfohlen | Bottom-Nav + `#log-badge-bot` |
| `log-badge` | Empfohlen | Fehler-Badge Sidebar-Konsole |
| `log-badge-bot` | Empfohlen | Fehler-Badge Bottom-Nav |
**CSS-Klassen:** `.panel`, `.nav-btn`, `.bnav-btn`, `.nav-text` (in Sidebar-Buttons)
---
## Header & Banner
| ID | Pflicht | Verwendung |
|----|---------|------------|
| `lang-btn` | **Pflicht** | Sprachumschalter (ohne null-Check) |
| `conn-btn` | **Pflicht** | Verbinden/Trennen, `updateConnBtn()` |
| `settings-btn` | Optional | Nur HTML `openSettings()` per onclick |
| `printer-dropdown-wrap` | Empfohlen | Multi-Drucker UI |
| `printer-dropdown-btn` | Optional | Nur HTML-onclick |
| `printer-dropdown-menu` | Empfohlen | Dropdown-Inhalt |
| `h-pname` | Empfohlen | Druckername (Multi-Modus) |
| `h-pname-single` | Empfohlen | Druckername (Einzel-Modus) |
| `h-version` | Empfohlen | Bridge-Version aus `/api/state` |
| `h-badge` | Empfohlen | Status-Badge CSS-Klasse |
| `h-state` | Empfohlen | Status-Text |
| `conn-error-banner` | Empfohlen | MQTT-Verbindungsfehler |
| `file-ready-banner` | Empfohlen | Slicer-Upload bereit |
| `file-ready-name` | Empfohlen | Dateiname im Banner |
| `file-ready-btn` | Empfohlen | Druck starten |
| `file-slots-btn` | Empfohlen | Slots wählen |
| `file-cancel-btn` | Empfohlen | Banner abbrechen |
---
## Dashboard Kamera
| ID | Pflicht | Verwendung |
|----|---------|------------|
| `d-light-toggle` | **Pflicht** | Licht sync in `applyState()` (`.checked`) |
| `cam-toggle-btn` | Empfohlen | Start/Stop-Label |
| `cam-img` | Empfohlen | MJPEG-Stream |
| `cam-placeholder` | Empfohlen | Platzhalter sichtbar/unsichtbar |
| `cam-placeholder-txt` | Empfohlen | i18n Platzhaltertext |
| `cam-spinner` | Empfohlen | Lade-Animation |
| `cam-overlay` | Empfohlen | Dateiname über Video |
| `cam-fname` | Empfohlen | Dateiname in Overlay |
| `cam-wrap` | Optional | Nur HTML/CSS |
---
## Dashboard Fortschritt & Druck
| ID | Pflicht | Verwendung |
|----|---------|------------|
| `d-pct` | Empfohlen | Prozent-Anzeige |
| `d-pbar` | Empfohlen | Fortschrittsbalken |
| `d-layers` | Empfohlen | Layer X / Y |
| `d-elapsed` | Empfohlen | Verstrichene Zeit |
| `d-remain` | Empfohlen | Restzeit |
| `d-slicer-row` | Empfohlen | Slicer-Schätzung ein/aus |
| `d-slicer-time` | Empfohlen | Slicer-Zeitwert |
| `d-fname` | Empfohlen | Aktueller Dateiname |
| `d-thumbnail` | Empfohlen | GCode-Vorschaubild |
| `d-btn-pause` | Empfohlen | i18n + onclick |
| `d-btn-resume` | Empfohlen | i18n + onclick |
| `d-btn-cancel` | Empfohlen | i18n + onclick |
| `d-ctrl-btns` | Optional | Nur Layout |
| `p-fname` | Optional | Legacy/Print-Panel (falls vorhanden) |
| `d-card-progress` | Optional | i18n Kartentitel |
| `d-lbl-elapsed` | Optional | i18n |
| `d-lbl-remain` | Optional | i18n |
| `d-lbl-layers` | Optional | i18n |
| `d-slicer-label` | Optional | i18n |
---
## Dashboard Temperaturen
| ID | Pflicht | Verwendung |
|----|---------|------------|
| `d-nt` | Empfohlen | Ist-Temperatur Düse |
| `d-nt-t` | Empfohlen | Soll-Temperatur Düse |
| `d-bt` | Empfohlen | Ist-Temperatur Bett |
| `d-bt-t` | Empfohlen | Soll-Temperatur Bett |
| `d-ntbar` | Empfohlen | Fortschrittsbalken Düse |
| `d-btbar` | Empfohlen | Fortschrittsbalken Bett |
| `p-nozzle-inp` | Empfohlen | Eingabe Ziel Düse (`setNozzle`) |
| `p-bed-inp` | Empfohlen | Eingabe Ziel Bett (`setBed`) |
| `d-chart` | Empfohlen | Canvas Temperatur-Verlauf |
| `d-chart-label` | Optional | i18n |
| `d-card-temps` | Optional | i18n |
| `d-lbl-bed` | Optional | i18n |
**CSS-Klassen:** `.lbl-set`, `.lbl-off`, `.temp-input`
---
## Dashboard Achsen & Schrittweite
| ID | Pflicht | Verwendung |
|----|---------|------------|
| `step-display` | Empfohlen | Anzeige aktuelle Schrittweite |
| `ptitle-motion-xy` | Optional | i18n |
| `ptitle-motion-z` | Optional | i18n |
**CSS-Klassen:** `.step-btn`, `.joy`, `.lbl-home-z`, `.lbl-home-xy`, `.lbl-home-all`, `.lbl-disable-motors`, `.lbl-step`
---
## Dashboard Geschwindigkeit & Lüfter
| ID | Pflicht | Verwendung |
|----|---------|------------|
| `d-spd-1` | Empfohlen | Speed-Modus Leise |
| `d-spd-2` | Empfohlen | Speed-Modus Normal |
| `d-spd-3` | Empfohlen | Speed-Modus Sport |
| `d-spd-bar` | Empfohlen | Speed-Balken |
| `d-fan` | Empfohlen | Lüfter-Slider |
| `d-fan-val` | Empfohlen | Lüfter-Prozent-Anzeige |
| `d-card-speed` | Optional | i18n |
| `d-card-lightfan` | Optional | i18n |
| `d-spd-lbl-1` | Optional | i18n |
| `d-spd-lbl-2` | Optional | i18n |
| `d-spd-lbl-3` | Optional | i18n |
| `d-lbl-light` | Optional | i18n |
**CSS-Klassen:** `.lbl-feed`, `.lbl-unload` (AMS-Bereich)
---
## Dashboard AMS
| ID | Pflicht | Verwendung |
|----|---------|------------|
| `ams-slots` | **Pflicht** | Container für dynamische Slot-Kacheln |
| `ams-slot-sel` | Empfohlen | Slot-Auswahl für Feed/Unload |
| `ams-slot-label` | Optional | Nur HTML-oninput Label |
| `ams-no-data` | Optional | Platzhalter (wird durch JS ersetzt) |
| `btn-unload` | Optional | Nur HTML |
| `d-card-ams` | Optional | i18n |
| `d-card-cam` | Optional | i18n |
---
## Einstellungen (`#settings-modal`)
| ID | Pflicht | Verwendung |
|----|---------|------------|
| `settings-modal` | **Pflicht** | Modal open/close (`.classList`) |
| `s-printer-name` | Empfohlen | Config-Feld |
| `s-printer-ip` | **Pflicht** | Config + IP-Hinweis-Listener |
| `s-mqtt-port` | Empfohlen | Config |
| `s-username` | Empfohlen | Config |
| `s-password` | Empfohlen | Config |
| `s-device-id` | Empfohlen | Config |
| `s-mode-id` | Empfohlen | Config |
| `s-default-slot` | Empfohlen | AMS-Standard-Slot |
| `s-auto-leveling` | Empfohlen | Checkbox Auto-Leveling |
| `poll-1` | Empfohlen | Poll 1 s |
| `poll-2` | Empfohlen | Poll 2 s |
| `poll-5` | Empfohlen | Poll 5 s |
| `s-version-label` | Empfohlen | Versionsanzeige |
| `update-status` | Empfohlen | Update-Status-Text |
| `btn-update-apply` | Empfohlen | Update installieren |
| `update-changelog` | Empfohlen | Changelog-Anzeige |
| `btn-save-settings` | Empfohlen | Speichern |
| `btn-update-check` | Optional | Nur HTML-onclick |
| `lbl-ip-hint` | Empfohlen | IP-Format-Warnung |
| `modal-title-settings` | Optional | i18n |
| `modal-sec-connection` | Optional | i18n |
| `modal-sec-print` | Optional | i18n |
| `modal-sec-poll` | Optional | i18n |
| `modal-sec-version` | Optional | i18n |
| `lbl-printer-name``lbl-mode-id` | Optional | i18n Labels |
| `lbl-default-slot` | Optional | i18n |
| `lbl-auto-leveling` | Optional | i18n |
| `opt-slot-auto` | Optional | i18n |
| `opt-slot-0``opt-slot-3` | Optional | Nur HTML |
| `lbl-update-check` | Optional | i18n |
| `lbl-update-apply` | Optional | i18n |
**CSS-Klasse:** `.poll-btn`
---
## AMS-Slot-Dialog (`#slot-edit-modal`)
| ID | Pflicht | Verwendung |
|----|---------|------------|
| `slot-edit-modal` | **Pflicht** | Modal open/close |
| `slot-edit-title` | Empfohlen | Dialogtitel |
| `slot-edit-color` | Empfohlen | Farbwähler |
| `slot-edit-preview` | Empfohlen | Farbvorschau |
| `slot-edit-mat` | Empfohlen | Material-Text |
| `slot-mat-btns` | Empfohlen | Preset-Buttons (dynamisch) |
| `btn-slot-edit-save` | Empfohlen | Speichern |
| `lbl-slot-color` | Optional | i18n |
| `lbl-slot-material` | Optional | i18n |
**CSS-Klasse:** `.mat-preset-btn` (dynamisch)
---
## Filament-Druck-Dialog (`#filament-dialog`)
| ID | Pflicht | Verwendung |
|----|---------|------------|
| `filament-dialog` | **Pflicht** | Modal open/close |
| `fd-title` | Empfohlen | Dateiname im Titel |
| `fd-slots` | Empfohlen | Kanal→Slot-Zuordnung (dynamisch) |
---
## Drucker hinzufügen (`#add-printer-dialog`)
| ID | Pflicht | Verwendung |
|----|---------|------------|
| `add-printer-dialog` | **Pflicht** | Modal open/close |
| `apd-ip` | Empfohlen | IP-Eingabe |
| `apd-name` | Empfohlen | Name optional |
| `apd-status` | Empfohlen | Status/Fehlermeldung |
| `apd-confirm` | Empfohlen | Bestätigen |
| `apd-title` | Optional | i18n |
| `apd-lbl-ip` | Optional | i18n |
| `apd-lbl-name` | Optional | i18n |
| `add-printer-btn-label` | Optional | i18n |
---
## Drucker-Tab
| ID | Pflicht | Verwendung |
|----|---------|------------|
| `printers-grid` | Empfohlen | Container Drucker-Karten |
| `printers-panel-title` | Optional | i18n |
---
## Datei-Browser
| ID | Pflicht | Verwendung |
|----|---------|------------|
| `store-grid` | Empfohlen | Datei-Karten (dynamisch) |
| `store-empty` | Empfohlen | Leer-/Kein-Treffer-Hinweis |
| `store-search` | Empfohlen | Suche |
| `store-filter` | Empfohlen | Filter Select |
| `store-sort` | Empfohlen | Sortierung Select |
| `store-refresh-btn` | Optional | i18n Button-Text |
| `store-panel-title` | Optional | i18n |
| `sf-all`, `sf-ok`, `sf-err`, `sf-new` | Optional | i18n Optionen |
| `ss-date`, `ss-name`, `ss-dur` | Optional | i18n Optionen |
---
## Konsole
| ID | Pflicht | Verwendung |
|----|---------|------------|
| `console-log` | Empfohlen | Log-Ausgabe |
| `log-filter` | Empfohlen | Textfilter |
| `btn-autoscroll` | Empfohlen | Auto-Scroll Toggle |
| `logdir-all` | Empfohlen | Filter Alle |
| `logdir-rx` | Empfohlen | Filter RX |
| `logdir-tx` | Empfohlen | Filter TX |
| `ptitle-console` | Optional | i18n |
| `btn-log-dl` | Optional | Nur HTML Download-Link `/api/log/download` |
**CSS-Klassen:** `.log-dir-btn`, `.log-topic-btn` (Topic-Buttons nutzen `data-topic`, keine festen IDs)
---
## IDs nur in HTML (nicht in app.js)
Diese IDs sind im Default-Theme vorhanden, werden von `app.js` aber nicht per `getElementById` angesprochen:
| ID | Anmerkung |
|----|-----------|
| `settings-btn` | Nur `onclick="openSettings()"` |
| `printer-dropdown-btn` | Nur onclick |
| `btn-update-check` | Nur onclick |
| `opt-slot-0``opt-slot-3` | Option-Labels statisch |
| `cam-wrap` | Layout |
| `d-ctrl-btns` | Layout |
| `ams-no-data` | Initial-Platzhalter, wird von JS überschrieben |
| `ams-slot-label` | Nur inline oninput |
| `btn-unload` | Nur HTML |
| `btn-log-dl` | Statischer Download-Link |
---
## Schnell-Check vor Theme-Release
- [ ] Alle vier `panel-*` vorhanden und in `.panel` gewrappt
- [ ] `lang-btn`, `conn-btn`, `d-light-toggle`, `settings-modal`, `ams-slots` vorhanden
- [ ] Alle Modals: `settings-modal`, `slot-edit-modal`, `filament-dialog`, `add-printer-dialog`
- [ ] Navigation: `nb-*` / `bnb-*` für `dashboard`, `printers`, `store`, `console`
- [ ] Dashboard-Kern: Temps, Progress, Fan, Speed, AMS-Container
- [ ] Store: `store-grid`, `store-search`, `store-filter`, `store-sort`
- [ ] Konsole: `console-log`, `log-filter`
- [ ] `index.html` bindet `/kx/ui/style.css` und `/kx/ui/app.js` ein
- [ ] Alle `onclick`-Handler aus [THEME-JS-HOOKS.md](./THEME-JS-HOOKS.md) gesetzt oder ersetzt
---
## Siehe auch
- [THEME-JS-HOOKS.md](./THEME-JS-HOOKS.md) alle JS-Funktionsaufrufe mit Kontext
- Theme-Ordner: `web/themes/<name>/` mit `index.html`, `style.css`, `app.js`
- Aktivierung: `--ui-theme <name>` oder `KX_UI_THEME=<name>`

View File

@@ -0,0 +1,290 @@
# KX-Bridge Theme JavaScript-Hooks
Referenz für eigenes Theme-Layout (`index.html` + gemeinsames `app.js`).
**Quelle:** `default/index.html` und `default/app.js`
**Stand:** Theme-Struktur mit getrennten Dateien (`index.html`, `style.css`, `app.js`)
---
## Hinweise für Theme-Entwickler
- Alle `onclick`-Handler rufen **globale Funktionen** aus `app.js` auf Namen beibehalten oder in eigenem JS neu binden.
- `app.js` wird unter `/kx/ui/app.js` geladen und ist pro Theme austauschbar; die Standard-Implementierung erwartet die Hooks unten.
- Dynamisch erzeugtes HTML (AMS-Slots, Datei-Karten, Drucker-Karten) setzt zusätzliche `onclick`-Handler siehe Abschnitt **Dynamisch aus app.js**.
---
## Globale / Header
| Aufruf | HTML-Element | Kontext |
|--------|--------------|---------|
| `startReadyFile()` | `#file-ready-btn` | GCode vom Slicer bereit → sofort drucken |
| `startReadyFileWithSlots()` | `#file-slots-btn` | Druck mit AMS-Slot-Zuordnung |
| `cancelReadyFile()` | `#file-cancel-btn` | Upload-Banner schließen |
| `togglePrinterDropdown()` | `#printer-dropdown-btn` | Multi-Drucker-Dropdown |
| `toggleTheme()` | Theme-Button | Hell/Dunkel (`data-theme`, localStorage) |
| `toggleLang()` | `#lang-btn` | DE/EN umschalten |
| `openSettings()` | `#settings-btn` | Einstellungs-Modal |
| `toggleConnection()` | `#conn-btn` | MQTT verbinden / trennen |
---
## Einstellungs-Modal (`#settings-modal`)
| Aufruf | HTML-Element | Kontext |
|--------|--------------|---------|
| `closeSettings()` | Overlay (Backdrop), Schließen-Button | Modal schließen |
| `setPoll(1000)` | `#poll-1` | Status-Poll 1 s |
| `setPoll(2000)` | `#poll-2` | Status-Poll 2 s |
| `setPoll(5000)` | `#poll-5` | Status-Poll 5 s |
| `checkUpdate()` | `#btn-update-check` | Update prüfen |
| `applyUpdate()` | `#btn-update-apply` | Update installieren |
| `saveSettings()` | `#btn-save-settings` | Config speichern → Neustart |
**Ohne `onclick` in HTML:** IP-Hinweis für `#s-printer-ip` wird in `app.js` per `addEventListener('input', …)` gebunden.
---
## AMS-Slot bearbeiten (`#slot-edit-modal`)
| Aufruf | HTML-Element | Kontext |
|--------|--------------|---------|
| `closeSlotEdit()` | Overlay, ✕ | Dialog schließen |
| `highlightMatBtn(this.value)` | `#slot-edit-mat` `oninput` | Material-Presets hervorheben |
| `saveSlotEdit()` | `#btn-slot-edit-save` | Farbe/Material speichern |
**Inline (keine benannte Funktion):**
| Code | Element | Zweck |
|------|---------|--------|
| `document.getElementById('slot-edit-preview').style.background=this.value` | `#slot-edit-color` | Farbvorschau |
**Dynamisch aus `app.js`:**
| Aufruf | Kontext |
|--------|---------|
| `openSlotEdit(i)` | Klick auf AMS-Kachel in `#ams-slots` |
| `selectMatPreset('PLA'…)` | Material-Preset-Buttons in `#slot-mat-btns` |
---
## Navigation (Sidebar + Bottom-Nav)
| Aufruf | HTML-Element | Kontext |
|--------|--------------|---------|
| `showPanel('dashboard')` | `#nb-dashboard`, `#bnb-dashboard` | Dashboard |
| `showPanel('printers'); loadPrinterTab()` | `#nb-printers`, `#bnb-printers` | Drucker-Tab |
| `showPanel('store'); loadStore()` | `#nb-store`, `#bnb-store` | Datei-Browser |
| `showPanel('console'); clearLogBadge()` | `#nb-console`, `#bnb-console` | Konsole, Badge leeren |
**Erforderliche Panel-IDs:** `panel-dashboard`, `panel-printers`, `panel-store`, `panel-console`
**Nav-IDs:** `nb-*` und `bnb-*` mit gleichem Suffix (`dashboard`, `printers`, `store`, `console`)
---
## Dashboard Kamera
| Aufruf | HTML-Element | Kontext |
|--------|--------------|---------|
| `setLight()` | `#d-light-toggle` `onchange` | Drucker-Licht |
| `toggleCam()` | `#cam-toggle-btn` | Stream starten/stoppen |
Intern: `toggleCam()``camStart()` / `camStop()` (nur in `app.js`).
---
## Dashboard Drucksteuerung
| Aufruf | HTML-Element | Kontext |
|--------|--------------|---------|
| `printAction('pause')` | `#d-btn-pause` | Pausieren |
| `printAction('resume')` | `#d-btn-resume` | Fortsetzen |
| `confirmCancel()` | `#d-btn-cancel` | Bestätigung → `printAction('cancel')` |
---
## Dashboard Temperaturen
| Aufruf | HTML-Element | Kontext |
|--------|--------------|---------|
| `setNozzle()` | Set-Button Nozzle | Zieltemperatur Düse (`#p-nozzle-inp`) |
| `setBed()` | Set-Button Bett | Zieltemperatur Bett (`#p-bed-inp`) |
**Inline:** Aus-Button setzt Input auf `0`, dann `setNozzle()` bzw. `setBed()`.
---
## Dashboard Achsensteuerung
| Aufruf | HTML-Element | Kontext |
|--------|--------------|---------|
| `move(1, 1, getStep())` | Joypad ▲ | Y+ |
| `move(1, -1, getStep())` | Joypad ▼ | Y |
| `move(0, -1, getStep())` | Joypad ◀ | X |
| `move(0, 1, getStep())` | Joypad ▶ | X+ |
| `move(2, 1, getStep())` | Z ▲ | Z+ |
| `move(2, -1, getStep())` | Z ▼ | Z |
| `homeAll()` | Joypad ⌂, Home-All | Referenzfahrt alle Achsen |
| `homeZ()` | Home Z | Nur Z |
| `homeXY()` | Home XY | Nur X/Y |
| `disableMotors()` | Motors Off | Motoren aus |
| `setStep(this, 0.1\|1\|5\|10)` | Schrittweiten-Buttons | Jog-Schrittweite |
| `getStep()` | in allen `move(…)` | Aktuelle Schrittweite (Hilfsfunktion) |
---
## Dashboard Druckgeschwindigkeit
| Aufruf | HTML-Element | Kontext |
|--------|--------------|---------|
| `setSpeed(1)` | `#d-spd-1` | Leise |
| `setSpeed(2)` | `#d-spd-2` | Normal |
| `setSpeed(3)` | `#d-spd-3` | Sport |
---
## Dashboard Lüfter
| Aufruf | HTML-Element | Kontext |
|--------|--------------|---------|
| `setFan()` | `#d-fan` `onchange` | Slider-Wert senden |
| `quickFan(0\|25\|50\|75\|100)` | Schnell-Buttons | Vordefinierte Stufen |
**Inline:** `#d-fan` `oninput``#d-fan-val` aktualisieren.
---
## Dashboard AMS / Filament
| Aufruf | HTML-Element | Kontext |
|--------|--------------|---------|
| `amsFeed(1)` | Einziehen | Filament laden |
| `amsFeed(2)` | Ausziehen | Filament entladen |
**Inline:** `#ams-slot-sel` `oninput``#ams-slot-label` („Slot 1…4“).
---
## Drucker-Tab (`#panel-printers`)
| Aufruf | HTML-Element | Kontext |
|--------|--------------|---------|
| `openAddPrinterDialog()` | „+ Drucker hinzufügen“ | Dialog öffnen |
| `loadPrinterTab()` | ↻-Button | Liste neu laden |
**Dynamisch:** `removePrinter(id, name)` auf Drucker-Karten in `#printers-grid`.
---
## Datei-Browser (`#panel-store`)
| Aufruf | HTML-Element | Kontext |
|--------|--------------|---------|
| `loadStore()` | `#store-refresh-btn` | Dateiliste laden |
| `renderStore()` | `#store-search`, `#store-filter`, `#store-sort` | Filtern/Sortieren |
**Dynamisch:** `storePrint(fileId, filename)`, `storeDelete(fileId)` auf Karten in `#store-grid`.
---
## Konsole (`#panel-console`)
| Aufruf | HTML-Element | Kontext |
|--------|--------------|---------|
| `renderLog()` | `#log-filter` `oninput`, Clear-Button | Filtern / leeren |
| `toggleAutoScroll()` | `#btn-autoscroll` | Auto-Scroll |
| `setLogDir('all'\|'rx'\|'tx')` | `#logdir-all`, `#logdir-rx`, `#logdir-tx` | Richtungsfilter |
| `setLogTopic('multiColorBox'\|'print'\|'info'\|'status')` | Topic-Buttons | MQTT-Topic |
| `onLogScroll()` | `#console-log` `onscroll` | Auto-Scroll bei manuellem Scroll deaktivieren |
**Inline Clear:** `consoleLogs=[]; renderLog()` (keine eigene Funktion).
---
## Filament-Dialog (`#filament-dialog`)
| Aufruf | HTML-Element | Kontext |
|--------|--------------|---------|
| `closeFilamentDialog()` | Overlay, Abbrechen, ✕ | Schließen |
| `confirmFilamentPrint()` | „▶ Drucken“ | Slot-Zuordnung bestätigen |
Inhalt von `#fd-slots` wird von `openFilamentDialog()` befüllt (nach `startReadyFileWithSlots()` / `storePrint()`).
---
## Drucker-hinzufügen-Dialog (`#add-printer-dialog`)
| Aufruf | HTML-Element | Kontext |
|--------|--------------|---------|
| `closeAddPrinterDialog()` | Overlay, Abbrechen, ✕ | Schließen |
| `confirmAddPrinter()` | `#apd-confirm` | IP/Name senden → Neustart |
---
## Modal-Backdrop-Muster
Mehrere Overlays nutzen:
```html
onclick="if(event.target===this)closeSettings()"
```
| Funktion | Modal-ID |
|----------|----------|
| `closeSettings()` | `#settings-modal` |
| `closeSlotEdit()` | `#slot-edit-modal` |
| `closeFilamentDialog()` | `#filament-dialog` |
| `closeAddPrinterDialog()` | `#add-printer-dialog` |
---
## Funktionen ohne HTML-Hook (automatisch)
| Funktion | Wann / wofür |
|----------|----------------|
| `poll()` | Periodisch `/api/state` Dashboard-Update |
| `applyState()` | Poll-Daten ins DOM schreiben |
| `initPrinters()` | Multi-Drucker-Liste beim Start |
| `renderPrinterDropdown()` | Header-Dropdown |
| `applyLang()` | Übersetzungen (Start + nach `toggleLang()`) |
| `updateConnBtn()` | Verbindungs-Button |
| `updateHistory()` / `drawChart()` | Temperatur-Verlauf `#d-chart` |
| `connect()` (SSE) | Live-Server-Log |
| `openFilamentDialog()` | Multi-Farben-Druck |
| `openSlotEdit()` | AMS-Kacheln |
| `storePrint()` / `storeDelete()` | Datei-Karten |
| `removePrinter()` | Drucker-Karten |
| `camStart()` / `camStop()` | Kamera (auch auto bei Druckstart) |
---
## CSS-Klassen (keine IDs, aber von app.js genutzt)
| Klasse | Verwendung |
|--------|------------|
| `.panel` | Tab-Panels (`showPanel`) |
| `.nav-btn`, `.bnav-btn` | Navigation active-State |
| `.nav-text` | Sidebar-Label (i18n) |
| `.lbl-set`, `.lbl-off` | Temp-Button-Texte |
| `.lbl-home-z`, `.lbl-home-xy`, `.lbl-home-all`, `.lbl-disable-motors` | Home-Buttons |
| `.lbl-step` | Schrittweiten-Label |
| `.lbl-feed`, `.lbl-unload` | AMS-Buttons |
| `.temp-input` | Placeholder i18n |
| `.step-btn` | Schrittweiten-Auswahl |
| `.poll-btn` | Poll-Intervall |
| `.log-dir-btn`, `.log-topic-btn` | Log-Filter |
| `.mat-preset-btn` | Slot-Material (dynamisch) |
| `.ams-slot` | AMS-Kacheln (dynamisch) |
---
## Theme aktivieren
```bash
python kobrax_moonraker_bridge.py … --ui-theme default
# oder
KX_UI_THEME=new1 python kobrax_moonraker_bridge.py …
```
Pro Theme-Ordner: `index.html`, `style.css`, `app.js` unter `web/themes/<name>/`.

1267
web/themes/default/app.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,492 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>KX-Bridge</title>
<link rel="stylesheet" href="/kx/ui/style.css?v=__UI_ASSETS_VER__">
</head>
<body>
<div id="conn-error-banner" style="display:none;background:#c0392b;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:999;"></div>
<div id="file-ready-banner" style="display:none;background:#1a6e3c;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:998;display:none;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap">
<span>📄 <span id="file-ready-name"></span></span>
<button id="file-ready-btn" onclick="startReadyFile()"
style="padding:5px 16px;background:#fff;color:#1a6e3c;border:none;border-radius:6px;font-weight:700;cursor:pointer;font-size:13px"></button>
<button id="file-slots-btn" onclick="startReadyFileWithSlots()"
style="padding:5px 16px;background:rgba(255,255,255,0.15);color:#fff;border:1px solid rgba(255,255,255,0.5);border-radius:6px;font-weight:700;cursor:pointer;font-size:13px"></button>
<button id="file-cancel-btn" onclick="cancelReadyFile()"
style="padding:5px 16px;background:rgba(255,255,255,0.15);color:#fff;border:1px solid rgba(255,255,255,0.5);border-radius:6px;font-weight:700;cursor:pointer;font-size:13px"></button>
</div>
<header>
<div class="logo">⬡ KX-Bridge</div>
<div style="flex:1"></div>
<div id="printer-dropdown-wrap" style="display:none;position:relative">
<button id="printer-dropdown-btn" onclick="togglePrinterDropdown()" style="background:var(--raised);border:1px solid var(--border);border-radius:6px;padding:4px 10px;color:var(--txt);cursor:pointer;font-size:13px;display:flex;align-items:center;gap:6px">
<span id="h-pname">Anycubic Kobra X</span><span style="opacity:.5"></span>
</button>
<div id="printer-dropdown-menu" style="display:none;position:absolute;top:calc(100% + 4px);right:0;background:var(--card);border:1px solid var(--border);border-radius:8px;min-width:200px;z-index:200;box-shadow:0 4px 16px #0006;overflow:hidden">
</div>
</div>
<div id="h-pname-single" class="hname">Anycubic Kobra X</div>
<span id="h-version" style="font-size:11px;opacity:.5;margin-left:6px"></span>
<div class="hbadge" id="h-badge"><span class="dot"></span><span id="h-state">Standby</span></div>
<button class="theme-btn" onclick="toggleTheme()">☀ / ☾</button>
<button class="theme-btn" onclick="toggleLang()" id="lang-btn">EN</button>
<button class="theme-btn" onclick="openSettings()" id="settings-btn" title="Einstellungen"></button>
<button class="conn-btn disconnected" id="conn-btn" onclick="toggleConnection()">⚡ Verbinden</button>
</header>
<!-- ═══ SETTINGS MODAL ═══ -->
<div class="modal-overlay" id="settings-modal" onclick="if(event.target===this)closeSettings()">
<div class="modal-box">
<div class="modal-header">
<span class="modal-title" id="modal-title-settings">Einstellungen</span>
<button class="modal-close" onclick="closeSettings()"></button>
</div>
<div>
<div class="modal-field" style="margin-bottom:12px">
<label id="lbl-printer-name" style="font-weight:600">Drucker-Name</label>
<input type="text" id="s-printer-name" placeholder="z.B. Kobra X Links">
</div>
<div class="modal-section" id="modal-sec-connection">Verbindung</div>
<div class="modal-field">
<label id="lbl-printer-ip">Drucker-IP</label>
<input type="text" id="s-printer-ip" placeholder="192.168.x.x">
<small id="lbl-ip-hint" style="color:#f80;display:none"></small>
</div>
<div class="modal-field">
<label id="lbl-mqtt-port">MQTT-Port</label>
<input type="number" id="s-mqtt-port" placeholder="9883">
</div>
<div class="modal-field">
<label id="lbl-username">MQTT-Benutzername</label>
<input type="text" id="s-username" placeholder="userXXXXXXXX" autocomplete="new-password">
</div>
<div class="modal-field">
<label id="lbl-password">MQTT-Passwort</label>
<input type="password" id="s-password" autocomplete="new-password">
</div>
<div class="modal-field">
<label id="lbl-device-id">Device-ID</label>
<input type="text" id="s-device-id" placeholder="32 Hex-Zeichen">
</div>
<div class="modal-field">
<label id="lbl-mode-id">Mode-ID</label>
<input type="text" id="s-mode-id" placeholder="20030">
</div>
</div>
<div>
<div class="modal-section" id="modal-sec-print">Druckeinstellungen</div>
<div class="modal-field">
<label id="lbl-default-slot">Standard-Slot (Einfarbdruck)</label>
<select id="s-default-slot">
<option value="auto" id="opt-slot-auto">Auto (alle belegten Slots)</option>
<option value="0" id="opt-slot-0">Slot 1</option>
<option value="1" id="opt-slot-1">Slot 2</option>
<option value="2" id="opt-slot-2">Slot 3</option>
<option value="3" id="opt-slot-3">Slot 4</option>
</select>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-auto-leveling" style="width:auto;margin:0">
<label id="lbl-auto-leveling" style="margin:0;cursor:pointer" for="s-auto-leveling">Auto-Leveling vor Druck</label>
</div>
</div>
<div>
<div class="modal-section" id="modal-sec-poll">Poll-Intervall</div>
<div class="poll-btns">
<button class="poll-btn" onclick="setPoll(1000)" id="poll-1">1s</button>
<button class="poll-btn active" onclick="setPoll(2000)" id="poll-2">2s</button>
<button class="poll-btn" onclick="setPoll(5000)" id="poll-5">5s</button>
</div>
</div>
<div>
<div class="modal-section" id="modal-sec-version">Version</div>
<div class="update-row">
<span id="s-version-label" style="font-size:13px;color:var(--txt)"></span>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="checkUpdate()" id="btn-update-check">🔄 <span id="lbl-update-check">Auf Updates prüfen</span></button>
</div>
<div class="update-status" id="update-status" style="margin-top:6px"></div>
<button class="btn btn-sm btn-accent" id="btn-update-apply" style="display:none;margin-top:8px" onclick="applyUpdate()">
<span id="lbl-update-apply">Jetzt installieren</span>
</button>
<div id="update-changelog" style="display:none;margin-top:10px;background:var(--raised);border-radius:6px;padding:10px;font-size:11px;font-family:var(--mono);color:var(--txt2);white-space:pre-wrap;max-height:180px;overflow-y:auto;line-height:1.6"></div>
</div>
<button class="modal-save" onclick="saveSettings()" id="btn-save-settings">Speichern &amp; Neustart</button>
</div>
</div>
<!-- ═══ AMS SLOT EDIT DIALOG ═══ -->
<div class="modal-overlay" id="slot-edit-modal" onclick="if(event.target===this)closeSlotEdit()">
<div class="modal-box" style="max-width:340px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<span class="modal-title" id="slot-edit-title"></span>
<button onclick="closeSlotEdit()" style="background:none;border:none;color:var(--txt2);font-size:20px;cursor:pointer;line-height:1"></button>
</div>
<div style="display:flex;align-items:center;gap:16px;margin-bottom:20px">
<div id="slot-edit-preview" style="width:56px;height:56px;border-radius:50%;border:3px solid rgba(255,255,255,.2);flex-shrink:0"></div>
<div style="flex:1">
<div style="font-size:11px;color:var(--txt2);margin-bottom:4px" id="lbl-slot-color"></div>
<input type="color" id="slot-edit-color"
oninput="document.getElementById('slot-edit-preview').style.background=this.value"
style="width:100%;height:36px;border:1px solid var(--border);border-radius:6px;background:var(--raised);cursor:pointer;padding:2px">
</div>
</div>
<div style="margin-bottom:20px">
<div style="font-size:11px;color:var(--txt2);margin-bottom:6px" id="lbl-slot-material"></div>
<div style="display:flex;flex-wrap:wrap;gap:6px" id="slot-mat-btns">
</div>
<input type="text" id="slot-edit-mat"
oninput="highlightMatBtn(this.value)"
style="margin-top:8px;width:100%;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px;box-sizing:border-box">
</div>
<button class="modal-save" id="btn-slot-edit-save" onclick="saveSlotEdit()"></button>
</div>
</div>
<div class="layout">
<nav class="sidebar">
<button class="nav-btn active" onclick="showPanel('dashboard')" id="nb-dashboard">
<span class="nav-icon"></span><span class="nav-text">Dashboard</span></button>
<button class="nav-btn" onclick="showPanel('printers');loadPrinterTab()" id="nb-printers">
<span class="nav-icon">🖨</span><span class="nav-text">Drucker</span></button>
<button class="nav-btn" onclick="showPanel('store');loadStore()" id="nb-store">
<span class="nav-icon">🗂</span><span class="nav-text">Browser</span></button>
<button class="nav-btn" onclick="showPanel('console');clearLogBadge()" id="nb-console">
<span class="nav-icon"></span><span class="nav-text">Konsole</span><span id="log-badge" style="display:none;margin-left:4px;background:var(--err);color:#fff;border-radius:10px;font-size:10px;padding:1px 5px;font-weight:700"></span></button>
</nav>
<main>
<!-- ═══ DASHBOARD ═══ -->
<div class="panel active" id="panel-dashboard">
<div class="grid">
<!-- Kamera -->
<div class="card" style="grid-column:1/-1">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
<div class="card-title" style="margin-bottom:0"><span>📷</span> <span id="d-card-cam">Kamera</span></div>
<div style="display:flex;align-items:center;gap:10px">
<span id="d-lbl-light" style="font-size:12px;color:var(--txt2)">💡 Licht</span>
<label class="toggle">
<input type="checkbox" id="d-light-toggle" onchange="setLight()">
<span class="toggle-track"></span>
<span class="toggle-thumb"></span>
</label>
</div>
</div>
<div class="cam-wrap" id="cam-wrap">
<div class="cam-placeholder" id="cam-placeholder"><span id="cam-placeholder-txt">📷 Kamera nicht gestartet</span></div>
<div class="cam-spinner" id="cam-spinner"></div>
<img id="cam-img" style="display:none;width:100%;height:auto" alt="Kamera">
<div class="cam-overlay" id="cam-overlay" style="display:none">
<div style="font-size:12px;color:#fff" id="cam-fname"></div>
</div>
<button class="cam-toggle" onclick="toggleCam()" id="cam-toggle-btn">▶ Kamera</button>
</div>
</div>
<!-- Fortschritt -->
<div class="card" style="grid-column:1/-1">
<div class="card-title"><span></span> <span id="d-card-progress">Fortschritt</span></div>
<img id="d-thumbnail" src="" alt="" style="display:none;width:100%;max-height:160px;object-fit:contain;border-radius:8px;background:#111;margin-bottom:10px">
<div class="pct-big"><span id="d-pct">0</span><small>%</small></div>
<div style="display:flex;align-items:center;gap:10px;margin:8px 0">
<div class="progress-bar" style="flex:1;margin:0"><div class="progress-fill" id="d-pbar" style="width:0%"></div></div>
<div class="time-block" style="padding:6px 10px;min-width:72px;text-align:center;flex-shrink:0">
<div class="time-label" id="d-lbl-layers"></div>
<div class="time-val" style="font-size:16px" id="d-layers"></div>
</div>
</div>
<div class="time-grid">
<div class="time-block">
<div class="time-label" id="d-lbl-elapsed"></div>
<div class="time-val" id="d-elapsed"></div>
</div>
<div class="time-block" id="d-slicer-row" style="display:none">
<div class="time-label" id="d-slicer-label"></div>
<div class="time-val" id="d-slicer-time"></div>
</div>
<div class="time-block" style="color:var(--acc)">
<div class="time-label" id="d-lbl-remain"></div>
<div class="time-val" id="d-remain" style="color:var(--acc)"></div>
</div>
</div>
<div class="fname" id="d-fname" title="" style="margin-top:6px"></div>
<div class="ctrl-btns" id="d-ctrl-btns" style="margin-top:12px">
<button class="btn btn-pause btn-sm" id="d-btn-pause" onclick="printAction('pause')">⏸ Pause</button>
<button class="btn btn-resume btn-sm" id="d-btn-resume" onclick="printAction('resume')">▶ Weiter</button>
<button class="btn btn-cancel btn-sm" id="d-btn-cancel" onclick="confirmCancel()">✕ Stopp</button>
</div>
</div>
<!-- Temperatursteuerung + Verlauf -->
<div class="card" style="grid-column:1/-1">
<div class="card-title"><span></span> <span id="d-card-temps">Temperaturen</span></div>
<div class="temp-card-inner">
<div class="temp-block">
<div class="temp-label">Nozzle</div>
<div class="temp-row">
<div class="temp-val" id="d-nt"></div>
<div class="temp-unit">°C</div>
</div>
<div class="temp-target"><span id="d-nt-t">0</span>°C</div>
<div class="progress-bar" style="margin:8px 0 0">
<div class="progress-fill" id="d-ntbar" style="width:0%;background:linear-gradient(90deg,var(--accent2),#ffb020)"></div>
</div>
<div class="temp-edit" style="margin-top:10px">
<input type="number" class="temp-input" id="p-nozzle-inp" placeholder="Ziel" min="0" max="300" style="flex:1">
<button class="btn btn-sm btn-accent" onclick="setNozzle()"><span class="lbl-set">Set</span></button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="document.getElementById('p-nozzle-inp').value=0;setNozzle()"><span class="lbl-off">Aus</span></button>
</div>
</div>
<div class="temp-block">
<div class="temp-label" id="d-lbl-bed">Bett</div>
<div class="temp-row">
<div class="temp-val" id="d-bt"></div>
<div class="temp-unit">°C</div>
</div>
<div class="temp-target"><span id="d-bt-t">0</span>°C</div>
<div class="progress-bar" style="margin:8px 0 0">
<div class="progress-fill" id="d-btbar" style="width:0%;background:linear-gradient(90deg,#ff6b35,var(--warn))"></div>
</div>
<div class="temp-edit" style="margin-top:10px">
<input type="number" class="temp-input" id="p-bed-inp" placeholder="Ziel" min="0" max="120" style="flex:1">
<button class="btn btn-sm btn-accent" onclick="setBed()"><span class="lbl-set">Set</span></button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="document.getElementById('p-bed-inp').value=0;setBed()"><span class="lbl-off">Aus</span></button>
</div>
</div>
</div>
<div style="margin-top:14px">
<div style="font-size:10px;color:var(--txt2);margin-bottom:4px" id="d-chart-label">Verlauf (letzte 60 Messungen)</div>
<canvas id="d-chart" width="800" height="120" style="width:100%;height:120px;background:var(--raised);border-radius:8px"></canvas>
</div>
</div>
<!-- Achsensteuerung -->
<div class="card">
<div class="card-title"><span></span> <span id="ptitle-motion-xy">XY-Achsen</span></div>
<div class="joypad">
<div></div>
<button class="joy" onclick="move(1,1,getStep())" title="Y+"></button>
<div></div>
<button class="joy" onclick="move(0,-1,getStep())" title="X"></button>
<button class="joy home" onclick="homeAll()" title="Home All"></button>
<button class="joy" onclick="move(0,1,getStep())" title="X+"></button>
<div></div>
<button class="joy" onclick="move(1,-1,getStep())" title="Y"></button>
<div></div>
</div>
<div class="step-btns">
<button class="step-btn" onclick="setStep(this,0.1)">0.1</button>
<button class="step-btn active" onclick="setStep(this,1)">1</button>
<button class="step-btn" onclick="setStep(this,5)">5</button>
<button class="step-btn" onclick="setStep(this,10)">10 mm</button>
</div>
<div class="home-btns">
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeZ()"><span class="lbl-home-z">Home Z</span></button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeXY()"><span class="lbl-home-xy">Home XY</span></button>
<button class="btn btn-sm btn-accent" onclick="homeAll()"><span class="lbl-home-all">Home All</span></button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="disableMotors()"><span class="lbl-disable-motors">Motors Off</span></button>
</div>
</div>
<div class="card">
<div class="card-title"><span></span> <span id="ptitle-motion-z">Z-Achse</span></div>
<div class="joypad" style="grid-template-columns:52px;grid-template-rows:repeat(2,52px)">
<button class="joy" onclick="move(2,1,getStep())" title="Z+"></button>
<button class="joy" onclick="move(2,-1,getStep())" title="Z"></button>
</div>
<div style="text-align:center;margin-top:8px;font-size:12px;color:var(--txt2)"><span class="lbl-step">Schrittweite:</span> <span id="step-display">1</span> mm</div>
</div>
<!-- Print Speed -->
<div class="card">
<div class="card-title"><span>🏎</span> <span id="d-card-speed">Druckgeschwindigkeit</span></div>
<div style="display:flex;gap:8px;margin-top:4px">
<button class="spd-btn" id="d-spd-1" onclick="setSpeed(1)">
<span class="spd-icon">🐢</span>
<span id="d-spd-lbl-1">Leise</span>
</button>
<button class="spd-btn spd-active" id="d-spd-2" onclick="setSpeed(2)">
<span class="spd-icon"></span>
<span id="d-spd-lbl-2">Normal</span>
</button>
<button class="spd-btn" id="d-spd-3" onclick="setSpeed(3)">
<span class="spd-icon">🚀</span>
<span id="d-spd-lbl-3">Sport</span>
</button>
</div>
<div class="spd-bar" style="margin-top:12px">
<div class="spd-bar-fill" id="d-spd-bar" style="width:50%"></div>
</div>
</div>
<!-- Lüfter -->
<div class="card">
<div class="card-title"><span>🌀</span> <span id="d-card-lightfan">Lüfter</span></div>
<div class="slider-row">
<input type="range" class="slider" min="0" max="100" value="0" id="d-fan" oninput="document.getElementById('d-fan-val').textContent=this.value" onchange="setFan()">
<span class="slider-val" id="d-fan-val">0</span>
</div>
<div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="quickFan(0)">Aus</button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="quickFan(25)">25%</button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="quickFan(50)">50%</button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="quickFan(75)">75%</button>
<button class="btn btn-sm btn-accent" onclick="quickFan(100)">100%</button>
</div>
</div>
<!-- AMS -->
<div class="card" style="grid-column:1/-1" id="d-ams-card">
<div class="card-title"><span></span> <span id="d-card-ams">AMS / Filamentbox</span></div>
<div class="ams-slots" id="ams-slots">
<div style="grid-column:1/-1;text-align:center;color:var(--txt2);padding:20px" id="ams-no-data">Keine AMS-Daten empfangen</div>
</div>
<div id="ams-controls" style="margin-top:16px;display:flex;flex-direction:column;gap:10px">
<div style="font-size:12px;color:var(--txt2);margin-bottom:2px" id="ams-slot-lbl">Slot auswählen</div>
<div style="display:flex;align-items:center;gap:10px">
<input type="range" id="ams-slot-sel" min="0" max="3" step="1" value="0"
class="slider" style="flex:1"
oninput="document.getElementById('ams-slot-label').textContent='Slot '+(parseInt(this.value)+1)">
<span id="ams-slot-label" style="min-width:48px;font-size:13px;font-weight:600">Slot 1</span>
</div>
<div style="display:flex;gap:10px">
<button class="btn" style="flex:1" onclick="amsFeed(1)"><span class="lbl-feed">Einziehen</span></button>
<button id="btn-unload" class="btn" style="flex:1" onclick="amsFeed(2)"><span class="lbl-unload">Ausziehen</span></button>
</div>
</div>
</div>
</div>
</div>
<!-- ═══ CONSOLE ═══ -->
<!-- ═══ DRUCKER ═══ -->
<div class="panel" id="panel-printers">
<div class="card">
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<span id="printers-panel-title">🖨 Drucker</span>
<div style="display:flex;gap:8px">
<button onclick="openAddPrinterDialog()" style="font-size:12px;padding:4px 12px;background:var(--accent);border:none;border-radius:6px;color:#fff;cursor:pointer;font-weight:600">+ <span id="add-printer-btn-label">Drucker hinzufügen</span></button>
<button onclick="loadPrinterTab()" style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer"></button>
</div>
</div>
<div id="printers-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:14px"></div>
</div>
</div>
<!-- ═══ GCODE STORE ═══ -->
<div class="panel" id="panel-store">
<div class="card">
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<span id="store-panel-title">🗂 Datei-Browser</span>
<button id="store-refresh-btn" onclick="loadStore()" style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">↻ Aktualisieren</button>
</div>
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
<input id="store-search" type="text" placeholder="🔍 Suche…" oninput="renderStore()"
style="flex:1;min-width:140px;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<select id="store-filter" onchange="renderStore()"
style="padding:6px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<option value="all" id="sf-all">Alle</option>
<option value="completed" id="sf-ok">✓ Erfolgreich</option>
<option value="failed" id="sf-err">✗ Fehler</option>
<option value="never" id="sf-new">Neu</option>
</select>
<select id="store-sort" onchange="renderStore()"
style="padding:6px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<option value="date_desc" id="ss-date">↓ Datum</option>
<option value="name_asc" id="ss-name">AZ Name</option>
<option value="duration_asc" id="ss-dur">⏱ Druckzeit</option>
</select>
</div>
<div id="store-empty" style="display:none;color:var(--txt2);text-align:center;padding:40px 0;font-size:14px">
</div>
<div id="store-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px"></div>
</div>
</div>
<div class="panel" id="panel-console">
<div class="card">
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center">
<span><span></span> <span id="ptitle-console">Ereignis-Log</span></span>
<a id="btn-log-dl" href="/api/log/download" download="kx-bridge.log"
style="font-size:12px;padding:4px 10px;background:var(--raised);border-radius:6px;color:var(--txt2);text-decoration:none">⬇ Download</a>
</div>
<div style="display:flex;gap:6px;margin-bottom:6px;flex-wrap:wrap;align-items:center">
<input id="log-filter" type="text" placeholder="Filter…"
oninput="renderLog()"
style="flex:1;min-width:120px;padding:5px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:12px;font-family:var(--mono)">
<button id="btn-autoscroll" onclick="toggleAutoScroll()"
style="font-size:12px;padding:5px 10px;border-radius:6px;border:1px solid var(--border);background:var(--accent);color:#fff;cursor:pointer;white-space:nowrap">⬇ Auto</button>
<button onclick="consoleLogs=[];renderLog()"
style="font-size:12px;padding:5px 10px;border-radius:6px;border:1px solid var(--border);background:var(--raised);color:var(--txt2);cursor:pointer">✕ Clear</button>
</div>
<div style="display:flex;gap:5px;margin-bottom:8px;flex-wrap:wrap">
<span style="font-size:11px;color:var(--txt2);align-self:center;margin-right:2px">Dir:</span>
<button class="log-dir-btn active" id="logdir-all" onclick="setLogDir('all')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer"></button>
<button class="log-dir-btn" id="logdir-rx" onclick="setLogDir('rx')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">RX</button>
<button class="log-dir-btn" id="logdir-tx" onclick="setLogDir('tx')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">TX</button>
<span style="font-size:11px;color:var(--txt2);align-self:center;margin-left:6px;margin-right:2px">Topic:</span>
<button class="log-topic-btn" data-topic="multiColorBox" onclick="setLogTopic('multiColorBox')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">AMS</button>
<button class="log-topic-btn" data-topic="print" onclick="setLogTopic('print')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">print</button>
<button class="log-topic-btn" data-topic="info" onclick="setLogTopic('info')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">info</button>
<button class="log-topic-btn" data-topic="status" onclick="setLogTopic('status')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">status</button>
</div>
<div class="console" id="console-log" style="height:calc(100vh - 260px);min-height:160px" onscroll="onLogScroll()"></div>
</div>
</div>
</main>
</div>
<nav class="bottom-nav">
<button class="bnav-btn active" onclick="showPanel('dashboard')" id="bnb-dashboard"><span class="bnav-icon"></span>Dashboard</button>
<button class="bnav-btn" onclick="showPanel('printers');loadPrinterTab()" id="bnb-printers"><span class="bnav-icon">🖨</span>Drucker</button>
<button class="bnav-btn" onclick="showPanel('store');loadStore()" id="bnb-store"><span class="bnav-icon">🗂</span>Browser</button>
<button class="bnav-btn" onclick="showPanel('console');clearLogBadge()" id="bnb-console"><span class="bnav-icon"></span>Log<span id="log-badge-bot" style="display:none;margin-left:3px;background:var(--err);color:#fff;border-radius:10px;font-size:10px;padding:1px 4px;font-weight:700"></span></button>
</nav>
<!-- Filament-Slot-Dialog -->
<div class="modal-overlay" id="filament-dialog" onclick="if(event.target===this)closeFilamentDialog()">
<div class="modal-box" style="max-width:380px;width:100%">
<div class="modal-header" style="margin-bottom:14px">
<span class="modal-title" id="fd-title" style="font-size:14px;word-break:break-all"></span>
<button onclick="closeFilamentDialog()" style="background:none;border:none;font-size:18px;cursor:pointer;color:var(--txt2)"></button>
</div>
<p style="font-size:12px;color:var(--txt2);margin-bottom:10px">GCode-Kanal → AMS-Slot zuweisen:</p>
<div id="fd-slots" style="display:flex;flex-direction:column;gap:8px;margin-bottom:16px"></div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button onclick="closeFilamentDialog()" style="padding:8px 16px;background:var(--raised);border:1px solid var(--border);border-radius:8px;color:var(--txt);cursor:pointer">Abbrechen</button>
<button onclick="confirmFilamentPrint()" style="padding:8px 18px;background:var(--accent);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600">▶ Drucken</button>
</div>
</div>
</div>
<!-- Drucker-hinzufügen-Dialog -->
<div class="modal-overlay" id="add-printer-dialog" onclick="if(event.target===this)closeAddPrinterDialog()">
<div class="modal-box" style="max-width:380px;width:100%">
<div class="modal-header" style="margin-bottom:14px">
<span class="modal-title" id="apd-title">Drucker hinzufügen</span>
<button onclick="closeAddPrinterDialog()" style="background:none;border:none;font-size:18px;cursor:pointer;color:var(--txt2)"></button>
</div>
<label id="apd-lbl-ip" style="display:block;font-size:12px;color:var(--txt2);margin-bottom:4px">Drucker-IP</label>
<input type="text" id="apd-ip" placeholder="192.168.1.100" style="width:100%;box-sizing:border-box;padding:8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);margin-bottom:10px">
<label id="apd-lbl-name" style="display:block;font-size:12px;color:var(--txt2);margin-bottom:4px">Name (optional)</label>
<input type="text" id="apd-name" placeholder="z.B. Kobra X Wohnzimmer" style="width:100%;box-sizing:border-box;padding:8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);margin-bottom:6px">
<div id="apd-status" style="font-size:12px;margin:8px 0;min-height:16px;color:var(--txt2)"></div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button onclick="closeAddPrinterDialog()" style="padding:8px 16px;background:var(--raised);border:1px solid var(--border);border-radius:8px;color:var(--txt);cursor:pointer">Abbrechen</button>
<button id="apd-confirm" onclick="confirmAddPrinter()" style="padding:8px 18px;background:var(--accent);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600">Hinzufügen</button>
</div>
</div>
</div>
<footer style="text-align:center;padding:12px;font-size:11px;color:var(--txt2);border-top:1px solid var(--border);margin-top:auto">
&copy; ViewIT 2026
</footer>
<script src="/kx/ui/app.js?v=__UI_ASSETS_VER__"></script>
</body>
</html>

View File

@@ -0,0 +1,277 @@
:root{
--bg:#1a1a1f;--card:#24242c;--raised:#2e2e3a;--border:#3a3a4a;
--txt:#f0f0f5;--txt2:#8888aa;--accent:#00c8ff;--accent2:#ff6b35;
--ok:#4cde80;--err:#ff4d6d;--warn:#ffb020;
--font:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
--mono:"JetBrains Mono","Fira Code",monospace;
}
[data-theme=light]{
--bg:#f0f0f5;--card:#fff;--raised:#e8e8f0;--border:#d0d0e0;
--txt:#1a1a2e;--txt2:#666680;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--txt);font-family:var(--font);font-size:14px;min-height:100vh;display:flex;flex-direction:column}
a{color:var(--accent);text-decoration:none}
/* ── HEADER ── */
header{background:var(--card);border-bottom:1px solid var(--border);
display:flex;align-items:center;gap:12px;padding:0 20px;height:52px;
position:sticky;top:0;z-index:100}
.logo{font-size:18px;font-weight:700;color:var(--accent);letter-spacing:-.02em}
.hname{font-size:13px;color:var(--txt2)}
.hbadge{display:flex;align-items:center;gap:6px;font-size:12px;font-weight:600;
padding:4px 10px;border-radius:20px;background:var(--raised);color:var(--txt2);
text-transform:uppercase;letter-spacing:.04em}
.hbadge.printing{background:#0d2d1a;color:var(--ok)}
.hbadge.complete{background:#0d1f38;color:#60b0ff}
.hbadge.error{background:#2d0d0d;color:var(--err)}
.hbadge .dot{width:7px;height:7px;border-radius:50%;background:currentColor}
.hbadge.printing .dot{animation:pulse 1.4s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}}
.theme-btn{background:none;border:1px solid var(--border);color:var(--txt2);
border-radius:8px;padding:6px 10px;cursor:pointer;font-size:13px;transition:.15s}
.theme-btn:hover{border-color:var(--accent);color:var(--accent)}
.conn-btn{border-radius:8px;padding:6px 12px;cursor:pointer;font-size:13px;
font-weight:600;border:none;transition:.15s}
.conn-btn.disconnected{background:var(--accent);color:#fff}
.conn-btn.disconnected:hover{opacity:.85}
.conn-btn.connected{background:transparent;border:1px solid var(--border);color:var(--txt2)}
.conn-btn.connected:hover{border-color:#e05;color:#e05}
/* ── LAYOUT ── */
.layout{display:flex;flex:1;min-height:0}
nav.sidebar{width:200px;background:var(--card);border-right:1px solid var(--border);
display:flex;flex-direction:column;padding:12px 8px;gap:2px;flex-shrink:0}
.nav-btn{background:none;border:none;color:var(--txt2);text-align:left;
padding:9px 12px;border-radius:8px;cursor:pointer;font-size:13px;
display:flex;align-items:center;gap:10px;transition:.12s;width:100%}
.nav-btn:hover{background:var(--raised);color:var(--txt)}
.nav-btn.active{background:var(--raised);color:var(--accent)}
.nav-icon{font-size:16px;width:20px;text-align:center}
main{flex:1;overflow-y:auto;padding:20px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:16px}
/* ── CARD ── */
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;
padding:18px;transition:box-shadow .15s,transform .15s}
.card:hover{box-shadow:0 4px 20px rgba(0,0,0,.3);transform:translateY(-1px)}
.card-title{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:var(--txt2);
margin-bottom:14px;display:flex;align-items:center;gap:8px}
.card-title span{font-size:14px}
/* ── HERO ── */
.hero{grid-column:1/-1;display:grid;grid-template-columns:1fr 320px;gap:16px}
@media(max-width:900px){.hero{grid-template-columns:1fr}}
.cam-wrap{background:#0a0a0e;border-radius:10px;overflow:hidden;
min-height:180px;max-height:320px;display:flex;align-items:center;justify-content:center;position:relative}
.cam-wrap img,.cam-wrap video{width:100%;max-height:320px;height:auto;display:block;object-fit:contain}
.cam-placeholder{color:var(--txt2);font-size:13px;text-align:center;padding:20px}
@keyframes spin{to{transform:rotate(360deg)}}
.cam-spinner{width:40px;height:40px;border:3px solid rgba(255,255,255,.15);
border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;display:none}
.cam-overlay{position:absolute;bottom:0;left:0;right:0;
background:linear-gradient(transparent,rgba(0,0,0,.75));padding:14px}
.cam-toggle{position:absolute;top:10px;right:10px;background:rgba(0,0,0,.5);
border:1px solid rgba(255,255,255,.2);color:#fff;border-radius:8px;
padding:6px 10px;cursor:pointer;font-size:12px;backdrop-filter:blur(4px)}
.cam-toggle:hover{background:rgba(0,0,0,.7)}
/* ── PROGRESS ── */
.hero-info{display:flex;flex-direction:column;gap:12px}
.pct-big{font-size:52px;font-weight:700;line-height:1;color:var(--txt)}
.pct-big small{font-size:20px;font-weight:400;color:var(--txt2)}
.progress-bar{height:8px;background:var(--raised);border-radius:4px;overflow:hidden;margin:4px 0}
.progress-fill{height:100%;background:linear-gradient(90deg,var(--accent),#0080cc);
border-radius:4px;transition:width .6s ease}
.meta-row{display:flex;justify-content:space-between;font-size:12px;color:var(--txt2)}
.layer-badge{background:var(--raised);border-radius:6px;padding:4px 8px;
font-family:var(--mono);font-size:12px;color:var(--txt)}
.fname{font-size:12px;color:var(--txt2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
background:var(--raised);border-radius:6px;padding:6px 8px}
/* ── PRINT CONTROLS ── */
.ctrl-btns{display:flex;gap:8px;flex-wrap:wrap}
.btn{border:none;border-radius:8px;padding:10px 16px;font-size:13px;font-weight:600;
cursor:pointer;transition:opacity .15s,transform .1s;white-space:nowrap}
.btn:hover{opacity:.85;transform:translateY(-1px)}
.btn:active{transform:translateY(0)}
.btn-start{background:var(--ok);color:#0d2010}
.btn-pause{background:var(--raised);color:var(--txt);border:1px solid var(--border)}
.btn-resume{background:#0d2d1a;color:var(--ok);border:1px solid var(--ok)}
.btn-cancel{background:#2d0d0d;color:var(--err);border:1px solid var(--err)}
.btn-accent{background:var(--accent);color:#001a24}
.btn-sm{padding:7px 12px;font-size:12px}
.spd-btn{flex:1;border:1.5px solid var(--border);background:var(--raised);color:var(--txt);
border-radius:10px;padding:14px 8px;font-size:13px;font-weight:600;cursor:pointer;
transition:all .15s;display:flex;flex-direction:column;align-items:center;gap:4px}
.spd-btn:hover{border-color:var(--accent);color:var(--accent)}
.spd-btn.spd-active{border-color:var(--accent);background:rgba(0,200,255,.12);color:var(--accent)}
.spd-btn .spd-icon{font-size:22px}
.spd-bar{height:4px;border-radius:2px;background:var(--border);margin-top:10px;overflow:hidden}
.spd-bar-fill{height:100%;border-radius:2px;background:linear-gradient(90deg,var(--accent2),var(--accent));transition:width .3s}
/* ── TIME CARDS ── */
.time-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:8px}
.time-block{background:var(--raised);border-radius:10px;padding:10px 12px}
.time-label{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--txt2);margin-bottom:4px}
.time-val{font-size:20px;font-weight:700;font-family:var(--mono);color:var(--txt)}
/* ── TEMPS ── */
.temp-pair{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.temp-card-inner{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.temp-block{background:var(--raised);border-radius:10px;padding:14px;position:relative}
.temp-label{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--txt2);margin-bottom:6px}
.temp-row{display:flex;align-items:baseline;gap:6px}
.temp-val{font-size:30px;font-weight:700;font-family:var(--mono)}
.temp-unit{font-size:14px;color:var(--txt2)}
.temp-target{font-size:11px;color:var(--txt2);margin-top:2px}
.temp-arc{position:absolute;top:12px;right:12px}
.temp-edit{display:flex;gap:6px;margin-top:10px}
.temp-input{background:var(--bg);border:1px solid var(--border);color:var(--txt);
border-radius:6px;padding:5px 8px;font-size:13px;font-family:var(--mono);width:70px}
.temp-input:focus{outline:none;border-color:var(--accent)}
.chart-wrap{margin-top:12px}
canvas.tchart{width:100%;height:60px;display:block;border-radius:6px;background:var(--raised)}
/* ── MOTION ── */
.joypad{display:grid;grid-template-columns:repeat(3,52px);
grid-template-rows:repeat(3,52px);gap:6px;justify-content:center;margin:8px auto}
.joy{background:var(--raised);border:1px solid var(--border);color:var(--txt);
border-radius:10px;font-size:18px;cursor:pointer;transition:.12s;
display:flex;align-items:center;justify-content:center}
.joy:hover{background:var(--accent);color:#001a24;border-color:var(--accent)}
.joy:active{transform:scale(.93)}
.joy.home{font-size:14px;background:var(--bg)}
.step-btns{display:flex;gap:6px;justify-content:center;flex-wrap:wrap;margin-top:10px}
.step-btn{background:var(--raised);border:1px solid var(--border);color:var(--txt2);
border-radius:6px;padding:5px 10px;font-size:12px;cursor:pointer;transition:.12s}
.step-btn.active,.step-btn:hover{background:var(--accent);color:#001a24;border-color:var(--accent)}
.home-btns{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px;justify-content:center}
/* ── AMS ── */
.ams-slots{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
.ams-slot{background:var(--raised);border-radius:10px;padding:10px;text-align:center;
border:2px solid transparent;transition:.2s;position:relative}
.ams-slot.active{border-color:var(--slot-color,var(--accent));
box-shadow:0 0 12px rgba(var(--slot-rgb,0,200,255),.3)}
.slot-circle{width:36px;height:36px;border-radius:50%;margin:0 auto 6px;border:2px solid rgba(255,255,255,.15)}
.slot-label{font-size:11px;color:var(--txt2);font-family:var(--mono)}
.slot-material{font-size:12px;font-weight:600;margin-bottom:2px}
/* ── LIGHT + FAN ── */
.toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}
.toggle-label{font-size:13px;font-weight:600}
.toggle{position:relative;width:44px;height:24px;cursor:pointer}
.toggle input{opacity:0;width:0;height:0;position:absolute}
.toggle-track{width:44px;height:24px;background:var(--raised);border-radius:12px;
border:1px solid var(--border);transition:.25s;display:block}
.toggle input:checked+.toggle-track{background:var(--accent)}
.toggle-thumb{position:absolute;top:3px;left:3px;width:18px;height:18px;
background:#fff;border-radius:50%;transition:.25s;pointer-events:none}
.toggle input:checked~.toggle-thumb{transform:translateX(20px)}
.slider-row{display:flex;align-items:center;gap:10px;margin-top:8px}
.slider-label{font-size:12px;color:var(--txt2);width:80px}
.slider{flex:1;-webkit-appearance:none;height:4px;border-radius:2px;
background:var(--raised);outline:none;cursor:pointer}
.slider::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;
border-radius:50%;background:var(--accent);cursor:pointer;transition:.1s}
.slider::-webkit-slider-thumb:hover{transform:scale(1.2)}
.slider-val{font-family:var(--mono);font-size:12px;color:var(--txt);width:30px;text-align:right}
/* ── CONSOLE ── */
.console{background:#0a0a0e;border-radius:8px;padding:10px;font-family:var(--mono);
font-size:11px;color:#8888aa;overflow-y:auto;line-height:1.6}
.console .ts{color:#444;margin-right:6px}
.console .msg-info{color:#8888aa}
.console .msg-ok{color:var(--ok)}
.console .msg-err{color:var(--err)}
.console .msg-warn{color:var(--warn)}
/* ── PANELS ── */
.panel{display:none}
.panel.active{display:block}
/* ── MODAL ── */
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);
z-index:200;align-items:center;justify-content:center;padding:16px}
.modal-overlay.open{display:flex}
.modal-box{background:var(--card);border:1px solid var(--border);border-radius:14px;
width:100%;max-width:480px;max-height:90vh;overflow-y:auto;padding:24px;
display:flex;flex-direction:column;gap:18px}
.modal-header{display:flex;align-items:center;justify-content:space-between}
.modal-title{font-size:15px;font-weight:700;color:var(--txt)}
.modal-close{background:none;border:none;color:var(--txt2);font-size:20px;
cursor:pointer;padding:4px 8px;border-radius:6px}
.modal-close:hover{background:var(--raised);color:var(--txt)}
.modal-section{font-size:10px;text-transform:uppercase;letter-spacing:.1em;
color:var(--txt2);margin-bottom:6px;margin-top:4px}
.modal-field{display:flex;flex-direction:column;gap:4px;margin-bottom:10px}
.modal-field label{font-size:12px;color:var(--txt2)}
.modal-field input{background:var(--raised);border:1px solid var(--border);
border-radius:7px;color:var(--txt);padding:7px 10px;font-size:13px;width:100%}
.modal-field input:focus{outline:none;border-color:var(--accent)}
.poll-btns{display:flex;gap:8px}
.poll-btn{flex:1;padding:7px;background:var(--raised);border:1px solid var(--border);
border-radius:7px;color:var(--txt2);cursor:pointer;font-size:13px;transition:all .15s}
.poll-btn.active{background:var(--accent);border-color:var(--accent);color:#000;font-weight:600}
.update-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.update-status{font-size:12px;color:var(--txt2);flex:1;min-width:0}
.modal-save{width:100%;padding:10px;background:var(--accent);border:none;
border-radius:8px;color:#000;font-weight:700;font-size:14px;cursor:pointer;margin-top:4px}
.modal-save:hover{opacity:.88}
/* ── BOTTOM NAV (mobile) ── */
nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
background:var(--card);border-top:1px solid var(--border);
justify-content:space-around;padding:8px 0 max(8px,env(safe-area-inset-bottom))}
.bnav-btn{background:none;border:none;color:var(--txt2);display:flex;
flex-direction:column;align-items:center;gap:3px;cursor:pointer;font-size:10px;padding:4px 8px}
.bnav-btn.active{color:var(--accent)}
.bnav-icon{font-size:20px}
/* ── Tablet (7691100px): schmale Sidebar ── */
@media(min-width:769px) and (max-width:1100px){
nav.sidebar{width:52px;padding:12px 4px}
.nav-btn .nav-text{display:none}
.nav-btn{justify-content:center;padding:10px}
.nav-icon{width:auto}
.grid{grid-template-columns:repeat(2,1fr)}
.hero{grid-template-columns:1fr}
}
/* ── Mobile (≤768px): Bottom-Nav, 1-Spalte ── */
@media(max-width:768px){
nav.sidebar{display:none}
nav.bottom-nav{display:flex}
main{padding:10px;padding-bottom:72px}
/* Header kompakt */
header{padding:0 12px;gap:8px}
.hname{display:none}
/* 1-Spalten-Grid, full-width spans funktionieren weiterhin */
.grid{grid-template-columns:1fr;gap:12px}
/* Hero: Kamera über Info */
.hero{grid-template-columns:1fr}
.cam-wrap{max-height:220px}
/* Temp-Pair und Temp-Card übereinander */
.temp-pair{grid-template-columns:1fr}
.temp-card-inner{grid-template-columns:1fr}
/* AMS: 2 Spalten */
.ams-slots{grid-template-columns:repeat(2,1fr)}
/* Joypad etwas kleiner */
.joypad{grid-template-columns:repeat(3,44px);grid-template-rows:repeat(3,44px);gap:5px}
.joy{font-size:16px}
/* Buttons größere Touch-Targets */
.btn{padding:10px 14px;font-size:13px}
.btn-sm{padding:8px 12px}
.step-btn{padding:8px 12px;font-size:13px}
/* Modal vollbreite auf kleinen Screens */
.modal-box{padding:16px;border-radius:10px}
.poll-btns{gap:6px}
}

1267
web/themes/new1/app.js Normal file

File diff suppressed because it is too large Load Diff

492
web/themes/new1/index.html Normal file
View File

@@ -0,0 +1,492 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>KX-Bridge</title>
<link rel="stylesheet" href="/kx/ui/style.css?v=__UI_ASSETS_VER__">
</head>
<body>
<div id="conn-error-banner" style="display:none;background:#c0392b;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:999;"></div>
<div id="file-ready-banner" style="display:none;background:#1a6e3c;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:998;display:none;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap">
<span>📄 <span id="file-ready-name"></span></span>
<button id="file-ready-btn" onclick="startReadyFile()"
style="padding:5px 16px;background:#fff;color:#1a6e3c;border:none;border-radius:6px;font-weight:700;cursor:pointer;font-size:13px"></button>
<button id="file-slots-btn" onclick="startReadyFileWithSlots()"
style="padding:5px 16px;background:rgba(255,255,255,0.15);color:#fff;border:1px solid rgba(255,255,255,0.5);border-radius:6px;font-weight:700;cursor:pointer;font-size:13px"></button>
<button id="file-cancel-btn" onclick="cancelReadyFile()"
style="padding:5px 16px;background:rgba(255,255,255,0.15);color:#fff;border:1px solid rgba(255,255,255,0.5);border-radius:6px;font-weight:700;cursor:pointer;font-size:13px"></button>
</div>
<header>
<div class="logo">⬡ KX-Bridge (new1)</div>
<div style="flex:1"></div>
<div id="printer-dropdown-wrap" style="display:none;position:relative">
<button id="printer-dropdown-btn" onclick="togglePrinterDropdown()" style="background:var(--raised);border:1px solid var(--border);border-radius:6px;padding:4px 10px;color:var(--txt);cursor:pointer;font-size:13px;display:flex;align-items:center;gap:6px">
<span id="h-pname">Anycubic Kobra X</span><span style="opacity:.5"></span>
</button>
<div id="printer-dropdown-menu" style="display:none;position:absolute;top:calc(100% + 4px);right:0;background:var(--card);border:1px solid var(--border);border-radius:8px;min-width:200px;z-index:200;box-shadow:0 4px 16px #0006;overflow:hidden">
</div>
</div>
<div id="h-pname-single" class="hname">Anycubic Kobra X</div>
<span id="h-version" style="font-size:11px;opacity:.5;margin-left:6px"></span>
<div class="hbadge" id="h-badge"><span class="dot"></span><span id="h-state">Standby</span></div>
<button class="theme-btn" onclick="toggleTheme()">☀ / ☾</button>
<button class="theme-btn" onclick="toggleLang()" id="lang-btn">EN</button>
<button class="theme-btn" onclick="openSettings()" id="settings-btn" title="Einstellungen"></button>
<button class="conn-btn disconnected" id="conn-btn" onclick="toggleConnection()">⚡ Verbinden</button>
</header>
<!-- ═══ SETTINGS MODAL ═══ -->
<div class="modal-overlay" id="settings-modal" onclick="if(event.target===this)closeSettings()">
<div class="modal-box">
<div class="modal-header">
<span class="modal-title" id="modal-title-settings">Einstellungen</span>
<button class="modal-close" onclick="closeSettings()"></button>
</div>
<div>
<div class="modal-field" style="margin-bottom:12px">
<label id="lbl-printer-name" style="font-weight:600">Drucker-Name</label>
<input type="text" id="s-printer-name" placeholder="z.B. Kobra X Links">
</div>
<div class="modal-section" id="modal-sec-connection">Verbindung</div>
<div class="modal-field">
<label id="lbl-printer-ip">Drucker-IP</label>
<input type="text" id="s-printer-ip" placeholder="192.168.x.x">
<small id="lbl-ip-hint" style="color:#f80;display:none"></small>
</div>
<div class="modal-field">
<label id="lbl-mqtt-port">MQTT-Port</label>
<input type="number" id="s-mqtt-port" placeholder="9883">
</div>
<div class="modal-field">
<label id="lbl-username">MQTT-Benutzername</label>
<input type="text" id="s-username" placeholder="userXXXXXXXX" autocomplete="new-password">
</div>
<div class="modal-field">
<label id="lbl-password">MQTT-Passwort</label>
<input type="password" id="s-password" autocomplete="new-password">
</div>
<div class="modal-field">
<label id="lbl-device-id">Device-ID</label>
<input type="text" id="s-device-id" placeholder="32 Hex-Zeichen">
</div>
<div class="modal-field">
<label id="lbl-mode-id">Mode-ID</label>
<input type="text" id="s-mode-id" placeholder="20030">
</div>
</div>
<div>
<div class="modal-section" id="modal-sec-print">Druckeinstellungen</div>
<div class="modal-field">
<label id="lbl-default-slot">Standard-Slot (Einfarbdruck)</label>
<select id="s-default-slot">
<option value="auto" id="opt-slot-auto">Auto (alle belegten Slots)</option>
<option value="0" id="opt-slot-0">Slot 1</option>
<option value="1" id="opt-slot-1">Slot 2</option>
<option value="2" id="opt-slot-2">Slot 3</option>
<option value="3" id="opt-slot-3">Slot 4</option>
</select>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-auto-leveling" style="width:auto;margin:0">
<label id="lbl-auto-leveling" style="margin:0;cursor:pointer" for="s-auto-leveling">Auto-Leveling vor Druck</label>
</div>
</div>
<div>
<div class="modal-section" id="modal-sec-poll">Poll-Intervall</div>
<div class="poll-btns">
<button class="poll-btn" onclick="setPoll(1000)" id="poll-1">1s</button>
<button class="poll-btn active" onclick="setPoll(2000)" id="poll-2">2s</button>
<button class="poll-btn" onclick="setPoll(5000)" id="poll-5">5s</button>
</div>
</div>
<div>
<div class="modal-section" id="modal-sec-version">Version</div>
<div class="update-row">
<span id="s-version-label" style="font-size:13px;color:var(--txt)"></span>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="checkUpdate()" id="btn-update-check">🔄 <span id="lbl-update-check">Auf Updates prüfen</span></button>
</div>
<div class="update-status" id="update-status" style="margin-top:6px"></div>
<button class="btn btn-sm btn-accent" id="btn-update-apply" style="display:none;margin-top:8px" onclick="applyUpdate()">
<span id="lbl-update-apply">Jetzt installieren</span>
</button>
<div id="update-changelog" style="display:none;margin-top:10px;background:var(--raised);border-radius:6px;padding:10px;font-size:11px;font-family:var(--mono);color:var(--txt2);white-space:pre-wrap;max-height:180px;overflow-y:auto;line-height:1.6"></div>
</div>
<button class="modal-save" onclick="saveSettings()" id="btn-save-settings">Speichern &amp; Neustart</button>
</div>
</div>
<!-- ═══ AMS SLOT EDIT DIALOG ═══ -->
<div class="modal-overlay" id="slot-edit-modal" onclick="if(event.target===this)closeSlotEdit()">
<div class="modal-box" style="max-width:340px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<span class="modal-title" id="slot-edit-title"></span>
<button onclick="closeSlotEdit()" style="background:none;border:none;color:var(--txt2);font-size:20px;cursor:pointer;line-height:1"></button>
</div>
<div style="display:flex;align-items:center;gap:16px;margin-bottom:20px">
<div id="slot-edit-preview" style="width:56px;height:56px;border-radius:50%;border:3px solid rgba(255,255,255,.2);flex-shrink:0"></div>
<div style="flex:1">
<div style="font-size:11px;color:var(--txt2);margin-bottom:4px" id="lbl-slot-color"></div>
<input type="color" id="slot-edit-color"
oninput="document.getElementById('slot-edit-preview').style.background=this.value"
style="width:100%;height:36px;border:1px solid var(--border);border-radius:6px;background:var(--raised);cursor:pointer;padding:2px">
</div>
</div>
<div style="margin-bottom:20px">
<div style="font-size:11px;color:var(--txt2);margin-bottom:6px" id="lbl-slot-material"></div>
<div style="display:flex;flex-wrap:wrap;gap:6px" id="slot-mat-btns">
</div>
<input type="text" id="slot-edit-mat"
oninput="highlightMatBtn(this.value)"
style="margin-top:8px;width:100%;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px;box-sizing:border-box">
</div>
<button class="modal-save" id="btn-slot-edit-save" onclick="saveSlotEdit()"></button>
</div>
</div>
<div class="layout">
<nav class="sidebar">
<button class="nav-btn active" onclick="showPanel('dashboard')" id="nb-dashboard">
<span class="nav-icon"></span><span class="nav-text">Dashboard</span></button>
<button class="nav-btn" onclick="showPanel('printers');loadPrinterTab()" id="nb-printers">
<span class="nav-icon">🖨</span><span class="nav-text">Drucker</span></button>
<button class="nav-btn" onclick="showPanel('store');loadStore()" id="nb-store">
<span class="nav-icon">🗂</span><span class="nav-text">Browser</span></button>
<button class="nav-btn" onclick="showPanel('console');clearLogBadge()" id="nb-console">
<span class="nav-icon"></span><span class="nav-text">Konsole</span><span id="log-badge" style="display:none;margin-left:4px;background:var(--err);color:#fff;border-radius:10px;font-size:10px;padding:1px 5px;font-weight:700"></span></button>
</nav>
<main>
<!-- ═══ DASHBOARD ═══ -->
<div class="panel active" id="panel-dashboard">
<div class="grid">
<!-- Kamera -->
<div class="card" style="grid-column:1/-1">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
<div class="card-title" style="margin-bottom:0"><span>📷</span> <span id="d-card-cam">Kamera</span></div>
<div style="display:flex;align-items:center;gap:10px">
<span id="d-lbl-light" style="font-size:12px;color:var(--txt2)">💡 Licht</span>
<label class="toggle">
<input type="checkbox" id="d-light-toggle" onchange="setLight()">
<span class="toggle-track"></span>
<span class="toggle-thumb"></span>
</label>
</div>
</div>
<div class="cam-wrap" id="cam-wrap">
<div class="cam-placeholder" id="cam-placeholder"><span id="cam-placeholder-txt">📷 Kamera nicht gestartet</span></div>
<div class="cam-spinner" id="cam-spinner"></div>
<img id="cam-img" style="display:none;width:100%;height:auto" alt="Kamera">
<div class="cam-overlay" id="cam-overlay" style="display:none">
<div style="font-size:12px;color:#fff" id="cam-fname"></div>
</div>
<button class="cam-toggle" onclick="toggleCam()" id="cam-toggle-btn">▶ Kamera</button>
</div>
</div>
<!-- Fortschritt -->
<div class="card" style="grid-column:1/-1">
<div class="card-title"><span></span> <span id="d-card-progress">Fortschritt</span></div>
<img id="d-thumbnail" src="" alt="" style="display:none;width:100%;max-height:160px;object-fit:contain;border-radius:8px;background:#111;margin-bottom:10px">
<div class="pct-big"><span id="d-pct">0</span><small>%</small></div>
<div style="display:flex;align-items:center;gap:10px;margin:8px 0">
<div class="progress-bar" style="flex:1;margin:0"><div class="progress-fill" id="d-pbar" style="width:0%"></div></div>
<div class="time-block" style="padding:6px 10px;min-width:72px;text-align:center;flex-shrink:0">
<div class="time-label" id="d-lbl-layers"></div>
<div class="time-val" style="font-size:16px" id="d-layers"></div>
</div>
</div>
<div class="time-grid">
<div class="time-block">
<div class="time-label" id="d-lbl-elapsed"></div>
<div class="time-val" id="d-elapsed"></div>
</div>
<div class="time-block" id="d-slicer-row" style="display:none">
<div class="time-label" id="d-slicer-label"></div>
<div class="time-val" id="d-slicer-time"></div>
</div>
<div class="time-block" style="color:var(--acc)">
<div class="time-label" id="d-lbl-remain"></div>
<div class="time-val" id="d-remain" style="color:var(--acc)"></div>
</div>
</div>
<div class="fname" id="d-fname" title="" style="margin-top:6px"></div>
<div class="ctrl-btns" id="d-ctrl-btns" style="margin-top:12px">
<button class="btn btn-pause btn-sm" id="d-btn-pause" onclick="printAction('pause')">⏸ Pause</button>
<button class="btn btn-resume btn-sm" id="d-btn-resume" onclick="printAction('resume')">▶ Weiter</button>
<button class="btn btn-cancel btn-sm" id="d-btn-cancel" onclick="confirmCancel()">✕ Stopp</button>
</div>
</div>
<!-- Temperatursteuerung + Verlauf -->
<div class="card" style="grid-column:1/-1">
<div class="card-title"><span></span> <span id="d-card-temps">Temperaturen</span></div>
<div class="temp-card-inner">
<div class="temp-block">
<div class="temp-label">Nozzle</div>
<div class="temp-row">
<div class="temp-val" id="d-nt"></div>
<div class="temp-unit">°C</div>
</div>
<div class="temp-target"><span id="d-nt-t">0</span>°C</div>
<div class="progress-bar" style="margin:8px 0 0">
<div class="progress-fill" id="d-ntbar" style="width:0%;background:linear-gradient(90deg,var(--accent2),#ffb020)"></div>
</div>
<div class="temp-edit" style="margin-top:10px">
<input type="number" class="temp-input" id="p-nozzle-inp" placeholder="Ziel" min="0" max="300" style="flex:1">
<button class="btn btn-sm btn-accent" onclick="setNozzle()"><span class="lbl-set">Set</span></button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="document.getElementById('p-nozzle-inp').value=0;setNozzle()"><span class="lbl-off">Aus</span></button>
</div>
</div>
<div class="temp-block">
<div class="temp-label" id="d-lbl-bed">Bett</div>
<div class="temp-row">
<div class="temp-val" id="d-bt"></div>
<div class="temp-unit">°C</div>
</div>
<div class="temp-target"><span id="d-bt-t">0</span>°C</div>
<div class="progress-bar" style="margin:8px 0 0">
<div class="progress-fill" id="d-btbar" style="width:0%;background:linear-gradient(90deg,#ff6b35,var(--warn))"></div>
</div>
<div class="temp-edit" style="margin-top:10px">
<input type="number" class="temp-input" id="p-bed-inp" placeholder="Ziel" min="0" max="120" style="flex:1">
<button class="btn btn-sm btn-accent" onclick="setBed()"><span class="lbl-set">Set</span></button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="document.getElementById('p-bed-inp').value=0;setBed()"><span class="lbl-off">Aus</span></button>
</div>
</div>
</div>
<div style="margin-top:14px">
<div style="font-size:10px;color:var(--txt2);margin-bottom:4px" id="d-chart-label">Verlauf (letzte 60 Messungen)</div>
<canvas id="d-chart" width="800" height="120" style="width:100%;height:120px;background:var(--raised);border-radius:8px"></canvas>
</div>
</div>
<!-- Achsensteuerung -->
<div class="card">
<div class="card-title"><span></span> <span id="ptitle-motion-xy">XY-Achsen</span></div>
<div class="joypad">
<div></div>
<button class="joy" onclick="move(1,1,getStep())" title="Y+"></button>
<div></div>
<button class="joy" onclick="move(0,-1,getStep())" title="X"></button>
<button class="joy home" onclick="homeAll()" title="Home All"></button>
<button class="joy" onclick="move(0,1,getStep())" title="X+"></button>
<div></div>
<button class="joy" onclick="move(1,-1,getStep())" title="Y"></button>
<div></div>
</div>
<div class="step-btns">
<button class="step-btn" onclick="setStep(this,0.1)">0.1</button>
<button class="step-btn active" onclick="setStep(this,1)">1</button>
<button class="step-btn" onclick="setStep(this,5)">5</button>
<button class="step-btn" onclick="setStep(this,10)">10 mm</button>
</div>
<div class="home-btns">
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeZ()"><span class="lbl-home-z">Home Z</span></button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="homeXY()"><span class="lbl-home-xy">Home XY</span></button>
<button class="btn btn-sm btn-accent" onclick="homeAll()"><span class="lbl-home-all">Home All</span></button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="disableMotors()"><span class="lbl-disable-motors">Motors Off</span></button>
</div>
</div>
<div class="card">
<div class="card-title"><span></span> <span id="ptitle-motion-z">Z-Achse</span></div>
<div class="joypad" style="grid-template-columns:52px;grid-template-rows:repeat(2,52px)">
<button class="joy" onclick="move(2,1,getStep())" title="Z+"></button>
<button class="joy" onclick="move(2,-1,getStep())" title="Z"></button>
</div>
<div style="text-align:center;margin-top:8px;font-size:12px;color:var(--txt2)"><span class="lbl-step">Schrittweite:</span> <span id="step-display">1</span> mm</div>
</div>
<!-- Print Speed -->
<div class="card">
<div class="card-title"><span>🏎</span> <span id="d-card-speed">Druckgeschwindigkeit</span></div>
<div style="display:flex;gap:8px;margin-top:4px">
<button class="spd-btn" id="d-spd-1" onclick="setSpeed(1)">
<span class="spd-icon">🐢</span>
<span id="d-spd-lbl-1">Leise</span>
</button>
<button class="spd-btn spd-active" id="d-spd-2" onclick="setSpeed(2)">
<span class="spd-icon"></span>
<span id="d-spd-lbl-2">Normal</span>
</button>
<button class="spd-btn" id="d-spd-3" onclick="setSpeed(3)">
<span class="spd-icon">🚀</span>
<span id="d-spd-lbl-3">Sport</span>
</button>
</div>
<div class="spd-bar" style="margin-top:12px">
<div class="spd-bar-fill" id="d-spd-bar" style="width:50%"></div>
</div>
</div>
<!-- Lüfter -->
<div class="card">
<div class="card-title"><span>🌀</span> <span id="d-card-lightfan">Lüfter</span></div>
<div class="slider-row">
<input type="range" class="slider" min="0" max="100" value="0" id="d-fan" oninput="document.getElementById('d-fan-val').textContent=this.value" onchange="setFan()">
<span class="slider-val" id="d-fan-val">0</span>
</div>
<div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="quickFan(0)">Aus</button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="quickFan(25)">25%</button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="quickFan(50)">50%</button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="quickFan(75)">75%</button>
<button class="btn btn-sm btn-accent" onclick="quickFan(100)">100%</button>
</div>
</div>
<!-- AMS -->
<div class="card" style="grid-column:1/-1" id="d-ams-card">
<div class="card-title"><span></span> <span id="d-card-ams">AMS / Filamentbox</span></div>
<div class="ams-slots" id="ams-slots">
<div style="grid-column:1/-1;text-align:center;color:var(--txt2);padding:20px" id="ams-no-data">Keine AMS-Daten empfangen</div>
</div>
<div id="ams-controls" style="margin-top:16px;display:flex;flex-direction:column;gap:10px">
<div style="font-size:12px;color:var(--txt2);margin-bottom:2px" id="ams-slot-lbl">Slot auswählen</div>
<div style="display:flex;align-items:center;gap:10px">
<input type="range" id="ams-slot-sel" min="0" max="3" step="1" value="0"
class="slider" style="flex:1"
oninput="document.getElementById('ams-slot-label').textContent='Slot '+(parseInt(this.value)+1)">
<span id="ams-slot-label" style="min-width:48px;font-size:13px;font-weight:600">Slot 1</span>
</div>
<div style="display:flex;gap:10px">
<button class="btn" style="flex:1" onclick="amsFeed(1)"><span class="lbl-feed">Einziehen</span></button>
<button id="btn-unload" class="btn" style="flex:1" onclick="amsFeed(2)"><span class="lbl-unload">Ausziehen</span></button>
</div>
</div>
</div>
</div>
</div>
<!-- ═══ CONSOLE ═══ -->
<!-- ═══ DRUCKER ═══ -->
<div class="panel" id="panel-printers">
<div class="card">
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<span id="printers-panel-title">🖨 Drucker</span>
<div style="display:flex;gap:8px">
<button onclick="openAddPrinterDialog()" style="font-size:12px;padding:4px 12px;background:var(--accent);border:none;border-radius:6px;color:#fff;cursor:pointer;font-weight:600">+ <span id="add-printer-btn-label">Drucker hinzufügen</span></button>
<button onclick="loadPrinterTab()" style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer"></button>
</div>
</div>
<div id="printers-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:14px"></div>
</div>
</div>
<!-- ═══ GCODE STORE ═══ -->
<div class="panel" id="panel-store">
<div class="card">
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<span id="store-panel-title">🗂 Datei-Browser</span>
<button id="store-refresh-btn" onclick="loadStore()" style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">↻ Aktualisieren</button>
</div>
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
<input id="store-search" type="text" placeholder="🔍 Suche…" oninput="renderStore()"
style="flex:1;min-width:140px;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<select id="store-filter" onchange="renderStore()"
style="padding:6px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<option value="all" id="sf-all">Alle</option>
<option value="completed" id="sf-ok">✓ Erfolgreich</option>
<option value="failed" id="sf-err">✗ Fehler</option>
<option value="never" id="sf-new">Neu</option>
</select>
<select id="store-sort" onchange="renderStore()"
style="padding:6px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<option value="date_desc" id="ss-date">↓ Datum</option>
<option value="name_asc" id="ss-name">AZ Name</option>
<option value="duration_asc" id="ss-dur">⏱ Druckzeit</option>
</select>
</div>
<div id="store-empty" style="display:none;color:var(--txt2);text-align:center;padding:40px 0;font-size:14px">
</div>
<div id="store-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px"></div>
</div>
</div>
<div class="panel" id="panel-console">
<div class="card">
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center">
<span><span></span> <span id="ptitle-console">Ereignis-Log</span></span>
<a id="btn-log-dl" href="/api/log/download" download="kx-bridge.log"
style="font-size:12px;padding:4px 10px;background:var(--raised);border-radius:6px;color:var(--txt2);text-decoration:none">⬇ Download</a>
</div>
<div style="display:flex;gap:6px;margin-bottom:6px;flex-wrap:wrap;align-items:center">
<input id="log-filter" type="text" placeholder="Filter…"
oninput="renderLog()"
style="flex:1;min-width:120px;padding:5px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:12px;font-family:var(--mono)">
<button id="btn-autoscroll" onclick="toggleAutoScroll()"
style="font-size:12px;padding:5px 10px;border-radius:6px;border:1px solid var(--border);background:var(--accent);color:#fff;cursor:pointer;white-space:nowrap">⬇ Auto</button>
<button onclick="consoleLogs=[];renderLog()"
style="font-size:12px;padding:5px 10px;border-radius:6px;border:1px solid var(--border);background:var(--raised);color:var(--txt2);cursor:pointer">✕ Clear</button>
</div>
<div style="display:flex;gap:5px;margin-bottom:8px;flex-wrap:wrap">
<span style="font-size:11px;color:var(--txt2);align-self:center;margin-right:2px">Dir:</span>
<button class="log-dir-btn active" id="logdir-all" onclick="setLogDir('all')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer"></button>
<button class="log-dir-btn" id="logdir-rx" onclick="setLogDir('rx')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">RX</button>
<button class="log-dir-btn" id="logdir-tx" onclick="setLogDir('tx')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">TX</button>
<span style="font-size:11px;color:var(--txt2);align-self:center;margin-left:6px;margin-right:2px">Topic:</span>
<button class="log-topic-btn" data-topic="multiColorBox" onclick="setLogTopic('multiColorBox')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">AMS</button>
<button class="log-topic-btn" data-topic="print" onclick="setLogTopic('print')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">print</button>
<button class="log-topic-btn" data-topic="info" onclick="setLogTopic('info')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">info</button>
<button class="log-topic-btn" data-topic="status" onclick="setLogTopic('status')" style="font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);cursor:pointer">status</button>
</div>
<div class="console" id="console-log" style="height:calc(100vh - 260px);min-height:160px" onscroll="onLogScroll()"></div>
</div>
</div>
</main>
</div>
<nav class="bottom-nav">
<button class="bnav-btn active" onclick="showPanel('dashboard')" id="bnb-dashboard"><span class="bnav-icon"></span>Dashboard</button>
<button class="bnav-btn" onclick="showPanel('printers');loadPrinterTab()" id="bnb-printers"><span class="bnav-icon">🖨</span>Drucker</button>
<button class="bnav-btn" onclick="showPanel('store');loadStore()" id="bnb-store"><span class="bnav-icon">🗂</span>Browser</button>
<button class="bnav-btn" onclick="showPanel('console');clearLogBadge()" id="bnb-console"><span class="bnav-icon"></span>Log<span id="log-badge-bot" style="display:none;margin-left:3px;background:var(--err);color:#fff;border-radius:10px;font-size:10px;padding:1px 4px;font-weight:700"></span></button>
</nav>
<!-- Filament-Slot-Dialog -->
<div class="modal-overlay" id="filament-dialog" onclick="if(event.target===this)closeFilamentDialog()">
<div class="modal-box" style="max-width:380px;width:100%">
<div class="modal-header" style="margin-bottom:14px">
<span class="modal-title" id="fd-title" style="font-size:14px;word-break:break-all"></span>
<button onclick="closeFilamentDialog()" style="background:none;border:none;font-size:18px;cursor:pointer;color:var(--txt2)"></button>
</div>
<p style="font-size:12px;color:var(--txt2);margin-bottom:10px">GCode-Kanal → AMS-Slot zuweisen:</p>
<div id="fd-slots" style="display:flex;flex-direction:column;gap:8px;margin-bottom:16px"></div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button onclick="closeFilamentDialog()" style="padding:8px 16px;background:var(--raised);border:1px solid var(--border);border-radius:8px;color:var(--txt);cursor:pointer">Abbrechen</button>
<button onclick="confirmFilamentPrint()" style="padding:8px 18px;background:var(--accent);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600">▶ Drucken</button>
</div>
</div>
</div>
<!-- Drucker-hinzufügen-Dialog -->
<div class="modal-overlay" id="add-printer-dialog" onclick="if(event.target===this)closeAddPrinterDialog()">
<div class="modal-box" style="max-width:380px;width:100%">
<div class="modal-header" style="margin-bottom:14px">
<span class="modal-title" id="apd-title">Drucker hinzufügen</span>
<button onclick="closeAddPrinterDialog()" style="background:none;border:none;font-size:18px;cursor:pointer;color:var(--txt2)"></button>
</div>
<label id="apd-lbl-ip" style="display:block;font-size:12px;color:var(--txt2);margin-bottom:4px">Drucker-IP</label>
<input type="text" id="apd-ip" placeholder="192.168.1.100" style="width:100%;box-sizing:border-box;padding:8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);margin-bottom:10px">
<label id="apd-lbl-name" style="display:block;font-size:12px;color:var(--txt2);margin-bottom:4px">Name (optional)</label>
<input type="text" id="apd-name" placeholder="z.B. Kobra X Wohnzimmer" style="width:100%;box-sizing:border-box;padding:8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);margin-bottom:6px">
<div id="apd-status" style="font-size:12px;margin:8px 0;min-height:16px;color:var(--txt2)"></div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button onclick="closeAddPrinterDialog()" style="padding:8px 16px;background:var(--raised);border:1px solid var(--border);border-radius:8px;color:var(--txt);cursor:pointer">Abbrechen</button>
<button id="apd-confirm" onclick="confirmAddPrinter()" style="padding:8px 18px;background:var(--accent);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600">Hinzufügen</button>
</div>
</div>
</div>
<footer style="text-align:center;padding:12px;font-size:11px;color:var(--txt2);border-top:1px solid var(--border);margin-top:auto">
&copy; ViewIT 2026
</footer>
<script src="/kx/ui/app.js?v=__UI_ASSETS_VER__"></script>
</body>
</html>

277
web/themes/new1/style.css Normal file
View File

@@ -0,0 +1,277 @@
:root{
--bg:#1a1a1f;--card:#24242c;--raised:#2e2e3a;--border:#3a3a4a;
--txt:#f0f0f5;--txt2:#8888aa;--accent:#00c8ff;--accent2:#ff6b35;
--ok:#4cde80;--err:#ff4d6d;--warn:#ffb020;
--font:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
--mono:"JetBrains Mono","Fira Code",monospace;
}
[data-theme=light]{
--bg:#f0f0f5;--card:#fff;--raised:#e8e8f0;--border:#d0d0e0;
--txt:#1a1a2e;--txt2:#666680;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--txt);font-family:var(--font);font-size:14px;min-height:100vh;display:flex;flex-direction:column}
a{color:var(--accent);text-decoration:none}
/* ── HEADER ── */
header{background:var(--card);border-bottom:1px solid var(--border);
display:flex;align-items:center;gap:12px;padding:0 20px;height:52px;
position:sticky;top:0;z-index:100}
.logo{font-size:18px;font-weight:700;color:var(--accent);letter-spacing:-.02em}
.hname{font-size:13px;color:var(--txt2)}
.hbadge{display:flex;align-items:center;gap:6px;font-size:12px;font-weight:600;
padding:4px 10px;border-radius:20px;background:var(--raised);color:var(--txt2);
text-transform:uppercase;letter-spacing:.04em}
.hbadge.printing{background:#0d2d1a;color:var(--ok)}
.hbadge.complete{background:#0d1f38;color:#60b0ff}
.hbadge.error{background:#2d0d0d;color:var(--err)}
.hbadge .dot{width:7px;height:7px;border-radius:50%;background:currentColor}
.hbadge.printing .dot{animation:pulse 1.4s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}}
.theme-btn{background:none;border:1px solid var(--border);color:var(--txt2);
border-radius:8px;padding:6px 10px;cursor:pointer;font-size:13px;transition:.15s}
.theme-btn:hover{border-color:var(--accent);color:var(--accent)}
.conn-btn{border-radius:8px;padding:6px 12px;cursor:pointer;font-size:13px;
font-weight:600;border:none;transition:.15s}
.conn-btn.disconnected{background:var(--accent);color:#fff}
.conn-btn.disconnected:hover{opacity:.85}
.conn-btn.connected{background:transparent;border:1px solid var(--border);color:var(--txt2)}
.conn-btn.connected:hover{border-color:#e05;color:#e05}
/* ── LAYOUT ── */
.layout{display:flex;flex:1;min-height:0}
nav.sidebar{width:200px;background:var(--card);border-right:1px solid var(--border);
display:flex;flex-direction:column;padding:12px 8px;gap:2px;flex-shrink:0}
.nav-btn{background:none;border:none;color:var(--txt2);text-align:left;
padding:9px 12px;border-radius:8px;cursor:pointer;font-size:13px;
display:flex;align-items:center;gap:10px;transition:.12s;width:100%}
.nav-btn:hover{background:var(--raised);color:var(--txt)}
.nav-btn.active{background:var(--raised);color:var(--accent)}
.nav-icon{font-size:16px;width:20px;text-align:center}
main{flex:1;overflow-y:auto;padding:20px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:16px}
/* ── CARD ── */
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;
padding:18px;transition:box-shadow .15s,transform .15s}
.card:hover{box-shadow:0 4px 20px rgba(0,0,0,.3);transform:translateY(-1px)}
.card-title{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:var(--txt2);
margin-bottom:14px;display:flex;align-items:center;gap:8px}
.card-title span{font-size:14px}
/* ── HERO ── */
.hero{grid-column:1/-1;display:grid;grid-template-columns:1fr 320px;gap:16px}
@media(max-width:900px){.hero{grid-template-columns:1fr}}
.cam-wrap{background:#0a0a0e;border-radius:10px;overflow:hidden;
min-height:180px;max-height:320px;display:flex;align-items:center;justify-content:center;position:relative}
.cam-wrap img,.cam-wrap video{width:100%;max-height:320px;height:auto;display:block;object-fit:contain}
.cam-placeholder{color:var(--txt2);font-size:13px;text-align:center;padding:20px}
@keyframes spin{to{transform:rotate(360deg)}}
.cam-spinner{width:40px;height:40px;border:3px solid rgba(255,255,255,.15);
border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;display:none}
.cam-overlay{position:absolute;bottom:0;left:0;right:0;
background:linear-gradient(transparent,rgba(0,0,0,.75));padding:14px}
.cam-toggle{position:absolute;top:10px;right:10px;background:rgba(0,0,0,.5);
border:1px solid rgba(255,255,255,.2);color:#fff;border-radius:8px;
padding:6px 10px;cursor:pointer;font-size:12px;backdrop-filter:blur(4px)}
.cam-toggle:hover{background:rgba(0,0,0,.7)}
/* ── PROGRESS ── */
.hero-info{display:flex;flex-direction:column;gap:12px}
.pct-big{font-size:52px;font-weight:700;line-height:1;color:var(--txt)}
.pct-big small{font-size:20px;font-weight:400;color:var(--txt2)}
.progress-bar{height:8px;background:var(--raised);border-radius:4px;overflow:hidden;margin:4px 0}
.progress-fill{height:100%;background:linear-gradient(90deg,var(--accent),#0080cc);
border-radius:4px;transition:width .6s ease}
.meta-row{display:flex;justify-content:space-between;font-size:12px;color:var(--txt2)}
.layer-badge{background:var(--raised);border-radius:6px;padding:4px 8px;
font-family:var(--mono);font-size:12px;color:var(--txt)}
.fname{font-size:12px;color:var(--txt2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
background:var(--raised);border-radius:6px;padding:6px 8px}
/* ── PRINT CONTROLS ── */
.ctrl-btns{display:flex;gap:8px;flex-wrap:wrap}
.btn{border:none;border-radius:8px;padding:10px 16px;font-size:13px;font-weight:600;
cursor:pointer;transition:opacity .15s,transform .1s;white-space:nowrap}
.btn:hover{opacity:.85;transform:translateY(-1px)}
.btn:active{transform:translateY(0)}
.btn-start{background:var(--ok);color:#0d2010}
.btn-pause{background:var(--raised);color:var(--txt);border:1px solid var(--border)}
.btn-resume{background:#0d2d1a;color:var(--ok);border:1px solid var(--ok)}
.btn-cancel{background:#2d0d0d;color:var(--err);border:1px solid var(--err)}
.btn-accent{background:var(--accent);color:#001a24}
.btn-sm{padding:7px 12px;font-size:12px}
.spd-btn{flex:1;border:1.5px solid var(--border);background:var(--raised);color:var(--txt);
border-radius:10px;padding:14px 8px;font-size:13px;font-weight:600;cursor:pointer;
transition:all .15s;display:flex;flex-direction:column;align-items:center;gap:4px}
.spd-btn:hover{border-color:var(--accent);color:var(--accent)}
.spd-btn.spd-active{border-color:var(--accent);background:rgba(0,200,255,.12);color:var(--accent)}
.spd-btn .spd-icon{font-size:22px}
.spd-bar{height:4px;border-radius:2px;background:var(--border);margin-top:10px;overflow:hidden}
.spd-bar-fill{height:100%;border-radius:2px;background:linear-gradient(90deg,var(--accent2),var(--accent));transition:width .3s}
/* ── TIME CARDS ── */
.time-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:8px}
.time-block{background:var(--raised);border-radius:10px;padding:10px 12px}
.time-label{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--txt2);margin-bottom:4px}
.time-val{font-size:20px;font-weight:700;font-family:var(--mono);color:var(--txt)}
/* ── TEMPS ── */
.temp-pair{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.temp-card-inner{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.temp-block{background:var(--raised);border-radius:10px;padding:14px;position:relative}
.temp-label{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--txt2);margin-bottom:6px}
.temp-row{display:flex;align-items:baseline;gap:6px}
.temp-val{font-size:30px;font-weight:700;font-family:var(--mono)}
.temp-unit{font-size:14px;color:var(--txt2)}
.temp-target{font-size:11px;color:var(--txt2);margin-top:2px}
.temp-arc{position:absolute;top:12px;right:12px}
.temp-edit{display:flex;gap:6px;margin-top:10px}
.temp-input{background:var(--bg);border:1px solid var(--border);color:var(--txt);
border-radius:6px;padding:5px 8px;font-size:13px;font-family:var(--mono);width:70px}
.temp-input:focus{outline:none;border-color:var(--accent)}
.chart-wrap{margin-top:12px}
canvas.tchart{width:100%;height:60px;display:block;border-radius:6px;background:var(--raised)}
/* ── MOTION ── */
.joypad{display:grid;grid-template-columns:repeat(3,52px);
grid-template-rows:repeat(3,52px);gap:6px;justify-content:center;margin:8px auto}
.joy{background:var(--raised);border:1px solid var(--border);color:var(--txt);
border-radius:10px;font-size:18px;cursor:pointer;transition:.12s;
display:flex;align-items:center;justify-content:center}
.joy:hover{background:var(--accent);color:#001a24;border-color:var(--accent)}
.joy:active{transform:scale(.93)}
.joy.home{font-size:14px;background:var(--bg)}
.step-btns{display:flex;gap:6px;justify-content:center;flex-wrap:wrap;margin-top:10px}
.step-btn{background:var(--raised);border:1px solid var(--border);color:var(--txt2);
border-radius:6px;padding:5px 10px;font-size:12px;cursor:pointer;transition:.12s}
.step-btn.active,.step-btn:hover{background:var(--accent);color:#001a24;border-color:var(--accent)}
.home-btns{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px;justify-content:center}
/* ── AMS ── */
.ams-slots{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
.ams-slot{background:var(--raised);border-radius:10px;padding:10px;text-align:center;
border:2px solid transparent;transition:.2s;position:relative}
.ams-slot.active{border-color:var(--slot-color,var(--accent));
box-shadow:0 0 12px rgba(var(--slot-rgb,0,200,255),.3)}
.slot-circle{width:36px;height:36px;border-radius:50%;margin:0 auto 6px;border:2px solid rgba(255,255,255,.15)}
.slot-label{font-size:11px;color:var(--txt2);font-family:var(--mono)}
.slot-material{font-size:12px;font-weight:600;margin-bottom:2px}
/* ── LIGHT + FAN ── */
.toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}
.toggle-label{font-size:13px;font-weight:600}
.toggle{position:relative;width:44px;height:24px;cursor:pointer}
.toggle input{opacity:0;width:0;height:0;position:absolute}
.toggle-track{width:44px;height:24px;background:var(--raised);border-radius:12px;
border:1px solid var(--border);transition:.25s;display:block}
.toggle input:checked+.toggle-track{background:var(--accent)}
.toggle-thumb{position:absolute;top:3px;left:3px;width:18px;height:18px;
background:#fff;border-radius:50%;transition:.25s;pointer-events:none}
.toggle input:checked~.toggle-thumb{transform:translateX(20px)}
.slider-row{display:flex;align-items:center;gap:10px;margin-top:8px}
.slider-label{font-size:12px;color:var(--txt2);width:80px}
.slider{flex:1;-webkit-appearance:none;height:4px;border-radius:2px;
background:var(--raised);outline:none;cursor:pointer}
.slider::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;
border-radius:50%;background:var(--accent);cursor:pointer;transition:.1s}
.slider::-webkit-slider-thumb:hover{transform:scale(1.2)}
.slider-val{font-family:var(--mono);font-size:12px;color:var(--txt);width:30px;text-align:right}
/* ── CONSOLE ── */
.console{background:#0a0a0e;border-radius:8px;padding:10px;font-family:var(--mono);
font-size:11px;color:#8888aa;overflow-y:auto;line-height:1.6}
.console .ts{color:#444;margin-right:6px}
.console .msg-info{color:#8888aa}
.console .msg-ok{color:var(--ok)}
.console .msg-err{color:var(--err)}
.console .msg-warn{color:var(--warn)}
/* ── PANELS ── */
.panel{display:none}
.panel.active{display:block}
/* ── MODAL ── */
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);
z-index:200;align-items:center;justify-content:center;padding:16px}
.modal-overlay.open{display:flex}
.modal-box{background:var(--card);border:1px solid var(--border);border-radius:14px;
width:100%;max-width:480px;max-height:90vh;overflow-y:auto;padding:24px;
display:flex;flex-direction:column;gap:18px}
.modal-header{display:flex;align-items:center;justify-content:space-between}
.modal-title{font-size:15px;font-weight:700;color:var(--txt)}
.modal-close{background:none;border:none;color:var(--txt2);font-size:20px;
cursor:pointer;padding:4px 8px;border-radius:6px}
.modal-close:hover{background:var(--raised);color:var(--txt)}
.modal-section{font-size:10px;text-transform:uppercase;letter-spacing:.1em;
color:var(--txt2);margin-bottom:6px;margin-top:4px}
.modal-field{display:flex;flex-direction:column;gap:4px;margin-bottom:10px}
.modal-field label{font-size:12px;color:var(--txt2)}
.modal-field input{background:var(--raised);border:1px solid var(--border);
border-radius:7px;color:var(--txt);padding:7px 10px;font-size:13px;width:100%}
.modal-field input:focus{outline:none;border-color:var(--accent)}
.poll-btns{display:flex;gap:8px}
.poll-btn{flex:1;padding:7px;background:var(--raised);border:1px solid var(--border);
border-radius:7px;color:var(--txt2);cursor:pointer;font-size:13px;transition:all .15s}
.poll-btn.active{background:var(--accent);border-color:var(--accent);color:#000;font-weight:600}
.update-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.update-status{font-size:12px;color:var(--txt2);flex:1;min-width:0}
.modal-save{width:100%;padding:10px;background:var(--accent);border:none;
border-radius:8px;color:#000;font-weight:700;font-size:14px;cursor:pointer;margin-top:4px}
.modal-save:hover{opacity:.88}
/* ── BOTTOM NAV (mobile) ── */
nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
background:var(--card);border-top:1px solid var(--border);
justify-content:space-around;padding:8px 0 max(8px,env(safe-area-inset-bottom))}
.bnav-btn{background:none;border:none;color:var(--txt2);display:flex;
flex-direction:column;align-items:center;gap:3px;cursor:pointer;font-size:10px;padding:4px 8px}
.bnav-btn.active{color:var(--accent)}
.bnav-icon{font-size:20px}
/* ── Tablet (7691100px): schmale Sidebar ── */
@media(min-width:769px) and (max-width:1100px){
nav.sidebar{width:52px;padding:12px 4px}
.nav-btn .nav-text{display:none}
.nav-btn{justify-content:center;padding:10px}
.nav-icon{width:auto}
.grid{grid-template-columns:repeat(2,1fr)}
.hero{grid-template-columns:1fr}
}
/* ── Mobile (≤768px): Bottom-Nav, 1-Spalte ── */
@media(max-width:768px){
nav.sidebar{display:none}
nav.bottom-nav{display:flex}
main{padding:10px;padding-bottom:72px}
/* Header kompakt */
header{padding:0 12px;gap:8px}
.hname{display:none}
/* 1-Spalten-Grid, full-width spans funktionieren weiterhin */
.grid{grid-template-columns:1fr;gap:12px}
/* Hero: Kamera über Info */
.hero{grid-template-columns:1fr}
.cam-wrap{max-height:220px}
/* Temp-Pair und Temp-Card übereinander */
.temp-pair{grid-template-columns:1fr}
.temp-card-inner{grid-template-columns:1fr}
/* AMS: 2 Spalten */
.ams-slots{grid-template-columns:repeat(2,1fr)}
/* Joypad etwas kleiner */
.joypad{grid-template-columns:repeat(3,44px);grid-template-rows:repeat(3,44px);gap:5px}
.joy{font-size:16px}
/* Buttons größere Touch-Targets */
.btn{padding:10px 14px;font-size:13px}
.btn-sm{padding:8px 12px}
.step-btn{padding:8px 12px;font-size:13px}
/* Modal vollbreite auf kleinen Screens */
.modal-box{padding:16px;border-radius:10px}
.poll-btns{gap:6px}
}