From ae397e1ea3d82c21596d89398eabec47c2db5192 Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Thu, 20 Aug 2026 11:03:34 +0200 Subject: [PATCH] first commit SwyxWeb: React-Frontend und Spring-Boot-Backend mit SwyxTray-Mock. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 17 + README.md | 294 +++ backend/.mvn/wrapper/maven-wrapper.properties | 3 + backend/mvnw | 295 +++ backend/mvnw.cmd | 189 ++ backend/pom.xml | 52 + .../swyxweb/BackendApplication.java | 15 + .../swyxweb/config/CorsConfig.java | 19 + .../swyxweb/config/WebSocketConfig.java | 31 + .../swyxweb/config/WebSocketProperties.java | 15 + .../swyxweb/web/ConfigController.java | 38 + .../swyxweb/web/MockController.java | 32 + .../swyxweb/websocket/LineState.java | 86 + .../swyxweb/websocket/MockTab.java | 15 + .../websocket/SwyxTrayMockHandler.java | 405 +++ .../src/main/resources/application.properties | 14 + .../swyxweb/BackendApplicationTests.java | 13 + frontend/.env.example | 6 + frontend/index.html | 12 + frontend/package-lock.json | 2273 +++++++++++++++++ frontend/package.json | 29 + frontend/src/App.tsx | 5 + frontend/src/components/BrowserTabsPanel.tsx | 196 ++ frontend/src/components/CallList.tsx | 85 + frontend/src/components/DialPanel.tsx | 46 + frontend/src/components/FocusPanel.tsx | 113 + frontend/src/components/IncomingCallCard.tsx | 48 + frontend/src/components/MessageLog.tsx | 39 + frontend/src/components/StatusBadge.tsx | 18 + frontend/src/components/Tabs.tsx | 42 + frontend/src/config.ts | 30 + frontend/src/hooks/useCallNotifications.ts | 44 + frontend/src/hooks/useSwyxTray.ts | 174 ++ frontend/src/index.css | 597 +++++ frontend/src/main.tsx | 15 + frontend/src/pages/HomePage.tsx | 248 ++ frontend/src/swyx/SwyxTrayClient.ts | 356 +++ frontend/src/swyx/protocol.test.ts | 334 +++ frontend/src/swyx/protocol.ts | 323 +++ frontend/src/vite-env.d.ts | 10 + frontend/tsconfig.app.json | 21 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 18 + frontend/vite.config.ts | 18 + 44 files changed, 6640 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/.mvn/wrapper/maven-wrapper.properties create mode 100755 backend/mvnw create mode 100644 backend/mvnw.cmd create mode 100644 backend/pom.xml create mode 100644 backend/src/main/java/de/appcreation/swyxweb/BackendApplication.java create mode 100644 backend/src/main/java/de/appcreation/swyxweb/config/CorsConfig.java create mode 100644 backend/src/main/java/de/appcreation/swyxweb/config/WebSocketConfig.java create mode 100644 backend/src/main/java/de/appcreation/swyxweb/config/WebSocketProperties.java create mode 100644 backend/src/main/java/de/appcreation/swyxweb/web/ConfigController.java create mode 100644 backend/src/main/java/de/appcreation/swyxweb/web/MockController.java create mode 100644 backend/src/main/java/de/appcreation/swyxweb/websocket/LineState.java create mode 100644 backend/src/main/java/de/appcreation/swyxweb/websocket/MockTab.java create mode 100644 backend/src/main/java/de/appcreation/swyxweb/websocket/SwyxTrayMockHandler.java create mode 100644 backend/src/main/resources/application.properties create mode 100644 backend/src/test/java/de/appcreation/swyxweb/BackendApplicationTests.java create mode 100644 frontend/.env.example create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/BrowserTabsPanel.tsx create mode 100644 frontend/src/components/CallList.tsx create mode 100644 frontend/src/components/DialPanel.tsx create mode 100644 frontend/src/components/FocusPanel.tsx create mode 100644 frontend/src/components/IncomingCallCard.tsx create mode 100644 frontend/src/components/MessageLog.tsx create mode 100644 frontend/src/components/StatusBadge.tsx create mode 100644 frontend/src/components/Tabs.tsx create mode 100644 frontend/src/config.ts create mode 100644 frontend/src/hooks/useCallNotifications.ts create mode 100644 frontend/src/hooks/useSwyxTray.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/HomePage.tsx create mode 100644 frontend/src/swyx/SwyxTrayClient.ts create mode 100644 frontend/src/swyx/protocol.test.ts create mode 100644 frontend/src/swyx/protocol.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba5587b --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Backend +backend/target/ +!backend/.mvn/wrapper/maven-wrapper.properties + +# Frontend +frontend/node_modules/ +frontend/dist/ +frontend/.env +frontend/.env.local +*.tsbuildinfo + +# Tooling / OS +.idea/ +.vscode/ +*.iml +.DS_Store +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..d1580c1 --- /dev/null +++ b/README.md @@ -0,0 +1,294 @@ +# SwyxWeb + +React-Frontend mit Spring-Boot-Backend. Die Startseite verbindet sich **aus dem Browser heraus** +per WebSocket mit der **SwyxTray-App** (`ws://192.168.180.135:17654/ws`), meldet eingehende +Anrufe und startet ausgehende Anrufe. + +``` +SwyxWeb/ +├── backend/ Spring Boot 4.1 (Java 21, Maven Wrapper) +└── frontend/ React 19 + TypeScript + Vite +``` + +## Wer verbindet sich mit wem? + +Die WebSocket-Verbindung wird ausschließlich im Browser aufgebaut – in +[SwyxTrayClient.ts](frontend/src/swyx/SwyxTrayClient.ts) über `new WebSocket(url)`. +Das Backend baut **keine** eigene WebSocket-Verbindung auf; es hat zwei Aufgaben: + +1. `GET /api/config` liefert dem Frontend die Zieladresse, damit sie nicht im JS-Bundle + fest verdrahtet ist (konfigurierbar in [application.properties](backend/src/main/resources/application.properties)). +2. Es stellt unter seinem eigenen Port einen **SwyxTray-Mock** bereit, um die Startseite ohne + echte Telefonanlage testen zu können (siehe unten). Im Normalbetrieb wird er nicht benutzt. + +**Zwei getrennte Ports:** `8080` ist der Port dieser Anwendung (REST-API und Mock), +`17654` der Port der SwyxTray-App auf 192.168.180.135. Sie sind unabhängig voneinander. + +## Protokoll der SwyxTray-App + +Quelle: Mitschnitt gegen die laufende App (Version 1.0.0.0, `protocol: 1`) am 13.08.2026. +Seit der Tab-Verwaltung meldet die App im `hello` **`protocol: 6`**; die älteren Nachrichten +sind unverändert geblieben. Implementiert in [protocol.ts](frontend/src/swyx/protocol.ts) und +[SwyxTrayClient.ts](frontend/src/swyx/SwyxTrayClient.ts). + +Ein Anruf wird über die **1-basierte Leitungsnummer `line`** identifiziert – so, wie sie auch +in SwyxIt! erscheint. Eine Anruf-Id gibt es nicht. + +> **Die App schickt keine Ereignisnachrichten.** Jede Zustandsänderung – auch ein eingehender +> Anruf – kommt als **vollständiger `snapshot`** über alle vier Leitungen. Die Anrufmeldung der +> Startseite entsteht deshalb aus dem Vergleich zweier Snapshots +> ([mergeSnapshot](frontend/src/swyx/protocol.ts)). + +### Begrüßung (App → Seite, beim Verbinden) + +```json +{ "app": "SwyxTray", "version": "1.0.0.0", "protocol": 1, "session": 7, "type": "hello" } +``` + +Direkt danach folgt der erste Snapshot. + +### Snapshot (App → Seite, bei jeder Änderung) + +```json +{ "connected": true, "serverUp": true, "overall": "Ringing", "statusText": "Eingehender Ruf", + "user": "Muster, M.", "server": "127.0.0.1", "type": "snapshot", + "lines": [ + { "line": 1, "state": "LSRinging", "stateCode": 3, "stateText": "klingelt", + "peer": "001602107449", "peerNumber": "001602107449", "peerName": "", + "busy": true, "selected": true }, + { "line": 2, "state": "LSInactive", "stateCode": 0, "stateText": "frei", + "peer": "unbekannt", "peerNumber": "", "peerName": "", "busy": false, "selected": false } + ] } +``` + +`state` trägt die Namen der CLMgr-Aufzählung `LineState`, `stateText` den Klartext aus SwyxIt!. +Belegt sind bisher `LSInactive / 0 / "frei"` und `LSRinging / 3 / "klingelt"`. +[callStateOf](frontend/src/swyx/protocol.ts) bildet beides auf `incoming` · `outgoing` · +`connected` ab und wertet dabei **Name und Klartext** aus; ein unbekannter Zustand gilt als +**belegt**, damit eine klingelnde Leitung nie stillschweigend verschwindet. `peer` ist die +fertige Anzeigeform, `"unbekannt"` der Platzhalter für „keine Gegenstelle". Freie Leitungen +schickt die App immer mit; die Startseite blendet sie aus. + +### Kommandos (Seite → App) + +```jsonc +// -> +{ "id": 7, "cmd": "call", "number": "+49 30 1234567" } +// <- Quittung, id gespiegelt +{ "id": 7, "ok": true, "line": 2, "type": "result" } +// <- Fehlerfall (ungültige Nummer, keine Verbindung zu SwyxIt!) +{ "id": 7, "ok": false, "error": "…", "type": "result" } +``` + +`ok: true` heißt **nur**, dass CLMgr den Auftrag angenommen hat – gewählt wird asynchron. +Der tatsächliche Verlauf kommt danach über die folgenden Snapshots. Die Startseite weist im +Wählbereich darauf hin. + +| Kommando | Felder | Zweck | Antwort | +|------------|----------------|-------------------------------------------|-----------------------------| +| `call` | `number` | Wählvorgang starten | `line` | +| `answer` | `line` | Anruf annehmen | – | +| `hangup` | `line` | Anruf beenden/ablehnen | – | +| `ping` | – | Verbindungstest | – | +| `status` | – | Snapshot anfordern | – | +| `focus` | `title`, `url` | Fenster in den Vordergrund holen | `focused` | +| `tabs` | – | Offene Tabs des Firefox-Plugins auflisten | `tabs` | +| `opentab` | `url` | Neuen Tab öffnen | `tabId` | +| `closetab` | `tabId` | Tab schließen | – | + +Alles andere quittiert die App mit `"Unbekanntes Kommando '…'."`, ein fehlendes `cmd` mit +`"Feld 'cmd' fehlt."`. + +### Fenster in den Vordergrund holen + +```jsonc +// -> +{ "id": 9, "cmd": "focus", "title": "Kundenakte Muster GmbH", "url": "https://crm.example.local/kunden/4711" } +// <- Fenster war da und ist jetzt vorn +{ "type": "result", "id": 9, "ok": true, "focused": true } +// <- kein Fenster gefunden, stattdessen Browser mit der URL gestartet +{ "type": "result", "id": 9, "ok": true, "focused": false } +``` + +`title` wird als **Teilzeichenkette** gesucht, unabhängig von der Groß-/Kleinschreibung – +`"swyxit"` findet also das Fenster „SwyxIt!". `url` ist optional und wird nur benutzt, wenn +kein Fenster passt; fehlt sie in diesem Fall, antwortet die App mit +`ok: false` und `"Kein Fenster mit Titel '…' gefunden und keine URL angegeben."`. Ein +fehlender oder leerer Titel ergibt `"Feld 'title' fehlt."`. + +Seit der Umstellung des Tab-Bereichs benutzt die Startseite `focus` nicht mehr; das Kommando +bleibt aber im Protokoll und in `SwyxTrayClient.focusWindow()` erhalten. + +### Tabs des Firefox-Plugins + +Die drei Tab-Kommandos reicht die SwyxTray-App **unverändert an das Firefox-Plugin durch** – +gemeint sind also die Tabs des Firefox auf dem Rechner der App, nicht die Bereiche der +Startseite. Es sind dieselben Kanäle, die auch die Tray-Menüpunkte *Lese Tabs*, *Tab öffnen* +und *Tab schliessen* benutzen. + +```jsonc +// -> +{ "id": 12, "cmd": "tabs" } +// <- +{ "type": "result", "id": 12, "ok": true, "tabs": [ + { "id": 43, "title": "Kundenakte", "url": "https://crm.example.local/kunden/4711", "active": true }, + { "id": 44, "title": "SwyxWeb", "url": "http://localhost:5173/", "active": false } ] } + +// -> +{ "id": 13, "cmd": "opentab", "url": "https://crm.example.local/kunden/4711" } +// <- +{ "type": "result", "id": 13, "ok": true, "tabId": 43 } + +// -> tabId stammt aus einer tabs-Antwort +{ "id": 14, "cmd": "closetab", "tabId": 43 } +// <- +{ "type": "result", "id": 14, "ok": true } +``` + +`url` muss eine **absolute http(s)-Adresse** sein – dieselbe Prüfung wie bei `focus`; sonst +antwortet die App mit `ok: false`. Ist **kein Plugin verbunden**, endet jedes der drei +Kommandos nach dem Zeitfenster von rund fünf Sekunden ebenfalls mit `ok: false`. Deshalb +wartet die Startseite auf diese drei Kommandos 15 statt 10 Sekunden +([SwyxTrayClient.ts](frontend/src/swyx/SwyxTrayClient.ts)). + +Auch hier gilt: **die App meldet Tab-Änderungen nicht von selbst.** Die Liste wird nach jedem +Öffnen und Schließen neu über `tabs` geholt. + +Alle Regeln sind in [protocol.test.ts](frontend/src/swyx/protocol.test.ts) festgehalten – +darunter die wörtlich mitgeschnittene Nachricht eines echten eingehenden Anrufs. + +## Funktionen der Startseite + +Die Seite ist in drei Bereiche aufgeteilt ([Tabs.tsx](frontend/src/components/Tabs.tsx)): + +| Tab | Inhalt | +|---|---| +| **Anrufe** | Rufnummer wählen (`call`), belegte Leitungen mit Aktionen und dem Zustand der App (`statusText`, angemeldeter Benutzer, Warnung bei `connected: false`), Verlauf der Anrufereignisse | +| **Tabs** | Offene Tabs des Firefox-Plugins anzeigen (`tabs`), einzeln schließen (`closetab`) und eine URL als neuen Tab öffnen (`opentab`); die Liste wird beim ersten Öffnen des Bereichs und nach jeder Änderung geholt, die eingetippte URL übersteht einen Reload (`localStorage`) | +| **Verbindung** | Adresse, Verbinden/Trennen, Benachrichtigungen erlauben, Diagnose: Rohnachrichten-Log, Status abfragen, Freitext senden | + +Die **Meldung eines eingehenden Anrufs** steht bewusst *über* den Tabs – mit Leitungsnummer, +Name und Rufnummer, „Annehmen"/„Ablehnen" –, damit sie in keinem Bereich untergeht. Liegt der +Browser-Tab im Hintergrund, kommt zusätzlich eine System-Benachrichtigung (nach Erlaubnis). + +Weiteres: + +- Der Verlauf entsteht aus dem Vergleich aufeinanderfolgender Snapshots. +- Eine Nachricht, die keinem der drei Typen entspricht, wird im Log ausdrücklich als + „nicht erkannt" vermerkt statt still verworfen. +- Automatischer Reconnect mit exponentiellem Backoff, nur nach unerwartetem Abbruch. + +## Starten + +### Aus VS Code + +Im Debug-Panel die Compound-Konfiguration **„SwyxWeb starten (Backend + Frontend)"** wählen +([launch.json](.vscode/launch.json)). Sie startet das Backend im Debugger, danach den +Vite-Dev-Server und öffnet Chrome, sobald dieser bereit ist – Breakpoints funktionieren auf +beiden Seiten. „Stop" beendet beides zusammen. + +Alternative **„SwyxWeb starten (lokaler Test gegen SwyxTray-Mock)"**: `/api/config` liefert dann +`ws://localhost:8080/ws`, die Startseite spricht also mit dem SwyxTray-Mock. + +Benötigte Extensions: *Extension Pack for Java* (Backend) und *JavaScript Debugger* (im +VS Code enthalten, für Vite und Chrome). + +### Backend + +```bash +cd backend +./mvnw spring-boot:run +``` + +Läuft auf `http://0.0.0.0:8080`. Achtung: Auf diesem Rechner belegt bereits ein anderes +Projekt Port 8080 – dann auf einen freien Port ausweichen: + +```bash +./mvnw spring-boot:run -Dspring-boot.run.arguments="--server.port=8081" +``` + +Der Port der SwyxTray-App (17654) ändert sich dadurch nicht. + +### Frontend + +```bash +cd frontend +npm install +npm run dev +``` + +Öffnet `http://localhost:5173`. Der Vite-Dev-Server leitet `/api` an `http://localhost:8080` +weiter (überschreibbar per `VITE_BACKEND_URL`). Die WebSocket-Verbindung läuft **nicht** über +diesen Proxy – der Browser verbindet sich direkt mit der SwyxTray-App. + +## Ohne echte Telefonanlage testen + +Der [SwyxTrayMockHandler](backend/src/main/java/de/appcreation/swyxweb/websocket/SwyxTrayMockHandler.java) +spricht dasselbe Protokoll: `hello` (mit `protocol: 6`) und Snapshot beim Verbinden, Quittungen +auf `call`/`answer`/`hangup`/`ping`/`status`/`focus`/`tabs`/`opentab`/`closetab` und nach jeder +Änderung einen vollständigen Snapshot über alle vier Leitungen. Einen eingehenden Anruf auslösen: + +```bash +curl -X POST "http://localhost:8080/api/mock/incoming-call?number=%2B493012345&name=Muster%20GmbH" +``` + +Für `focus` täuscht der Mock die Fenster **„SwyxIt!"**, **„SwyxWeb – Google Chrome"** und +**„Kundenakte Muster GmbH"** vor (Teilzeichenkette, Schreibweise egal). Ein passender Titel +ergibt `focused: true`, ein unbekannter mit URL `focused: false`, ein unbekannter ohne URL +denselben Fehler wie die echte App. Geöffnet wird natürlich nichts – der Mock protokolliert nur. + +Für die Tab-Kommandos spielt der Mock das Firefox-Plugin und führt eine Tabliste im Speicher – +anfangs **„SwyxWeb"** und **„Kundenakte Muster GmbH"**. `opentab` hängt einen Tab an und +liefert dessen `tabId`, `closetab` entfernt ihn; eine unbekannte Kennung ergibt +`"Kein Tab mit der Kennung …"`, eine relative Adresse denselben Fehler wie die echte App. +Geöffnet wird auch hier nichts. Der Mock bildet den Fall „kein Plugin verbunden" **nicht** nach; +er antwortet immer sofort. + +Die Startseite muss dazu auf `ws://localhost:8080/ws` zeigen – entweder im Adressfeld +eintragen oder das Backend mit `--app.websocket.host=localhost --app.websocket.port=8080` +starten (macht die VS-Code-Konfiguration „lokaler Test" automatisch). + +## Konfiguration der WebSocket-Adresse + +Es gilt die erste Quelle, die etwas liefert: + +| Priorität | Quelle | Beispiel | +|---|---|---| +| 1 | Eingabefeld auf der Startseite | zur Laufzeit änderbar | +| 2 | `GET /api/config` vom Backend | `app.websocket.host` in `application.properties` | +| 3 | `VITE_WS_URL` aus `frontend/.env` | siehe [.env.example](frontend/.env.example) | +| 4 | Default im Code | `ws://192.168.180.135:17654/ws` | + +Backend-seitig: + +```properties +server.port=8080 # Port dieser Anwendung + +app.websocket.host=192.168.180.135 # SwyxTray-App +app.websocket.port=17654 +app.websocket.path=/ws +app.websocket.secure=false # true ⇒ wss:// +``` + +## Build und Tests + +```bash +cd backend && ./mvnw clean package # erzeugt target/backend-0.0.1-SNAPSHOT.jar +cd frontend && npm test && npm run build +``` + +Um das Frontend aus dem Backend auszuliefern, `frontend/dist/*` nach +`backend/src/main/resources/static/` kopieren und neu packen. + +## Hinweise + +- **HTTPS-Seite ⇒ `wss://`**: Ein über HTTPS ausgelieferter Browser blockiert unverschlüsselte + `ws://`-Verbindungen (Mixed Content). Im Dev-Betrieb über `http://` ist `ws://` in Ordnung. +- **CORS/Origin**: Der Mock akzeptiert alle Origins (`setAllowedOriginPatterns("*")`), damit der + Vite-Dev-Server auf Port 5173 sich verbinden kann. Für die Produktion einschränken – siehe + [WebSocketConfig.java](backend/src/main/java/de/appcreation/swyxweb/config/WebSocketConfig.java) + und [CorsConfig.java](backend/src/main/java/de/appcreation/swyxweb/config/CorsConfig.java). +- **Firewall**: Port 17654 muss auf 192.168.180.135 eingehend freigegeben sein, sonst + scheitert der Verbindungsaufbau ohne aussagekräftige Browser-Fehlermeldung. +- **System-Benachrichtigungen** verlangen eine Benutzerinteraktion zur Erlaubniserteilung und + funktionieren nur über `https://` oder `localhost`. diff --git a/backend/.mvn/wrapper/maven-wrapper.properties b/backend/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..216df05 --- /dev/null +++ b/backend/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip diff --git a/backend/mvnw b/backend/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/backend/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/backend/mvnw.cmd b/backend/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/backend/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..24a39c1 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,52 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 4.1.0 + + + de.appcreation + backend + 0.0.1-SNAPSHOT + swyxweb-backend + Spring-Boot-Backend mit WebSocket-Endpunkt für die SwyxWeb-Startseite + + + 21 + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-websocket + + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.boot + spring-boot-starter-websocket-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/backend/src/main/java/de/appcreation/swyxweb/BackendApplication.java b/backend/src/main/java/de/appcreation/swyxweb/BackendApplication.java new file mode 100644 index 0000000..7f8b389 --- /dev/null +++ b/backend/src/main/java/de/appcreation/swyxweb/BackendApplication.java @@ -0,0 +1,15 @@ +package de.appcreation.swyxweb; + +import de.appcreation.swyxweb.config.WebSocketProperties; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; + +@SpringBootApplication +@EnableConfigurationProperties(WebSocketProperties.class) +public class BackendApplication { + + public static void main(String[] args) { + SpringApplication.run(BackendApplication.class, args); + } +} diff --git a/backend/src/main/java/de/appcreation/swyxweb/config/CorsConfig.java b/backend/src/main/java/de/appcreation/swyxweb/config/CorsConfig.java new file mode 100644 index 0000000..9ec184c --- /dev/null +++ b/backend/src/main/java/de/appcreation/swyxweb/config/CorsConfig.java @@ -0,0 +1,19 @@ +package de.appcreation.swyxweb.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Erlaubt dem Vite-Dev-Server den Zugriff auf die REST-Endpunkte. + */ +@Configuration +public class CorsConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") + .allowedOriginPatterns("*") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS"); + } +} diff --git a/backend/src/main/java/de/appcreation/swyxweb/config/WebSocketConfig.java b/backend/src/main/java/de/appcreation/swyxweb/config/WebSocketConfig.java new file mode 100644 index 0000000..ac36fc9 --- /dev/null +++ b/backend/src/main/java/de/appcreation/swyxweb/config/WebSocketConfig.java @@ -0,0 +1,31 @@ +package de.appcreation.swyxweb.config; + +import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +/** + * Stellt den SwyxTray-Mock unter dem konfigurierten Pfad bereit, damit sich die + * Startseite auch ohne die echte SwyxTray-App testen lässt. + */ +@Configuration +@EnableWebSocket +public class WebSocketConfig implements WebSocketConfigurer { + + private final SwyxTrayMockHandler handler; + private final WebSocketProperties properties; + + public WebSocketConfig(SwyxTrayMockHandler handler, WebSocketProperties properties) { + this.handler = handler; + this.properties = properties; + } + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler(handler, properties.path()) + // Der Browser meldet sich von einem anderen Origin (Vite-Dev-Server) aus an. + .setAllowedOriginPatterns("*"); + } +} diff --git a/backend/src/main/java/de/appcreation/swyxweb/config/WebSocketProperties.java b/backend/src/main/java/de/appcreation/swyxweb/config/WebSocketProperties.java new file mode 100644 index 0000000..1d74249 --- /dev/null +++ b/backend/src/main/java/de/appcreation/swyxweb/config/WebSocketProperties.java @@ -0,0 +1,15 @@ +package de.appcreation.swyxweb.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Verbindungsdaten des WebSocket-Servers, die das Frontend über /api/config abholt. + */ +@ConfigurationProperties(prefix = "app.websocket") +public record WebSocketProperties(String host, int port, String path, boolean secure) { + + /** Fertige URL, z. B. ws://192.168.180.135:8080/ws */ + public String url() { + return (secure ? "wss" : "ws") + "://" + host + ":" + port + path; + } +} diff --git a/backend/src/main/java/de/appcreation/swyxweb/web/ConfigController.java b/backend/src/main/java/de/appcreation/swyxweb/web/ConfigController.java new file mode 100644 index 0000000..d783c60 --- /dev/null +++ b/backend/src/main/java/de/appcreation/swyxweb/web/ConfigController.java @@ -0,0 +1,38 @@ +package de.appcreation.swyxweb.web; + +import de.appcreation.swyxweb.config.WebSocketProperties; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Liefert dem Frontend die Laufzeit-Konfiguration, damit die WebSocket-Adresse + * nicht im JavaScript-Bundle fest verdrahtet werden muss. + */ +@RestController +@RequestMapping("/api") +public class ConfigController { + + private final WebSocketProperties properties; + + public ConfigController(WebSocketProperties properties) { + this.properties = properties; + } + + public record ClientConfig(String websocketUrl, String host, int port, String path) { + } + + @GetMapping("/config") + public ClientConfig config() { + return new ClientConfig( + properties.url(), + properties.host(), + properties.port(), + properties.path()); + } + + @GetMapping("/health") + public java.util.Map health() { + return java.util.Map.of("status", "UP"); + } +} diff --git a/backend/src/main/java/de/appcreation/swyxweb/web/MockController.java b/backend/src/main/java/de/appcreation/swyxweb/web/MockController.java new file mode 100644 index 0000000..da6d884 --- /dev/null +++ b/backend/src/main/java/de/appcreation/swyxweb/web/MockController.java @@ -0,0 +1,32 @@ +package de.appcreation.swyxweb.web; + +import de.appcreation.swyxweb.websocket.LineState; +import de.appcreation.swyxweb.websocket.SwyxTrayMockHandler; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Auslöser für den SwyxTray-Mock: simuliert einen eingehenden Anruf, damit sich + * die Anrufmeldung der Startseite ohne echte Telefonanlage prüfen lässt. + * + *
curl -X POST "http://localhost:8080/api/mock/incoming-call?number=%2B493012345&name=Muster%20GmbH"
+ */ +@RestController +@RequestMapping("/api/mock") +public class MockController { + + private final SwyxTrayMockHandler mock; + + public MockController(SwyxTrayMockHandler mock) { + this.mock = mock; + } + + @PostMapping("/incoming-call") + public LineState incomingCall( + @RequestParam(required = false) String number, + @RequestParam(required = false) String name) { + return mock.simulateIncomingCall(number, name); + } +} diff --git a/backend/src/main/java/de/appcreation/swyxweb/websocket/LineState.java b/backend/src/main/java/de/appcreation/swyxweb/websocket/LineState.java new file mode 100644 index 0000000..61d53d9 --- /dev/null +++ b/backend/src/main/java/de/appcreation/swyxweb/websocket/LineState.java @@ -0,0 +1,86 @@ +package de.appcreation.swyxweb.websocket; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** + * Leitungseintrag im Snapshot – Feldnamen und Werte wie bei der echten + * SwyxTray-App: {@code state} trägt den Namen aus der CLMgr-Aufzählung + * {@code LineState}, {@code stateText} den deutschen Klartext aus SwyxIt!. + * + *

Mitgeschnitten und damit bestätigt sind {@code LSInactive / 0 / "frei"} und + * {@code LSRinging / 3 / "klingelt"}; die Werte für Wählen und Gespräch sind der + * Aufzählung nachempfunden. Die Startseite wertet {@code state} und + * {@code stateText} aus, nicht {@code stateCode}. + */ +public record LineState( + int line, + String state, + int stateCode, + String stateText, + String peer, + String peerNumber, + String peerName, + boolean busy, + boolean selected) { + + public static final String FREE = "LSInactive"; + public static final String RINGING = "LSRinging"; + public static final String DIALING = "LSDialing"; + public static final String ACTIVE = "LSActive"; + + /** Platzhalter der App für eine Leitung ohne Gegenstelle. */ + private static final String NO_PEER = "unbekannt"; + + public static LineState free(int line) { + return new LineState(line, FREE, 0, "frei", NO_PEER, "", "", false, false); + } + + public static LineState ringing(int line, String number, String name) { + return occupied(line, RINGING, 3, "klingelt", number, name); + } + + @JsonIgnore + public boolean isRinging() { + return RINGING.equals(state); + } + + public static LineState dialing(int line, String number, String name) { + return occupied(line, DIALING, 2, "wählt", number, name); + } + + public LineState connected() { + return occupied(line, ACTIVE, 6, "verbunden", peerNumber, peerName); + } + + /** Nur für den Mock – die App kennt dieses Feld nicht, also nicht serialisieren. */ + @JsonIgnore + public boolean isFree() { + return FREE.equals(state); + } + + private static LineState occupied( + int line, String state, int stateCode, String stateText, String number, String name) { + return new LineState( + line, state, stateCode, stateText, + peerOf(number, name), + number == null ? "" : number, + name == null ? "" : name, + true, true); + } + + /** + * Fertige Anzeigeform: Name mit Nummer in Klammern; fehlt eines, nur der + * vorhandene Wert. + */ + private static String peerOf(String number, String name) { + boolean hasName = name != null && !name.isBlank(); + boolean hasNumber = number != null && !number.isBlank(); + if (hasName && hasNumber) { + return name + " (" + number + ")"; + } + if (hasName) { + return name; + } + return hasNumber ? number : NO_PEER; + } +} diff --git a/backend/src/main/java/de/appcreation/swyxweb/websocket/MockTab.java b/backend/src/main/java/de/appcreation/swyxweb/websocket/MockTab.java new file mode 100644 index 0000000..e7596c3 --- /dev/null +++ b/backend/src/main/java/de/appcreation/swyxweb/websocket/MockTab.java @@ -0,0 +1,15 @@ +package de.appcreation.swyxweb.websocket; + +/** + * Tab des Firefox-Plugins im Mock – Feldnamen wie in der Quittung auf + * {@code tabs}: {@code {"id":43,"title":"…","url":"https://…","active":true}}. + * + *

Die echte SwyxTray-App erzeugt diese Einträge nicht selbst; sie reicht die + * Antwort des Plugins unverändert durch. + */ +public record MockTab(int id, String title, String url, boolean active) { + + public MockTab withActive(boolean nowActive) { + return new MockTab(id, title, url, nowActive); + } +} diff --git a/backend/src/main/java/de/appcreation/swyxweb/websocket/SwyxTrayMockHandler.java b/backend/src/main/java/de/appcreation/swyxweb/websocket/SwyxTrayMockHandler.java new file mode 100644 index 0000000..894edbe --- /dev/null +++ b/backend/src/main/java/de/appcreation/swyxweb/websocket/SwyxTrayMockHandler.java @@ -0,0 +1,405 @@ +package de.appcreation.swyxweb.websocket; + +import java.io.IOException; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +/** + * Simuliert die SwyxTray-App für Entwicklung und Test – im Format, das gegen die + * echte App (Version 1.0.0.0, {@code protocol: 1}) mitgeschnitten wurde. + * + *

    + *
  • Beim Verbinden: {@code {"app":"SwyxTray",…,"type":"hello"}}, danach ein Snapshot.
  • + *
  • Kommando: {@code {"id":7,"cmd":"call","number":"…"}} + * → {@code {"id":7,"ok":true,"line":2,"type":"result"}}
  • + *
  • Keine Ereignisnachrichten. Jede Änderung geht als vollständiger + * {@code snapshot} über alle Leitungen raus – auch ein eingehender Anruf.
  • + *
+ * + * Ein eingehender Anruf lässt sich über {@code POST /api/mock/incoming-call} auslösen. + * + *

Seit Protokoll 6 beantwortet der Mock zusätzlich {@code tabs}, {@code opentab} und + * {@code closetab}. Die echte App reicht diese Kommandos an das Firefox-Plugin durch; + * der Mock spielt das Plugin und führt eine Liste vorgetäuschter Tabs im Speicher. + */ +@Component +public class SwyxTrayMockHandler extends TextWebSocketHandler { + + private static final Logger log = LoggerFactory.getLogger(SwyxTrayMockHandler.class); + + /** So viele Leitungen meldet die echte App. */ + private static final int LINE_COUNT = 4; + + /** Vorgetäuschte Fenstertitel für {@code focus} – damit beide Zweige prüfbar sind. */ + private static final List MOCK_WINDOWS = List.of( + "SwyxIt!", "SwyxWeb – Google Chrome", "Kundenakte Muster GmbH"); + + /** Protokollstand der echten App seit der Tab-Verwaltung. */ + private static final int PROTOCOL_VERSION = 6; + + private final ObjectMapper mapper; + private final Map sessions = new ConcurrentHashMap<>(); + private final Map lines = new ConcurrentHashMap<>(); + // Vorgetäuschte Tabs des Firefox-Plugins, in der Reihenfolge des Öffnens. + private final Map tabs = Collections.synchronizedMap(new LinkedHashMap<>()); + private final AtomicInteger nextTabId = new AtomicInteger(41); + private final AtomicInteger nextSession = new AtomicInteger(); + // Serialisiert Begrüßung und Snapshot, damit die Reihenfolge garantiert ist. + private final Object sendLock = new Object(); + + public SwyxTrayMockHandler(ObjectMapper mapper) { + this.mapper = mapper; + for (int line = 1; line <= LINE_COUNT; line++) { + lines.put(line, LineState.free(line)); + } + // Zwei Tabs von Anfang an, damit die Startseite etwas anzuzeigen hat. + addTab("SwyxWeb", "http://localhost:5173/"); + addTab("Kundenakte Muster GmbH", "https://crm.example.local/kunden/4711"); + } + + @Override + public void afterConnectionEstablished(WebSocketSession session) { + sessions.put(session.getId(), session); + log.info("SwyxTray-Mock: Client {} verbunden ({} aktiv)", session.getId(), sessions.size()); + synchronized (sendLock) { + sendText(session, toJson(hello())); + sendText(session, toJson(snapshot())); + } + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { + sessions.remove(session.getId()); + log.info("SwyxTray-Mock: Client {} getrennt ({}) – {} aktiv", session.getId(), status, sessions.size()); + } + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) { + JsonNode request; + try { + request = mapper.readTree(message.getPayload()); + } catch (Exception e) { + sendText(session, toJson(result(0, false, null, "Ungültiges JSON: " + e.getMessage()))); + return; + } + + int id = request.path("id").asInt(0); + String cmd = request.path("cmd").asString(null); + log.debug("SwyxTray-Mock: Kommando {} (id={})", cmd, id); + + if (cmd == null) { + sendText(session, toJson(result(id, false, null, "Feld 'cmd' fehlt."))); + return; + } + + try { + // `focus` rührt keine Leitung an und schickt deshalb auch keinen Snapshot. + if ("focus".equals(cmd)) { + boolean focused = focus(request.path("title").asString(null), + request.path("url").asString(null)); + sendText(session, toJson(focusResult(id, focused))); + return; + } + + // Die drei Tab-Kommandos gehen bei der echten App ans Firefox-Plugin; + // sie berühren die Leitungen ebenfalls nicht. + switch (cmd) { + case "tabs" -> { + sendText(session, toJson(tabsResult(id, listTabs()))); + return; + } + case "opentab" -> { + sendText(session, toJson(openTabResult(id, openTab(request.path("url").asString(null))))); + return; + } + case "closetab" -> { + closeTab(request.path("tabId")); + sendText(session, toJson(result(id, true, null, null))); + return; + } + default -> { /* weiter unten */ } + } + + Integer line = switch (cmd) { + case "call" -> dial(request.path("number").asString(null)); + case "answer" -> answer(requiredLine(request)); + case "hangup" -> hangup(requiredLine(request)); + case "ping", "status" -> null; + default -> throw new IllegalArgumentException("Unbekanntes Kommando '" + cmd + "'."); + }; + sendText(session, toJson(result(id, true, line, null))); + broadcastSnapshot(); + } catch (IllegalArgumentException | IllegalStateException e) { + sendText(session, toJson(result(id, false, null, e.getMessage()))); + } + } + + // --- Kommandos ------------------------------------------------------- + + /** + * Nimmt den Wählauftrag an und belegt eine freie Leitung – wie CLMgr, das + * asynchron wählt; der Verlauf kommt über die folgenden Snapshots. + */ + private int dial(String number) { + if (number == null || number.isBlank()) { + throw new IllegalArgumentException("Parameter 'number' fehlt."); + } + int line = freeLine(); + lines.put(line, LineState.dialing(line, number.trim(), null)); + log.info("SwyxTray-Mock: Wähle {} auf Leitung {}", number, line); + return line; + } + + private int answer(int line) { + lines.put(line, occupied(line).connected()); + return line; + } + + private int hangup(int line) { + occupied(line); + lines.put(line, LineState.free(line)); + return line; + } + + /** + * Simuliert {@code focus}: Fenster mit passendem Titel „in den Vordergrund holen", + * sonst „Browser starten". Der Mock öffnet natürlich nichts, er protokolliert nur – + * die Antworten entsprechen aber denen der echten App. + * + * @return {@code true}, wenn ein Fenster gefunden wurde + */ + private boolean focus(String title, String url) { + if (title == null || title.isBlank()) { + throw new IllegalArgumentException("Feld 'title' fehlt."); + } + String wanted = title.trim(); + boolean found = MOCK_WINDOWS.stream() + .anyMatch(window -> window.toLowerCase().contains(wanted.toLowerCase())); + if (found) { + log.info("SwyxTray-Mock: Fenster '{}' in den Vordergrund geholt", wanted); + return true; + } + if (url == null || url.isBlank()) { + throw new IllegalArgumentException( + "Kein Fenster mit Titel '" + wanted + "' gefunden und keine URL angegeben."); + } + log.info("SwyxTray-Mock: Kein Fenster '{}' – würde {} im Browser öffnen", wanted, url.trim()); + return false; + } + + /** Die offenen Tabs in der Reihenfolge des Öffnens. */ + private List listTabs() { + synchronized (tabs) { + return List.copyOf(tabs.values()); + } + } + + /** + * Simuliert {@code opentab}. Die echte App prüft die Adresse selbst und lässt + * nur absolute http(s)-Adressen durch – der Mock hält sich an dieselbe Regel. + * + * @return Kennung des neuen Tabs + */ + private int openTab(String url) { + if (url == null || url.isBlank()) { + throw new IllegalArgumentException("Feld 'url' fehlt."); + } + String wanted = url.trim(); + URI uri; + try { + uri = URI.create(wanted); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Ungültige URL '" + wanted + "'."); + } + String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(); + if (!uri.isAbsolute() || !(scheme.equals("http") || scheme.equals("https"))) { + throw new IllegalArgumentException( + "Nur absolute http(s)-Adressen sind erlaubt, nicht '" + wanted + "'."); + } + MockTab tab = addTab(uri.getHost() == null ? wanted : uri.getHost(), wanted); + log.info("SwyxTray-Mock: Tab {} für {} geöffnet", tab.id(), wanted); + return tab.id(); + } + + /** Simuliert {@code closetab}; eine unbekannte Kennung ergibt einen Fehler. */ + private void closeTab(JsonNode tabId) { + if (!tabId.isNumber()) { + throw new IllegalArgumentException("Feld 'tabId' fehlt."); + } + int id = tabId.asInt(); + synchronized (tabs) { + if (tabs.remove(id) == null) { + throw new IllegalArgumentException("Kein Tab mit der Kennung " + id + "."); + } + } + log.info("SwyxTray-Mock: Tab {} geschlossen", id); + } + + /** Legt einen Tab an; der zuletzt geöffnete ist der aktive. */ + private MockTab addTab(String title, String url) { + MockTab tab = new MockTab(nextTabId.incrementAndGet(), title, url, true); + synchronized (tabs) { + tabs.replaceAll((id, existing) -> existing.withActive(false)); + tabs.put(tab.id(), tab); + } + return tab; + } + + /** Löst einen eingehenden Anruf aus: Leitung belegen, Snapshot verschicken. */ + public LineState simulateIncomingCall(String number, String name) { + int line = freeLine(); + LineState state = LineState.ringing( + line, + number == null || number.isBlank() ? "+493012345" : number.trim(), + name == null || name.isBlank() ? null : name.trim()); + lines.put(line, state); + log.info("SwyxTray-Mock: Eingehender Anruf von {} auf Leitung {}", state.peer(), line); + broadcastSnapshot(); + return state; + } + + // --- Hilfsmittel ----------------------------------------------------- + + private int requiredLine(JsonNode request) { + JsonNode node = request.path("line"); + if (!node.isNumber()) { + throw new IllegalArgumentException("Parameter 'line' fehlt."); + } + return node.asInt(); + } + + private LineState occupied(int line) { + LineState state = lines.get(line); + if (state == null || state.isFree()) { + throw new IllegalArgumentException("Leitung " + line + " ist nicht belegt."); + } + return state; + } + + private int freeLine() { + for (int line = 1; line <= LINE_COUNT; line++) { + if (lines.get(line).isFree()) { + return line; + } + } + throw new IllegalStateException("Alle Leitungen sind belegt."); + } + + private Map hello() { + Map message = new LinkedHashMap<>(); + message.put("app", "SwyxTray"); + message.put("version", "mock"); + message.put("protocol", PROTOCOL_VERSION); + message.put("session", nextSession.incrementAndGet()); + message.put("type", "hello"); + return message; + } + + /** Vollzustand über alle Leitungen, auch die freien – wie die echte App. */ + private Map snapshot() { + List entries = new ArrayList<>(LINE_COUNT); + for (int line = 1; line <= LINE_COUNT; line++) { + entries.add(lines.get(line)); + } + boolean anyRinging = entries.stream().anyMatch(state -> state.isRinging()); + boolean anyBusy = entries.stream().anyMatch(state -> !state.isFree()); + + Map message = new LinkedHashMap<>(); + message.put("connected", true); + message.put("serverUp", true); + // Gesamtzustand wie die echte App: "Ringing"/"Eingehender Ruf" bzw. "Idle"/"Bereit". + message.put("overall", anyRinging ? "Ringing" : anyBusy ? "Busy" : "Idle"); + message.put("statusText", anyRinging ? "Eingehender Ruf" : anyBusy ? "Im Gespräch" : "Bereit"); + message.put("user", "Mock, M."); + message.put("server", "127.0.0.1"); + message.put("lines", entries); + message.put("type", "snapshot"); + return message; + } + + /** Quittung auf {@code focus}; trägt statt der Leitung das Feld {@code focused}. */ + private Map focusResult(int id, boolean focused) { + Map message = new LinkedHashMap<>(); + message.put("id", id); + message.put("ok", true); + message.put("focused", focused); + message.put("type", "result"); + return message; + } + + /** Quittung auf {@code tabs}; trägt die Liste der Tabs. */ + private Map tabsResult(int id, List entries) { + Map message = new LinkedHashMap<>(); + message.put("id", id); + message.put("ok", true); + message.put("tabs", entries); + message.put("type", "result"); + return message; + } + + /** Quittung auf {@code opentab}; trägt die Kennung des neuen Tabs. */ + private Map openTabResult(int id, int tabId) { + Map message = new LinkedHashMap<>(); + message.put("id", id); + message.put("ok", true); + message.put("tabId", tabId); + message.put("type", "result"); + return message; + } + + private Map result(int id, boolean ok, Integer line, String error) { + Map message = new LinkedHashMap<>(); + message.put("id", id); + message.put("ok", ok); + if (line != null) { + message.put("line", line); + } + if (error != null) { + message.put("error", error); + } + message.put("type", "result"); + return message; + } + + private void broadcastSnapshot() { + String json = toJson(snapshot()); + synchronized (sendLock) { + sessions.values().forEach(session -> sendText(session, json)); + } + } + + private String toJson(Object value) { + return mapper.writeValueAsString(value); + } + + private void sendText(WebSocketSession session, String json) { + if (!session.isOpen()) { + return; + } + // sendMessage ist nicht threadsicher – Zugriffe pro Session serialisieren. + synchronized (session) { + try { + session.sendMessage(new TextMessage(json)); + } catch (IOException e) { + log.warn("Senden an {} fehlgeschlagen: {}", session.getId(), e.getMessage()); + } + } + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties new file mode 100644 index 0000000..d2e668d --- /dev/null +++ b/backend/src/main/resources/application.properties @@ -0,0 +1,14 @@ +spring.application.name=swyxweb-backend + +# Port dieser Anwendung (REST-API und der eingebaute Test-Endpunkt /ws). +server.address=0.0.0.0 +server.port=8080 + +# Adresse des WebSocket-Servers, die das Frontend über /api/config als +# Verbindungsziel bekommt. Unabhängig vom Port dieser Anwendung. +app.websocket.host=192.168.180.135 +app.websocket.port=17654 +app.websocket.path=/ws +app.websocket.secure=false + +logging.level.de.appcreation.swyxweb=DEBUG diff --git a/backend/src/test/java/de/appcreation/swyxweb/BackendApplicationTests.java b/backend/src/test/java/de/appcreation/swyxweb/BackendApplicationTests.java new file mode 100644 index 0000000..489631c --- /dev/null +++ b/backend/src/test/java/de/appcreation/swyxweb/BackendApplicationTests.java @@ -0,0 +1,13 @@ +package de.appcreation.swyxweb; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class BackendApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..589400a --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,6 @@ +# Adresse des WebSocket-Servers; überschreibt den Wert aus /api/config. +VITE_WS_URL=ws://192.168.180.135:17654/ws + +# Ziel des Dev-Proxys für /api, also der Port dieser Anwendung +# (Standard: http://localhost:8080) +VITE_BACKEND_URL=http://localhost:8080 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..2c5762f --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + SwyxWeb + + +

+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..191666b --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2273 @@ +{ + "name": "swyxweb-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "swyxweb-frontend", + "version": "0.1.0", + "dependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/node": "^26.2.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.3", + "vite": "^6.0.11", + "vitest": "^4.1.10" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz", + "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.404", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.404.tgz", + "integrity": "sha512-3WJtd7/lVq2Jnuz6wed1l9+1ZD2u2Tet1/1NBc4Iedkmgbu+I7YuAqdAQ8T+VZtnwysMsAf3IqSq9D1gyZjA2g==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8c85a74 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "swyxweb-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "test": "vitest run", + "typecheck": "tsc -b --noEmit false --emitDeclarationOnly false" + }, + "dependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/node": "^26.2.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.3", + "vite": "^6.0.11", + "vitest": "^4.1.10" + }, + "allowScripts": { + "esbuild@0.25.12": true + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..b9f2926 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,5 @@ +import HomePage from './pages/HomePage' + +export default function App() { + return +} diff --git a/frontend/src/components/BrowserTabsPanel.tsx b/frontend/src/components/BrowserTabsPanel.tsx new file mode 100644 index 0000000..3520087 --- /dev/null +++ b/frontend/src/components/BrowserTabsPanel.tsx @@ -0,0 +1,196 @@ +import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react' +import { describeTab, type BrowserTab, type ResultMessage } from '../swyx/protocol' + +interface Props { + /** Panel ist sichtbar – erst dann wird von selbst geladen. */ + active: boolean + disabled: boolean + busy: boolean + onList: () => Promise + onOpen: (url: string) => Promise + onClose: (tabId: number) => Promise +} + +const STORAGE_URL = 'swyxweb.tabs.url' + +function stored(key: string): string { + try { + return localStorage.getItem(key) ?? '' + } catch { + // Privater Modus o. Ä. – dann eben ohne Gedächtnis. + return '' + } +} + +function remember(key: string, value: string): void { + try { + localStorage.setItem(key, value) + } catch { + // absichtlich still + } +} + +/** + * Die App nimmt nur absolute http(s)-Adressen an. Der gleiche Test hier erspart + * den Umweg über App und Plugin und erklärt den Fehler an Ort und Stelle. + */ +function isAbsoluteHttpUrl(value: string): boolean { + try { + const parsed = new URL(value) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' + } catch { + return false + } +} + +/** + * Tabs des Firefox-Plugins: anzeigen (`tabs`), öffnen (`opentab`) und + * schließen (`closetab`). Die SwyxTray-App reicht alle drei Kommandos nur an + * das Plugin durch – ohne verbundenes Plugin antwortet sie mit einem Fehler. + */ +export default function BrowserTabsPanel({ active, disabled, busy, onList, onOpen, onClose }: Props) { + const [tabs, setTabs] = useState(null) + const [url, setUrl] = useState(() => stored(STORAGE_URL)) + const [hint, setHint] = useState(null) + const [outcome, setOutcome] = useState(null) + + // Nach einem Verbindungsabbruch soll beim nächsten Öffnen erneut geladen werden. + const loadedRef = useRef(false) + + /** + * Holt die Liste neu. Die Meldung des letzten Vorgangs bleibt dabei stehen – + * Öffnen und Schließen laden hinterher nach und würden sie sonst selbst löschen. + */ + const load = useCallback(async () => { + const result = await onList() + // null heißt: Fehler – der steht bereits in der Fehleranzeige der Seite. + if (!result) return + setTabs(result.tabs ?? []) + }, [onList]) + + useEffect(() => { + if (disabled) { + // Ohne Verbindung ist die Liste veraltet; sie wird beim nächsten Mal neu geholt. + loadedRef.current = false + setTabs(null) + return + } + if (!active || loadedRef.current) return + loadedRef.current = true + void load() + }, [active, disabled, load]) + + async function handleOpen(event: FormEvent) { + event.preventDefault() + const wanted = url.trim() + if (!wanted) return + if (!isAbsoluteHttpUrl(wanted)) { + setHint('Bitte eine vollständige Adresse angeben, z. B. https://example.com/seite.') + return + } + + setHint(null) + setOutcome(null) + remember(STORAGE_URL, wanted) + const result = await onOpen(wanted) + if (!result) return + + // Die Liste ist jetzt veraltet – die App meldet Änderungen nicht von selbst. + await load() + setOutcome( + result.tabId === undefined + ? `${wanted} geöffnet.` + : `${wanted} als Tab ${result.tabId} geöffnet.`, + ) + } + + async function handleClose(tab: BrowserTab) { + setOutcome(null) + const result = await onClose(tab.id) + if (!result) return + await load() + setOutcome(`Tab „${describeTab(tab)}" geschlossen.`) + } + + return ( + <> +
+

Tabs (Firefox-Plugin)

+ +
+ + {disabled ? ( +

Erst mit der SwyxTray-App verbinden.

+ ) : tabs === null ? ( +

Noch nicht abgefragt.

+ ) : tabs.length === 0 ? ( +

Keine offenen Tabs gemeldet.

+ ) : ( +
    + {tabs.map((tab) => ( +
  • + {tab.id} + + + {describeTab(tab)} + {tab.active && aktiv} + + {tab.url && {tab.url}} + + +
  • + ))} +
+ )} + +
+ +
+ +
+
+ +

+ Die Tabs gehören zum Firefox auf dem Rechner der SwyxTray-App. Ohne verbundenes Plugin + beantwortet die App die drei Kommandos nach etwa fünf Sekunden mit einem Fehler. +

+ {hint &&

{hint}

} + {outcome &&

{outcome}

} + + ) +} diff --git a/frontend/src/components/CallList.tsx b/frontend/src/components/CallList.tsx new file mode 100644 index 0000000..95c88d0 --- /dev/null +++ b/frontend/src/components/CallList.tsx @@ -0,0 +1,85 @@ +import { describeCall, type CallEvent, type CallEventKind } from '../swyx/protocol' + +const EVENT_LABELS: Record = { + incoming: 'klingelt', + outgoing: 'wählt', + connected: 'verbunden', + ended: 'beendet', +} + +interface Props { + calls: CallEvent[] + busy: boolean + onAnswer: (line: number) => void + onHangup: (line: number) => void +} + +/** Belegte Leitungen mit Aktionen. */ +export function ActiveCallList({ calls, busy, onAnswer, onHangup }: Props) { + if (calls.length === 0) { + return

Zurzeit keine belegte Leitung.

+ } + + return ( +
    + {calls.map((call) => ( +
  • + + L{call.line} + +
    + {describeCall(call)} + {/* Klartext der App bevorzugen – er ist genauer als unsere vier Zustände. */} + {call.stateText ?? EVENT_LABELS[call.event]} +
    +
    + {call.event === 'incoming' && ( + + )} + +
    +
  • + ))} +
+ ) +} + +/** Verlauf aller empfangenen Anruf-Ereignisse. */ +export function CallHistory({ events }: { events: CallEvent[] }) { + if (events.length === 0) { + return

Noch keine Anruf-Ereignisse empfangen.

+ } + + return ( +
    + {events.map((event, index) => ( +
  • + L{event.line} + + {EVENT_LABELS[event.event]} + + {describeCall(event)} + {event.direction && ( + + {event.direction === 'incoming' ? 'eingehend' : 'ausgehend'} + + )} +
  • + ))} +
+ ) +} diff --git a/frontend/src/components/DialPanel.tsx b/frontend/src/components/DialPanel.tsx new file mode 100644 index 0000000..cb2cc91 --- /dev/null +++ b/frontend/src/components/DialPanel.tsx @@ -0,0 +1,46 @@ +import { useState, type FormEvent } from 'react' + +interface Props { + disabled: boolean + busy: boolean + onDial: (number: string) => void +} + +/** Eingabe für Click-to-Dial (Kommando `call`). */ +export default function DialPanel({ disabled, busy, onDial }: Props) { + const [number, setNumber] = useState('') + + function handleSubmit(event: FormEvent) { + event.preventDefault() + const target = number.trim() + if (!target) return + onDial(target) + } + + return ( +
+ +
+ +
+
+ ) +} diff --git a/frontend/src/components/FocusPanel.tsx b/frontend/src/components/FocusPanel.tsx new file mode 100644 index 0000000..b6d3409 --- /dev/null +++ b/frontend/src/components/FocusPanel.tsx @@ -0,0 +1,113 @@ +import { useState, type FormEvent } from 'react' +import type { ResultMessage } from '../swyx/protocol' + +interface Props { + disabled: boolean + busy: boolean + onFocus: (title: string, url?: string) => Promise +} + +const STORAGE_TITLE = 'swyxweb.focus.title' +const STORAGE_URL = 'swyxweb.focus.url' + +/** Merkt sich die Eingaben über einen Neuladevorgang hinweg. */ +function stored(key: string): string { + try { + return localStorage.getItem(key) ?? '' + } catch { + // Privater Modus o. Ä. – dann eben ohne Gedächtnis. + return '' + } +} + +function remember(key: string, value: string): void { + try { + localStorage.setItem(key, value) + } catch { + // absichtlich still + } +} + +/** + * Eingabe für das Kommando `focus`: Fenster mit dem Titel nach vorn holen, + * ersatzweise die URL im Standardbrowser öffnen. + */ +export default function FocusPanel({ disabled, busy, onFocus }: Props) { + const [title, setTitle] = useState(() => stored(STORAGE_TITLE)) + const [url, setUrl] = useState(() => stored(STORAGE_URL)) + const [outcome, setOutcome] = useState(null) + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + const wantedTitle = title.trim() + const wantedUrl = url.trim() + if (!wantedTitle) return + + remember(STORAGE_TITLE, wantedTitle) + remember(STORAGE_URL, wantedUrl) + setOutcome(null) + + const result = await onFocus(wantedTitle, wantedUrl || undefined) + // null heißt: Fehler – der steht bereits in der Fehleranzeige der Seite. + if (!result) return + + setOutcome( + result.focused + ? `Fenster „${wantedTitle}" in den Vordergrund geholt.` + : wantedUrl + ? `Kein Fenster gefunden – Browser mit ${wantedUrl} geöffnet.` + : 'Kein Fenster gefunden.', + ) + } + + return ( +
+
+ +
+ +
+ +
+ +
+
+ +

+ Ein Teil des Titels genügt, die Schreibweise ist egal. Gibt es kein passendes Fenster, + startet die App den Standardbrowser mit der URL – ohne URL meldet sie einen Fehler. +

+ {outcome &&

{outcome}

} +
+ ) +} diff --git a/frontend/src/components/IncomingCallCard.tsx b/frontend/src/components/IncomingCallCard.tsx new file mode 100644 index 0000000..0b6a380 --- /dev/null +++ b/frontend/src/components/IncomingCallCard.tsx @@ -0,0 +1,48 @@ +import { type CallEvent } from '../swyx/protocol' + +interface Props { + call: CallEvent + busy: boolean + onAnswer: (line: number) => void + onHangup: (line: number) => void +} + +/** Meldung für einen eingehenden, noch klingelnden Anruf. */ +export default function IncomingCallCard({ call, busy, onAnswer, onHangup }: Props) { + // Name und Nummer getrennt anzeigen, sonst die fertige Anzeigeform der App. + const title = call.peerName ?? call.peerNumber ?? call.peer ?? `Leitung ${call.line}` + const subtitle = call.peerName ? call.peerNumber : undefined + + return ( +
+
+ +
+

Eingehender Anruf · Leitung {call.line}

+

{title}

+ {subtitle &&

{subtitle}

} +
+
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/MessageLog.tsx b/frontend/src/components/MessageLog.tsx new file mode 100644 index 0000000..eaacb3e --- /dev/null +++ b/frontend/src/components/MessageLog.tsx @@ -0,0 +1,39 @@ +import { useEffect, useRef } from 'react' +import type { LogEntry } from '../hooks/useSwyxTray' + +const PREFIX: Record = { + in: '←', + out: '→', + system: '•', +} + +const timeFormat = new Intl.DateTimeFormat('de-DE', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', +}) + +export default function MessageLog({ entries }: { entries: LogEntry[] }) { + const endRef = useRef(null) + + useEffect(() => { + endRef.current?.scrollIntoView({ block: 'nearest' }) + }, [entries]) + + if (entries.length === 0) { + return

Noch keine Nachrichten.

+ } + + return ( +
+ {entries.map((entry) => ( +
+ {timeFormat.format(entry.at)} + {PREFIX[entry.direction]} + {entry.text} +
+ ))} +
+
+ ) +} diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx new file mode 100644 index 0000000..a823e27 --- /dev/null +++ b/frontend/src/components/StatusBadge.tsx @@ -0,0 +1,18 @@ +import type { ConnectionStatus } from '../swyx/SwyxTrayClient' + +const LABELS: Record = { + idle: 'Nicht verbunden', + connecting: 'Verbinde …', + open: 'Verbunden', + closing: 'Trenne …', + closed: 'Getrennt', +} + +export default function StatusBadge({ status }: { status: ConnectionStatus }) { + return ( + + + ) +} diff --git a/frontend/src/components/Tabs.tsx b/frontend/src/components/Tabs.tsx new file mode 100644 index 0000000..49415eb --- /dev/null +++ b/frontend/src/components/Tabs.tsx @@ -0,0 +1,42 @@ +export interface Tab { + id: Id + label: string +} + +interface Props { + tabs: Tab[] + active: Id + onChange: (id: Id) => void +} + +/** Kennung des Knopfes zu einem Tab – die Panels verweisen mit aria-labelledby darauf. */ +export function tabId(id: string): string { + return `tab-${id}` +} + +/** Kennung des Panels zu einem Tab. */ +export function panelId(id: string): string { + return `panel-${id}` +} + +/** Tab-Leiste. Native Knöpfe, damit die Tastaturbedienung von selbst funktioniert. */ +export default function Tabs({ tabs, active, onChange }: Props) { + return ( + + ) +} diff --git a/frontend/src/config.ts b/frontend/src/config.ts new file mode 100644 index 0000000..065b2ab --- /dev/null +++ b/frontend/src/config.ts @@ -0,0 +1,30 @@ +/** Fallback-Adresse des WebSocket-Servers, falls weder .env noch /api/config etwas liefern. */ +export const DEFAULT_WS_HOST = '192.168.180.135' +export const DEFAULT_WS_PORT = 17654 +export const DEFAULT_WS_PATH = '/ws' + +export const DEFAULT_WS_URL = + import.meta.env.VITE_WS_URL ?? `ws://${DEFAULT_WS_HOST}:${DEFAULT_WS_PORT}${DEFAULT_WS_PATH}` + +export interface ClientConfig { + websocketUrl: string + host: string + port: number + path: string +} + +/** + * Holt die WebSocket-Adresse vom Backend. Schlägt der Aufruf fehl (Backend nicht + * erreichbar, rein statisches Hosting), bleibt es beim Default aus der .env. + */ +export async function fetchClientConfig(signal?: AbortSignal): Promise { + const response = await fetch('/api/config', { signal }) + if (!response.ok) { + throw new Error(`/api/config antwortete mit HTTP ${response.status}`) + } + const config = (await response.json()) as Partial + if (!config.websocketUrl) { + throw new Error('/api/config lieferte keine websocketUrl') + } + return config.websocketUrl +} diff --git a/frontend/src/hooks/useCallNotifications.ts b/frontend/src/hooks/useCallNotifications.ts new file mode 100644 index 0000000..853794e --- /dev/null +++ b/frontend/src/hooks/useCallNotifications.ts @@ -0,0 +1,44 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { describeCall, type CallEvent } from '../swyx/protocol' + +type Permission = NotificationPermission | 'unsupported' + +function currentPermission(): Permission { + return typeof Notification === 'undefined' ? 'unsupported' : Notification.permission +} + +/** + * Zeigt bei einem eingehenden Anruf eine System-Benachrichtigung, damit der + * Anruf auch bemerkt wird, wenn der Tab im Hintergrund liegt. + */ +export function useCallNotifications(ringingCall: CallEvent | null) { + const [permission, setPermission] = useState(currentPermission) + // Pro klingelnder Leitung nur einmal benachrichtigen. + const notifiedRef = useRef(null) + + const requestPermission = useCallback(() => { + if (typeof Notification === 'undefined') return + void Notification.requestPermission().then(setPermission) + }, []) + + useEffect(() => { + if (!ringingCall) { + notifiedRef.current = null + return + } + if (permission !== 'granted' || notifiedRef.current === ringingCall.line) return + + notifiedRef.current = ringingCall.line + const notification = new Notification(`Eingehender Anruf · Leitung ${ringingCall.line}`, { + body: describeCall(ringingCall), + tag: `swyx-line-${ringingCall.line}`, + }) + notification.onclick = () => { + window.focus() + notification.close() + } + return () => notification.close() + }, [ringingCall, permission]) + + return { permission, requestPermission } +} diff --git a/frontend/src/hooks/useSwyxTray.ts b/frontend/src/hooks/useSwyxTray.ts new file mode 100644 index 0000000..e55523b --- /dev/null +++ b/frontend/src/hooks/useSwyxTray.ts @@ -0,0 +1,174 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { SwyxTrayClient, type ConnectionStatus, type RawMessage } from '../swyx/SwyxTrayClient' +import { mergeSnapshot, type CallEvent, type SnapshotMessage } from '../swyx/protocol' + +export interface LogEntry extends RawMessage { + id: number +} + +/** Zustand der App selbst, aus dem Snapshot – unabhängig von den Leitungen. */ +export interface TrayState { + connected?: boolean + serverUp?: boolean + overall?: string + statusText?: string + user?: string + server?: string +} + +const MAX_LOG_ENTRIES = 300 +const MAX_HISTORY = 50 + +/** + * Bindet den SwyxTrayClient an React: Verbindungsstatus, Zustand der Leitungen, + * Ereignisverlauf und die Telefonie-Kommandos. + */ +export function useSwyxTray() { + const [status, setStatus] = useState('idle') + const [error, setError] = useState(null) + const [log, setLog] = useState([]) + const [history, setHistory] = useState([]) + // Belegte Leitungen, nach Leitungsnummer; jeder Snapshot schreibt sie neu. + const [lines, setLines] = useState>({}) + const [tray, setTray] = useState({}) + const [busy, setBusy] = useState(false) + + const clientRef = useRef(null) + const logIdRef = useRef(0) + // Stand der Leitungen für den Snapshot-Vergleich. + const linesRef = useRef>({}) + + if (clientRef.current === null) { + clientRef.current = new SwyxTrayClient() + } + const client = clientRef.current + + useEffect(() => { + const unsubscribe = [ + client.on('status', (next) => { + setStatus(next) + if (next === 'open') setError(null) + // Der Leitungszustand der alten Verbindung ist nach dem Abbruch wertlos; + // die App schickt beim Verbinden ohnehin einen Snapshot. + if (next === 'closed') { + linesRef.current = {} + setLines({}) + setTray({}) + } + }), + client.on('error', (message) => setError(message)), + client.on('raw', (message) => { + setLog((entries) => { + const next = [...entries, { ...message, id: logIdRef.current++ }] + return next.length > MAX_LOG_ENTRIES ? next.slice(next.length - MAX_LOG_ENTRIES) : next + }) + }), + client.on('snapshot', (snapshot: SnapshotMessage) => { + setTray({ + connected: snapshot.connected, + serverUp: snapshot.serverUp, + overall: snapshot.overall, + statusText: snapshot.statusText, + user: snapshot.user, + server: snapshot.server, + }) + // Der Vollzustand ersetzt den bisherigen Stand; die Anruf-Ereignisse für + // Verlauf und Benachrichtigung entstehen aus dem Vergleich mit ihm. + // Der Vergleich läuft über die Ref, nicht im State-Updater – der wird + // im StrictMode doppelt ausgeführt und würde den Verlauf verdoppeln. + const { lines: next, events } = mergeSnapshot(linesRef.current, snapshot.lines) + linesRef.current = next + setLines(next) + if (events.length > 0) { + setHistory((entries) => [...[...events].reverse(), ...entries].slice(0, MAX_HISTORY)) + } + }), + ] + return () => { + unsubscribe.forEach((off) => off()) + } + }, [client]) + + // Beim Verlassen der Seite Verbindung und Timer aufräumen. + useEffect(() => () => client.dispose(), [client]) + + const connect = useCallback((url: string) => client.connect(url), [client]) + const disconnect = useCallback(() => client.disconnect(), [client]) + + /** Führt ein Kommando aus und übernimmt Fehleranzeige und Busy-Zustand. */ + const run = useCallback(async (action: () => Promise): Promise => { + setBusy(true) + setError(null) + try { + return await action() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + return null + } finally { + setBusy(false) + } + }, []) + + const dial = useCallback((number: string) => run(() => client.dial(number)), [client, run]) + const answer = useCallback((line: number) => run(() => client.answer(line)), [client, run]) + const hangup = useCallback((line: number) => run(() => client.hangup(line)), [client, run]) + const refresh = useCallback(() => run(() => client.requestStatus()), [client, run]) + const focusWindow = useCallback( + (title: string, url?: string) => run(() => client.focusWindow(title, url)), + [client, run], + ) + + // Tabs des Firefox-Plugins; die App reicht diese drei Kommandos nur durch. + const listTabs = useCallback(() => run(() => client.listTabs()), [client, run]) + const openTab = useCallback((url: string) => run(() => client.openTab(url)), [client, run]) + const closeTab = useCallback((tabId: number) => run(() => client.closeTab(tabId)), [client, run]) + + const sendRaw = useCallback( + (text: string) => { + try { + client.sendRaw(text) + return true + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + return false + } + }, + [client], + ) + + const clearLog = useCallback(() => setLog([]), []) + + const calls = useMemo( + () => Object.values(lines).sort((a, b) => a.line - b.line), + [lines], + ) + + /** Erste klingelnde Leitung eines eingehenden Anrufs. */ + const ringingCall = useMemo( + () => calls.find((call) => call.event === 'incoming') ?? null, + [calls], + ) + + return { + status, + error, + log, + history, + calls, + ringingCall, + tray, + busy, + connect, + disconnect, + dial, + answer, + hangup, + refresh, + focusWindow, + listTabs, + openTab, + closeTab, + sendRaw, + clearLog, + } +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..791852c --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,597 @@ +:root { + --bg: #0f1116; + --surface: #171a21; + --surface-2: #1f2530; + --border: #2b3341; + --text: #e6e9ef; + --text-muted: #98a2b3; + --accent: #4f8cff; + --ok: #35c98a; + --warn: #e0b341; + --err: #f2585b; + color-scheme: dark; +} + +@media (prefers-color-scheme: light) { + :root { + --bg: #f5f7fa; + --surface: #ffffff; + --surface-2: #eef1f6; + --border: #d8dee9; + --text: #1b2230; + --text-muted: #5c6779; + color-scheme: light; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font-size: 15px; + line-height: 1.5; +} + +code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.9em; +} + +.page { + max-width: 900px; + margin: 0 auto; + padding: 32px 20px 48px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.page__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.page__header h1 { + margin: 0; + font-size: 26px; + letter-spacing: -0.02em; +} + +.page__subtitle { + margin: 2px 0 0; + color: var(--text-muted); +} + +.page__main { + display: flex; + flex-direction: column; + gap: 16px; +} + +.page__footer { + color: var(--text-muted); + font-size: 13px; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 18px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.card h2 { + margin: 0; + font-size: 16px; + font-weight: 600; +} + +.card__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.row { + display: flex; + align-items: flex-end; + gap: 10px; + flex-wrap: wrap; +} + +.row__actions { + display: flex; + gap: 8px; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1 1 260px; + min-width: 0; +} + +.field__label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.field__input { + width: 100%; + padding: 9px 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text); + font: inherit; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 14px; +} + +.field__input:focus { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.field__input:disabled { + opacity: 0.55; +} + +.button { + padding: 9px 16px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text); + font: inherit; + font-weight: 500; + cursor: pointer; +} + +.button:hover:not(:disabled) { + border-color: var(--accent); +} + +.button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.button--primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.button--ghost { + background: transparent; + padding: 5px 10px; + font-size: 13px; +} + +.note { + margin: 0; + font-size: 13px; + color: var(--text-muted); +} + +.note--error { + color: var(--err); +} + +.note--success { + color: var(--ok); +} + +.tabs { + display: flex; + gap: 4px; + border-bottom: 1px solid var(--border); +} + +.tabs__button { + padding: 9px 16px; + border: 1px solid transparent; + /* Die aktive Kante überdeckt die Linie der Leiste. */ + border-bottom: none; + margin-bottom: -1px; + border-radius: 10px 10px 0 0; + background: transparent; + color: var(--text-muted); + font: inherit; + font-weight: 500; + cursor: pointer; +} + +.tabs__button:hover:not(.tabs__button--active) { + color: var(--text); +} + +.tabs__button--active { + background: var(--surface); + border-color: var(--border); + color: var(--text); +} + +[role='tabpanel'] { + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Muss nach der Regel oben stehen: display:flex schlägt sonst das hidden-Attribut. */ +[role='tabpanel'][hidden] { + display: none; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface); + font-size: 13px; + font-weight: 500; + white-space: nowrap; +} + +.badge__dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--text-muted); +} + +.badge--open .badge__dot { + background: var(--ok); +} + +.badge--connecting .badge__dot, +.badge--closing .badge__dot { + background: var(--warn); + animation: pulse 1.1s ease-in-out infinite; +} + +.badge--error .badge__dot, +.badge--closed .badge__dot { + background: var(--err); +} + +@keyframes pulse { + 50% { + opacity: 0.25; + } +} + +@media (prefers-reduced-motion: reduce) { + .badge__dot { + animation: none; + } +} + +/* --- Eingehender Anruf --- */ + +.card--ringing { + border-color: var(--ok); + box-shadow: 0 0 0 1px var(--ok); +} + +.ringing { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +.ringing__icon { + font-size: 30px; + animation: shake 1s ease-in-out infinite; +} + +.ringing__text { + flex: 1 1 200px; + min-width: 0; +} + +.ringing__label { + margin: 0; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.ringing__number { + margin: 2px 0 0; + font-size: 22px; + font-weight: 600; + word-break: break-all; +} + +.ringing__meta { + margin: 2px 0 0; + color: var(--text-muted); + font-size: 13px; +} + +.ringing__actions { + display: flex; + gap: 8px; +} + +@keyframes shake { + 25% { + transform: rotate(-14deg); + } + 75% { + transform: rotate(14deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .ringing__icon { + animation: none; + } +} + +.button--accept { + background: var(--ok); + border-color: var(--ok); + color: #06281c; +} + +.button--reject { + background: var(--err); + border-color: var(--err); + color: #fff; +} + +.button--small { + padding: 5px 12px; + font-size: 13px; +} + +/* --- Anruflisten --- */ + +.calls, +.history { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.calls__item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface-2); +} + +.calls__item--incoming { + border-color: var(--ok); +} + +.calls__line, +.history__line { + flex: 0 0 auto; + padding: 2px 8px; + border-radius: 6px; + background: var(--border); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + font-weight: 600; +} + +.calls__info { + display: flex; + flex-direction: column; + min-width: 0; +} + +.calls__number { + font-weight: 600; + word-break: break-all; +} + +.calls__state { + font-size: 13px; + color: var(--text-muted); +} + +.calls__actions { + display: flex; + gap: 8px; +} + +.history__item { + display: flex; + align-items: baseline; + gap: 10px; + flex-wrap: wrap; + font-size: 14px; +} + +.history__time { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 13px; + color: var(--text-muted); + min-width: 68px; +} + +.history__badge { + padding: 1px 8px; + border-radius: 999px; + border: 1px solid var(--border); + font-size: 12px; + white-space: nowrap; +} + +.history__badge--incoming { + border-color: var(--ok); + color: var(--ok); +} + +.history__badge--connected, +.history__badge--outgoing { + border-color: var(--accent); + color: var(--accent); +} + +.history__badge--ended { + border-color: var(--err); + color: var(--err); +} + +.history__number { + word-break: break-all; +} + +.history__meta { + color: var(--text-muted); + font-size: 13px; +} + +/* --- Tabs des Firefox-Plugins --- */ + +.tablist { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.tablist__item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface-2); +} + +.tablist__item--active { + border-color: var(--accent); +} + +.tablist__id { + flex: 0 0 auto; + padding: 2px 8px; + border-radius: 6px; + background: var(--border); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + font-weight: 600; +} + +.tablist__info { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-width: 0; +} + +.tablist__title { + display: flex; + align-items: baseline; + gap: 8px; + font-weight: 600; + /* Lange Titel dürfen die Zeile nicht sprengen. */ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tablist__flag { + flex: 0 0 auto; + padding: 1px 8px; + border-radius: 999px; + border: 1px solid var(--accent); + color: var(--accent); + font-size: 12px; + font-weight: 500; +} + +.tablist__url { + font-size: 13px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.log { + height: 300px; + overflow-y: auto; + padding: 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface-2); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 13px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.log--empty { + align-items: center; + justify-content: center; + color: var(--text-muted); + margin: 0; +} + +.log__entry { + display: grid; + grid-template-columns: auto auto 1fr; + gap: 8px; + align-items: baseline; +} + +.log__time { + color: var(--text-muted); +} + +.log__text { + word-break: break-word; + white-space: pre-wrap; +} + +.log__entry--in .log__arrow { + color: var(--ok); +} + +.log__entry--out .log__arrow { + color: var(--accent); +} + +.log__entry--system { + color: var(--text-muted); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..683d930 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,15 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App.tsx' +import './index.css' + +const container = document.getElementById('root') +if (!container) { + throw new Error('Root-Element #root nicht gefunden') +} + +createRoot(container).render( + + + , +) diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx new file mode 100644 index 0000000..62100f7 --- /dev/null +++ b/frontend/src/pages/HomePage.tsx @@ -0,0 +1,248 @@ +import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react' +import { DEFAULT_WS_URL, fetchClientConfig } from '../config' +import { useSwyxTray } from '../hooks/useSwyxTray' +import { useCallNotifications } from '../hooks/useCallNotifications' +import StatusBadge from '../components/StatusBadge' +import MessageLog from '../components/MessageLog' +import IncomingCallCard from '../components/IncomingCallCard' +import DialPanel from '../components/DialPanel' +import BrowserTabsPanel from '../components/BrowserTabsPanel' +import Tabs, { panelId, tabId, type Tab } from '../components/Tabs' +import { ActiveCallList, CallHistory } from '../components/CallList' + +type TabName = 'calls' | 'browsertabs' | 'connection' + +const TABS: Tab[] = [ + { id: 'calls', label: 'Anrufe' }, + { id: 'browsertabs', label: 'Tabs' }, + { id: 'connection', label: 'Verbindung' }, +] + +/** Panel eines Tabs; ausgeblendet statt ausgebaut, damit Eingaben erhalten bleiben. */ +function TabPanel({ id, active, children }: { id: TabName; active: boolean; children: ReactNode }) { + return ( + + ) +} + +export default function HomePage() { + const [url, setUrl] = useState(DEFAULT_WS_URL) + const [configNote, setConfigNote] = useState(null) + const [tab, setTab] = useState('calls') + const [draft, setDraft] = useState('') + + const tray = useSwyxTray() + const { status, error, log, history, calls, ringingCall, busy, tray: appState } = tray + const isConnected = status === 'open' + const isBusyConnection = status === 'connecting' || status === 'open' || status === 'closing' + + const { permission, requestPermission } = useCallNotifications(ringingCall) + + // Der Benutzer soll seine eingetippte Adresse nicht durch die Backend-Antwort verlieren. + const urlTouched = useRef(false) + + useEffect(() => { + const controller = new AbortController() + fetchClientConfig(controller.signal) + .then((backendUrl) => { + if (controller.signal.aborted) return + setConfigNote(`Adresse vom Backend übernommen: ${backendUrl}`) + if (!urlTouched.current) setUrl(backendUrl) + }) + .catch((e: unknown) => { + if (controller.signal.aborted) return + const message = e instanceof Error ? e.message : String(e) + setConfigNote(`Backend-Konfiguration nicht abrufbar (${message}) – Standardadresse wird verwendet.`) + }) + return () => controller.abort() + }, []) + + function handleConnect(event: FormEvent) { + event.preventDefault() + const target = url.trim() + if (target) tray.connect(target) + } + + function handleSendRaw(event: FormEvent) { + event.preventDefault() + const message = draft.trim() + if (message && tray.sendRaw(message)) setDraft('') + } + + return ( +
+
+
+

SwyxWeb

+

Telefonie über die SwyxTray-App

+
+ +
+ +
+ {/* Steht bewusst über den Tabs: Ein klingelnder Anruf darf nicht davon + abhängen, welcher Bereich gerade offen ist. */} + {ringingCall && ( + void tray.answer(line)} + onHangup={(line) => void tray.hangup(line)} + /> + )} + + + + +
+

Anrufen

+ void tray.dial(number)} /> +

+ Die Quittung bestätigt nur die Annahme des Wählauftrags – der Verlauf erscheint + anschließend unter „Leitungen". +

+ {error &&

{error}

} +
+ +
+

Leitungen

+ void tray.answer(line)} + onHangup={(line) => void tray.hangup(line)} + /> + {isConnected && (appState.statusText || appState.user) && ( +

+ SwyxIt!: {appState.statusText ?? appState.overall ?? 'unbekannt'} + {appState.user && ` · ${appState.user}`} + {appState.connected === false && ' · keine Verbindung zu SwyxIt!'} + {appState.serverUp === false && ' · SwyxServer nicht erreichbar'} +

+ )} +
+ +
+

Verlauf

+ +
+
+ + +
+ + {error &&

{error}

} +
+
+ + +
+

Verbindung

+ +
+ +
+ + +
+
+ + {configNote &&

{configNote}

} + {error &&

{error}

} + {permission !== 'granted' && ( +

+ Für Hinweise bei eingehenden Anrufen auch außerhalb des Tabs:{' '} + +

+ )} +
+ +
+

Diagnose

+ +
+ +
+ + + +
+
+
+
+
+ +
+ SwyxTray · Kommandos call / answer / hangup /{' '} + tabs / opentab / closetab · Zustand über{' '} + snapshot · Standardziel {DEFAULT_WS_URL} +
+
+ ) +} diff --git a/frontend/src/swyx/SwyxTrayClient.ts b/frontend/src/swyx/SwyxTrayClient.ts new file mode 100644 index 0000000..2d564a6 --- /dev/null +++ b/frontend/src/swyx/SwyxTrayClient.ts @@ -0,0 +1,356 @@ +import { + COMMANDS, + parseHello, + parseResult, + parseSnapshot, + type HelloMessage, + type ResultMessage, + type SnapshotMessage, +} from './protocol' + +export type ConnectionStatus = 'idle' | 'connecting' | 'open' | 'closing' | 'closed' + +export interface RawMessage { + direction: 'in' | 'out' | 'system' + text: string + at: Date +} + +interface Listeners { + status: (status: ConnectionStatus) => void + hello: (hello: HelloMessage) => void + snapshot: (snapshot: SnapshotMessage) => void + raw: (message: RawMessage) => void + error: (message: string) => void +} + +interface Pending { + resolve: (value: ResultMessage) => void + reject: (reason: Error) => void + timer: ReturnType +} + +const CLOSE_CODE_NORMAL = 1000 +const DEFAULT_TIMEOUT_MS = 10000 +// Die Tab-Kommandos gehen ans Firefox-Plugin weiter; die App wartet darauf +// selbst fünf Sekunden, bevor sie mit einem Fehler antwortet. +const TAB_TIMEOUT_MS = 15000 + +/** + * WebSocket-Client für die SwyxTray-App. Die Verbindung wird im Browser + * aufgebaut; Quittungen werden über die numerische `id` zugeordnet, + * unaufgeforderte Anruf-Ereignisse an registrierte Hörer gemeldet. + */ +export class SwyxTrayClient { + private url: string | null = null + private socket: WebSocket | null = null + private nextId = 1 + private readonly pending = new Map() + private readonly listeners: { [K in keyof Listeners]: Set } = { + status: new Set(), + hello: new Set(), + snapshot: new Set(), + raw: new Set(), + error: new Set(), + } + + private reconnectTimer: ReturnType | null = null + private attempt = 0 + // Wunsch des Benutzers: nach manuellem Trennen nicht erneut verbinden. + private desiredConnected = false + + constructor( + private readonly reconnectDelayMs = 1000, + private readonly maxReconnectDelayMs = 15000, + ) {} + + on(event: K, listener: Listeners[K]): () => void { + this.listeners[event].add(listener as never) + return () => { + this.listeners[event].delete(listener as never) + } + } + + isConnected(): boolean { + return this.socket?.readyState === WebSocket.OPEN + } + + connect(url: string): void { + this.clearReconnectTimer() + this.desiredConnected = true + this.url = url + this.closeSocket('Neuverbindung') + this.open() + } + + disconnect(): void { + this.desiredConnected = false + this.clearReconnectTimer() + this.attempt = 0 + if (!this.socket) { + this.emit('status', 'closed') + return + } + this.emit('status', 'closing') + this.socket.close(CLOSE_CODE_NORMAL, 'Vom Benutzer getrennt') + } + + /** + * Sendet ein Kommando und löst mit dessen Quittung auf. Die Felder des + * Kommandos liegen flach neben `id` und `cmd`. + */ + send( + cmd: string, + payload: Record = {}, + timeoutMs = DEFAULT_TIMEOUT_MS, + ): Promise { + return new Promise((resolve, reject) => { + if (!this.isConnected()) { + reject(new Error('Keine Verbindung zur SwyxTray-App.')) + return + } + const id = this.nextId++ + const timer = setTimeout(() => { + this.pending.delete(id) + reject(new Error(`Zeitüberschreitung für Kommando '${cmd}'.`)) + }, timeoutMs) + this.pending.set(id, { resolve, reject, timer }) + + const message = JSON.stringify({ id, cmd, ...payload }) + this.socket!.send(message) + this.emit('raw', { direction: 'out', text: message, at: new Date() }) + }) + } + + /** Freitext senden – nur für den Diagnosebereich der Startseite. */ + sendRaw(text: string): void { + if (!this.isConnected()) { + throw new Error('Keine Verbindung zur SwyxTray-App.') + } + this.socket!.send(text) + this.emit('raw', { direction: 'out', text, at: new Date() }) + } + + /** + * Startet einen Wählvorgang. Die Quittung bestätigt nur die Annahme des + * Auftrags – der Verlauf kommt anschließend als Push-Nachricht. + */ + dial(number: string): Promise { + return this.send(COMMANDS.call, { number }) + } + + answer(line: number): Promise { + return this.send(COMMANDS.answer, { line }) + } + + hangup(line: number): Promise { + return this.send(COMMANDS.hangup, { line }) + } + + /** + * Holt auf dem Rechner der App das Fenster nach vorn, dessen Titel `title` + * enthält. Ohne Treffer öffnet die App `url` im Standardbrowser; fehlt auch + * die, antwortet sie mit einem Fehler. + */ + focusWindow(title: string, url?: string): Promise { + const payload: Record = { title } + // Leeres Feld gar nicht erst mitschicken – die App entscheidet an seinem + // Vorhandensein, ob sie ersatzweise den Browser startet. + if (url) payload.url = url + return this.send(COMMANDS.focus, payload) + } + + /** + * Fragt die offenen Tabs des Firefox-Plugins ab. Die App reicht das Kommando + * nur durch; ohne verbundenes Plugin antwortet sie nach ihrem Zeitfenster von + * fünf Sekunden mit einem Fehler. + */ + listTabs(): Promise { + return this.send(COMMANDS.tabs, {}, TAB_TIMEOUT_MS) + } + + /** Öffnet `url` als neuen Tab; die Quittung trägt dessen Kennung in `tabId`. */ + openTab(url: string): Promise { + return this.send(COMMANDS.openTab, { url }, TAB_TIMEOUT_MS) + } + + /** Schließt den Tab mit dieser Kennung – sie stammt aus einer `tabs`-Antwort. */ + closeTab(tabId: number): Promise { + return this.send(COMMANDS.closeTab, { tabId }, TAB_TIMEOUT_MS) + } + + ping(): Promise { + return this.send(COMMANDS.ping) + } + + /** Fordert einen frischen Snapshot an. */ + requestStatus(): Promise { + return this.send(COMMANDS.status) + } + + private open(): void { + const url = this.url + if (!url) return + + this.emit('status', 'connecting') + this.emit('raw', { direction: 'system', text: `Verbinde mit ${url} …`, at: new Date() }) + + let socket: WebSocket + try { + socket = new WebSocket(url) + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + this.emit('error', `Ungültige WebSocket-Adresse: ${message}`) + this.emit('status', 'closed') + return + } + this.socket = socket + + socket.onopen = () => { + if (this.socket !== socket) return + this.attempt = 0 + this.emit('status', 'open') + this.emit('raw', { direction: 'system', text: 'Verbindung hergestellt.', at: new Date() }) + } + + socket.onmessage = (event: MessageEvent) => { + if (this.socket !== socket) return + this.handleMessage(event.data) + } + + socket.onerror = () => { + if (this.socket !== socket) return + // Der Browser liefert aus Sicherheitsgründen keine Fehlerdetails. + this.emit('error', 'Verbindungsfehler – Adresse, Port und Erreichbarkeit der SwyxTray-App prüfen.') + } + + socket.onclose = (event: CloseEvent) => { + if (this.socket !== socket) return + this.socket = null + this.failAllPending(new Error('Verbindung zur SwyxTray-App geschlossen.')) + this.emit('status', 'closed') + this.emit('raw', { + direction: 'system', + text: `Verbindung geschlossen (Code ${event.code}${event.reason ? `, ${event.reason}` : ''}).`, + at: new Date(), + }) + if (this.desiredConnected) { + this.scheduleReconnect() + } + } + } + + private handleMessage(data: unknown): void { + if (typeof data !== 'string') { + const size = data instanceof Blob ? data.size : data instanceof ArrayBuffer ? data.byteLength : 0 + this.emit('raw', { direction: 'in', text: `[Binärdaten, ${size} Bytes]`, at: new Date() }) + return + } + this.emit('raw', { direction: 'in', text: data, at: new Date() }) + + let parsed: unknown + try { + parsed = JSON.parse(data) + } catch { + // Kein JSON – bleibt im Diagnoseprotokoll sichtbar, sonst ignorieren. + return + } + + const result = parseResult(parsed) + if (result) { + const request = this.pending.get(result.id) + if (!request) return + this.pending.delete(result.id) + clearTimeout(request.timer) + if (result.ok) { + request.resolve(result) + } else { + request.reject(new Error(result.error ?? 'Unbekannter Fehler.')) + } + return + } + + // Die App schickt keine Ereignisse: Jede Änderung – auch ein eingehender + // Anruf – kommt als vollständiger Snapshot. + const snapshot = parseSnapshot(parsed) + if (snapshot) { + this.emit('snapshot', snapshot) + return + } + + const hello = parseHello(parsed) + if (hello) { + this.emit('hello', hello) + return + } + + // Nicht stillschweigend verwerfen – sonst bliebe eine Protokolländerung + // der App unbemerkt, genau wie bei den früher erwarteten call-Ereignissen. + this.emit('raw', { + direction: 'system', + text: 'Nachricht nicht erkannt – Protokoll der App prüfen.', + at: new Date(), + }) + } + + private scheduleReconnect(): void { + if (this.reconnectTimer !== null) return + this.attempt += 1 + const delay = Math.min(this.reconnectDelayMs * 2 ** (this.attempt - 1), this.maxReconnectDelayMs) + this.emit('raw', { + direction: 'system', + text: `Neuer Versuch #${this.attempt} in ${Math.round(delay / 1000)} s …`, + at: new Date(), + }) + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null + if (this.desiredConnected) this.open() + }, delay) + } + + private clearReconnectTimer(): void { + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + } + + /** Schließt eine bestehende Verbindung, ohne einen Reconnect auszulösen. */ + private closeSocket(reason: string): void { + const socket = this.socket + if (!socket) return + socket.onopen = null + socket.onmessage = null + socket.onerror = null + socket.onclose = null + this.socket = null + if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) { + socket.close(CLOSE_CODE_NORMAL, reason) + } + this.failAllPending(new Error(reason)) + } + + private failAllPending(error: Error): void { + for (const request of this.pending.values()) { + clearTimeout(request.timer) + request.reject(error) + } + this.pending.clear() + } + + /** Beendet den Client endgültig (Seitenwechsel). */ + dispose(): void { + this.desiredConnected = false + this.clearReconnectTimer() + this.closeSocket('Seite verlassen') + for (const set of Object.values(this.listeners)) { + set.clear() + } + } + + private emit(event: K, ...args: Parameters): void { + for (const listener of this.listeners[event]) { + ;(listener as (...a: unknown[]) => void)(...args) + } + } +} diff --git a/frontend/src/swyx/protocol.test.ts b/frontend/src/swyx/protocol.test.ts new file mode 100644 index 0000000..ef36c62 --- /dev/null +++ b/frontend/src/swyx/protocol.test.ts @@ -0,0 +1,334 @@ +import { describe, expect, it } from 'vitest' +import { + callStateOf, + describeCall, + describeTab, + mergeSnapshot, + parseHello, + parseResult, + parseSnapshot, + parseTabs, + type CallEvent, +} from './protocol' + +/** + * Die Beispielnachrichten sind gegen die laufende SwyxTray-App 1.0.0.0 + * mitgeschnitten (ws://192.168.180.135:17654/ws). + */ +const HELLO = '{"app":"SwyxTray","version":"1.0.0.0","protocol":1,"session":7,"type":"hello"}' + +/** Wörtlich mitgeschnitten, während ein Anruf auf Leitung 1 einging. */ +const RINGING_SNAPSHOT = + '{"connected":true,"serverUp":true,"overall":"Ringing","statusText":"Eingehender Ruf",' + + '"user":"Muster, M.","server":"127.0.0.1","lines":[' + + '{"line":1,"state":"LSRinging","stateCode":3,"stateText":"klingelt","peer":"001602107449",' + + '"peerNumber":"001602107449","peerName":"","busy":true,"selected":true},' + + '{"line":2,"state":"LSInactive","stateCode":0,"stateText":"frei","peer":"unbekannt",' + + '"peerNumber":"","peerName":"","busy":false,"selected":false},' + + '{"line":3,"state":"LSInactive","stateCode":0,"stateText":"frei","peer":"unbekannt",' + + '"peerNumber":"","peerName":"","busy":false,"selected":false},' + + '{"line":4,"state":"LSInactive","stateCode":0,"stateText":"frei","peer":"unbekannt",' + + '"peerNumber":"","peerName":"","busy":false,"selected":false}],"type":"snapshot"}' + +/** Leitungseintrag im Snapshot; Vorgabe ist eine freie Leitung. */ +function line(overrides: Record = {}) { + return { + line: 1, + state: 'LSInactive', + stateCode: 0, + stateText: 'frei', + peer: 'unbekannt', + peerNumber: '', + peerName: '', + busy: false, + selected: false, + ...overrides, + } +} + +function snapshot(...entries: Record[]) { + return { + connected: true, + serverUp: true, + overall: 'Idle', + statusText: 'Bereit', + user: 'Muster, M.', + server: '127.0.0.1', + lines: entries, + type: 'snapshot', + } +} + +describe('parseHello', () => { + it('liest die Begrüßung der App', () => { + expect(parseHello(JSON.parse(HELLO))).toEqual({ + app: 'SwyxTray', + version: '1.0.0.0', + protocol: 1, + session: 7, + }) + }) + + it('hält andere Nachrichten für keine Begrüßung', () => { + expect(parseHello(snapshot())).toBeNull() + }) + + it('liest die Protokollversion der Tab-Verwaltung', () => { + expect( + parseHello(JSON.parse('{"app":"SwyxTray","version":"1.0.0.0","protocol":6,"session":1,"type":"hello"}')) + ?.protocol, + ).toBe(6) + }) +}) + +describe('callStateOf', () => { + it('hält freie Leitungen für frei', () => { + expect(callStateOf('LSInactive', 'frei')).toBeNull() + expect(callStateOf('LSTerminate', 'beendet')).toBeNull() + }) + + it('erkennt einen eingehenden Anruf am Zustand', () => { + expect(callStateOf('LSRinging', 'klingelt')).toBe('incoming') + }) + + it('erkennt ihn auch nur am Klartext der App', () => { + expect(callStateOf('LSIrgendwas', 'klingelt')).toBe('incoming') + }) + + it('erkennt den Wählvorgang', () => { + expect(callStateOf('LSDialing', 'wählt')).toBe('outgoing') + expect(callStateOf('LSAlerting', 'Verbindungsaufbau')).toBe('outgoing') + }) + + it('hält alles Unbekannte für belegt statt für frei', () => { + // Sonst verschwände eine Leitung stillschweigend – der Fehler, wegen dem + // eingehende Anrufe gar nicht angezeigt wurden. + expect(callStateOf('LSActive', 'verbunden')).toBe('connected') + expect(callStateOf('LSVölligNeu', 'was auch immer')).toBe('connected') + }) +}) + +describe('parseSnapshot', () => { + it('liest den Zustand der App', () => { + expect(parseSnapshot(snapshot(line()))).toMatchObject({ + connected: true, + serverUp: true, + overall: 'Idle', + statusText: 'Bereit', + user: 'Muster, M.', + server: '127.0.0.1', + }) + }) + + it('verwirft freie Leitungen – die App schickt immer alle vier', () => { + const parsed = parseSnapshot( + snapshot(line({ line: 1 }), line({ line: 2 }), line({ line: 3 }), line({ line: 4 })), + ) + expect(parsed?.lines).toEqual([]) + }) + + it('liest die klingelnde Leitung mit Gegenstelle', () => { + const parsed = parseSnapshot( + snapshot( + line({ line: 1 }), + line({ + line: 2, + state: 'LSRinging', + stateCode: 4, + stateText: 'klingelt', + peer: 'Muster GmbH (+493012345)', + peerNumber: '+493012345', + peerName: 'Muster GmbH', + busy: true, + }), + ), + ) + expect(parsed?.lines).toEqual([ + { + line: 2, + event: 'incoming', + direction: 'incoming', + peer: 'Muster GmbH (+493012345)', + peerNumber: '+493012345', + peerName: 'Muster GmbH', + stateText: 'klingelt', + state: 'LSRinging', + }, + ]) + }) + + it('lässt den Platzhalter "unbekannt" weg', () => { + const parsed = parseSnapshot(snapshot(line({ state: 'LSActive', stateText: 'verbunden' }))) + expect(parsed?.lines[0]).toMatchObject({ line: 1, event: 'connected', peer: undefined }) + }) + + it('hält andere Nachrichten für keinen Snapshot', () => { + expect(parseSnapshot(JSON.parse(HELLO))).toBeNull() + expect(parseSnapshot('hallo')).toBeNull() + }) +}) + +describe('echter eingehender Anruf', () => { + it('meldet den mitgeschnittenen Anruf als klingelnde Leitung', () => { + const parsed = parseSnapshot(JSON.parse(RINGING_SNAPSHOT)) + expect(parsed?.statusText).toBe('Eingehender Ruf') + expect(parsed?.lines).toEqual([ + { + line: 1, + event: 'incoming', + direction: 'incoming', + peer: '001602107449', + peerNumber: '001602107449', + peerName: undefined, + stateText: 'klingelt', + state: 'LSRinging', + }, + ]) + + // Aus dem Vergleich mit dem vorherigen Stand entsteht die Anrufmeldung. + const { events } = mergeSnapshot({}, parsed!.lines) + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ line: 1, event: 'incoming' }) + }) + + it('räumt die Leitung wieder ab, wenn der Anrufer auflegt', () => { + const ringing = parseSnapshot(JSON.parse(RINGING_SNAPSHOT))! + const { lines } = mergeSnapshot({}, ringing.lines) + const idle = parseSnapshot(snapshot(line({ line: 1 }), line({ line: 2 })))! + const { lines: next, events } = mergeSnapshot(lines, idle.lines) + expect(next).toEqual({}) + expect(events[0]).toMatchObject({ line: 1, event: 'ended', peerNumber: '001602107449' }) + }) +}) + +describe('mergeSnapshot', () => { + const ringing: CallEvent = { + line: 2, + event: 'incoming', + direction: 'incoming', + peerNumber: '+493012345', + peerName: 'Muster GmbH', + } + + it('meldet eine neu klingelnde Leitung', () => { + const { lines, events } = mergeSnapshot({}, [ringing]) + expect(events).toEqual([ringing]) + expect(lines[2]).toEqual(ringing) + }) + + it('meldet nichts, solange sich nichts ändert', () => { + const { lines } = mergeSnapshot({}, [ringing]) + expect(mergeSnapshot(lines, [ringing]).events).toEqual([]) + }) + + it('führt die Richtung über das Annehmen hinweg mit', () => { + const { lines } = mergeSnapshot({}, [ringing]) + const connected = { ...ringing, event: 'connected' as const, direction: undefined } + const { events } = mergeSnapshot(lines, [connected]) + expect(events).toEqual([{ ...connected, direction: 'incoming' }]) + }) + + it('meldet das Ende, wenn die Leitung im Snapshot fehlt', () => { + const { lines } = mergeSnapshot({}, [ringing]) + const { lines: next, events } = mergeSnapshot(lines, []) + expect(events).toEqual([{ ...ringing, event: 'ended', state: undefined, stateText: undefined }]) + expect(next).toEqual({}) + }) + + it('erkennt einen zweiten Anruf auf derselben Leitung', () => { + const { lines } = mergeSnapshot({}, [ringing]) + const other = { ...ringing, peerNumber: '+499998888', peerName: 'Andere GmbH' } + expect(mergeSnapshot(lines, [other]).events).toEqual([other]) + }) +}) + +describe('parseResult', () => { + it('liest die Erfolgsquittung mit Leitungsnummer', () => { + expect(parseResult(JSON.parse('{"id":11,"ok":true,"line":4,"type":"result"}'))).toEqual({ + id: 11, + ok: true, + line: 4, + error: undefined, + }) + }) + + it('liest die Fehlerquittung', () => { + expect( + parseResult(JSON.parse('{"id":8,"ok":false,"error":"Unbekanntes Kommando","type":"result"}')), + ).toMatchObject({ id: 8, ok: false, error: 'Unbekanntes Kommando' }) + }) + + it('liest die beiden Quittungen auf focus', () => { + // Fenster war da und ist jetzt vorn. + expect(parseResult(JSON.parse('{"type":"result","id":9,"ok":true,"focused":true}'))).toMatchObject( + { id: 9, ok: true, focused: true }, + ) + // Kein Fenster gefunden – die App hat die URL im Browser geöffnet. + expect(parseResult(JSON.parse('{"type":"result","id":9,"ok":true,"focused":false}'))).toMatchObject( + { id: 9, ok: true, focused: false }, + ) + }) + + it('lässt focused bei allen anderen Quittungen offen', () => { + expect(parseResult(JSON.parse('{"id":11,"ok":true,"line":4,"type":"result"}'))?.focused).toBeUndefined() + }) + + it('liest die Quittung auf tabs', () => { + const raw = + '{"type":"result","id":12,"ok":true,"tabs":[' + + '{"id":43,"title":"Kundenakte","url":"https://crm.example.local/kunden/4711","active":true},' + + '{"id":44,"title":"SwyxWeb","url":"http://localhost:5173/","active":false}]}' + expect(parseResult(JSON.parse(raw))?.tabs).toEqual([ + { id: 43, title: 'Kundenakte', url: 'https://crm.example.local/kunden/4711', active: true }, + { id: 44, title: 'SwyxWeb', url: 'http://localhost:5173/', active: false }, + ]) + }) + + it('liest die Kennung aus der Quittung auf opentab', () => { + expect(parseResult(JSON.parse('{"type":"result","id":13,"ok":true,"tabId":43}'))).toMatchObject({ + id: 13, + ok: true, + tabId: 43, + }) + }) + + it('lässt tabs und tabId bei allen anderen Quittungen offen', () => { + const result = parseResult(JSON.parse('{"id":11,"ok":true,"line":4,"type":"result"}')) + expect(result?.tabs).toBeUndefined() + expect(result?.tabId).toBeUndefined() + }) + + it('hält einen Snapshot für keine Quittung', () => { + expect(parseResult(snapshot())).toBeNull() + }) +}) + +describe('parseTabs', () => { + it('nimmt fehlende Felder hin – nur die Kennung ist Pflicht', () => { + expect(parseTabs([{ id: 7 }])).toEqual([{ id: 7, title: undefined, url: undefined, active: false }]) + }) + + it('verwirft Einträge ohne brauchbare Kennung', () => { + // Ohne Kennung ließe sich der Tab ohnehin nicht schließen. + expect(parseTabs([{ title: 'Ohne Kennung' }, 'Unfug', null, { id: 9 }])).toEqual([ + { id: 9, title: undefined, url: undefined, active: false }, + ]) + }) +}) + +describe('describeTab', () => { + it('nimmt den Titel, sonst die URL, sonst die Kennung', () => { + expect(describeTab({ id: 1, title: 'Kundenakte', url: 'https://x/', active: true })).toBe('Kundenakte') + expect(describeTab({ id: 2, url: 'https://x/', active: false })).toBe('https://x/') + expect(describeTab({ id: 3, active: false })).toBe('Tab 3') + }) +}) + +describe('describeCall', () => { + it('bevorzugt die fertige Anzeigeform der App', () => { + expect(describeCall({ line: 1, event: 'incoming', peer: 'Muster GmbH (+49301)' })).toBe( + 'Muster GmbH (+49301)', + ) + expect(describeCall({ line: 3, event: 'connected' })).toBe('Leitung 3') + }) +}) diff --git a/frontend/src/swyx/protocol.ts b/frontend/src/swyx/protocol.ts new file mode 100644 index 0000000..95924fb --- /dev/null +++ b/frontend/src/swyx/protocol.ts @@ -0,0 +1,323 @@ +/** + * Protokoll der SwyxTray-App, aufgezeichnet gegen `ws://192.168.180.135:17654/ws` + * (App-Version 1.0.0.0, `protocol: 1`; seit der Tab-Verwaltung meldet die App + * `protocol: 6`). + * + * App → Seite: + * { "app":"SwyxTray","version":"1.0.0.0","protocol":1,"session":7,"type":"hello" } + * { "type":"snapshot","connected":true,"serverUp":true,"overall":"Idle", + * "statusText":"Bereit","user":"Muster, M.","server":"127.0.0.1", + * "lines":[{"line":1,"state":"LSInactive","stateCode":0,"stateText":"frei", + * "peer":"unbekannt","peerNumber":"","peerName":"","busy":false, + * "selected":true}, …] } + * { "type":"result","id":7,"ok":true,"line":2 } + * + * Seite → App: + * { "id":7,"cmd":"call","number":"+49 30 1234567" } ferner answer/hangup (je `line`), + * ping, status und focus (bestätigt durch Sondierung; alles andere quittiert die App mit + * „Unbekanntes Kommando"). + * { "id":9,"cmd":"focus","title":"Kundenakte Muster GmbH","url":"https://…" } + * → { "type":"result","id":9,"ok":true,"focused":true } Fenster war da und ist jetzt vorn + * → { "type":"result","id":9,"ok":true,"focused":false } kein Fenster – Browser mit `url` + * Der Titel wird als Teilzeichenkette gesucht, unabhängig von der Schreibweise. Ohne + * Treffer und ohne `url` antwortet die App mit `ok:false`. + * + * Seit Protokoll 6 reicht die App drei weitere Kommandos unverändert an das + * Firefox-Plugin durch – die Tabs gehören also zum Browser auf dem Rechner der App: + * { "id":12,"cmd":"tabs" } + * → { "type":"result","id":12,"ok":true, + * "tabs":[{"id":43,"title":"Kundenakte","url":"https://…","active":true}, …] } + * { "id":13,"cmd":"opentab","url":"https://…" } → { …,"ok":true,"tabId":43 } + * { "id":14,"cmd":"closetab","tabId":43 } → { …,"ok":true } + * Ist kein Plugin verbunden, antwortet die App nach rund fünf Sekunden mit `ok:false`. + * + * **Die App kennt keine Ereignisnachrichten.** Jede Zustandsänderung – auch ein + * eingehender Anruf – kommt als vollständiger `snapshot` über *alle* Leitungen. + * Die Anrufmeldung der Seite entsteht deshalb aus dem Vergleich zweier Snapshots + * (siehe {@link mergeSnapshot}). + */ + +export type CallEventKind = 'incoming' | 'outgoing' | 'connected' | 'ended' +export type CallDirection = 'incoming' | 'outgoing' + +export interface CallEvent { + /** 1-basierte Leitungsnummer, wie in SwyxIt! */ + line: number + event: CallEventKind + /** Bei `connected` aus dem vorherigen Zustand derselben Leitung übernommen. */ + direction?: CallDirection + /** Fertige Anzeigeform der App, z. B. "Muster GmbH (+493012345)". */ + peer?: string + peerNumber?: string + peerName?: string + /** Klartext der App zum Leitungszustand, z. B. "klingelt". */ + stateText?: string + /** Rohzustand der App, z. B. "LSRinging" – für die Diagnose. */ + state?: string +} + +export interface ResultMessage { + id: number + ok: boolean + line?: number + /** Nur bei `focus`: true = Fenster geholt, false = Browser mit der URL gestartet. */ + focused?: boolean + /** Nur bei `tabs`: die offenen Tabs des Firefox-Plugins. */ + tabs?: BrowserTab[] + /** Nur bei `opentab`: Kennung des neu geöffneten Tabs. */ + tabId?: number + error?: string +} + +/** Ein Tab des Firefox-Plugins, wie ihn die Quittung auf `tabs` meldet. */ +export interface BrowserTab { + /** Kennung des Tabs; sie geht unverändert an `closetab` zurück. */ + id: number + title?: string + url?: string + /** Ist das der im Fenster sichtbare Tab? */ + active: boolean +} + +/** Begrüßung beim Verbindungsaufbau. */ +export interface HelloMessage { + app?: string + version?: string + protocol?: number + session?: number +} + +/** Vollzustand; die App schickt ihn beim Verbinden und nach jeder Änderung. */ +export interface SnapshotMessage { + /** Nur belegte Leitungen; freie Leitungen sind hier nicht enthalten. */ + lines: CallEvent[] + /** Ist die App mit SwyxIt! verbunden? */ + connected?: boolean + /** Ist der SwyxServer erreichbar? */ + serverUp?: boolean + /** Gesamtzustand, z. B. "Idle" oder "Dialing". */ + overall?: string + /** Klartext dazu, z. B. "Bereit". */ + statusText?: string + user?: string + server?: string + raw: unknown +} + +/** Kommandonamen; alle gegen die App bestätigt. */ +export const COMMANDS = { + call: 'call', + answer: 'answer', + hangup: 'hangup', + ping: 'ping', + status: 'status', + focus: 'focus', + tabs: 'tabs', + openTab: 'opentab', + closeTab: 'closetab', +} as const + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function asString(value: unknown): string | undefined { + if (typeof value === 'string' && value !== '') return value + if (typeof value === 'number') return String(value) + return undefined +} + +/** + * Ganze Zahlen aus dem Protokoll – Leitungsnummer, Kommando-Id, Tab-Kennung. + * Eine Ziffernfolge als Text wird ebenfalls akzeptiert. + */ +function asLine(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isInteger(value)) return value + if (typeof value === 'string' && /^\d+$/.test(value.trim())) return Number(value) + return undefined +} + +function asBoolean(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined +} + +/** + * Leitungszustand der App → Anrufzustand der Seite; `null` heißt „frei". + * + * `state` trägt die Namen der CLMgr-Aufzählung `LineState`, `stateText` den + * deutschen Klartext aus SwyxIt!. Bestätigt ist bisher nur `LSInactive`/"frei"; + * deshalb wird beides ausgewertet und ein **unbekannter Zustand als belegt** + * behandelt – eine klingelnde Leitung darf nie stillschweigend verschwinden. + */ +export function callStateOf(state?: string, stateText?: string): CallEventKind | null { + const name = (state ?? '').toLowerCase() + const text = (stateText ?? '').toLowerCase() + const has = (...needles: string[]) => needles.some((n) => name.includes(n) || text.includes(n)) + + // Frei oder gerade freigeworden. + if (has('inactive', 'disabled', 'terminate', 'frei', 'beendet')) return null + // Eingehend: die Leitung klingelt beim Benutzer. + if (has('ringing', 'knocking', 'klingelt', 'ruft an')) return 'incoming' + // Ausgehend: gewählt wird, die Gegenstelle wird gerufen. + if (has('dialing', 'initiate', 'alerting', 'wählt', 'wahl', 'verbindungsaufbau')) return 'outgoing' + // Alles Übrige gilt als belegt (aktiv, gehalten, Konferenz, unbekannt). + return 'connected' +} + +/** Liest einen Leitungseintrag aus dem Snapshot; `null`, wenn die Leitung frei ist. */ +export function parseSnapshotLine(raw: unknown): CallEvent | null { + if (!isRecord(raw)) return null + const line = asLine(raw.line) + if (line === undefined) return null + + const state = asString(raw.state) + const stateText = asString(raw.stateText) + const event = callStateOf(state, stateText) + if (!event) return null + + // "unbekannt" ist der Platzhalter der App für eine Leitung ohne Gegenstelle. + const peer = asString(raw.peer) + return { + line, + event, + // Bei `connected` verrät der Zustand die Richtung nicht – sie kommt aus dem + // vorherigen Snapshot derselben Leitung (siehe mergeSnapshot). + direction: event === 'incoming' || event === 'outgoing' ? event : undefined, + peer: peer === 'unbekannt' ? undefined : peer, + peerNumber: asString(raw.peerNumber), + peerName: asString(raw.peerName), + stateText, + state, + } +} + +/** Liest die Quittung auf ein Kommando. */ +export function parseResult(raw: unknown): ResultMessage | null { + if (!isRecord(raw)) return null + if (raw.type !== undefined && raw.type !== 'result') return null + if (typeof raw.ok !== 'boolean') return null + + const id = asLine(raw.id) + if (id === undefined) return null + + return { + id, + ok: raw.ok, + line: asLine(raw.line), + focused: asBoolean(raw.focused), + tabs: Array.isArray(raw.tabs) ? parseTabs(raw.tabs) : undefined, + tabId: asLine(raw.tabId), + error: asString(raw.error), + } +} + +/** + * Liest die Tabliste aus der Quittung auf `tabs`. Einträge ohne brauchbare + * Kennung fallen weg – ohne sie ließe sich der Tab ohnehin nicht schließen. + */ +export function parseTabs(raw: unknown[]): BrowserTab[] { + return raw.flatMap((entry) => { + if (!isRecord(entry)) return [] + const id = asLine(entry.id) + if (id === undefined) return [] + return [ + { + id, + title: asString(entry.title), + url: asString(entry.url), + active: asBoolean(entry.active) ?? false, + }, + ] + }) +} + +/** Anzeigename eines Tabs: Titel, sonst die URL, sonst die Kennung. */ +export function describeTab(tab: BrowserTab): string { + return tab.title ?? tab.url ?? `Tab ${tab.id}` +} + +/** Liest die Begrüßung. */ +export function parseHello(raw: unknown): HelloMessage | null { + if (!isRecord(raw) || raw.type !== 'hello') return null + return { + app: asString(raw.app), + version: asString(raw.version), + protocol: asLine(raw.protocol), + session: asLine(raw.session), + } +} + +/** + * Liest den Vollzustand. Freie Leitungen werden verworfen – die App schickt + * immer alle vier Leitungen mit, auch die unbenutzten. + */ +export function parseSnapshot(raw: unknown): SnapshotMessage | null { + if (!isRecord(raw) || raw.type !== 'snapshot') return null + + const entries = Array.isArray(raw.lines) ? raw.lines : [] + const lines = entries.flatMap((entry) => { + const parsed = parseSnapshotLine(entry) + return parsed ? [parsed] : [] + }) + + return { + lines, + connected: asBoolean(raw.connected), + serverUp: asBoolean(raw.serverUp), + overall: asString(raw.overall), + statusText: asString(raw.statusText), + user: asString(raw.user), + server: asString(raw.server), + raw, + } +} + +/** + * Führt einen Snapshot mit dem bisherigen Stand zusammen. + * + * Weil die App keine Ereignisse schickt, entstehen sie hier aus dem Vergleich: + * neue oder veränderte Leitungen ergeben ein Ereignis, verschwundene ein + * `ended`. Die Richtung wird über den Wechsel hinweg mitgeführt, damit ein + * angenommener Anruf im Verlauf eingehend bleibt. + */ +export function mergeSnapshot( + previous: Record, + lines: CallEvent[], +): { lines: Record; events: CallEvent[] } { + const next: Record = {} + const events: CallEvent[] = [] + + for (const entry of lines) { + const before = previous[entry.line] + // Gleiche Gegenstelle ⇒ derselbe Anruf; nur dann darf etwas übernommen werden. + const sameCall = + before !== undefined && + (entry.peerNumber ?? entry.peer) === (before.peerNumber ?? before.peer) + + const merged: CallEvent = { + ...entry, + direction: entry.direction ?? (sameCall ? before.direction : undefined), + peer: entry.peer ?? (sameCall ? before.peer : undefined), + peerNumber: entry.peerNumber ?? (sameCall ? before.peerNumber : undefined), + peerName: entry.peerName ?? (sameCall ? before.peerName : undefined), + } + next[entry.line] = merged + + if (!before || before.event !== merged.event || !sameCall) { + events.push(merged) + } + } + + for (const before of Object.values(previous)) { + if (!next[before.line]) { + events.push({ ...before, event: 'ended', stateText: undefined, state: undefined }) + } + } + + return { lines: next, events: events.sort((a, b) => a.line - b.line) } +} + +/** Anzeigename einer Leitung: fertige Anzeigeform, sonst Name oder Nummer. */ +export function describeCall(event: CallEvent): string { + return event.peer ?? event.peerName ?? event.peerNumber ?? `Leitung ${event.line}` +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..b2ebf9f --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_WS_URL?: string + readonly VITE_BACKEND_URL?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..e30b12c --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..d74e9df --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "types": ["node"] + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..530762b --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + host: true, + port: 5173, + proxy: { + // REST-Aufrufe im Dev-Betrieb an das Spring-Boot-Backend weiterreichen. + '/api': { + target: process.env.VITE_BACKEND_URL ?? 'http://localhost:8080', + changeOrigin: true, + }, + }, + }, +})