commit d6956395c8c05bfd394f2a3c8322a190cae9d615 Author: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Mon Jul 6 08:53:14 2026 +0200 Rebrand CommGate to gsmnode and adopt new design system Rename the whole project from CommGate to gsmnode and re-skin every surface onto the new signal-green + ink design system (Space Grotesk / IBM Plex Sans / JetBrains Mono, flat surfaces, two-arrow routing mark, lowercase gsm+node wordmark). - Web App + API panel: rewrite token layer, gn-* utilities, data-gsm-theme, new logo/favicon assets; both builds verified. - Phone App (Flutter): green theme, new mark/wordmark widgets, adaptive launcher icons; rename Android applicationId/package to app.gsmnode.phone; bump AGP to 8.6.0 and google_fonts to 8.1.0; debug APK build verified. - API Server (Go): panel routes, User-Agent, health service id, branding. - Home Assistant Plugin: rename integration domain sms_gateway to gsmnode (folder, DOMAIN, services, entity ids, classes); py_compile verified. - Update all READMEs; add .gitignore (excludes Design/, build artifacts). Co-Authored-By: Claude Opus 4.8 diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..b373b84 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,19 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "web-app", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "cwd": "Web App/web", + "port": 5173 + }, + { + "name": "api-server", + "runtimeExecutable": "./api-server.exe", + "runtimeArgs": [], + "cwd": "API Server", + "port": 8080 + } + ] +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..5816aa8 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,49 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:docs.sms-gate.app)", + "Bash(go version *)", + "Bash(npm --version)", + "Bash(flutter --version)", + "Bash(java -version)", + "Bash(curl -s -m 5 -o /dev/null -w \"%{http_code}\" http://10.2.1.10:8028/api/health)", + "Bash(winget --version)", + "Bash(winget install *)", + "WebFetch(domain:capcom6.github.io)", + "Bash(curl -s -m 5 http://10.2.1.10:8028/api/health)", + "Bash(curl -s -m 5 -o /dev/null -w \"superuser-auth endpoint: %{http_code}\\\\n\" -X POST http://10.2.1.10:8028/api/collections/_superusers/auth-with-password)", + "Bash(curl -s -m 5 -o /dev/null -w \"legacy admins endpoint: %{http_code}\\\\n\" -X POST http://10.2.1.10:8028/api/admins/auth-with-password)", + "Bash(ls \"C:/Users/jania/.claude/projects/E--VS-Code-Projects-SMS-Gateway-Project/memory/\" 2>&1)", + "Bash(npm install *)", + "Bash(npm run *)", + "Bash(git --version)", + "Bash(winget search *)", + "PowerShell(winget install --id Microsoft.OpenJDK.17 -e --accept-source-agreements --accept-package-agreements --disable-interactivity 2>&1)", + "Bash(rm -f test/widget_test.dart)", + "Bash(rmdir test *)", + "Bash(cp /tmp/phone_login.png \"C:/Users/jania/AppData/Local/Temp/claude/E--VS-Code-Projects-SMS-Gateway-Project/1197fea8-15b7-4bf5-83ae-354f909b16ae/scratchpad/phone_login.png\" && echo copied && ls -lh \"C:/Users/jania/AppData/Local/Temp/claude/E--VS-Code-Projects-SMS-Gateway-Project/1197fea8-15b7-4bf5-83ae-354f909b16ae/scratchpad/phone_login.png\")", + "Bash(/c/Users/jania/AppData/Local/Android/sdk/platform-tools/adb.exe -s R3CT206F84W shell pidof app.smsgateway.sms_gateway_phone)", + "Bash(/c/Users/jania/AppData/Local/Android/sdk/platform-tools/adb.exe -s R3CT206F84W shell dumpsys activity activities)", + "Bash(python -m py_compile __init__.py const.py notify.py)", + "Bash(python -c \"import json; json.load\\(open\\('manifest.json'\\)\\); print\\('MANIFEST JSON OK'\\)\")", + "Bash(./api-server.exe)", + "Bash(./web-app.exe)", + "Bash(ipconfig)", + "Bash(curl -s -m 2 -o /dev/null http://127.0.0.1:8090/)", + "Bash(break)", + "Bash(curl -s -m 5 http://127.0.0.1:8090/)", + "Bash(curl -s -m 5 http://127.0.0.1:8090/api/health)", + "Bash(curl -s -m 5 -o /dev/null -w 'OPTIONS /api/auth/login -> %{http_code}\\\\n' -X OPTIONS -H 'Origin: http://10.2.1.101:8090' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: authorization,content-type' http://10.2.1.101:8080/api/auth/login)", + "Bash(curl -s -m 5 -D - -o /dev/null -X POST -H 'Origin: http://10.2.1.101:8090' -H 'Content-Type: application/json' -d '{\"email\":\"e2e@test.local\",\"password\":\"e2eTestPass123\"}' http://10.2.1.101:8080/api/auth/login)", + "Bash(curl -s -m 2 -o /dev/null http://127.0.0.1:8080/api/health)", + "Bash(node -e ' *)", + "Bash(python -c \"import json; print\\('manifest v', json.load\\(open\\('manifest.json'\\)\\)['version']\\)\")", + "Bash(curl -s -m 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/api/health)", + "Bash(echo \"API :8080 -> $\\(curl -s -m 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/api/health 2>&1\\)\")", + "Bash(curl -s -m 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:8090/)", + "Bash(echo \"Web :8090 -> $\\(curl -s -m 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:8090/ 2>&1\\)\")", + "Bash(tasklist //FI \"IMAGENAME eq api-server.exe\")", + "Bash(tasklist //FI \"IMAGENAME eq web-app.exe\")" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94c475b --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Design assets / source kits — excluded per project decision +Design/ + +# Node +node_modules/ + +# Flutter / Dart +Phone App/build/ +Phone App/.dart_tool/ +Phone App/.flutter-plugins +Phone App/.flutter-plugins-dependencies +Phone App/.packages +Phone App/pubspec.lock + +# Android / Gradle +.gradle/ +.kotlin/ +*.iml +Phone App/android/local.properties +Phone App/android/key.properties + +# Go build output / binaries +*.exe + +# Environment & secrets +.env +*.local + +# OS / editor +.DS_Store +Thumbs.db diff --git a/API Server/.env.example b/API Server/.env.example new file mode 100644 index 0000000..76c74ad --- /dev/null +++ b/API Server/.env.example @@ -0,0 +1,28 @@ +# API Server configuration +# Copy to .env and adjust. The server also reads plain environment variables. + +# Address the API Server listens on +API_ADDR=:8080 + +# PocketBase base URL (no trailing slash) +POCKETBASE_URL=http://10.2.1.10:8028 + +# PocketBase superuser credentials. The API Server is the ONLY thing that talks +# to PocketBase, so it authenticates as a superuser and enforces ownership in +# application logic. Lock all collection API rules to superuser-only. +PB_ADMIN_EMAIL=admin@example.com +PB_ADMIN_PASSWORD=change-me + +# Secret used to sign client JWTs (use a long random string in production) +JWT_SECRET=dev-insecure-change-me-please + +# Access token lifetime (Go duration: 30m, 24h, 168h ...) +JWT_ACCESS_TTL=24h + +# How long a message/call may stay unprocessed before it's marked Failed. +# A device must pull it (Pending) and report a result (Processed) within this +# window, else the background sweeper fails it. +MESSAGE_TTL=5m + +# CORS allowed origins for the Web App (comma separated, or * for any) +CORS_ALLOW_ORIGINS=* diff --git a/API Server/.gitignore b/API Server/.gitignore new file mode 100644 index 0000000..6dfa40c --- /dev/null +++ b/API Server/.gitignore @@ -0,0 +1,5 @@ +.env +/server +/server.exe +/tmp/ +*.log diff --git a/API Server/README.md b/API Server/README.md new file mode 100644 index 0000000..d19f4a0 --- /dev/null +++ b/API Server/README.md @@ -0,0 +1,154 @@ +# gsmnode — API Server + +Go service that is the **single trusted entry point** in front of PocketBase. +The Web App and Phone App talk only to this server; it performs all PocketBase +access as a superuser and enforces ownership in application logic. + +``` +Web App ─┐ + ├─► API Server (:8080) ─► PocketBase (10.2.1.10:8028) +Phone App ─┘ +``` + +The server root (`GET /`) serves a gsmnode-branded **web panel**: a live health +readout plus a quick reference of both API audiences. Open +http://localhost:8080/ in a browser to check the server at a glance. + +The panel is a **Vue 3 + Tailwind v4** app in [`panel/`](panel/), built into +`internal/api/dist` and embedded into the Go binary at compile time: + +```powershell +cd panel +npm install +npm run build # outputs to ../internal/api/dist +cd ..; go build -o api-server.exe ./cmd/server # embeds the fresh dist +``` + +For panel development with hot reload (proxies `/api` to a running server on +`:8080`): `cd panel; npm run dev` → http://localhost:5174. + +## Requirements + +- Go 1.26+ (`go version`) +- A reachable PocketBase v0.23+ instance and its **superuser** credentials +- Node 18+ (only for the setup scripts) + +## 1. Configure + +```powershell +Copy-Item .env.example .env +# edit .env: set PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD, and a strong JWT_SECRET +``` + +| Variable | Purpose | Default | +|---|---|---| +| `API_ADDR` | Listen address | `:8080` | +| `POCKETBASE_URL` | PocketBase base URL | `http://10.2.1.10:8028` | +| `PB_ADMIN_EMAIL` / `PB_ADMIN_PASSWORD` | PocketBase superuser login | — (required) | +| `JWT_SECRET` | Signs client JWTs | dev placeholder | +| `JWT_ACCESS_TTL` | Access-token lifetime | `24h` | +| `CORS_ALLOW_ORIGINS` | Comma list, or `*` | `*` | + +## 2. Set up PocketBase collections + +Creates/updates `devices`, `messages`, `inbox`, `webhooks` (the default `users` +auth collection is reused). Idempotent. + +```powershell +$env:POCKETBASE_URL="http://10.2.1.10:8028" +$env:PB_ADMIN_EMAIL="admin@example.com" +$env:PB_ADMIN_PASSWORD="your-password" +node scripts/setup-pocketbase.mjs +``` + +Create a login user: + +```powershell +node scripts/create-user.mjs user@example.com "user-password" "Display Name" +``` + +## 3. Run + +```powershell +./scripts/Run-ApiServer.ps1 +# or: go run ./cmd/server +``` + +Health check: `GET http://localhost:8080/api/health`. + +## API + +### Authentication scheme + +- **Client API** (`/api/...`): `Authorization: Bearer ` from `/api/auth/login`. + Used by the Web App and integrators. +- **Mobile API** (`/api/mobile/...`): `Authorization: Bearer ` + returned by device registration. Used by the Phone App. + +### Client / 3rd-party endpoints + +| Method | Path | Description | +|---|---|---| +| `GET` | `/api/health` | Readiness probe (public) | +| `POST` | `/api/auth/login` | `{email, password}` → JWT + user | +| `POST` | `/api/auth/refresh` | New JWT (auth) | +| `GET` | `/api/auth/me` | Current user | +| `GET` | `/api/devices` | List your devices | +| `DELETE` | `/api/devices/{id}` | Remove a device | +| `POST` | `/api/messages` | Enqueue SMS `{phone_numbers[], text_message, device_id?, sim_number?, schedule_at?}` | +| `POST` | `/api/calls` | Enqueue a phone call `{phone_number, device_id?}` | +| `GET` | `/api/messages` | List messages (`?status=&device_id=&type=&page=&per_page=`) | +| `GET` | `/api/messages/{id}` | Message state | +| `GET` | `/api/inbox` | Received SMS | +| `GET` | `/api/webhooks` | List webhooks | +| `POST` | `/api/webhooks` | Register `{event, url, device_id?}` | +| `DELETE` | `/api/webhooks/{id}` | Delete webhook | + +### Mobile / device endpoints + +| Method | Path | Description | +|---|---|---| +| `POST` | `/api/mobile/v1/device` | Register device (auth: **user JWT**) → returns `auth_token` | +| `POST` | `/api/mobile/v1/ping` | Heartbeat (auth: device token) | +| `GET` | `/api/mobile/v1/messages` | Pull pending messages; marks them `Processed` | +| `PATCH` | `/api/mobile/v1/messages/{id}` | Report `{status, error?}` (`Sent`/`Delivered`/`Failed`) | +| `POST` | `/api/mobile/v1/inbox` | Report received SMS `{phone_number, message, received_at?}` | + +### Message lifecycle + +`Pending` → (device pulls) `Processed` → `Sent` → `Delivered`, or `Failed`. + +Calls are stored as messages with `type: "call"` (SMS is `type: "sms"`) and flow +through the same pull/report pipeline. The device dials the number natively and +reports `Sent` (a call has no delivery report) or `Failed`. + +### Webhook events + +`sms:received`, `sms:sent`, `sms:delivered`, `sms:failed`. Delivered as +`POST {event, device_id, payload, created_at}` to the registered URL. + +## Quick smoke test + +```powershell +# log in +$login = curl -s -X POST http://localhost:8080/api/auth/login ` + -H "Content-Type: application/json" ` + -d '{"email":"user@example.com","password":"user-password"}' | ConvertFrom-Json +$token = $login.access_token + +# register a device (as the phone app would) +curl -s -X POST http://localhost:8080/api/mobile/v1/device ` + -H "Authorization: Bearer $token" -H "Content-Type: application/json" ` + -d '{"device_id":"test-1","name":"Test Phone","platform":"android"}' +``` + +## Project layout + +``` +cmd/server/main.go entry point, wiring, graceful shutdown +internal/config env/.env configuration +internal/pb PocketBase REST client (superuser) +internal/auth JWT issue/verify, device token generation +internal/api router, middleware, handlers +scripts/ PocketBase setup + run helpers +``` diff --git a/API Server/cmd/server/main.go b/API Server/cmd/server/main.go new file mode 100644 index 0000000..d1f6c1d --- /dev/null +++ b/API Server/cmd/server/main.go @@ -0,0 +1,64 @@ +// Command server runs the gsmnode API Server. It is the single trusted +// entry point in front of PocketBase: the Web App and Phone App talk only to +// this service, which in turn performs all PocketBase access. +package main + +import ( + "context" + "errors" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "smsgateway/apiserver/internal/api" + "smsgateway/apiserver/internal/auth" + "smsgateway/apiserver/internal/config" + "smsgateway/apiserver/internal/pb" +) + +func main() { + log.SetFlags(log.LstdFlags | log.Lmsgprefix) + log.SetPrefix("[api] ") + + cfg := config.Load() + + client := pb.New(cfg.PocketBaseURL, cfg.PBAdminEmail, cfg.PBAdminPass) + jwtMgr := auth.NewManager(cfg.JWTSecret, cfg.JWTAccessTTL) + srv := api.New(cfg, client, jwtMgr) + + // Background worker that fails messages no device processed in time. + workerCtx, workerCancel := context.WithCancel(context.Background()) + defer workerCancel() + srv.StartExpiryWorker(workerCtx) + + httpServer := &http.Server{ + Addr: cfg.Addr, + Handler: srv.Handler(), + ReadHeaderTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + go func() { + log.Printf("listening on %s (PocketBase: %s)", cfg.Addr, cfg.PocketBaseURL) + if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("server error: %v", err) + } + }() + + // Graceful shutdown. + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + <-stop + log.Println("shutting down...") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := httpServer.Shutdown(ctx); err != nil { + log.Printf("shutdown error: %v", err) + } + log.Println("stopped") +} diff --git a/API Server/go.mod b/API Server/go.mod new file mode 100644 index 0000000..5c06511 --- /dev/null +++ b/API Server/go.mod @@ -0,0 +1,5 @@ +module smsgateway/apiserver + +go 1.26 + +require github.com/golang-jwt/jwt/v5 v5.2.1 diff --git a/API Server/go.sum b/API Server/go.sum new file mode 100644 index 0000000..f56d3e6 --- /dev/null +++ b/API Server/go.sum @@ -0,0 +1,2 @@ +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= diff --git a/API Server/internal/api/auth.go b/API Server/internal/api/auth.go new file mode 100644 index 0000000..8cbefcf --- /dev/null +++ b/API Server/internal/api/auth.go @@ -0,0 +1,101 @@ +package api + +import ( + "net/http" + "strings" +) + +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type userDTO struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name,omitempty"` +} + +type tokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresAt string `json:"expires_at"` + User userDTO `json:"user"` +} + +// handleLogin authenticates a user against PocketBase and issues a client JWT. +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + req.Email = strings.TrimSpace(req.Email) + if req.Email == "" || req.Password == "" { + writeError(w, http.StatusBadRequest, "email and password are required") + return + } + + res, err := s.pb.AuthWithPassword(r.Context(), colUsers, req.Email, req.Password) + if err != nil { + writeError(w, http.StatusUnauthorized, "invalid credentials") + return + } + + user := recordToUser(res.Record) + token, exp, err := s.jwt.Issue(user.ID, user.Email, user.Name) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not issue token") + return + } + writeJSON(w, http.StatusOK, tokenResponse{ + AccessToken: token, + TokenType: "Bearer", + ExpiresAt: exp.UTC().Format("2006-01-02T15:04:05Z07:00"), + User: user, + }) +} + +// handleRefresh issues a fresh token for an already-authenticated user. +func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) { + id, email := userFromCtx(r.Context()) + rec, err := s.pb.GetOne(r.Context(), colUsers, id) + if err != nil { + writeUpstreamError(w, err) + return + } + user := recordToUser(rec) + if user.Email == "" { + user.Email = email + } + token, exp, err := s.jwt.Issue(user.ID, user.Email, user.Name) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not issue token") + return + } + writeJSON(w, http.StatusOK, tokenResponse{ + AccessToken: token, + TokenType: "Bearer", + ExpiresAt: exp.UTC().Format("2006-01-02T15:04:05Z07:00"), + User: user, + }) +} + +// handleMe returns the current user's profile. +func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { + id, _ := userFromCtx(r.Context()) + rec, err := s.pb.GetOne(r.Context(), colUsers, id) + if err != nil { + writeUpstreamError(w, err) + return + } + writeJSON(w, http.StatusOK, recordToUser(rec)) +} + +func recordToUser(rec map[string]any) userDTO { + return userDTO{ + ID: asString(rec["id"]), + Email: asString(rec["email"]), + Name: asString(rec["name"]), + } +} diff --git a/API Server/internal/api/calls.go b/API Server/internal/api/calls.go new file mode 100644 index 0000000..114bb52 --- /dev/null +++ b/API Server/internal/api/calls.go @@ -0,0 +1,54 @@ +package api + +import ( + "net/http" + "strings" + + "smsgateway/apiserver/internal/pb" +) + +type enqueueCallRequest struct { + PhoneNumber string `json:"phone_number"` + DeviceID string `json:"device_id"` +} + +// handleEnqueueCall queues an outbound phone call for one of the user's devices. +// A call is stored as a message with type "call"; the device pulls it through +// the same mobile pipeline as SMS and places the call natively. +func (s *Server) handleEnqueueCall(w http.ResponseWriter, r *http.Request) { + uid, _ := userFromCtx(r.Context()) + + var req enqueueCallRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + phone := strings.TrimSpace(req.PhoneNumber) + if phone == "" { + writeError(w, http.StatusBadRequest, "phone_number is required") + return + } + + deviceRecID, err := s.resolveDevice(r, uid, req.DeviceID) + if err != nil { + writeUpstreamError(w, err) + return + } + if deviceRecID == "" { + writeError(w, http.StatusBadRequest, "no device available; register a device first") + return + } + + rec, err := s.pb.Create(r.Context(), colMessages, pb.Record{ + "phone_numbers": []string{phone}, + "type": msgTypeCall, + "device": deviceRecID, + "owner": uid, + "status": statusPending, + }) + if err != nil { + writeUpstreamError(w, err) + return + } + writeJSON(w, http.StatusAccepted, recordToMessage(rec)) +} diff --git a/API Server/internal/api/devices.go b/API Server/internal/api/devices.go new file mode 100644 index 0000000..432d4e9 --- /dev/null +++ b/API Server/internal/api/devices.go @@ -0,0 +1,191 @@ +package api + +import ( + "net/http" + "strings" + "time" + + "smsgateway/apiserver/internal/auth" + "smsgateway/apiserver/internal/pb" +) + +type deviceDTO struct { + ID string `json:"id"` + DeviceID string `json:"device_id"` + Name string `json:"name"` + Platform string `json:"platform"` + AppVersion string `json:"app_version"` + Status string `json:"status"` + LastSeenAt string `json:"last_seen_at"` +} + +// onlineWindow is how recently a device must have pinged to count as online. +// The phone pings every ~60s, so this tolerates a couple of missed pings. +const onlineWindow = 3 * time.Minute + +func recordToDevice(rec pb.Record) deviceDTO { + lastSeen := asString(rec["last_seen_at"]) + status := "offline" + if deviceOnline(lastSeen) { + status = "online" + } + return deviceDTO{ + ID: asString(rec["id"]), + DeviceID: asString(rec["device_id"]), + Name: asString(rec["name"]), + Platform: asString(rec["platform"]), + AppVersion: asString(rec["app_version"]), + Status: status, + LastSeenAt: lastSeen, + } +} + +// deviceOnline reports whether last_seen_at is within the online window. It +// accepts the PocketBase datetime format as well as RFC3339. +func deviceOnline(lastSeenAt string) bool { + if lastSeenAt == "" { + return false + } + for _, layout := range []string{ + "2006-01-02 15:04:05.999Z07:00", + "2006-01-02 15:04:05.999Z", + time.RFC3339, + } { + if t, err := time.Parse(layout, lastSeenAt); err == nil { + return time.Since(t) < onlineWindow + } + } + return false +} + +// handleListDevices returns the authenticated user's devices. +func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { + uid, _ := userFromCtx(r.Context()) + res, err := s.pb.List(r.Context(), colDevices, pb.ListOptions{ + Filter: "owner = " + pbQuote(uid), + Sort: "-created", + PerPage: 200, + }) + if err != nil { + writeUpstreamError(w, err) + return + } + out := make([]deviceDTO, 0, len(res.Items)) + for _, rec := range res.Items { + out = append(out, recordToDevice(rec)) + } + writeJSON(w, http.StatusOK, map[string]any{"items": out, "total": res.TotalItems}) +} + +// handleDeleteDevice removes a device owned by the user. +func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request) { + uid, _ := userFromCtx(r.Context()) + id := r.PathValue("id") + + rec, err := s.pb.GetOne(r.Context(), colDevices, id) + if err != nil { + writeUpstreamError(w, err) + return + } + if asString(rec["owner"]) != uid { + writeError(w, http.StatusForbidden, "not your device") + return + } + if err := s.pb.Delete(r.Context(), colDevices, id); err != nil { + writeUpstreamError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +type registerDeviceRequest struct { + DeviceID string `json:"device_id"` + Name string `json:"name"` + Platform string `json:"platform"` + AppVersion string `json:"app_version"` + PushToken string `json:"push_token"` +} + +type registerDeviceResponse struct { + deviceDTO + AuthToken string `json:"auth_token"` +} + +// handleRegisterDevice registers (or re-registers) a mobile device for the +// authenticated user and returns an opaque device token. Re-registering with +// the same device_id rotates the token and updates metadata. +func (s *Server) handleRegisterDevice(w http.ResponseWriter, r *http.Request) { + uid, _ := userFromCtx(r.Context()) + + var req registerDeviceRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + req.DeviceID = strings.TrimSpace(req.DeviceID) + if req.DeviceID == "" { + writeError(w, http.StatusBadRequest, "device_id is required") + return + } + if req.Platform == "" { + req.Platform = "android" + } + + token, err := auth.NewDeviceToken() + if err != nil { + writeError(w, http.StatusInternalServerError, "could not generate token") + return + } + + now := time.Now().UTC().Format("2006-01-02 15:04:05.000Z") + fields := pb.Record{ + "device_id": req.DeviceID, + "name": req.Name, + "platform": req.Platform, + "app_version": req.AppVersion, + "push_token": req.PushToken, + "status": "online", + "last_seen_at": now, + "auth_token": token, + "owner": uid, + } + + // Upsert by (owner, device_id). + existing, err := s.pb.FindFirst(r.Context(), colDevices, + "owner = "+pbQuote(uid)+" && device_id = "+pbQuote(req.DeviceID), "") + if err != nil { + writeUpstreamError(w, err) + return + } + + var rec pb.Record + if existing != nil { + rec, err = s.pb.Update(r.Context(), colDevices, asString(existing["id"]), fields) + } else { + rec, err = s.pb.Create(r.Context(), colDevices, fields) + } + if err != nil { + writeUpstreamError(w, err) + return + } + + writeJSON(w, http.StatusOK, registerDeviceResponse{ + deviceDTO: recordToDevice(rec), + AuthToken: token, + }) +} + +// handlePing updates a device heartbeat (last_seen_at + online status). +func (s *Server) handlePing(w http.ResponseWriter, r *http.Request) { + device := deviceFromCtx(r.Context()) + now := time.Now().UTC().Format("2006-01-02 15:04:05.000Z") + _, err := s.pb.Update(r.Context(), colDevices, asString(device["id"]), pb.Record{ + "status": "online", + "last_seen_at": now, + }) + if err != nil { + writeUpstreamError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"status": "ok"}) +} diff --git a/API Server/internal/api/dist/assets/index-FjfYnLdU.css b/API Server/internal/api/dist/assets/index-FjfYnLdU.css new file mode 100644 index 0000000..076224a --- /dev/null +++ b/API Server/internal/api/dist/assets/index-FjfYnLdU.css @@ -0,0 +1 @@ +@import"https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap";/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:"IBM Plex Sans", ui-sans-serif, system-ui, "Segoe UI", sans-serif;--font-mono:"JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;--spacing:.25rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--font-weight-medium:500;--font-weight-semibold:600;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:"IBM Plex Sans", ui-sans-serif, system-ui, "Segoe UI", sans-serif;--default-mono-font-family:"JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;--font-display:"Space Grotesk", ui-sans-serif, system-ui, "Segoe UI", sans-serif;--radius-sm:6px;--radius-md:8px}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.mx-auto{margin-inline:auto}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.gn-btn-sec{border-radius:var(--radius-md);border:1px solid var(--border-strong);background:var(--surface-card);height:40px;color:var(--text-primary);font-family:var(--font-sans);cursor:pointer;transition:background-color var(--dur-fast) var(--ease-standard),transform var(--dur-fast) var(--ease-standard);justify-content:center;align-items:center;gap:8px;padding:0 16px;font-size:.875rem;font-weight:600;display:inline-flex}.flex{display:flex}.inline-flex{display:inline-flex}.gn-btn-sm{border-radius:var(--radius-sm);height:32px;padding:0 12px;font-size:.75rem}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-4{height:calc(var(--spacing) * 4)}.h-8{height:calc(var(--spacing) * 8)}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-4{width:calc(var(--spacing) * 4)}.w-full{width:100%}.max-w-3xl{max-width:var(--container-3xl)}.flex-1{flex:1}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.overflow-hidden{overflow:hidden}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:12px}.rounded-sm{border-radius:6px}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-subtle{border-color:var(--border-subtle)}.bg-card{background-color:var(--surface-card)}.bg-current{background-color:currentColor}.bg-danger-tint{background-color:var(--danger-tint)}.bg-success-tint{background-color:var(--success-tint)}.bg-sunken{background-color:var(--bg-sunken)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-1{padding-block:var(--spacing)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.pt-12{padding-top:calc(var(--spacing) * 12)}.pb-16{padding-bottom:calc(var(--spacing) * 16)}.text-center{text-align:center}.text-left{text-align:left}.gn-eyebrow{font-family:var(--font-mono);letter-spacing:.16em;text-transform:uppercase;color:var(--text-muted);font-size:11px}.font-mono{font-family:JetBrains Mono,ui-monospace,SFMono-Regular,Menlo,monospace}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[11px\]{font-size:11px}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.whitespace-nowrap{white-space:nowrap}.text-brand-text{color:var(--text-brand)}.text-danger{color:var(--danger)}.text-muted{color:var(--text-muted)}.text-primary{color:var(--text-primary)}.text-secondary{color:var(--text-secondary)}.text-success{color:var(--success)}.text-warning{color:var(--warning)}.shadow-sm{--tw-shadow:var(--sh-sm);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:var(--sh-xs);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media(hover:hover){.hover\:bg-sunken:hover{background-color:var(--bg-sunken)}}}:root{--green-100:oklch(95% .04 152);--green-200:oklch(88% .07 152);--green-300:oklch(78% .11 152);--green-500:oklch(60% .14 152);--green-600:oklch(52% .13 152);--green-700:oklch(44% .11 152);--green-on-dark:oklch(72% .14 152);--ink:#12161c;--ink-900:#0a0d11;--ink-800:#1a1f27;--paper:#fafaf9;--white:#fff;--gray-50:#f4f5f4;--gray-100:#e9ebea;--gray-200:#dcdfde;--gray-300:#c2c7c6;--gray-400:#9aa0a2;--gray-500:#6b7278;--gray-600:#4a5157;--gray-700:#333a40;--success:oklch(60% .14 152);--warning:oklch(72% .15 75);--danger:oklch(60% .17 25);--info:oklch(62% .13 245);--success-x:oklch(95% .04 152);--warning-x:oklch(95% .05 80);--danger-x:oklch(95% .05 25);--info-x:oklch(95% .04 245);--bg-page:var(--paper);--bg-sunken:var(--gray-50);--surface-card:var(--white);--surface-raised:var(--white);--surface-inverse:var(--ink);--surface-code:var(--ink-900);--border-subtle:#12161c14;--border-strong:#12161c33;--border-focus:var(--green-500);--text-primary:var(--ink);--text-secondary:var(--gray-700);--text-muted:var(--gray-500);--text-inverse:#f5f6f4;--text-brand:var(--green-600);--text-link:var(--green-600);--brand:var(--green-500);--brand-hover:var(--green-600);--brand-active:var(--green-700);--brand-tint:var(--green-100);--brand-contrast:var(--white);--brand-on-dark:var(--green-on-dark);--success-tint:var(--success-x);--warning-tint:var(--warning-x);--danger-tint:var(--danger-x);--info-tint:var(--info-x);--ring-focus:0 0 0 3px oklch(60% .14 152/.45);--ring-danger:0 0 0 3px oklch(60% .17 25/.35);--sh-xs:0 1px 2px #12161c0a;--sh-sm:0 1px 2px #12161c0f, 0 1px 1px #12161c0a;--sh-md:0 4px 12px #12161c14;--sh-lg:0 8px 24px #12161c24;--sh-xl:0 16px 40px #12161c2e;--dur-instant:.1s;--dur-fast:.12s;--dur-base:.18s;--dur-slow:.26s;--ease-standard:cubic-bezier(.2, 0, 0, 1);--ease-entrance:cubic-bezier(.16, 1, .3, 1)}[data-gsm-theme=dark]{--bg-page:var(--ink-900);--bg-sunken:#0e1216;--surface-card:#14191f;--surface-raised:#1a2027;--surface-inverse:var(--white);--surface-code:#05070a;--border-subtle:#ffffff12;--border-strong:#ffffff2e;--border-focus:var(--green-on-dark);--text-primary:#f5f6f4;--text-secondary:var(--gray-200);--text-muted:var(--gray-400);--text-inverse:var(--ink);--text-brand:var(--green-on-dark);--text-link:var(--green-on-dark);--brand:var(--green-500);--brand-hover:var(--green-on-dark);--brand-active:var(--green-600);--brand-tint:oklch(60% .14 152/.16);--brand-contrast:var(--white);--success:var(--green-300);--warning:oklch(80% .14 80);--danger:oklch(72% .16 25);--info:oklch(72% .12 245);--success-tint:oklch(60% .14 152/.16);--warning-tint:oklch(72% .15 75/.16);--danger-tint:oklch(60% .17 25/.18);--info-tint:oklch(62% .13 245/.16);--ring-focus:0 0 0 3px oklch(72% .14 152/.45);--sh-xs:0 1px 2px #0006;--sh-sm:0 1px 3px #00000080, 0 1px 2px #0006;--sh-md:0 4px 12px #00000080, 0 2px 4px #0006;--sh-lg:0 8px 24px #0009, 0 4px 8px #0006;--sh-xl:0 16px 40px #000000b3;color-scheme:dark}html,body,#app{height:100%}body{font-family:var(--font-sans);background:var(--bg-page);color:var(--text-primary);-webkit-font-smoothing:antialiased}h1,h2,h3{font-family:var(--font-display);letter-spacing:-.02em}.gn-input:focus,.gn-textarea:focus{border-color:var(--border-focus);box-shadow:var(--ring-focus);outline:none}.gn-input::placeholder,.gn-textarea::placeholder{color:var(--text-muted)}.gn-btn-pri:hover:not(:disabled){background:var(--brand-hover)}.gn-btn-pri:active:not(:disabled){background:var(--brand-active);transform:translateY(1px)}.gn-btn-pri:focus-visible{box-shadow:var(--ring-focus);outline:none}.gn-btn-pri:disabled{opacity:.45;cursor:not-allowed}.gn-btn-sec:hover:not(:disabled){background:var(--bg-sunken)}.gn-btn-sec:active:not(:disabled){transform:translateY(1px)}.gn-btn-sec:focus-visible{box-shadow:var(--ring-focus);outline:none}.gn-btn-sec:disabled{opacity:.45;cursor:not-allowed}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000} diff --git a/API Server/internal/api/dist/assets/index-Hipq9mYq.js b/API Server/internal/api/dist/assets/index-Hipq9mYq.js new file mode 100644 index 0000000..53ca99a --- /dev/null +++ b/API Server/internal/api/dist/assets/index-Hipq9mYq.js @@ -0,0 +1,72 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))n(r);new MutationObserver(r=>{for(const i of r)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&n(o)}).observe(document,{childList:!0,subtree:!0});function s(r){const i={};return r.integrity&&(i.integrity=r.integrity),r.referrerPolicy&&(i.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?i.credentials="include":r.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function n(r){if(r.ep)return;r.ep=!0;const i=s(r);fetch(r.href,i)}})();/** +* @vue/shared v3.5.39 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function Ns(e){const t=Object.create(null);for(const s of e.split(","))t[s]=1;return s=>s in t}const B={},nt=[],Pe=()=>{},Vn=()=>!1,ss=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),ns=e=>e.startsWith("onUpdate:"),te=Object.assign,Ws=(e,t)=>{const s=e.indexOf(t);s>-1&&e.splice(s,1)},Qr=Object.prototype.hasOwnProperty,$=(e,t)=>Qr.call(e,t),M=Array.isArray,rt=e=>Ft(e)==="[object Map]",Bn=e=>Ft(e)==="[object Set]",an=e=>Ft(e)==="[object Date]",I=e=>typeof e=="function",J=e=>typeof e=="string",Me=e=>typeof e=="symbol",H=e=>e!==null&&typeof e=="object",kn=e=>(H(e)||I(e))&&I(e.then)&&I(e.catch),Gn=Object.prototype.toString,Ft=e=>Gn.call(e),ei=e=>Ft(e).slice(8,-1),qn=e=>Ft(e)==="[object Object]",Ks=e=>J(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,yt=Ns(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),rs=e=>{const t=Object.create(null);return(s=>t[s]||(t[s]=e(s)))},ti=/-\w/g,de=rs(e=>e.replace(ti,t=>t.slice(1).toUpperCase())),si=/\B([A-Z])/g,Qe=rs(e=>e.replace(si,"-$1").toLowerCase()),Jn=rs(e=>e.charAt(0).toUpperCase()+e.slice(1)),hs=rs(e=>e?`on${Jn(e)}`:""),Ee=(e,t)=>!Object.is(e,t),ps=(e,...t)=>{for(let s=0;s{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:n,value:s})},ni=e=>{const t=parseFloat(e);return isNaN(t)?e:t};let dn;const is=()=>dn||(dn=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function Us(e){if(M(e)){const t={};for(let s=0;s{if(s){const n=s.split(ii);n.length>1&&(t[n[0].trim()]=n[1].trim())}}),t}function Dt(e){let t="";if(J(e))t=e;else if(M(e))for(let s=0;s!!(e&&e.__v_isRef===!0),Ae=e=>J(e)?e:e==null?"":M(e)||H(e)&&(e.toString===Gn||!I(e.toString))?Xn(e)?Ae(e.value):JSON.stringify(e,Zn,2):String(e),Zn=(e,t)=>Xn(t)?Zn(e,t.value):rt(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((s,[n,r],i)=>(s[gs(n,i)+" =>"]=r,s),{})}:Bn(t)?{[`Set(${t.size})`]:[...t.values()].map(s=>gs(s))}:Me(t)?gs(t):H(t)&&!M(t)&&!qn(t)?String(t):t,gs=(e,t="")=>{var s;return Me(e)?`Symbol(${(s=e.description)!=null?s:t})`:e};/** +* @vue/reactivity v3.5.39 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let Z;class ai{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!t&&Z&&(Z.active?(this.parent=Z,this.index=(Z.scopes||(Z.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,s;if(this.scopes)for(t=0,s=this.scopes.length;t0&&--this._on===0){if(Z===this)Z=this.prevScope;else{let t=Z;for(;t;){if(t.prevScope===this){t.prevScope=this.prevScope;break}t=t.prevScope}}this.prevScope=void 0}}stop(t){if(this._active){this._active=!1;let s,n;for(s=0,n=this.effects.length;s0)return;if(vt){let t=vt;for(vt=void 0;t;){const s=t.next;t.next=void 0,t.flags&=-9,t=s}}let e;for(;xt;){let t=xt;for(xt=void 0;t;){const s=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(n){e||(e=n)}t=s}}if(e)throw e}function sr(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function nr(e){let t,s=e.depsTail,n=s;for(;n;){const r=n.prevDep;n.version===-1?(n===s&&(s=r),Gs(n),hi(n)):t=n,n.dep.activeLink=n.prevActiveLink,n.prevActiveLink=void 0,n=r}e.deps=t,e.depsTail=s}function Es(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(rr(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function rr(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===Ot)||(e.globalVersion=Ot,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!Es(e))))return;e.flags|=2;const t=e.dep,s=V,n=he;V=e,he=!0;try{sr(e);const r=e.fn(e._value);(t.version===0||Ee(r,e._value))&&(e.flags|=128,e._value=r,t.version++)}catch(r){throw t.version++,r}finally{V=s,he=n,nr(e),e.flags&=-3}}function Gs(e,t=!1){const{dep:s,prevSub:n,nextSub:r}=e;if(n&&(n.nextSub=r,e.prevSub=void 0),r&&(r.prevSub=n,e.nextSub=void 0),s.subs===e&&(s.subs=n,!n&&s.computed)){s.computed.flags&=-5;for(let i=s.computed.deps;i;i=i.nextDep)Gs(i,!0)}!t&&!--s.sc&&s.map&&s.map.delete(s.key)}function hi(e){const{prevDep:t,nextDep:s}=e;t&&(t.nextDep=s,e.prevDep=void 0),s&&(s.prevDep=t,e.nextDep=void 0)}let he=!0;const ir=[];function Ie(){ir.push(he),he=!1}function Re(){const e=ir.pop();he=e===void 0?!0:e}function hn(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const s=V;V=void 0;try{t()}finally{V=s}}}let Ot=0;class pi{constructor(t,s){this.sub=t,this.dep=s,this.version=s.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class qs{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!V||!he||V===this.computed)return;let s=this.activeLink;if(s===void 0||s.sub!==V)s=this.activeLink=new pi(V,this),V.deps?(s.prevDep=V.depsTail,V.depsTail.nextDep=s,V.depsTail=s):V.deps=V.depsTail=s,or(s);else if(s.version===-1&&(s.version=this.version,s.nextDep)){const n=s.nextDep;n.prevDep=s.prevDep,s.prevDep&&(s.prevDep.nextDep=n),s.prevDep=V.depsTail,s.nextDep=void 0,V.depsTail.nextDep=s,V.depsTail=s,V.deps===s&&(V.deps=n)}return s}trigger(t){this.version++,Ot++,this.notify(t)}notify(t){Bs();try{for(let s=this.subs;s;s=s.prevSub)s.sub.notify()&&s.sub.dep.notify()}finally{ks()}}}function or(e){if(e.dep.sc++,e.sub.flags&4){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let n=t.deps;n;n=n.nextDep)or(n)}const s=e.dep.subs;s!==e&&(e.prevSub=s,s&&(s.nextSub=e)),e.dep.subs=e}}const As=new WeakMap,Xe=Symbol(""),Os=Symbol(""),Pt=Symbol("");function Q(e,t,s){if(he&&V){let n=As.get(e);n||As.set(e,n=new Map);let r=n.get(s);r||(n.set(s,r=new qs),r.map=n,r.key=s),r.track()}}function je(e,t,s,n,r,i){const o=As.get(e);if(!o){Ot++;return}const l=f=>{f&&f.trigger()};if(Bs(),t==="clear")o.forEach(l);else{const f=M(e),d=f&&Ks(s);if(f&&s==="length"){const a=Number(n);o.forEach((h,w)=>{(w==="length"||w===Pt||!Me(w)&&w>=a)&&l(h)})}else switch((s!==void 0||o.has(void 0))&&l(o.get(s)),d&&l(o.get(Pt)),t){case"add":f?d&&l(o.get("length")):(l(o.get(Xe)),rt(e)&&l(o.get(Os)));break;case"delete":f||(l(o.get(Xe)),rt(e)&&l(o.get(Os)));break;case"set":rt(e)&&l(o.get(Xe));break}}ks()}function et(e){const t=j(e);return t===e?t:(Q(t,"iterate",Pt),ue(e)?t:t.map(pe))}function os(e){return Q(e=j(e),"iterate",Pt),e}function Te(e,t){return $e(e)?ct(Ze(e)?pe(t):t):pe(t)}const gi={__proto__:null,[Symbol.iterator](){return _s(this,Symbol.iterator,e=>Te(this,e))},concat(...e){return et(this).concat(...e.map(t=>M(t)?et(t):t))},entries(){return _s(this,"entries",e=>(e[1]=Te(this,e[1]),e))},every(e,t){return Fe(this,"every",e,t,void 0,arguments)},filter(e,t){return Fe(this,"filter",e,t,s=>s.map(n=>Te(this,n)),arguments)},find(e,t){return Fe(this,"find",e,t,s=>Te(this,s),arguments)},findIndex(e,t){return Fe(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return Fe(this,"findLast",e,t,s=>Te(this,s),arguments)},findLastIndex(e,t){return Fe(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return Fe(this,"forEach",e,t,void 0,arguments)},includes(...e){return bs(this,"includes",e)},indexOf(...e){return bs(this,"indexOf",e)},join(e){return et(this).join(e)},lastIndexOf(...e){return bs(this,"lastIndexOf",e)},map(e,t){return Fe(this,"map",e,t,void 0,arguments)},pop(){return pt(this,"pop")},push(...e){return pt(this,"push",e)},reduce(e,...t){return pn(this,"reduce",e,t)},reduceRight(e,...t){return pn(this,"reduceRight",e,t)},shift(){return pt(this,"shift")},some(e,t){return Fe(this,"some",e,t,void 0,arguments)},splice(...e){return pt(this,"splice",e)},toReversed(){return et(this).toReversed()},toSorted(e){return et(this).toSorted(e)},toSpliced(...e){return et(this).toSpliced(...e)},unshift(...e){return pt(this,"unshift",e)},values(){return _s(this,"values",e=>Te(this,e))}};function _s(e,t,s){const n=os(e),r=n[t]();return n!==e&&!ue(e)&&(r._next=r.next,r.next=()=>{const i=r._next();return i.done||(i.value=s(i.value)),i}),r}const mi=Array.prototype;function Fe(e,t,s,n,r,i){const o=os(e),l=o!==e&&!ue(e),f=o[t];if(f!==mi[t]){const h=f.apply(e,i);return l?pe(h):h}let d=s;o!==e&&(l?d=function(h,w){return s.call(this,Te(e,h),w,e)}:s.length>2&&(d=function(h,w){return s.call(this,h,w,e)}));const a=f.call(o,d,n);return l&&r?r(a):a}function pn(e,t,s,n){const r=os(e),i=r!==e&&!ue(e);let o=s,l=!1;r!==e&&(i?(l=n.length===0,o=function(d,a,h){return l&&(l=!1,d=Te(e,d)),s.call(this,d,Te(e,a),h,e)}):s.length>3&&(o=function(d,a,h){return s.call(this,d,a,h,e)}));const f=r[t](o,...n);return l?Te(e,f):f}function bs(e,t,s){const n=j(e);Q(n,"iterate",Pt);const r=n[t](...s);return(r===-1||r===!1)&&Xs(s[0])?(s[0]=j(s[0]),n[t](...s)):r}function pt(e,t,s=[]){Ie(),Bs();const n=j(e)[t].apply(e,s);return ks(),Re(),n}const _i=Ns("__proto__,__v_isRef,__isVue"),lr=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(Me));function bi(e){Me(e)||(e=String(e));const t=j(this);return Q(t,"has",e),t.hasOwnProperty(e)}class cr{constructor(t=!1,s=!1){this._isReadonly=t,this._isShallow=s}get(t,s,n){if(s==="__v_skip")return t.__v_skip;const r=this._isReadonly,i=this._isShallow;if(s==="__v_isReactive")return!r;if(s==="__v_isReadonly")return r;if(s==="__v_isShallow")return i;if(s==="__v_raw")return n===(r?i?Oi:dr:i?ar:ur).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(n)?t:void 0;const o=M(t);if(!r){let f;if(o&&(f=gi[s]))return f;if(s==="hasOwnProperty")return bi}const l=Reflect.get(t,s,ee(t)?t:n);if((Me(s)?lr.has(s):_i(s))||(r||Q(t,"get",s),i))return l;if(ee(l)){const f=o&&Ks(s)?l:l.value;return r&&H(f)?Ms(f):f}return H(l)?r?Ms(l):zs(l):l}}class fr extends cr{constructor(t=!1){super(!1,t)}set(t,s,n,r){let i=t[s];const o=M(t)&&Ks(s);if(!this._isShallow){const d=$e(i);if(!ue(n)&&!$e(n)&&(i=j(i),n=j(n)),!o&&ee(i)&&!ee(n))return d||(i.value=n),!0}const l=o?Number(s)e,Ut=e=>Reflect.getPrototypeOf(e);function wi(e,t,s){return function(...n){const r=this.__v_raw,i=j(r),o=rt(i),l=e==="entries"||e===Symbol.iterator&&o,f=e==="keys"&&o,d=r[e](...n),a=s?Ps:t?ct:pe;return!t&&Q(i,"iterate",f?Os:Xe),te(Object.create(d),{next(){const{value:h,done:w}=d.next();return w?{value:h,done:w}:{value:l?[a(h[0]),a(h[1])]:a(h),done:w}}})}}function Vt(e){return function(...t){return e==="delete"?!1:e==="clear"?void 0:this}}function Ti(e,t){const s={get(r){const i=this.__v_raw,o=j(i),l=j(r);e||(Ee(r,l)&&Q(o,"get",r),Q(o,"get",l));const{has:f}=Ut(o),d=t?Ps:e?ct:pe;if(f.call(o,r))return d(i.get(r));if(f.call(o,l))return d(i.get(l));i!==o&&i.get(r)},get size(){const r=this.__v_raw;return!e&&Q(j(r),"iterate",Xe),r.size},has(r){const i=this.__v_raw,o=j(i),l=j(r);return e||(Ee(r,l)&&Q(o,"has",r),Q(o,"has",l)),r===l?i.has(r):i.has(r)||i.has(l)},forEach(r,i){const o=this,l=o.__v_raw,f=j(l),d=t?Ps:e?ct:pe;return!e&&Q(f,"iterate",Xe),l.forEach((a,h)=>r.call(i,d(a),d(h),o))}};return te(s,e?{add:Vt("add"),set:Vt("set"),delete:Vt("delete"),clear:Vt("clear")}:{add(r){const i=j(this),o=Ut(i),l=j(r),f=!t&&!ue(r)&&!$e(r)?l:r;return o.has.call(i,f)||Ee(r,f)&&o.has.call(i,r)||Ee(l,f)&&o.has.call(i,l)||(i.add(f),je(i,"add",f,f)),this},set(r,i){!t&&!ue(i)&&!$e(i)&&(i=j(i));const o=j(this),{has:l,get:f}=Ut(o);let d=l.call(o,r);d||(r=j(r),d=l.call(o,r));const a=f.call(o,r);return o.set(r,i),d?Ee(i,a)&&je(o,"set",r,i):je(o,"add",r,i),this},delete(r){const i=j(this),{has:o,get:l}=Ut(i);let f=o.call(i,r);f||(r=j(r),f=o.call(i,r)),l&&l.call(i,r);const d=i.delete(r);return f&&je(i,"delete",r,void 0),d},clear(){const r=j(this),i=r.size!==0,o=r.clear();return i&&je(r,"clear",void 0,void 0),o}}),["keys","values","entries",Symbol.iterator].forEach(r=>{s[r]=wi(r,e,t)}),s}function Js(e,t){const s=Ti(e,t);return(n,r,i)=>r==="__v_isReactive"?!e:r==="__v_isReadonly"?e:r==="__v_raw"?n:Reflect.get($(s,r)&&r in n?s:n,r,i)}const Ci={get:Js(!1,!1)},Ei={get:Js(!1,!0)},Ai={get:Js(!0,!1)};const ur=new WeakMap,ar=new WeakMap,dr=new WeakMap,Oi=new WeakMap;function Pi(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function zs(e){return $e(e)?e:Ys(e,!1,xi,Ci,ur)}function Mi(e){return Ys(e,!1,Si,Ei,ar)}function Ms(e){return Ys(e,!0,vi,Ai,dr)}function Ys(e,t,s,n,r){if(!H(e)||e.__v_raw&&!(t&&e.__v_isReactive)||e.__v_skip||!Object.isExtensible(e))return e;const i=r.get(e);if(i)return i;const o=Pi(ei(e));if(o===0)return e;const l=new Proxy(e,o===2?n:s);return r.set(e,l),l}function Ze(e){return $e(e)?Ze(e.__v_raw):!!(e&&e.__v_isReactive)}function $e(e){return!!(e&&e.__v_isReadonly)}function ue(e){return!!(e&&e.__v_isShallow)}function Xs(e){return e?!!e.__v_raw:!1}function j(e){const t=e&&e.__v_raw;return t?j(t):e}function Ii(e){return!$(e,"__v_skip")&&Object.isExtensible(e)&&zn(e,"__v_skip",!0),e}const pe=e=>H(e)?zs(e):e,ct=e=>H(e)?Ms(e):e;function ee(e){return e?e.__v_isRef===!0:!1}function mt(e){return Ri(e,!1)}function Ri(e,t){return ee(e)?e:new Fi(e,t)}class Fi{constructor(t,s){this.dep=new qs,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=s?t:j(t),this._value=s?t:pe(t),this.__v_isShallow=s}get value(){return this.dep.track(),this._value}set value(t){const s=this._rawValue,n=this.__v_isShallow||ue(t)||$e(t);t=n?t:j(t),Ee(t,s)&&(this._rawValue=t,this._value=n?t:pe(t),this.dep.trigger())}}function We(e){return ee(e)?e.value:e}const Di={get:(e,t,s)=>t==="__v_raw"?e:We(Reflect.get(e,t,s)),set:(e,t,s,n)=>{const r=e[t];return ee(r)&&!ee(s)?(r.value=s,!0):Reflect.set(e,t,s,n)}};function hr(e){return Ze(e)?e:new Proxy(e,Di)}class Li{constructor(t,s,n){this.fn=t,this.setter=s,this._value=void 0,this.dep=new qs(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=Ot-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!s,this.isSSR=n}notify(){if(this.flags|=16,!(this.flags&8)&&V!==this)return tr(this,!0),!0}get value(){const t=this.dep.track();return rr(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter&&this.setter(t)}}function ji(e,t,s=!1){let n,r;return I(e)?n=e:(n=e.get,r=e.set),new Li(n,r,s)}const Bt={},qt=new WeakMap;let Ye;function $i(e,t=!1,s=Ye){if(s){let n=qt.get(s);n||qt.set(s,n=[]),n.push(e)}}function Hi(e,t,s=B){const{immediate:n,deep:r,once:i,scheduler:o,augmentJob:l,call:f}=s,d=A=>r?A:ue(A)||r===!1||r===0?Ue(A,1):Ue(A);let a,h,w,T,D=!1,P=!1;if(ee(e)?(h=()=>e.value,D=ue(e)):Ze(e)?(h=()=>d(e),D=!0):M(e)?(P=!0,D=e.some(A=>Ze(A)||ue(A)),h=()=>e.map(A=>{if(ee(A))return A.value;if(Ze(A))return d(A);if(I(A))return f?f(A,2):A()})):I(e)?t?h=f?()=>f(e,2):e:h=()=>{if(w){Ie();try{w()}finally{Re()}}const A=Ye;Ye=a;try{return f?f(e,3,[T]):e(T)}finally{Ye=A}}:h=Pe,t&&r){const A=h,Y=r===!0?1/0:r;h=()=>Ue(A(),Y)}const G=di(),W=()=>{a.stop(),G&&G.active&&Ws(G.effects,a)};if(i&&t){const A=t;t=(...Y)=>{const me=A(...Y);return W(),me}}let F=P?new Array(e.length).fill(Bt):Bt;const k=A=>{if(!(!(a.flags&1)||!a.dirty&&!A))if(t){const Y=a.run();if(A||r||D||(P?Y.some((me,_e)=>Ee(me,F[_e])):Ee(Y,F))){w&&w();const me=Ye;Ye=a;try{const _e=[Y,F===Bt?void 0:P&&F[0]===Bt?[]:F,T];F=Y,f?f(t,3,_e):t(..._e)}finally{Ye=me}}}else a.run()};return l&&l(k),a=new Qn(h),a.scheduler=o?()=>o(k,!1):k,T=A=>$i(A,!1,a),w=a.onStop=()=>{const A=qt.get(a);if(A){if(f)f(A,4);else for(const Y of A)Y();qt.delete(a)}},t?n?k(!0):F=a.run():o?o(k.bind(null,!0),!0):a.run(),W.pause=a.pause.bind(a),W.resume=a.resume.bind(a),W.stop=W,W}function Ue(e,t=1/0,s){if(t<=0||!H(e)||e.__v_skip||(s=s||new Map,(s.get(e)||0)>=t))return e;if(s.set(e,t),t--,ee(e))Ue(e.value,t,s);else if(M(e))for(let n=0;n{Ue(n,t,s)});else if(qn(e)){for(const n in e)Ue(e[n],t,s);for(const n of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,n)&&Ue(e[n],t,s)}return e}/** +* @vue/runtime-core v3.5.39 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function Lt(e,t,s,n){try{return n?e(...n):e()}catch(r){ls(r,t,s)}}function ge(e,t,s,n){if(I(e)){const r=Lt(e,t,s,n);return r&&kn(r)&&r.catch(i=>{ls(i,t,s)}),r}if(M(e)){const r=[];for(let i=0;i>>1,r=re[n],i=Mt(r);i=Mt(s)?re.push(e):re.splice(Ki(t),0,e),e.flags|=1,gr()}}function gr(){Jt||(Jt=pr.then(_r))}function Ui(e){M(e)?it.push(...e):Ke&&e.id===-1?Ke.splice(st+1,0,e):e.flags&1||(it.push(e),e.flags|=1),gr()}function gn(e,t,s=we+1){for(;sMt(s)-Mt(n));if(it.length=0,Ke){Ke.push(...t);return}for(Ke=t,st=0;ste.id==null?e.flags&2?-1:1/0:e.id;function _r(e){try{for(we=0;we{n._d&&Zt(-1);const i=zt(t);let o;try{o=e(...r)}finally{zt(i),n._d&&Zt(1)}return o};return n._n=!0,n._c=!0,n._d=!0,n}function Je(e,t,s,n){const r=e.dirs,i=t&&t.dirs;for(let o=0;o1)return s&&I(t)?t.call(n&&n.proxy):t}}const ki=Symbol.for("v-scx"),Gi=()=>St(ki);function ys(e,t,s){return yr(e,t,s)}function yr(e,t,s=B){const{immediate:n,deep:r,flush:i,once:o}=s,l=te({},s),f=t&&n||!t&&i!=="post";let d;if(Rt){if(i==="sync"){const T=Gi();d=T.__watcherHandles||(T.__watcherHandles=[])}else if(!f){const T=()=>{};return T.stop=Pe,T.resume=Pe,T.pause=Pe,T}}const a=ie;l.call=(T,D,P)=>ge(T,a,D,P);let h=!1;i==="post"?l.scheduler=T=>{le(T,a&&a.suspense)}:i!=="sync"&&(h=!0,l.scheduler=(T,D)=>{D?T():Zs(T)}),l.augmentJob=T=>{t&&(T.flags|=4),h&&(T.flags|=2,a&&(T.id=a.uid,T.i=a))};const w=Hi(e,t,l);return Rt&&(d?d.push(w):f&&w()),w}function qi(e,t,s){const n=this.proxy,r=J(e)?e.includes(".")?xr(n,e):()=>n[e]:e.bind(n,n);let i;I(t)?i=t:(i=t.handler,s=t);const o=jt(this),l=yr(r,i.bind(n),s);return o(),l}function xr(e,t){const s=t.split(".");return()=>{let n=e;for(let r=0;re.__isTeleport,xs=Symbol("_leaveCb");function Qs(e,t){e.shapeFlag&6&&e.component?(e.transition=t,Qs(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function vr(e){e.ids=[e.ids[0]+e.ids[2]+++"-",0,0]}function mn(e,t){let s;return!!((s=Object.getOwnPropertyDescriptor(e,t))&&!s.configurable)}const Yt=new WeakMap;function wt(e,t,s,n,r=!1){if(M(e)){e.forEach((P,G)=>wt(P,t&&(M(t)?t[G]:t),s,n,r));return}if(Tt(n)&&!r){n.shapeFlag&512&&n.type.__asyncResolved&&n.component.subTree.component&&wt(e,t,s,n.component.subTree);return}const i=n.shapeFlag&4?nn(n.component):n.el,o=r?null:i,{i:l,r:f}=e,d=t&&t.r,a=l.refs===B?l.refs={}:l.refs,h=l.setupState,w=j(h),T=h===B?Vn:P=>mn(a,P)?!1:$(w,P),D=(P,G)=>!(G&&mn(a,G));if(d!=null&&d!==f){if(_n(t),J(d))a[d]=null,T(d)&&(h[d]=null);else if(ee(d)){const P=t;D(d,P.k)&&(d.value=null),P.k&&(a[P.k]=null)}}if(I(f)){Ie();try{Lt(f,l,12,[o,a])}finally{Re()}}else{const P=J(f),G=ee(f);if(P||G){const W=()=>{if(e.f){const F=P?T(f)?h[f]:a[f]:D()||!e.k?f.value:a[e.k];if(r)M(F)&&Ws(F,i);else if(M(F))F.includes(i)||F.push(i);else if(P)a[f]=[i],T(f)&&(h[f]=a[f]);else{const k=[i];D(f,e.k)&&(f.value=k),e.k&&(a[e.k]=k)}}else P?(a[f]=o,T(f)&&(h[f]=o)):G&&(D(f,e.k)&&(f.value=o),e.k&&(a[e.k]=o))};if(o){const F=()=>{W(),Yt.delete(e)};F.id=-1,Yt.set(e,F),le(F,s)}else _n(e),W()}}}function _n(e){const t=Yt.get(e);t&&(t.flags|=8,Yt.delete(e))}is().requestIdleCallback;is().cancelIdleCallback;const Tt=e=>!!e.type.__asyncLoader,Sr=e=>e.type.__isKeepAlive;function Yi(e,t){wr(e,"a",t)}function Xi(e,t){wr(e,"da",t)}function wr(e,t,s=ie){const n=e.__wdc||(e.__wdc=()=>{let r=s;for(;r;){if(r.isDeactivated)return;r=r.parent}return e()});if(cs(t,n,s),s){let r=s.parent;for(;r&&r.parent;)Sr(r.parent.vnode)&&Zi(n,t,s,r),r=r.parent}}function Zi(e,t,s,n){const r=cs(t,e,n,!0);en(()=>{Ws(n[t],r)},s)}function cs(e,t,s=ie,n=!1){if(s){const r=s[e]||(s[e]=[]),i=t.__weh||(t.__weh=(...o)=>{Ie();const l=jt(s),f=ge(t,s,e,o);return l(),Re(),f});return n?r.unshift(i):r.push(i),i}}const He=e=>(t,s=ie)=>{(!Rt||e==="sp")&&cs(e,(...n)=>t(...n),s)},Qi=He("bm"),Tr=He("m"),eo=He("bu"),to=He("u"),so=He("bum"),en=He("um"),no=He("sp"),ro=He("rtg"),io=He("rtc");function oo(e,t=ie){cs("ec",e,t)}const lo=Symbol.for("v-ndc");function co(e,t,s,n){let r;const i=s,o=M(e);if(o||J(e)){const l=o&&Ze(e);let f=!1,d=!1;l&&(f=!ue(e),d=$e(e),e=os(e)),r=new Array(e.length);for(let a=0,h=e.length;at(l,f,void 0,i));else{const l=Object.keys(e);r=new Array(l.length);for(let f=0,d=l.length;fe?Br(e)?nn(e):Is(e.parent):null,Ct=te(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>Is(e.parent),$root:e=>Is(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>Er(e),$forceUpdate:e=>e.f||(e.f=()=>{Zs(e.update)}),$nextTick:e=>e.n||(e.n=Wi.bind(e.proxy)),$watch:e=>qi.bind(e)}),vs=(e,t)=>e!==B&&!e.__isScriptSetup&&$(e,t),fo={get({_:e},t){if(t==="__v_skip")return!0;const{ctx:s,setupState:n,data:r,props:i,accessCache:o,type:l,appContext:f}=e;if(t[0]!=="$"){const w=o[t];if(w!==void 0)switch(w){case 1:return n[t];case 2:return r[t];case 4:return s[t];case 3:return i[t]}else{if(vs(n,t))return o[t]=1,n[t];if(r!==B&&$(r,t))return o[t]=2,r[t];if($(i,t))return o[t]=3,i[t];if(s!==B&&$(s,t))return o[t]=4,s[t];Rs&&(o[t]=0)}}const d=Ct[t];let a,h;if(d)return t==="$attrs"&&Q(e.attrs,"get",""),d(e);if((a=l.__cssModules)&&(a=a[t]))return a;if(s!==B&&$(s,t))return o[t]=4,s[t];if(h=f.config.globalProperties,$(h,t))return h[t]},set({_:e},t,s){const{data:n,setupState:r,ctx:i}=e;return vs(r,t)?(r[t]=s,!0):n!==B&&$(n,t)?(n[t]=s,!0):$(e.props,t)||t[0]==="$"&&t.slice(1)in e?!1:(i[t]=s,!0)},has({_:{data:e,setupState:t,accessCache:s,ctx:n,appContext:r,props:i,type:o}},l){let f;return!!(s[l]||e!==B&&l[0]!=="$"&&$(e,l)||vs(t,l)||$(i,l)||$(n,l)||$(Ct,l)||$(r.config.globalProperties,l)||(f=o.__cssModules)&&f[l])},defineProperty(e,t,s){return s.get!=null?e._.accessCache[t]=0:$(s,"value")&&this.set(e,t,s.value,null),Reflect.defineProperty(e,t,s)}};function bn(e){return M(e)?e.reduce((t,s)=>(t[s]=null,t),{}):e}let Rs=!0;function uo(e){const t=Er(e),s=e.proxy,n=e.ctx;Rs=!1,t.beforeCreate&&yn(t.beforeCreate,e,"bc");const{data:r,computed:i,methods:o,watch:l,provide:f,inject:d,created:a,beforeMount:h,mounted:w,beforeUpdate:T,updated:D,activated:P,deactivated:G,beforeDestroy:W,beforeUnmount:F,destroyed:k,unmounted:A,render:Y,renderTracked:me,renderTriggered:_e,errorCaptured:Ne,serverPrefetch:$t,expose:ke,inheritAttrs:ut,components:Ht,directives:Nt,filters:as}=t;if(d&&ao(d,n,null),o)for(const q in o){const U=o[q];I(U)&&(n[q]=U.bind(s))}if(r){const q=r.call(s,s);H(q)&&(e.data=zs(q))}if(Rs=!0,i)for(const q in i){const U=i[q],Ge=I(U)?U.bind(s,s):I(U.get)?U.get.bind(s,s):Pe,Wt=!I(U)&&I(U.set)?U.set.bind(s):Pe,qe=Gr({get:Ge,set:Wt});Object.defineProperty(n,q,{enumerable:!0,configurable:!0,get:()=>qe.value,set:be=>qe.value=be})}if(l)for(const q in l)Cr(l[q],n,s,q);if(f){const q=I(f)?f.call(s):f;Reflect.ownKeys(q).forEach(U=>{Bi(U,q[U])})}a&&yn(a,e,"c");function se(q,U){M(U)?U.forEach(Ge=>q(Ge.bind(s))):U&&q(U.bind(s))}if(se(Qi,h),se(Tr,w),se(eo,T),se(to,D),se(Yi,P),se(Xi,G),se(oo,Ne),se(io,me),se(ro,_e),se(so,F),se(en,A),se(no,$t),M(ke))if(ke.length){const q=e.exposed||(e.exposed={});ke.forEach(U=>{Object.defineProperty(q,U,{get:()=>s[U],set:Ge=>s[U]=Ge,enumerable:!0})})}else e.exposed||(e.exposed={});Y&&e.render===Pe&&(e.render=Y),ut!=null&&(e.inheritAttrs=ut),Ht&&(e.components=Ht),Nt&&(e.directives=Nt),$t&&vr(e)}function ao(e,t,s=Pe){M(e)&&(e=Fs(e));for(const n in e){const r=e[n];let i;H(r)?"default"in r?i=St(r.from||n,r.default,!0):i=St(r.from||n):i=St(r),ee(i)?Object.defineProperty(t,n,{enumerable:!0,configurable:!0,get:()=>i.value,set:o=>i.value=o}):t[n]=i}}function yn(e,t,s){ge(M(e)?e.map(n=>n.bind(t.proxy)):e.bind(t.proxy),t,s)}function Cr(e,t,s,n){let r=n.includes(".")?xr(s,n):()=>s[n];if(J(e)){const i=t[e];I(i)&&ys(r,i)}else if(I(e))ys(r,e.bind(s));else if(H(e))if(M(e))e.forEach(i=>Cr(i,t,s,n));else{const i=I(e.handler)?e.handler.bind(s):t[e.handler];I(i)&&ys(r,i,e)}}function Er(e){const t=e.type,{mixins:s,extends:n}=t,{mixins:r,optionsCache:i,config:{optionMergeStrategies:o}}=e.appContext,l=i.get(t);let f;return l?f=l:!r.length&&!s&&!n?f=t:(f={},r.length&&r.forEach(d=>Xt(f,d,o,!0)),Xt(f,t,o)),H(t)&&i.set(t,f),f}function Xt(e,t,s,n=!1){const{mixins:r,extends:i}=t;i&&Xt(e,i,s,!0),r&&r.forEach(o=>Xt(e,o,s,!0));for(const o in t)if(!(n&&o==="expose")){const l=ho[o]||s&&s[o];e[o]=l?l(e[o],t[o]):t[o]}return e}const ho={data:xn,props:vn,emits:vn,methods:_t,computed:_t,beforeCreate:ne,created:ne,beforeMount:ne,mounted:ne,beforeUpdate:ne,updated:ne,beforeDestroy:ne,beforeUnmount:ne,destroyed:ne,unmounted:ne,activated:ne,deactivated:ne,errorCaptured:ne,serverPrefetch:ne,components:_t,directives:_t,watch:go,provide:xn,inject:po};function xn(e,t){return t?e?function(){return te(I(e)?e.call(this,this):e,I(t)?t.call(this,this):t)}:t:e}function po(e,t){return _t(Fs(e),Fs(t))}function Fs(e){if(M(e)){const t={};for(let s=0;st==="modelValue"||t==="model-value"?e.modelModifiers:e[`${t}Modifiers`]||e[`${de(t)}Modifiers`]||e[`${Qe(t)}Modifiers`];function yo(e,t,...s){if(e.isUnmounted)return;const n=e.vnode.props||B;let r=s;const i=t.startsWith("update:"),o=i&&bo(n,t.slice(7));o&&(o.trim&&(r=s.map(a=>J(a)?a.trim():a)),o.number&&(r=s.map(ni)));let l,f=n[l=hs(t)]||n[l=hs(de(t))];!f&&i&&(f=n[l=hs(Qe(t))]),f&&ge(f,e,6,r);const d=n[l+"Once"];if(d){if(!e.emitted)e.emitted={};else if(e.emitted[l])return;e.emitted[l]=!0,ge(d,e,6,r)}}const xo=new WeakMap;function Or(e,t,s=!1){const n=s?xo:t.emitsCache,r=n.get(e);if(r!==void 0)return r;const i=e.emits;let o={},l=!1;if(!I(e)){const f=d=>{const a=Or(d,t,!0);a&&(l=!0,te(o,a))};!s&&t.mixins.length&&t.mixins.forEach(f),e.extends&&f(e.extends),e.mixins&&e.mixins.forEach(f)}return!i&&!l?(H(e)&&n.set(e,null),null):(M(i)?i.forEach(f=>o[f]=null):te(o,i),H(e)&&n.set(e,o),o)}function fs(e,t){return!e||!ss(t)?!1:(t=t.slice(2),t=t==="Once"?t:t.replace(/Once$/,""),$(e,t[0].toLowerCase()+t.slice(1))||$(e,Qe(t))||$(e,t))}function Sn(e){const{type:t,vnode:s,proxy:n,withProxy:r,propsOptions:[i],slots:o,attrs:l,emit:f,render:d,renderCache:a,props:h,data:w,setupState:T,ctx:D,inheritAttrs:P}=e,G=zt(e);let W,F;try{if(s.shapeFlag&4){const A=r||n,Y=A;W=Ce(d.call(Y,A,a,h,T,w,D)),F=l}else{const A=t;W=Ce(A.length>1?A(h,{attrs:l,slots:o,emit:f}):A(h,null)),F=t.props?l:vo(l)}}catch(A){Et.length=0,ls(A,e,1),W=oe(Be)}let k=W;if(F&&P!==!1){const A=Object.keys(F),{shapeFlag:Y}=k;A.length&&Y&7&&(i&&A.some(ns)&&(F=So(F,i)),k=ft(k,F,!1,!0))}return s.dirs&&(k=ft(k,null,!1,!0),k.dirs=k.dirs?k.dirs.concat(s.dirs):s.dirs),s.transition&&Qs(k,s.transition),W=k,zt(G),W}const vo=e=>{let t;for(const s in e)(s==="class"||s==="style"||ss(s))&&((t||(t={}))[s]=e[s]);return t},So=(e,t)=>{const s={};for(const n in e)(!ns(n)||!(n.slice(9)in t))&&(s[n]=e[n]);return s};function wo(e,t,s){const{props:n,children:r,component:i}=e,{props:o,children:l,patchFlag:f}=t,d=i.emitsOptions;if(t.dirs||t.transition)return!0;if(s&&f>=0){if(f&1024)return!0;if(f&16)return n?wn(n,o,d):!!o;if(f&8){const a=t.dynamicProps;for(let h=0;hObject.create(Mr),Rr=e=>Object.getPrototypeOf(e)===Mr;function Co(e,t,s,n=!1){const r={},i=Ir();e.propsDefaults=Object.create(null),Fr(e,t,r,i);for(const o in e.propsOptions[0])o in r||(r[o]=void 0);s?e.props=n?r:Mi(r):e.type.props?e.props=r:e.props=i,e.attrs=i}function Eo(e,t,s,n){const{props:r,attrs:i,vnode:{patchFlag:o}}=e,l=j(r),[f]=e.propsOptions;let d=!1;if((n||o>0)&&!(o&16)){if(o&8){const a=e.vnode.dynamicProps;for(let h=0;h{f=!0;const[w,T]=Dr(h,t,!0);te(o,w),T&&l.push(...T)};!s&&t.mixins.length&&t.mixins.forEach(a),e.extends&&a(e.extends),e.mixins&&e.mixins.forEach(a)}if(!i&&!f)return H(e)&&n.set(e,nt),nt;if(M(i))for(let a=0;ae==="_"||e==="_ctx"||e==="$stable",sn=e=>M(e)?e.map(Ce):[Ce(e)],Oo=(e,t,s)=>{if(t._n)return t;const n=Vi((...r)=>sn(t(...r)),s);return n._c=!1,n},Lr=(e,t,s)=>{const n=e._ctx;for(const r in e){if(tn(r))continue;const i=e[r];if(I(i))t[r]=Oo(r,i,n);else if(i!=null){const o=sn(i);t[r]=()=>o}}},jr=(e,t)=>{const s=sn(t);e.slots.default=()=>s},$r=(e,t,s)=>{for(const n in t)(s||!tn(n))&&(e[n]=t[n])},Po=(e,t,s)=>{const n=e.slots=Ir();if(e.vnode.shapeFlag&32){const r=t._;r?($r(n,t,s),s&&zn(n,"_",r,!0)):Lr(t,n)}else t&&jr(e,t)},Mo=(e,t,s)=>{const{vnode:n,slots:r}=e;let i=!0,o=B;if(n.shapeFlag&32){const l=t._;l?s&&l===1?i=!1:$r(r,t,s):(i=!t.$stable,Lr(t,r)),o=t}else t&&(jr(e,t),o={default:1});if(i)for(const l in r)!tn(l)&&o[l]==null&&delete r[l]},le=Lo;function Io(e){return Ro(e)}function Ro(e,t){const s=is();s.__VUE__=!0;const{insert:n,remove:r,patchProp:i,createElement:o,createText:l,createComment:f,setText:d,setElementText:a,parentNode:h,nextSibling:w,setScopeId:T=Pe,insertStaticContent:D}=e,P=(c,u,p,b=null,_=null,g=null,v=void 0,x=null,y=!!u.dynamicChildren)=>{if(c===u)return;c&&!gt(c,u)&&(b=Kt(c),be(c,_,g,!0),c=null),u.patchFlag===-2&&(y=!1,u.dynamicChildren=null);const{type:m,ref:E,shapeFlag:S}=u;switch(m){case us:G(c,u,p,b);break;case Be:W(c,u,p,b);break;case ws:c==null&&F(u,p,b,v);break;case ae:Ht(c,u,p,b,_,g,v,x,y);break;default:S&1?Y(c,u,p,b,_,g,v,x,y):S&6?Nt(c,u,p,b,_,g,v,x,y):(S&64||S&128)&&m.process(c,u,p,b,_,g,v,x,y,dt)}E!=null&&_?wt(E,c&&c.ref,g,u||c,!u):E==null&&c&&c.ref!=null&&wt(c.ref,null,g,c,!0)},G=(c,u,p,b)=>{if(c==null)n(u.el=l(u.children),p,b);else{const _=u.el=c.el;u.children!==c.children&&d(_,u.children)}},W=(c,u,p,b)=>{c==null?n(u.el=f(u.children||""),p,b):u.el=c.el},F=(c,u,p,b)=>{[c.el,c.anchor]=D(c.children,u,p,b,c.el,c.anchor)},k=({el:c,anchor:u},p,b)=>{let _;for(;c&&c!==u;)_=w(c),n(c,p,b),c=_;n(u,p,b)},A=({el:c,anchor:u})=>{let p;for(;c&&c!==u;)p=w(c),r(c),c=p;r(u)},Y=(c,u,p,b,_,g,v,x,y)=>{if(u.type==="svg"?v="svg":u.type==="math"&&(v="mathml"),c==null)me(u,p,b,_,g,v,x,y);else{const m=c.el&&c.el._isVueCE?c.el:null;try{m&&m._beginPatch(),$t(c,u,_,g,v,x,y)}finally{m&&m._endPatch()}}},me=(c,u,p,b,_,g,v,x)=>{let y,m;const{props:E,shapeFlag:S,transition:C,dirs:O}=c;if(y=c.el=o(c.type,g,E&&E.is,E),S&8?a(y,c.children):S&16&&Ne(c.children,y,null,b,_,Ss(c,g),v,x),O&&Je(c,null,b,"created"),_e(y,c,c.scopeId,v,b),E){for(const K in E)K!=="value"&&!yt(K)&&i(y,K,null,E[K],g,b);"value"in E&&i(y,"value",null,E.value,g),(m=E.onVnodeBeforeMount)&&Se(m,b,c)}O&&Je(c,null,b,"beforeMount");const R=Fo(_,C);R&&C.beforeEnter(y),n(y,u,p),((m=E&&E.onVnodeMounted)||R||O)&&le(()=>{try{m&&Se(m,b,c),R&&C.enter(y),O&&Je(c,null,b,"mounted")}finally{}},_)},_e=(c,u,p,b,_)=>{if(p&&T(c,p),b)for(let g=0;g{for(let m=y;m{const x=u.el=c.el;let{patchFlag:y,dynamicChildren:m,dirs:E}=u;y|=c.patchFlag&16;const S=c.props||B,C=u.props||B;let O;if(p&&ze(p,!1),(O=C.onVnodeBeforeUpdate)&&Se(O,p,u,c),E&&Je(u,c,p,"beforeUpdate"),p&&ze(p,!0),m&&(!c.dynamicChildren||c.dynamicChildren.length!==m.length)&&(y=0,v=!1,m=null),(S.innerHTML&&C.innerHTML==null||S.textContent&&C.textContent==null)&&a(x,""),m?ke(c.dynamicChildren,m,x,p,b,Ss(u,_),g):v||U(c,u,x,null,p,b,Ss(u,_),g,!1),y>0){if(y&16)ut(x,S,C,p,_);else if(y&2&&S.class!==C.class&&i(x,"class",null,C.class,_),y&4&&i(x,"style",S.style,C.style,_),y&8){const R=u.dynamicProps;for(let K=0;K{O&&Se(O,p,u,c),E&&Je(u,c,p,"updated")},b)},ke=(c,u,p,b,_,g,v)=>{for(let x=0;x{if(u!==p){if(u!==B)for(const g in u)!yt(g)&&!(g in p)&&i(c,g,u[g],null,_,b);for(const g in p){if(yt(g))continue;const v=p[g],x=u[g];v!==x&&g!=="value"&&i(c,g,x,v,_,b)}"value"in p&&i(c,"value",u.value,p.value,_)}},Ht=(c,u,p,b,_,g,v,x,y)=>{const m=u.el=c?c.el:l(""),E=u.anchor=c?c.anchor:l("");let{patchFlag:S,dynamicChildren:C,slotScopeIds:O}=u;O&&(x=x?x.concat(O):O),c==null?(n(m,p,b),n(E,p,b),Ne(u.children||[],p,E,_,g,v,x,y)):S>0&&S&64&&C&&c.dynamicChildren&&c.dynamicChildren.length===C.length?(ke(c.dynamicChildren,C,p,_,g,v,x),(u.key!=null||_&&u===_.subTree)&&Hr(c,u,!0)):U(c,u,p,E,_,g,v,x,y)},Nt=(c,u,p,b,_,g,v,x,y)=>{u.slotScopeIds=x,c==null?u.shapeFlag&512?_.ctx.activate(u,p,b,v,y):as(u,p,b,_,g,v,y):rn(c,u,y)},as=(c,u,p,b,_,g,v)=>{const x=c.component=Vo(c,b,_);if(Sr(c)&&(x.ctx.renderer=dt),ko(x,!1,v),x.asyncDep){if(_&&_.registerDep(x,se,v),!c.el){const y=x.subTree=oe(Be);W(null,y,u,p),c.placeholder=y.el}}else se(x,c,u,p,_,g,v)},rn=(c,u,p)=>{const b=u.component=c.component;if(wo(c,u,p))if(b.asyncDep&&!b.asyncResolved){q(b,u,p);return}else b.next=u,b.update();else u.el=c.el,b.vnode=u},se=(c,u,p,b,_,g,v)=>{const x=()=>{if(c.isMounted){let{next:S,bu:C,u:O,parent:R,vnode:K}=c;{const xe=Nr(c);if(xe){S&&(S.el=K.el,q(c,S,v)),xe.asyncDep.then(()=>{le(()=>{c.isUnmounted||m()},_)});return}}let N=S,z;ze(c,!1),S?(S.el=K.el,q(c,S,v)):S=K,C&&ps(C),(z=S.props&&S.props.onVnodeBeforeUpdate)&&Se(z,R,S,K),ze(c,!0);const X=Sn(c),ye=c.subTree;c.subTree=X,P(ye,X,h(ye.el),Kt(ye),c,_,g),S.el=X.el,N===null&&To(c,X.el),O&&le(O,_),(z=S.props&&S.props.onVnodeUpdated)&&le(()=>Se(z,R,S,K),_)}else{let S;const{el:C,props:O}=u,{bm:R,m:K,parent:N,root:z,type:X}=c,ye=Tt(u);ze(c,!1),R&&ps(R),!ye&&(S=O&&O.onVnodeBeforeMount)&&Se(S,N,u),ze(c,!0);{z.ce&&z.ce._hasShadowRoot()&&z.ce._injectChildStyle(X,c.parent?c.parent.type:void 0);const xe=c.subTree=Sn(c);P(null,xe,p,b,c,_,g),u.el=xe.el}if(K&&le(K,_),!ye&&(S=O&&O.onVnodeMounted)){const xe=u;le(()=>Se(S,N,xe),_)}(u.shapeFlag&256||N&&Tt(N.vnode)&&N.vnode.shapeFlag&256)&&c.a&&le(c.a,_),c.isMounted=!0,u=p=b=null}};c.scope.on();const y=c.effect=new Qn(x);c.scope.off();const m=c.update=y.run.bind(y),E=c.job=y.runIfDirty.bind(y);E.i=c,E.id=c.uid,y.scheduler=()=>Zs(E),ze(c,!0),m()},q=(c,u,p)=>{u.component=c;const b=c.vnode.props;c.vnode=u,c.next=null,Eo(c,u.props,b,p),Mo(c,u.children,p),Ie(),gn(c),Re()},U=(c,u,p,b,_,g,v,x,y=!1)=>{const m=c&&c.children,E=c?c.shapeFlag:0,S=u.children,{patchFlag:C,shapeFlag:O}=u;if(C>0){if(C&128){Wt(m,S,p,b,_,g,v,x,y);return}else if(C&256){Ge(m,S,p,b,_,g,v,x,y);return}}O&8?(E&16&&at(m,_,g),S!==m&&a(p,S)):E&16?O&16?Wt(m,S,p,b,_,g,v,x,y):at(m,_,g,!0):(E&8&&a(p,""),O&16&&Ne(S,p,b,_,g,v,x,y))},Ge=(c,u,p,b,_,g,v,x,y)=>{c=c||nt,u=u||nt;const m=c.length,E=u.length,S=Math.min(m,E);let C;for(C=0;CE?at(c,_,g,!0,!1,S):Ne(u,p,b,_,g,v,x,y,S)},Wt=(c,u,p,b,_,g,v,x,y)=>{let m=0;const E=u.length;let S=c.length-1,C=E-1;for(;m<=S&&m<=C;){const O=c[m],R=u[m]=y?Le(u[m]):Ce(u[m]);if(gt(O,R))P(O,R,p,null,_,g,v,x,y);else break;m++}for(;m<=S&&m<=C;){const O=c[S],R=u[C]=y?Le(u[C]):Ce(u[C]);if(gt(O,R))P(O,R,p,null,_,g,v,x,y);else break;S--,C--}if(m>S){if(m<=C){const O=C+1,R=OC)for(;m<=S;)be(c[m],_,g,!0),m++;else{const O=m,R=m,K=new Map;for(m=R;m<=C;m++){const ce=u[m]=y?Le(u[m]):Ce(u[m]);ce.key!=null&&K.set(ce.key,m)}let N,z=0;const X=C-R+1;let ye=!1,xe=0;const ht=new Array(X);for(m=0;m=X){be(ce,_,g,!0);continue}let ve;if(ce.key!=null)ve=K.get(ce.key);else for(N=R;N<=C;N++)if(ht[N-R]===0&>(ce,u[N])){ve=N;break}ve===void 0?be(ce,_,g,!0):(ht[ve-R]=m+1,ve>=xe?xe=ve:ye=!0,P(ce,u[ve],p,null,_,g,v,x,y),z++)}const cn=ye?Do(ht):nt;for(N=cn.length-1,m=X-1;m>=0;m--){const ce=R+m,ve=u[ce],fn=u[ce+1],un=ce+1{const{el:g,type:v,transition:x,children:y,shapeFlag:m}=c;if(m&6){qe(c.component.subTree,u,p,b);return}if(m&128){c.suspense.move(u,p,b);return}if(m&64){v.move(c,u,p,dt);return}if(v===ae){n(g,u,p);for(let S=0;Sx.enter(g),_));else{const{leave:S,delayLeave:C,afterLeave:O}=x,R=()=>{c.ctx.isUnmounted?r(g):n(g,u,p)},K=()=>{const N=g._isLeaving||!!g[xs];g._isLeaving&&g[xs](!0),x.persisted&&!N?R():S(g,()=>{R(),O&&O()})};C?C(g,R,K):K()}else n(g,u,p)},be=(c,u,p,b=!1,_=!1)=>{const{type:g,props:v,ref:x,children:y,dynamicChildren:m,shapeFlag:E,patchFlag:S,dirs:C,cacheIndex:O,memo:R}=c;if(S===-2&&(_=!1),x!=null&&(Ie(),wt(x,null,p,c,!0),Re()),O!=null&&(u.renderCache[O]=void 0),E&256){u.ctx.deactivate(c);return}const K=E&1&&C,N=!Tt(c);let z;if(N&&(z=v&&v.onVnodeBeforeUnmount)&&Se(z,u,c),E&6)Zr(c.component,p,b);else{if(E&128){c.suspense.unmount(p,b);return}K&&Je(c,null,u,"beforeUnmount"),E&64?c.type.remove(c,u,p,dt,b):m&&!m.hasOnce&&(g!==ae||S>0&&S&64)?at(m,u,p,!1,!0):(g===ae&&S&384||!_&&E&16)&&at(y,u,p),b&&on(c)}const X=R!=null&&O==null;(N&&(z=v&&v.onVnodeUnmounted)||K||X)&&le(()=>{z&&Se(z,u,c),K&&Je(c,null,u,"unmounted"),X&&(c.el=null)},p)},on=c=>{const{type:u,el:p,anchor:b,transition:_}=c;if(u===ae){Xr(p,b);return}if(u===ws){A(c);return}const g=()=>{r(p),_&&!_.persisted&&_.afterLeave&&_.afterLeave()};if(c.shapeFlag&1&&_&&!_.persisted){const{leave:v,delayLeave:x}=_,y=()=>v(p,g);x?x(c.el,g,y):y()}else g()},Xr=(c,u)=>{let p;for(;c!==u;)p=w(c),r(c),c=p;r(u)},Zr=(c,u,p)=>{const{bum:b,scope:_,job:g,subTree:v,um:x,m:y,a:m}=c;Cn(y),Cn(m),b&&ps(b),_.stop(),g&&(g.flags|=8,be(v,c,u,p)),x&&le(x,u),le(()=>{c.isUnmounted=!0},u)},at=(c,u,p,b=!1,_=!1,g=0)=>{for(let v=g;v{if(c.shapeFlag&6)return Kt(c.component.subTree);if(c.shapeFlag&128)return c.suspense.next();const u=w(c.anchor||c.el),p=u&&u[Ji];return p?w(p):u};let ds=!1;const ln=(c,u,p)=>{let b;c==null?u._vnode&&(be(u._vnode,null,null,!0),b=u._vnode.component):P(u._vnode||null,c,u,null,null,null,p),u._vnode=c,ds||(ds=!0,gn(b),mr(),ds=!1)},dt={p:P,um:be,m:qe,r:on,mt:as,mc:Ne,pc:U,pbc:ke,n:Kt,o:e};return{render:ln,hydrate:void 0,createApp:_o(ln)}}function Ss({type:e,props:t},s){return s==="svg"&&e==="foreignObject"||s==="mathml"&&e==="annotation-xml"&&t&&t.encoding&&t.encoding.includes("html")?void 0:s}function ze({effect:e,job:t},s){s?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function Fo(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function Hr(e,t,s=!1){const n=e.children,r=t.children;if(M(n)&&M(r))for(let i=0;i>1,e[s[l]]0&&(t[n]=s[i-1]),s[i]=n)}}for(i=s.length,o=s[i-1];i-- >0;)s[i]=o,o=t[o];return s}function Nr(e){const t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:Nr(t)}function Cn(e){if(e)for(let t=0;te.__isSuspense;function Lo(e,t){t&&t.pendingBranch?M(e)?t.effects.push(...e):t.effects.push(e):Ui(e)}const ae=Symbol.for("v-fgt"),us=Symbol.for("v-txt"),Be=Symbol.for("v-cmt"),ws=Symbol.for("v-stc"),Et=[];let fe=null;function Ve(e=!1){Et.push(fe=e?null:[])}function jo(){Et.pop(),fe=Et[Et.length-1]||null}let It=1;function Zt(e,t=!1){It+=e,e<0&&fe&&t&&(fe.hasOnce=!0)}function Ur(e){return e.dynamicChildren=It>0?fe||nt:null,jo(),It>0&&fe&&fe.push(e),e}function At(e,t,s,n,r,i){return Ur(L(e,t,s,n,r,i,!0))}function Ls(e,t,s,n,r){return Ur(oe(e,t,s,n,r,!0))}function Qt(e){return e?e.__v_isVNode===!0:!1}function gt(e,t){return e.type===t.type&&e.key===t.key}const Vr=({key:e})=>e??null,kt=({ref:e,ref_key:t,ref_for:s})=>(typeof e=="number"&&(e=""+e),e!=null?J(e)||ee(e)||I(e)?{i:Oe,r:e,k:t,f:!!s}:e:null);function L(e,t=null,s=null,n=0,r=null,i=e===ae?0:1,o=!1,l=!1){const f={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&Vr(t),ref:t&&kt(t),scopeId:br,slotScopeIds:null,children:s,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:i,patchFlag:n,dynamicProps:r,dynamicChildren:null,appContext:null,ctx:Oe};return l?(es(f,s),i&128&&e.normalize(f)):s&&(f.shapeFlag|=J(s)?8:16),It>0&&!o&&fe&&(f.patchFlag>0||i&6)&&f.patchFlag!==32&&fe.push(f),f}const oe=$o;function $o(e,t=null,s=null,n=0,r=null,i=!1){if((!e||e===lo)&&(e=Be),Qt(e)){const l=ft(e,t,!0);return s&&es(l,s),It>0&&!i&&fe&&(l.shapeFlag&6?fe[fe.indexOf(e)]=l:fe.push(l)),l.patchFlag=-2,l}if(zo(e)&&(e=e.__vccOpts),t){t=Ho(t);let{class:l,style:f}=t;l&&!J(l)&&(t.class=Dt(l)),H(f)&&(Xs(f)&&!M(f)&&(f=te({},f)),t.style=Us(f))}const o=J(e)?1:Kr(e)?128:zi(e)?64:H(e)?4:I(e)?2:0;return L(e,t,s,n,r,o,i,!0)}function Ho(e){return e?Xs(e)||Rr(e)?te({},e):e:null}function ft(e,t,s=!1,n=!1){const{props:r,ref:i,patchFlag:o,children:l,transition:f}=e,d=t?Wo(r||{},t):r,a={__v_isVNode:!0,__v_skip:!0,type:e.type,props:d,key:d&&Vr(d),ref:t&&t.ref?s&&i?M(i)?i.concat(kt(t)):[i,kt(t)]:kt(t):i,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:l,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==ae?o===-1?16:o|16:o,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:f,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&ft(e.ssContent),ssFallback:e.ssFallback&&ft(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return f&&n&&Qs(a,f.clone(a)),a}function Gt(e=" ",t=0){return oe(us,null,e,t)}function No(e="",t=!1){return t?(Ve(),Ls(Be,null,e)):oe(Be,null,e)}function Ce(e){return e==null||typeof e=="boolean"?oe(Be):M(e)?oe(ae,null,e.slice()):Qt(e)?Le(e):oe(us,null,String(e))}function Le(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:ft(e)}function es(e,t){let s=0;const{shapeFlag:n}=e;if(t==null)t=null;else if(M(t))s=16;else if(typeof t=="object")if(n&65){const r=t.default;r&&(r._c&&(r._d=!1),es(e,r()),r._c&&(r._d=!0));return}else{s=32;const r=t._;!r&&!Rr(t)?t._ctx=Oe:r===3&&Oe&&(Oe.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else if(I(t)){if(n&65){es(e,{default:t});return}t={default:t,_ctx:Oe},s=32}else t=String(t),n&64?(s=16,t=[Gt(t)]):s=8;e.children=t,e.shapeFlag|=s}function Wo(...e){const t={};for(let s=0;sie||Oe;let ts,js;{const e=is(),t=(s,n)=>{let r;return(r=e[s])||(r=e[s]=[]),r.push(n),i=>{r.length>1?r.forEach(o=>o(i)):r[0](i)}};ts=t("__VUE_INSTANCE_SETTERS__",s=>ie=s),js=t("__VUE_SSR_SETTERS__",s=>Rt=s)}const jt=e=>{const t=ie;return ts(e),e.scope.on(),()=>{e.scope.off(),ts(t)}},En=()=>{ie&&ie.scope.off(),ts(null)};function Br(e){return e.vnode.shapeFlag&4}let Rt=!1;function ko(e,t=!1,s=!1){t&&js(t);const{props:n,children:r}=e.vnode,i=Br(e);Co(e,n,i,t),Po(e,r,s||t);const o=i?Go(e,t):void 0;return t&&js(!1),o}function Go(e,t){const s=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,fo);const{setup:n}=s;if(n){Ie();const r=e.setupContext=n.length>1?Jo(e):null,i=jt(e),o=Lt(n,e,0,[e.props,r]),l=kn(o);if(Re(),i(),(l||e.sp)&&!Tt(e)&&vr(e),l){if(o.then(En,En),t)return o.then(f=>{An(e,f)}).catch(f=>{ls(f,e,0)});e.asyncDep=o}else An(e,o)}else kr(e)}function An(e,t,s){I(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:H(t)&&(e.setupState=hr(t)),kr(e)}function kr(e,t,s){const n=e.type;e.render||(e.render=n.render||Pe);{const r=jt(e);Ie();try{uo(e)}finally{Re(),r()}}}const qo={get(e,t){return Q(e,"get",""),e[t]}};function Jo(e){const t=s=>{e.exposed=s||{}};return{attrs:new Proxy(e.attrs,qo),slots:e.slots,emit:e.emit,expose:t}}function nn(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy(hr(Ii(e.exposed)),{get(t,s){if(s in t)return t[s];if(s in Ct)return Ct[s](e)},has(t,s){return s in t||s in Ct}})):e.proxy}function zo(e){return I(e)&&"__vccOpts"in e}const Gr=(e,t)=>ji(e,t,Rt);function $s(e,t,s){try{Zt(-1);const n=arguments.length;return n===2?H(t)&&!M(t)?Qt(t)?oe(e,null,[t]):oe(e,t):oe(e,null,t):(n>3?s=Array.prototype.slice.call(arguments,2):n===3&&Qt(s)&&(s=[s]),oe(e,t,s))}finally{Zt(1)}}const Yo="3.5.39";/** +* @vue/runtime-dom v3.5.39 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let Hs;const On=typeof window<"u"&&window.trustedTypes;if(On)try{Hs=On.createPolicy("vue",{createHTML:e=>e})}catch{}const qr=Hs?e=>Hs.createHTML(e):e=>e,Xo="http://www.w3.org/2000/svg",Zo="http://www.w3.org/1998/Math/MathML",De=typeof document<"u"?document:null,Pn=De&&De.createElement("template"),Qo={insert:(e,t,s)=>{t.insertBefore(e,s||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,s,n)=>{const r=t==="svg"?De.createElementNS(Xo,e):t==="mathml"?De.createElementNS(Zo,e):s?De.createElement(e,{is:s}):De.createElement(e);return e==="select"&&n&&n.multiple!=null&&r.setAttribute("multiple",n.multiple),r},createText:e=>De.createTextNode(e),createComment:e=>De.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>De.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,s,n,r,i){const o=s?s.previousSibling:t.lastChild;if(r&&(r===i||r.nextSibling))for(;t.insertBefore(r.cloneNode(!0),s),!(r===i||!(r=r.nextSibling)););else{Pn.innerHTML=qr(n==="svg"?`${e}`:n==="mathml"?`${e}`:e);const l=Pn.content;if(n==="svg"||n==="mathml"){const f=l.firstChild;for(;f.firstChild;)l.appendChild(f.firstChild);l.removeChild(f)}t.insertBefore(l,s)}return[o?o.nextSibling:t.firstChild,s?s.previousSibling:t.lastChild]}},el=Symbol("_vtc");function tl(e,t,s){const n=e[el];n&&(t=(t?[t,...n]:[...n]).join(" ")),t==null?e.removeAttribute("class"):s?e.setAttribute("class",t):e.className=t}const Mn=Symbol("_vod"),sl=Symbol("_vsh"),nl=Symbol(""),rl=/(?:^|;)\s*display\s*:/;function il(e,t,s){const n=e.style,r=J(s);let i=!1;if(s&&!r){if(t)if(J(t))for(const o of t.split(";")){const l=o.slice(0,o.indexOf(":")).trim();s[l]==null&&bt(n,l,"")}else for(const o in t)s[o]==null&&bt(n,o,"");for(const o in s){o==="display"&&(i=!0);const l=s[o];l!=null?ll(e,o,!J(t)&&t?t[o]:void 0,l)||bt(n,o,l):bt(n,o,"")}}else if(r){if(t!==s){const o=n[nl];o&&(s+=";"+o),n.cssText=s,i=rl.test(s)}}else t&&e.removeAttribute("style");Mn in e&&(e[Mn]=i?n.display:"",e[sl]&&(n.display="none"))}const In=/\s*!important$/;function bt(e,t,s){if(M(s))s.forEach(n=>bt(e,t,n));else if(s==null&&(s=""),t.startsWith("--"))e.setProperty(t,s);else{const n=ol(e,t);In.test(s)?e.setProperty(Qe(n),s.replace(In,""),"important"):e[n]=s}}const Rn=["Webkit","Moz","ms"],Ts={};function ol(e,t){const s=Ts[t];if(s)return s;let n=de(t);if(n!=="filter"&&n in e)return Ts[t]=n;n=Jn(n);for(let r=0;rCs||(pl.then(()=>Cs=0),Cs=Date.now());function ml(e,t){const s=n=>{if(!n._vts)n._vts=Date.now();else if(n._vts<=s.attached)return;const r=s.value;if(M(r)){const i=n.stopImmediatePropagation;n.stopImmediatePropagation=()=>{i.call(n),n._stopped=!0};const o=r.slice(),l=[n];for(let f=0;fe.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,_l=(e,t,s,n,r,i)=>{const o=r==="svg";t==="class"?tl(e,n,o):t==="style"?il(e,s,n):ss(t)?ns(t)||ul(e,t,s,n,i):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):bl(e,t,n,o))?(Ln(e,t,n),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&Dn(e,t,n,o,i,t!=="value")):e._isVueCE&&(yl(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!J(n)))?Ln(e,de(t),n,i,t):(t==="true-value"?e._trueValue=n:t==="false-value"&&(e._falseValue=n),Dn(e,t,n,o))};function bl(e,t,s,n){if(n)return!!(t==="innerHTML"||t==="textContent"||t in e&&$n(t)&&I(s));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const r=e.tagName;if(r==="IMG"||r==="VIDEO"||r==="CANVAS"||r==="SOURCE")return!1}return $n(t)&&J(s)?!1:t in e}function yl(e,t){const s=e._def.props;if(!s)return!1;const n=de(t);return Array.isArray(s)?s.some(r=>de(r)===n):Object.keys(s).some(r=>de(r)===n)}const xl=te({patchProp:_l},Qo);let Hn;function vl(){return Hn||(Hn=Io(xl))}const Sl=((...e)=>{const t=vl().createApp(...e),{mount:s}=t;return t.mount=n=>{const r=Tl(n);if(!r)return;const i=t._component;!I(i)&&!i.render&&!i.template&&(i.template=r.innerHTML),r.nodeType===1&&(r.textContent="");const o=s(r,!1,wl(r));return r instanceof Element&&(r.removeAttribute("v-cloak"),r.setAttribute("data-v-app","")),o},t});function wl(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function Tl(e){return J(e)?document.querySelector(e):e}/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Nn=e=>e==="";/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Cl=(...e)=>e.filter((t,s,n)=>!!t&&t.trim()!==""&&n.indexOf(t)===s).join(" ").trim();/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Wn=e=>e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase();/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const El=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(t,s,n)=>n?n.toUpperCase():s.toLowerCase());/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Al=e=>{const t=El(e);return t.charAt(0).toUpperCase()+t.slice(1)};/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */var tt={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":2,"stroke-linecap":"round","stroke-linejoin":"round"};/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ol=Symbol("lucide-icons");function Pl(){return St(Ol,{})}/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ml=({name:e,iconNode:t,absoluteStrokeWidth:s,"absolute-stroke-width":n,strokeWidth:r,"stroke-width":i,size:o,color:l,...f},{slots:d})=>{const{size:a,color:h,strokeWidth:w=2,absoluteStrokeWidth:T=!1,class:D=""}=Pl(),P=Gr(()=>{const G=Nn(s)||Nn(n)||s===!0||n===!0||T===!0,W=r||i||w||tt["stroke-width"];return G?Number(W)*24/Number(o??a??tt.width):W});return $s("svg",{...tt,...f,width:o??a??tt.width,height:o??a??tt.height,stroke:l??h??tt.stroke,"stroke-width":P.value,class:Cl("lucide",D,...e?[`lucide-${Wn(Al(e))}-icon`,`lucide-${Wn(e)}`]:["lucide-icon"])},[...t.map(G=>$s(...G)),...d.default?[d.default()]:[]])};/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Jr=(e,t)=>(s,{slots:n,attrs:r})=>$s(Ml,{...r,...s,iconNode:t,name:e},n.default?{default:n.default}:void 0);/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Il=[["path",{d:"M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401",key:"kfwtm"}]],Rl=Jr("moon",Il);/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Fl=[["circle",{cx:"12",cy:"12",r:"4",key:"4exip2"}],["path",{d:"M12 2v2",key:"tus03m"}],["path",{d:"M12 20v2",key:"1lh1kg"}],["path",{d:"m4.93 4.93 1.41 1.41",key:"149t6j"}],["path",{d:"m17.66 17.66 1.41 1.41",key:"ptbguv"}],["path",{d:"M2 12h2",key:"1t8f8n"}],["path",{d:"M20 12h2",key:"1q8mjw"}],["path",{d:"m6.34 17.66-1.41 1.41",key:"1m8zz5"}],["path",{d:"m19.07 4.93-1.41 1.41",key:"1shlcs"}]],Dl=Jr("sun",Fl),zr="gsmnode-theme";function Ll(){try{return localStorage.getItem(zr)==="dark"?"dark":"light"}catch{return"light"}}const lt=mt(Ll());function Yr(e){lt.value=e,document.documentElement.setAttribute("data-gsm-theme",e);try{localStorage.setItem(zr,e)}catch{}}function Kn(){Yr(lt.value==="dark"?"light":"dark")}Yr(lt.value);const jl={class:"overflow-hidden rounded-lg border border-subtle bg-card shadow-xs"},$l={class:"flex items-center justify-between border-b border-subtle px-5 py-4"},Hl={class:"text-base font-semibold text-primary"},Nl={class:"gn-eyebrow"},Wl={class:"w-full text-left text-sm"},Kl={class:"border-t border-subtle px-5 py-2.5 font-mono text-xs whitespace-nowrap"},Ul={class:"text-primary"},Vl={class:"border-t border-subtle px-5 py-2.5 text-secondary"},Un={__name:"EndpointTable",props:{title:String,auth:String,endpoints:Array},setup(e){const t={GET:"text-success",POST:"text-brand-text",PATCH:"text-warning",DELETE:"text-danger"};return(s,n)=>(Ve(),At("div",jl,[L("div",$l,[L("div",Hl,Ae(e.title),1),L("span",Nl,Ae(e.auth),1)]),L("table",Wl,[n[0]||(n[0]=L("thead",null,[L("tr",{class:"gn-eyebrow"},[L("th",{class:"px-5 py-2.5 font-medium"},"Endpoint"),L("th",{class:"px-5 py-2.5 font-medium"},"Description")])],-1)),L("tbody",null,[(Ve(!0),At(ae,null,co(e.endpoints,r=>(Ve(),At("tr",{key:r.method+r.path,class:"transition-colors hover:bg-sunken"},[L("td",Kl,[L("span",{class:Dt(["font-semibold",t[r.method]])},Ae(r.method),3),L("span",Ul,Ae(r.path),1)]),L("td",Vl,Ae(r.desc),1)]))),128))])])]))}},Bl={class:"mx-auto flex max-w-3xl flex-col gap-6 px-6 pt-12 pb-16"},kl={class:"flex items-center gap-4"},Gl=["src"],ql=["title"],Jl={class:"rounded-lg border border-subtle bg-card shadow-sm"},zl={class:"flex items-center justify-between border-b border-subtle px-5 py-4"},Yl={class:"flex flex-wrap items-center gap-x-6 gap-y-2 px-5 py-4 font-mono text-xs text-secondary"},Xl={__name:"App",setup(e){const t=mt("checking"),s=mt(null),n=mt(null),r=mt(null);let i=null;async function o(){const a=performance.now();try{const h=await fetch("/api/health");n.value=Math.round(performance.now()-a),s.value=h.status,t.value=h.ok?"ok":"error"}catch{n.value=null,s.value=null,t.value="unreachable"}r.value=new Date}Tr(()=>{o(),i=setInterval(o,1e4)}),en(()=>clearInterval(i));const l={checking:{label:"checking",cls:"bg-sunken text-secondary"},ok:{label:"operational",cls:"bg-success-tint text-success"},error:{label:"error",cls:"bg-danger-tint text-danger"},unreachable:{label:"unreachable",cls:"bg-danger-tint text-danger"}},f=[{method:"POST",path:"/api/auth/login",desc:"Exchange email + password for a JWT"},{method:"GET",path:"/api/devices",desc:"List registered gateway devices"},{method:"DELETE",path:"/api/devices/{id}",desc:"Remove a device"},{method:"POST",path:"/api/messages",desc:"Queue an outbound SMS"},{method:"GET",path:"/api/messages",desc:"Outbound message history"},{method:"POST",path:"/api/calls",desc:"Queue an outbound phone call"},{method:"GET",path:"/api/inbox",desc:"Messages received by your devices"},{method:"GET",path:"/api/webhooks",desc:"List webhook subscriptions"},{method:"POST",path:"/api/webhooks",desc:"Register a webhook"}],d=[{method:"POST",path:"/api/mobile/v1/device",desc:"Register this phone as a gateway"},{method:"GET",path:"/api/mobile/v1/messages",desc:"Pull pending messages to send"},{method:"PATCH",path:"/api/mobile/v1/messages/{id}",desc:"Report sent / delivered / failed"},{method:"POST",path:"/api/mobile/v1/inbox",desc:"Push a received SMS"},{method:"POST",path:"/api/mobile/v1/ping",desc:"Device heartbeat"}];return(a,h)=>(Ve(),At("div",Bl,[L("div",kl,[L("img",{src:We(lt)==="dark"?"/gsmnode-horizontal-white.png":"/gsmnode-horizontal.png",alt:"gsmnode",class:"h-8"},null,8,Gl),h[2]||(h[2]=L("span",{class:"gn-eyebrow mt-1.5"},"API server",-1)),h[3]||(h[3]=L("div",{class:"flex-1"},null,-1)),L("button",{class:"gn-btn-sec gn-btn-sm",title:We(lt)==="dark"?"Switch to light theme":"Switch to dark theme",onClick:h[0]||(h[0]=(...w)=>We(Kn)&&We(Kn)(...w))},[We(lt)==="dark"?(Ve(),Ls(We(Dl),{key:0,class:"h-4 w-4"})):(Ve(),Ls(We(Rl),{key:1,class:"h-4 w-4"})),h[1]||(h[1]=Gt(" Theme ",-1))],8,ql)]),L("div",Jl,[L("div",zl,[h[5]||(h[5]=L("div",{class:"text-base font-semibold text-primary"},"Status",-1)),L("span",{class:Dt(["inline-flex items-center gap-1.5 rounded-sm px-2.5 py-1 font-mono text-xs font-medium",l[t.value].cls])},[h[4]||(h[4]=L("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),Gt(" "+Ae(l[t.value].label),1),t.value==="error"?(Ve(),At(ae,{key:0},[Gt(Ae(s.value),1)],64)):No("",!0)],2)]),L("div",Yl,[h[6]||(h[6]=L("span",null,"GET /api/health",-1)),L("span",null,Ae(n.value!==null?n.value+"ms":"—"),1),L("span",null,Ae(r.value?"checked "+r.value.toLocaleTimeString():"—"),1)])]),oe(Un,{title:"Client API",auth:"Bearer JWT",endpoints:f}),oe(Un,{title:"Mobile API",auth:"Device token",endpoints:d}),h[7]||(h[7]=L("p",{class:"text-center font-mono text-[11px] text-muted"}," gsmnode — turn any Android phone into an SMS gateway. ",-1))]))}};Sl(Xl).mount("#app"); diff --git a/API Server/internal/api/dist/favicon-32.png b/API Server/internal/api/dist/favicon-32.png new file mode 100644 index 0000000..23301f0 Binary files /dev/null and b/API Server/internal/api/dist/favicon-32.png differ diff --git a/API Server/internal/api/dist/favicon.svg b/API Server/internal/api/dist/favicon.svg new file mode 100644 index 0000000..4078054 --- /dev/null +++ b/API Server/internal/api/dist/favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/API Server/internal/api/dist/gsmnode-horizontal-white.png b/API Server/internal/api/dist/gsmnode-horizontal-white.png new file mode 100644 index 0000000..ac100c0 Binary files /dev/null and b/API Server/internal/api/dist/gsmnode-horizontal-white.png differ diff --git a/API Server/internal/api/dist/gsmnode-horizontal.png b/API Server/internal/api/dist/gsmnode-horizontal.png new file mode 100644 index 0000000..bcd2937 Binary files /dev/null and b/API Server/internal/api/dist/gsmnode-horizontal.png differ diff --git a/API Server/internal/api/dist/index.html b/API Server/internal/api/dist/index.html new file mode 100644 index 0000000..2deda0a --- /dev/null +++ b/API Server/internal/api/dist/index.html @@ -0,0 +1,16 @@ + + + + + + + + + gsmnode · API Server + + + + +
+ + diff --git a/API Server/internal/api/health.go b/API Server/internal/api/health.go new file mode 100644 index 0000000..e94ea67 --- /dev/null +++ b/API Server/internal/api/health.go @@ -0,0 +1,11 @@ +package api + +import "net/http" + +// handleHealth reports server readiness. +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "status": "ok", + "service": "gsmnode-api", + }) +} diff --git a/API Server/internal/api/inbox.go b/API Server/internal/api/inbox.go new file mode 100644 index 0000000..4420fd2 --- /dev/null +++ b/API Server/internal/api/inbox.go @@ -0,0 +1,96 @@ +package api + +import ( + "net/http" + "strings" + "time" + + "smsgateway/apiserver/internal/pb" +) + +type inboxDTO struct { + ID string `json:"id"` + DeviceID string `json:"device_id"` + PhoneNumber string `json:"phone_number"` + Message string `json:"message"` + ReceivedAt string `json:"received_at"` +} + +func recordToInbox(rec pb.Record) inboxDTO { + return inboxDTO{ + ID: asString(rec["id"]), + DeviceID: asString(rec["device"]), + PhoneNumber: asString(rec["phone_number"]), + Message: asString(rec["message"]), + ReceivedAt: asString(rec["received_at"]), + } +} + +// handleListInbox lists received SMS for the authenticated user. +func (s *Server) handleListInbox(w http.ResponseWriter, r *http.Request) { + uid, _ := userFromCtx(r.Context()) + res, err := s.pb.List(r.Context(), colInbox, pb.ListOptions{ + Filter: "owner = " + pbQuote(uid), + Sort: "-received_at", + Page: queryInt(r, "page", 1), + PerPage: clampPerPage(queryInt(r, "per_page", 50)), + }) + if err != nil { + writeUpstreamError(w, err) + return + } + out := make([]inboxDTO, 0, len(res.Items)) + for _, rec := range res.Items { + out = append(out, recordToInbox(rec)) + } + writeJSON(w, http.StatusOK, map[string]any{ + "items": out, "total": res.TotalItems, "page": res.Page, + }) +} + +type receiveSMSRequest struct { + PhoneNumber string `json:"phone_number"` + Message string `json:"message"` + ReceivedAt string `json:"received_at"` +} + +// handleReceiveSMS records an incoming SMS reported by a device and fires +// sms:received webhooks. +func (s *Server) handleReceiveSMS(w http.ResponseWriter, r *http.Request) { + device := deviceFromCtx(r.Context()) + + var req receiveSMSRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if strings.TrimSpace(req.PhoneNumber) == "" { + writeError(w, http.StatusBadRequest, "phone_number is required") + return + } + receivedAt := req.ReceivedAt + if receivedAt == "" { + receivedAt = time.Now().UTC().Format("2006-01-02 15:04:05.000Z") + } + + rec, err := s.pb.Create(r.Context(), colInbox, pb.Record{ + "device": asString(device["id"]), + "owner": asString(device["owner"]), + "phone_number": req.PhoneNumber, + "message": req.Message, + "received_at": receivedAt, + }) + if err != nil { + writeUpstreamError(w, err) + return + } + + s.dispatchWebhooks(asString(device["owner"]), asString(device["id"]), "sms:received", map[string]any{ + "inbox_id": asString(rec["id"]), + "phone_number": req.PhoneNumber, + "message": req.Message, + "received_at": receivedAt, + }) + + writeJSON(w, http.StatusCreated, recordToInbox(rec)) +} diff --git a/API Server/internal/api/messages.go b/API Server/internal/api/messages.go new file mode 100644 index 0000000..48cdb98 --- /dev/null +++ b/API Server/internal/api/messages.go @@ -0,0 +1,314 @@ +package api + +import ( + "net/http" + "strings" + "time" + + "smsgateway/apiserver/internal/pb" +) + +// Message lifecycle states. +const ( + statusPending = "Pending" // enqueued, not yet picked up by a device + statusProcessed = "Processed" // delivered to a device for sending + statusSent = "Sent" // device handed it to the SIM/radio + statusDelivered = "Delivered" // delivery report received + statusFailed = "Failed" // sending failed +) + +var validReportStates = map[string]bool{ + statusSent: true, statusDelivered: true, statusFailed: true, statusProcessed: true, +} + +// Message kinds. +const ( + msgTypeSMS = "sms" + msgTypeCall = "call" +) + +type messageDTO struct { + ID string `json:"id"` + Type string `json:"type"` + PhoneNumbers []string `json:"phone_numbers"` + TextMessage string `json:"text_message"` + DeviceID string `json:"device_id,omitempty"` + SimNumber int `json:"sim_number,omitempty"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + ScheduleAt string `json:"schedule_at,omitempty"` + CreatedAt string `json:"created_at"` +} + +func recordToMessage(rec pb.Record) messageDTO { + msgType := asString(rec["type"]) + if msgType == "" { + msgType = msgTypeSMS + } + return messageDTO{ + ID: asString(rec["id"]), + Type: msgType, + PhoneNumbers: asStringSlice(rec["phone_numbers"]), + TextMessage: asString(rec["text_message"]), + DeviceID: asString(rec["device"]), + SimNumber: asInt(rec["sim_number"]), + Status: asString(rec["status"]), + Error: asString(rec["error"]), + ScheduleAt: asString(rec["schedule_at"]), + CreatedAt: asString(rec["created"]), + } +} + +type enqueueRequest struct { + PhoneNumbers []string `json:"phone_numbers"` + TextMessage string `json:"text_message"` + DeviceID string `json:"device_id"` + SimNumber int `json:"sim_number"` + ScheduleAt string `json:"schedule_at"` +} + +// handleEnqueueMessage queues an outbound SMS for one of the user's devices. +func (s *Server) handleEnqueueMessage(w http.ResponseWriter, r *http.Request) { + uid, _ := userFromCtx(r.Context()) + + var req enqueueRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + phones := cleanPhones(req.PhoneNumbers) + if len(phones) == 0 { + writeError(w, http.StatusBadRequest, "at least one phone number is required") + return + } + if strings.TrimSpace(req.TextMessage) == "" { + writeError(w, http.StatusBadRequest, "text_message is required") + return + } + + // Resolve the target device: explicit device_id, else the user's first + // online device. + deviceRecID, err := s.resolveDevice(r, uid, req.DeviceID) + if err != nil { + writeUpstreamError(w, err) + return + } + if deviceRecID == "" { + writeError(w, http.StatusBadRequest, "no device available; register a device first") + return + } + + fields := pb.Record{ + "phone_numbers": phones, + "text_message": req.TextMessage, + "type": msgTypeSMS, + "device": deviceRecID, + "owner": uid, + "status": statusPending, + } + if req.SimNumber > 0 { + fields["sim_number"] = req.SimNumber + } + if req.ScheduleAt != "" { + fields["schedule_at"] = req.ScheduleAt + } + + rec, err := s.pb.Create(r.Context(), colMessages, fields) + if err != nil { + writeUpstreamError(w, err) + return + } + writeJSON(w, http.StatusAccepted, recordToMessage(rec)) +} + +// resolveDevice returns the PocketBase device record id to target. requested may +// be either the internal record id or the client-facing device_id. +func (s *Server) resolveDevice(r *http.Request, uid, requested string) (string, error) { + requested = strings.TrimSpace(requested) + if requested != "" { + dev, err := s.pb.FindFirst(r.Context(), colDevices, + "owner = "+pbQuote(uid)+" && (id = "+pbQuote(requested)+" || device_id = "+pbQuote(requested)+")", "") + if err != nil { + return "", err + } + if dev == nil { + return "", nil + } + return asString(dev["id"]), nil + } + dev, err := s.pb.FindFirst(r.Context(), colDevices, + "owner = "+pbQuote(uid), "-last_seen_at") + if err != nil || dev == nil { + return "", err + } + return asString(dev["id"]), nil +} + +// handleListMessages lists the user's messages with optional filters. +func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) { + uid, _ := userFromCtx(r.Context()) + + filters := []string{"owner = " + pbQuote(uid)} + if st := r.URL.Query().Get("status"); st != "" { + filters = append(filters, "status = "+pbQuote(st)) + } + if dev := r.URL.Query().Get("device_id"); dev != "" { + filters = append(filters, "device = "+pbQuote(dev)) + } + if t := r.URL.Query().Get("type"); t != "" { + filters = append(filters, "type = "+pbQuote(t)) + } + + res, err := s.pb.List(r.Context(), colMessages, pb.ListOptions{ + Filter: strings.Join(filters, " && "), + Sort: "-created", + Page: queryInt(r, "page", 1), + PerPage: clampPerPage(queryInt(r, "per_page", 50)), + }) + if err != nil { + writeUpstreamError(w, err) + return + } + out := make([]messageDTO, 0, len(res.Items)) + for _, rec := range res.Items { + out = append(out, recordToMessage(rec)) + } + writeJSON(w, http.StatusOK, map[string]any{ + "items": out, "total": res.TotalItems, "page": res.Page, + }) +} + +// handleGetMessage returns a single message's state. +func (s *Server) handleGetMessage(w http.ResponseWriter, r *http.Request) { + uid, _ := userFromCtx(r.Context()) + rec, err := s.pb.GetOne(r.Context(), colMessages, r.PathValue("id")) + if err != nil { + writeUpstreamError(w, err) + return + } + if asString(rec["owner"]) != uid { + writeError(w, http.StatusForbidden, "not your message") + return + } + writeJSON(w, http.StatusOK, recordToMessage(rec)) +} + +// handlePullMessages returns pending messages for the calling device and marks +// them Processed so they are not handed out twice. +func (s *Server) handlePullMessages(w http.ResponseWriter, r *http.Request) { + device := deviceFromCtx(r.Context()) + deviceID := asString(device["id"]) + + res, err := s.pb.List(r.Context(), colMessages, pb.ListOptions{ + Filter: "device = " + pbQuote(deviceID) + " && status = " + pbQuote(statusPending), + Sort: "created", + PerPage: clampPerPage(queryInt(r, "limit", 20)), + }) + if err != nil { + writeUpstreamError(w, err) + return + } + + out := make([]messageDTO, 0, len(res.Items)) + for _, rec := range res.Items { + id := asString(rec["id"]) + updated, uerr := s.pb.Update(r.Context(), colMessages, id, pb.Record{"status": statusProcessed}) + if uerr != nil { + // Skip a message we couldn't claim rather than failing the pull. + continue + } + out = append(out, recordToMessage(updated)) + } + writeJSON(w, http.StatusOK, map[string]any{"items": out}) +} + +type reportRequest struct { + Status string `json:"status"` + Error string `json:"error"` +} + +// handleReportMessage records a delivery state reported by the device and fires +// the matching webhooks. +func (s *Server) handleReportMessage(w http.ResponseWriter, r *http.Request) { + device := deviceFromCtx(r.Context()) + id := r.PathValue("id") + + var req reportRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if !validReportStates[req.Status] { + writeError(w, http.StatusBadRequest, "invalid status") + return + } + + rec, err := s.pb.GetOne(r.Context(), colMessages, id) + if err != nil { + writeUpstreamError(w, err) + return + } + if asString(rec["device"]) != asString(device["id"]) { + writeError(w, http.StatusForbidden, "message not assigned to this device") + return + } + + fields := pb.Record{"status": req.Status, "error": req.Error} + now := time.Now().UTC().Format("2006-01-02 15:04:05.000Z") + switch req.Status { + case statusSent: + fields["sent_at"] = now + case statusDelivered: + fields["delivered_at"] = now + } + updated, err := s.pb.Update(r.Context(), colMessages, id, fields) + if err != nil { + writeUpstreamError(w, err) + return + } + + // Dispatch webhooks for terminal/notable states. + if event := eventForStatus(req.Status); event != "" { + s.dispatchWebhooks(asString(device["owner"]), asString(device["id"]), event, map[string]any{ + "message_id": id, + "phone_numbers": asStringSlice(updated["phone_numbers"]), + "status": req.Status, + "error": req.Error, + }) + } + + writeJSON(w, http.StatusOK, recordToMessage(updated)) +} + +func eventForStatus(status string) string { + switch status { + case statusSent: + return "sms:sent" + case statusDelivered: + return "sms:delivered" + case statusFailed: + return "sms:failed" + default: + return "" + } +} + +func cleanPhones(in []string) []string { + out := make([]string, 0, len(in)) + for _, p := range in { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +func clampPerPage(n int) int { + if n <= 0 { + return 50 + } + if n > 200 { + return 200 + } + return n +} diff --git a/API Server/internal/api/panel.go b/API Server/internal/api/panel.go new file mode 100644 index 0000000..add9942 --- /dev/null +++ b/API Server/internal/api/panel.go @@ -0,0 +1,23 @@ +package api + +import ( + "embed" + "io/fs" + "net/http" +) + +// The gsmnode web panel: a Vue 3 + Tailwind app (source in panel/, built +// with `npm run build` into dist/) embedded at compile time and served at +// the server root. +// +//go:embed all:dist +var panelFS embed.FS + +// panelHandler serves the built panel assets. +func panelHandler() http.Handler { + sub, err := fs.Sub(panelFS, "dist") + if err != nil { + panic(err) // embedded dist is malformed; unreachable in a valid build + } + return http.FileServerFS(sub) +} diff --git a/API Server/internal/api/respond.go b/API Server/internal/api/respond.go new file mode 100644 index 0000000..8ada244 --- /dev/null +++ b/API Server/internal/api/respond.go @@ -0,0 +1,65 @@ +package api + +import ( + "encoding/json" + "errors" + "io" + "log" + "net/http" + + "smsgateway/apiserver/internal/pb" +) + +// writeJSON writes v as a JSON response with the given status code. +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if v == nil { + return + } + if err := json.NewEncoder(w).Encode(v); err != nil { + log.Printf("writeJSON: %v", err) + } +} + +// errorBody is the standard error envelope. +type errorBody struct { + Error string `json:"error"` +} + +// writeError writes a JSON error response. +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, errorBody{Error: msg}) +} + +// writeUpstreamError maps a PocketBase error onto an appropriate HTTP status. +func writeUpstreamError(w http.ResponseWriter, err error) { + var apiErr *pb.APIError + if errors.As(err, &apiErr) { + switch apiErr.Status { + case http.StatusNotFound: + writeError(w, http.StatusNotFound, "not found") + case http.StatusBadRequest: + writeError(w, http.StatusBadRequest, apiErr.Message) + case http.StatusForbidden: + writeError(w, http.StatusForbidden, "forbidden") + default: + log.Printf("upstream error: %v (%s)", apiErr, apiErr.Body) + writeError(w, http.StatusBadGateway, "upstream error") + } + return + } + log.Printf("internal error: %v", err) + writeError(w, http.StatusInternalServerError, "internal error") +} + +// decodeJSON reads and decodes a JSON request body into v. +func decodeJSON(r *http.Request, v any) error { + defer io.Copy(io.Discard, r.Body) + dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20)) + dec.DisallowUnknownFields() + if err := dec.Decode(v); err != nil { + return err + } + return nil +} diff --git a/API Server/internal/api/server.go b/API Server/internal/api/server.go new file mode 100644 index 0000000..862a8fd --- /dev/null +++ b/API Server/internal/api/server.go @@ -0,0 +1,243 @@ +package api + +import ( + "context" + "log" + "net/http" + "strings" + "time" + + "smsgateway/apiserver/internal/auth" + "smsgateway/apiserver/internal/config" + "smsgateway/apiserver/internal/pb" +) + +// Collection names in PocketBase. +const ( + colUsers = "users" + colDevices = "devices" + colMessages = "messages" + colInbox = "inbox" + colWebhooks = "webhooks" +) + +// Server wires together the HTTP handlers and their dependencies. +type Server struct { + cfg config.Config + pb *pb.Client + jwt *auth.Manager +} + +// New constructs a Server. +func New(cfg config.Config, client *pb.Client, jwt *auth.Manager) *Server { + return &Server{cfg: cfg, pb: client, jwt: jwt} +} + +// Handler returns the root HTTP handler with all routes registered. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + + // Web panel (public) — embedded Vue + Tailwind app. Only the explicit + // panel paths are routed to it so unknown /api/* paths still 404 as JSON. + panel := panelHandler() + mux.Handle("GET /{$}", panel) + mux.Handle("GET /assets/", panel) + mux.Handle("GET /favicon.svg", panel) + mux.Handle("GET /favicon-32.png", panel) + mux.Handle("GET /gsmnode-horizontal.png", panel) + mux.Handle("GET /gsmnode-horizontal-white.png", panel) + + // Health (public) + mux.HandleFunc("GET /api/health", s.handleHealth) + + // Client / 3rd-party API (JWT auth) — used by the Web App and integrators. + mux.HandleFunc("POST /api/auth/login", s.handleLogin) + mux.HandleFunc("POST /api/auth/refresh", s.requireUser(s.handleRefresh)) + mux.HandleFunc("GET /api/auth/me", s.requireUser(s.handleMe)) + + mux.HandleFunc("GET /api/devices", s.requireUser(s.handleListDevices)) + mux.HandleFunc("DELETE /api/devices/{id}", s.requireUser(s.handleDeleteDevice)) + + mux.HandleFunc("GET /api/messages", s.requireUser(s.handleListMessages)) + mux.HandleFunc("POST /api/messages", s.requireUser(s.handleEnqueueMessage)) + mux.HandleFunc("GET /api/messages/{id}", s.requireUser(s.handleGetMessage)) + + mux.HandleFunc("POST /api/calls", s.requireUser(s.handleEnqueueCall)) + + mux.HandleFunc("GET /api/inbox", s.requireUser(s.handleListInbox)) + + mux.HandleFunc("GET /api/webhooks", s.requireUser(s.handleListWebhooks)) + mux.HandleFunc("POST /api/webhooks", s.requireUser(s.handleCreateWebhook)) + mux.HandleFunc("DELETE /api/webhooks/{id}", s.requireUser(s.handleDeleteWebhook)) + + // Mobile / device API — the phone app registers, pulls work, and reports. + // Registration is authenticated with the user's JWT; everything else with + // the opaque device token returned at registration. + mux.HandleFunc("POST /api/mobile/v1/device", s.requireUser(s.handleRegisterDevice)) + mux.HandleFunc("POST /api/mobile/v1/ping", s.requireDevice(s.handlePing)) + mux.HandleFunc("GET /api/mobile/v1/messages", s.requireDevice(s.handlePullMessages)) + mux.HandleFunc("PATCH /api/mobile/v1/messages/{id}", s.requireDevice(s.handleReportMessage)) + mux.HandleFunc("POST /api/mobile/v1/inbox", s.requireDevice(s.handleReceiveSMS)) + + return s.withMiddleware(mux) +} + +// withMiddleware applies CORS, request logging, and panic recovery globally. +func (s *Server) withMiddleware(next http.Handler) http.Handler { + return s.recoverer(s.cors(s.logger(next))) +} + +func (s *Server) logger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(sw, r) + log.Printf("%s %s %d %s", r.Method, r.URL.Path, sw.status, time.Since(start).Round(time.Millisecond)) + }) +} + +func (s *Server) recoverer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + log.Printf("panic: %v", rec) + writeError(w, http.StatusInternalServerError, "internal error") + } + }() + next.ServeHTTP(w, r) + }) +} + +func (s *Server) cors(next http.Handler) http.Handler { + allowed := map[string]bool{} + wildcard := false + for _, o := range s.cfg.AllowOrigins { + if o == "*" { + wildcard = true + } + allowed[o] = true + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin != "" && (wildcard || allowed[origin]) { + if wildcard { + w.Header().Set("Access-Control-Allow-Origin", "*") + } else { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Add("Vary", "Origin") + } + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + } + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +// statusWriter captures the response status code for logging. +type statusWriter struct { + http.ResponseWriter + status int + wrote bool +} + +func (w *statusWriter) WriteHeader(code int) { + if !w.wrote { + w.status = code + w.wrote = true + } + w.ResponseWriter.WriteHeader(code) +} + +func (w *statusWriter) Write(b []byte) (int, error) { + w.wrote = true + return w.ResponseWriter.Write(b) +} + +// --- auth context --- + +type ctxKey int + +const ( + ctxUser ctxKey = iota + ctxDevice +) + +// userFromCtx returns the authenticated user id/email from the request context. +func userFromCtx(ctx context.Context) (id, email string) { + if c, ok := ctx.Value(ctxUser).(*auth.Claims); ok { + return c.Subject, c.Email + } + return "", "" +} + +// deviceFromCtx returns the authenticated device record from the request context. +func deviceFromCtx(ctx context.Context) pb.Record { + if d, ok := ctx.Value(ctxDevice).(pb.Record); ok { + return d + } + return nil +} + +// requireUser wraps a handler to require a valid client JWT. +func (s *Server) requireUser(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + token := bearerToken(r) + if token == "" { + writeError(w, http.StatusUnauthorized, "missing bearer token") + return + } + claims, err := s.jwt.Verify(token) + if err != nil { + writeError(w, http.StatusUnauthorized, "invalid or expired token") + return + } + ctx := context.WithValue(r.Context(), ctxUser, claims) + next(w, r.WithContext(ctx)) + } +} + +// requireDevice wraps a handler to require a valid device token. The matching +// device record is loaded and attached to the request context. +func (s *Server) requireDevice(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + token := bearerToken(r) + if token == "" { + writeError(w, http.StatusUnauthorized, "missing device token") + return + } + device, err := s.pb.FindFirst(r.Context(), colDevices, + "auth_token = "+pbQuote(token), "") + if err != nil { + writeUpstreamError(w, err) + return + } + if device == nil { + writeError(w, http.StatusUnauthorized, "unknown device token") + return + } + ctx := context.WithValue(r.Context(), ctxDevice, device) + next(w, r.WithContext(ctx)) + } +} + +// bearerToken extracts a token from the Authorization header (with or without +// the "Bearer " prefix). +func bearerToken(r *http.Request) string { + h := r.Header.Get("Authorization") + if h == "" { + return "" + } + if after, ok := strings.CutPrefix(h, "Bearer "); ok { + return strings.TrimSpace(after) + } + return strings.TrimSpace(h) +} + +// pbQuote safely quotes a string value for a PocketBase filter expression. +func pbQuote(s string) string { + return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"` +} diff --git a/API Server/internal/api/sweeper.go b/API Server/internal/api/sweeper.go new file mode 100644 index 0000000..8460f5c --- /dev/null +++ b/API Server/internal/api/sweeper.go @@ -0,0 +1,74 @@ +package api + +import ( + "context" + "log" + "time" + + "smsgateway/apiserver/internal/pb" +) + +// sweepInterval is how often stale messages are checked. +const sweepInterval = 30 * time.Second + +// StartExpiryWorker launches a background loop that fails messages/calls which +// no device picked up or reported on within the configured MessageTTL. Without +// it, a message targeting an offline device would stay Pending forever. +func (s *Server) StartExpiryWorker(ctx context.Context) { + go func() { + ticker := time.NewTicker(sweepInterval) + defer ticker.Stop() + // Run once at startup so stale items clear promptly. + s.expireStaleMessages(ctx) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.expireStaleMessages(ctx) + } + } + }() +} + +// expireStaleMessages marks Pending/Processed messages older than MessageTTL as +// Failed. "Pending" means no online device pulled it; "Processed" means a device +// pulled it but never reported a terminal state (e.g. it crashed). +func (s *Server) expireStaleMessages(ctx context.Context) { + cutoff := time.Now().UTC().Add(-s.cfg.MessageTTL).Format("2006-01-02 15:04:05.000Z") + filter := `(status = "` + statusPending + `" || status = "` + statusProcessed + + `") && updated < "` + cutoff + `"` + + res, err := s.pb.List(ctx, colMessages, pb.ListOptions{Filter: filter, PerPage: 200}) + if err != nil { + log.Printf("expiry sweep failed: %v", err) + return + } + + expired := 0 + for _, rec := range res.Items { + // Don't expire messages scheduled for the future. + if asString(rec["schedule_at"]) != "" { + continue + } + id := asString(rec["id"]) + const reason = "expired: no device processed the message within the timeout" + if _, err := s.pb.Update(ctx, colMessages, id, pb.Record{ + "status": statusFailed, + "error": reason, + }); err != nil { + log.Printf("expire message %s: %v", id, err) + continue + } + expired++ + s.dispatchWebhooks(asString(rec["owner"]), asString(rec["device"]), "sms:failed", map[string]any{ + "message_id": id, + "type": asString(rec["type"]), + "status": statusFailed, + "error": reason, + }) + } + if expired > 0 { + log.Printf("expired %d stale message(s) older than %s", expired, s.cfg.MessageTTL) + } +} diff --git a/API Server/internal/api/util.go b/API Server/internal/api/util.go new file mode 100644 index 0000000..f280146 --- /dev/null +++ b/API Server/internal/api/util.go @@ -0,0 +1,65 @@ +package api + +import ( + "net/http" + "strconv" +) + +// asString coerces an arbitrary JSON value to a string. +func asString(v any) string { + switch t := v.(type) { + case string: + return t + case nil: + return "" + default: + return "" + } +} + +// asInt coerces a JSON number/string to an int. +func asInt(v any) int { + switch t := v.(type) { + case float64: + return int(t) + case int: + return t + case string: + if n, err := strconv.Atoi(t); err == nil { + return n + } + } + return 0 +} + +// asStringSlice coerces a JSON array (or single string) to []string. +func asStringSlice(v any) []string { + switch t := v.(type) { + case []any: + out := make([]string, 0, len(t)) + for _, e := range t { + if s, ok := e.(string); ok { + out = append(out, s) + } + } + return out + case []string: + return t + case string: + if t == "" { + return nil + } + return []string{t} + } + return nil +} + +// queryInt parses a query parameter as an int, falling back to def. +func queryInt(r *http.Request, key string, def int) int { + if v := r.URL.Query().Get(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} diff --git a/API Server/internal/api/webhooks.go b/API Server/internal/api/webhooks.go new file mode 100644 index 0000000..97585de --- /dev/null +++ b/API Server/internal/api/webhooks.go @@ -0,0 +1,170 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "log" + "net/http" + "time" + + "smsgateway/apiserver/internal/pb" +) + +var validWebhookEvents = map[string]bool{ + "sms:received": true, + "sms:sent": true, + "sms:delivered": true, + "sms:failed": true, +} + +type webhookDTO struct { + ID string `json:"id"` + Event string `json:"event"` + URL string `json:"url"` + DeviceID string `json:"device_id,omitempty"` +} + +func recordToWebhook(rec pb.Record) webhookDTO { + return webhookDTO{ + ID: asString(rec["id"]), + Event: asString(rec["event"]), + URL: asString(rec["url"]), + DeviceID: asString(rec["device"]), + } +} + +// handleListWebhooks lists the user's registered webhooks. +func (s *Server) handleListWebhooks(w http.ResponseWriter, r *http.Request) { + uid, _ := userFromCtx(r.Context()) + res, err := s.pb.List(r.Context(), colWebhooks, pb.ListOptions{ + Filter: "owner = " + pbQuote(uid), + Sort: "-created", + PerPage: 200, + }) + if err != nil { + writeUpstreamError(w, err) + return + } + out := make([]webhookDTO, 0, len(res.Items)) + for _, rec := range res.Items { + out = append(out, recordToWebhook(rec)) + } + writeJSON(w, http.StatusOK, map[string]any{"items": out, "total": res.TotalItems}) +} + +type createWebhookRequest struct { + Event string `json:"event"` + URL string `json:"url"` + DeviceID string `json:"device_id"` +} + +// handleCreateWebhook registers a webhook for the user. +func (s *Server) handleCreateWebhook(w http.ResponseWriter, r *http.Request) { + uid, _ := userFromCtx(r.Context()) + + var req createWebhookRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if !validWebhookEvents[req.Event] { + writeError(w, http.StatusBadRequest, "invalid event") + return + } + if req.URL == "" { + writeError(w, http.StatusBadRequest, "url is required") + return + } + + fields := pb.Record{"owner": uid, "event": req.Event, "url": req.URL} + if req.DeviceID != "" { + fields["device"] = req.DeviceID + } + rec, err := s.pb.Create(r.Context(), colWebhooks, fields) + if err != nil { + writeUpstreamError(w, err) + return + } + writeJSON(w, http.StatusCreated, recordToWebhook(rec)) +} + +// handleDeleteWebhook removes a webhook owned by the user. +func (s *Server) handleDeleteWebhook(w http.ResponseWriter, r *http.Request) { + uid, _ := userFromCtx(r.Context()) + id := r.PathValue("id") + + rec, err := s.pb.GetOne(r.Context(), colWebhooks, id) + if err != nil { + writeUpstreamError(w, err) + return + } + if asString(rec["owner"]) != uid { + writeError(w, http.StatusForbidden, "not your webhook") + return + } + if err := s.pb.Delete(r.Context(), colWebhooks, id); err != nil { + writeUpstreamError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// dispatchWebhooks delivers an event to every matching webhook for the owner. +// Delivery is best-effort and runs in the background so it never blocks the +// device/client request. +func (s *Server) dispatchWebhooks(owner, deviceID, event string, payload map[string]any) { + if owner == "" { + return + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + res, err := s.pb.List(ctx, colWebhooks, pb.ListOptions{ + Filter: "owner = " + pbQuote(owner) + " && event = " + pbQuote(event), + PerPage: 200, + }) + if err != nil { + log.Printf("webhook lookup failed for %s/%s: %v", owner, event, err) + return + } + + body := map[string]any{ + "event": event, + "device_id": deviceID, + "payload": payload, + "created_at": time.Now().UTC().Format(time.RFC3339), + } + raw, _ := json.Marshal(body) + + for _, hook := range res.Items { + // A webhook scoped to a specific device only fires for that device. + if dev := asString(hook["device"]); dev != "" && dev != deviceID { + continue + } + deliverWebhook(ctx, asString(hook["url"]), raw) + } + }() +} + +func deliverWebhook(ctx context.Context, url string, body []byte) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + log.Printf("webhook build request %s: %v", url, err) + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "gsmnode-api/1.0") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + log.Printf("webhook delivery to %s failed: %v", url, err) + return + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + log.Printf("webhook %s returned %d", url, resp.StatusCode) + } +} diff --git a/API Server/internal/auth/jwt.go b/API Server/internal/auth/jwt.go new file mode 100644 index 0000000..135d238 --- /dev/null +++ b/API Server/internal/auth/jwt.go @@ -0,0 +1,64 @@ +// Package auth issues and verifies the client-facing JWTs used by the Web App +// and other integrators. +package auth + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// Claims is the JWT payload for an authenticated user session. +type Claims struct { + Email string `json:"email"` + Name string `json:"name,omitempty"` + jwt.RegisteredClaims +} + +// Manager issues and verifies JWTs. +type Manager struct { + secret []byte + ttl time.Duration +} + +// NewManager creates a JWT manager. +func NewManager(secret []byte, ttl time.Duration) *Manager { + return &Manager{secret: secret, ttl: ttl} +} + +// Issue creates a signed access token for the given user. +func (m *Manager) Issue(userID, email, name string) (token string, expiresAt time.Time, err error) { + now := time.Now() + expiresAt = now.Add(m.ttl) + claims := Claims{ + Email: email, + Name: name, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: userID, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(expiresAt), + }, + } + t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := t.SignedString(m.secret) + return signed, expiresAt, err +} + +// Verify parses and validates a token, returning its claims. +func (m *Manager) Verify(token string) (*Claims, error) { + claims := &Claims{} + parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } + return m.secret, nil + }) + if err != nil { + return nil, err + } + if !parsed.Valid { + return nil, errors.New("invalid token") + } + return claims, nil +} diff --git a/API Server/internal/auth/token.go b/API Server/internal/auth/token.go new file mode 100644 index 0000000..8e21f4f --- /dev/null +++ b/API Server/internal/auth/token.go @@ -0,0 +1,16 @@ +package auth + +import ( + "crypto/rand" + "encoding/hex" +) + +// NewDeviceToken returns a cryptographically random opaque token used to +// authenticate a registered mobile device. +func NewDeviceToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/API Server/internal/config/config.go b/API Server/internal/config/config.go new file mode 100644 index 0000000..7c20824 --- /dev/null +++ b/API Server/internal/config/config.go @@ -0,0 +1,95 @@ +package config + +import ( + "log" + "os" + "strings" + "time" +) + +// Config holds all runtime configuration for the API Server. +type Config struct { + Addr string + PocketBaseURL string + PBAdminEmail string + PBAdminPass string + JWTSecret []byte + JWTAccessTTL time.Duration + AllowOrigins []string + MessageTTL time.Duration +} + +// Load reads configuration from environment variables, applying sensible +// defaults. A .env file, if present in the working directory, is loaded first. +func Load() Config { + loadDotEnv(".env") + + cfg := Config{ + Addr: getenv("API_ADDR", ":8080"), + PocketBaseURL: strings.TrimRight(getenv("POCKETBASE_URL", "http://10.2.1.10:8028"), "/"), + PBAdminEmail: getenv("PB_ADMIN_EMAIL", ""), + PBAdminPass: getenv("PB_ADMIN_PASSWORD", ""), + JWTSecret: []byte(getenv("JWT_SECRET", "dev-insecure-change-me-please")), + JWTAccessTTL: getdur("JWT_ACCESS_TTL", 24*time.Hour), + AllowOrigins: splitCSV(getenv("CORS_ALLOW_ORIGINS", "*")), + MessageTTL: getdur("MESSAGE_TTL", 5*time.Minute), + } + + if cfg.PBAdminEmail == "" || cfg.PBAdminPass == "" { + log.Println("WARNING: PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD are not set; PocketBase calls will fail") + } + return cfg +} + +func getenv(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func getdur(key string, def time.Duration) time.Duration { + if v := os.Getenv(key); v != "" { + if d, err := time.ParseDuration(v); err == nil { + return d + } + log.Printf("invalid duration for %s=%q, using default %s", key, v, def) + } + return def +} + +func splitCSV(s string) []string { + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +// loadDotEnv loads KEY=VALUE pairs from a .env file into the process env if they +// are not already set. It is intentionally minimal (no quoting rules beyond +// trimming surrounding quotes). +func loadDotEnv(path string) { + data, err := os.ReadFile(path) + if err != nil { + return + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, val, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + val = strings.Trim(strings.TrimSpace(val), `"'`) + if _, exists := os.LookupEnv(key); !exists { + _ = os.Setenv(key, val) + } + } +} diff --git a/API Server/internal/pb/client.go b/API Server/internal/pb/client.go new file mode 100644 index 0000000..5097417 --- /dev/null +++ b/API Server/internal/pb/client.go @@ -0,0 +1,265 @@ +// Package pb is a thin REST client for PocketBase (v0.23+). The API Server is +// the only component that talks to PocketBase: it authenticates as a superuser +// and performs all CRUD on behalf of users, enforcing ownership in app logic. +package pb + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "sync" + "time" +) + +// Record is a generic PocketBase record (a JSON object). +type Record map[string]any + +// ListResult is the envelope returned by PocketBase list endpoints. +type ListResult struct { + Page int `json:"page"` + PerPage int `json:"perPage"` + TotalItems int `json:"totalItems"` + TotalPages int `json:"totalPages"` + Items []Record `json:"items"` +} + +// APIError is returned for non-2xx PocketBase responses. +type APIError struct { + Status int + Message string + Body string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("pocketbase: %d %s", e.Status, e.Message) +} + +// NotFound reports whether err is a 404 from PocketBase. +func NotFound(err error) bool { + var ae *APIError + if e, ok := err.(*APIError); ok { + ae = e + } + return ae != nil && ae.Status == http.StatusNotFound +} + +// Client is a PocketBase REST client with automatic superuser token refresh. +type Client struct { + baseURL string + adminEmail string + adminPass string + http *http.Client + + mu sync.Mutex + token string + tokenExp time.Time +} + +// New creates a PocketBase client. +func New(baseURL, adminEmail, adminPass string) *Client { + return &Client{ + baseURL: baseURL, + adminEmail: adminEmail, + adminPass: adminPass, + http: &http.Client{Timeout: 15 * time.Second}, + } +} + +// AuthResult is returned by AuthWithPassword. +type AuthResult struct { + Token string `json:"token"` + Record Record `json:"record"` +} + +// authenticate logs in as a superuser and caches the token. +func (c *Client) authenticate(ctx context.Context) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.token != "" && time.Now().Before(c.tokenExp) { + return c.token, nil + } + + body := map[string]string{"identity": c.adminEmail, "password": c.adminPass} + var res AuthResult + if err := c.do(ctx, http.MethodPost, + "/api/collections/_superusers/auth-with-password", "", body, &res); err != nil { + return "", fmt.Errorf("superuser auth: %w", err) + } + c.token = res.Token + // PocketBase superuser tokens are long-lived; refresh conservatively. + c.tokenExp = time.Now().Add(10 * time.Minute) + return c.token, nil +} + +// AuthWithPassword verifies credentials against an auth collection (e.g. "users") +// and returns the user record. Used to validate logins; the API Server then +// issues its own JWT. +func (c *Client) AuthWithPassword(ctx context.Context, collection, identity, password string) (*AuthResult, error) { + token, err := c.authenticate(ctx) + if err != nil { + return nil, err + } + body := map[string]string{"identity": identity, "password": password} + var res AuthResult + path := "/api/collections/" + url.PathEscape(collection) + "/auth-with-password" + if err := c.do(ctx, http.MethodPost, path, token, body, &res); err != nil { + return nil, err + } + return &res, nil +} + +// Create inserts a record into a collection. +func (c *Client) Create(ctx context.Context, collection string, data any) (Record, error) { + token, err := c.authenticate(ctx) + if err != nil { + return nil, err + } + var out Record + path := "/api/collections/" + url.PathEscape(collection) + "/records" + if err := c.do(ctx, http.MethodPost, path, token, data, &out); err != nil { + return nil, err + } + return out, nil +} + +// Update patches a record. +func (c *Client) Update(ctx context.Context, collection, id string, data any) (Record, error) { + token, err := c.authenticate(ctx) + if err != nil { + return nil, err + } + var out Record + path := "/api/collections/" + url.PathEscape(collection) + "/records/" + url.PathEscape(id) + if err := c.do(ctx, http.MethodPatch, path, token, data, &out); err != nil { + return nil, err + } + return out, nil +} + +// Delete removes a record. +func (c *Client) Delete(ctx context.Context, collection, id string) error { + token, err := c.authenticate(ctx) + if err != nil { + return err + } + path := "/api/collections/" + url.PathEscape(collection) + "/records/" + url.PathEscape(id) + return c.do(ctx, http.MethodDelete, path, token, nil, nil) +} + +// GetOne fetches a single record by id. +func (c *Client) GetOne(ctx context.Context, collection, id string) (Record, error) { + token, err := c.authenticate(ctx) + if err != nil { + return nil, err + } + var out Record + path := "/api/collections/" + url.PathEscape(collection) + "/records/" + url.PathEscape(id) + if err := c.do(ctx, http.MethodGet, path, token, nil, &out); err != nil { + return nil, err + } + return out, nil +} + +// ListOptions controls a list query. +type ListOptions struct { + Filter string + Sort string + Expand string + Page int + PerPage int +} + +// List queries records with optional filter/sort/pagination. +func (c *Client) List(ctx context.Context, collection string, opt ListOptions) (*ListResult, error) { + token, err := c.authenticate(ctx) + if err != nil { + return nil, err + } + q := url.Values{} + if opt.Filter != "" { + q.Set("filter", opt.Filter) + } + if opt.Sort != "" { + q.Set("sort", opt.Sort) + } + if opt.Expand != "" { + q.Set("expand", opt.Expand) + } + if opt.Page > 0 { + q.Set("page", strconv.Itoa(opt.Page)) + } + if opt.PerPage > 0 { + q.Set("perPage", strconv.Itoa(opt.PerPage)) + } + path := "/api/collections/" + url.PathEscape(collection) + "/records?" + q.Encode() + var out ListResult + if err := c.do(ctx, http.MethodGet, path, token, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// FindFirst returns the first record matching filter, or (nil, nil) if none. +func (c *Client) FindFirst(ctx context.Context, collection, filter, sort string) (Record, error) { + res, err := c.List(ctx, collection, ListOptions{Filter: filter, Sort: sort, PerPage: 1}) + if err != nil { + return nil, err + } + if len(res.Items) == 0 { + return nil, nil + } + return res.Items[0], nil +} + +// do performs an HTTP request against PocketBase and decodes the JSON response. +func (c *Client) do(ctx context.Context, method, path, token string, body, out any) error { + var reader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return err + } + reader = bytes.NewReader(b) + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader) + if err != nil { + return err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if token != "" { + req.Header.Set("Authorization", token) + } + + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + msg := http.StatusText(resp.StatusCode) + var pbErr struct { + Message string `json:"message"` + } + if json.Unmarshal(raw, &pbErr) == nil && pbErr.Message != "" { + msg = pbErr.Message + } + return &APIError{Status: resp.StatusCode, Message: msg, Body: string(raw)} + } + if out != nil && len(raw) > 0 { + if err := json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("decode response: %w", err) + } + } + return nil +} diff --git a/API Server/panel/index.html b/API Server/panel/index.html new file mode 100644 index 0000000..2fd2777 --- /dev/null +++ b/API Server/panel/index.html @@ -0,0 +1,15 @@ + + + + + + + + + gsmnode · API Server + + +
+ + + diff --git a/API Server/panel/package-lock.json b/API Server/panel/package-lock.json new file mode 100644 index 0000000..8cbc07c --- /dev/null +++ b/API Server/panel/package-lock.json @@ -0,0 +1,1974 @@ +{ + "name": "gsmnode-api-panel", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gsmnode-api-panel", + "version": "1.0.0", + "dependencies": { + "@lucide/vue": "^1.23.0", + "vue": "^3.5.13" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@vitejs/plugin-vue": "^5.2.1", + "tailwindcss": "^4.0.0", + "vite": "^6.0.7" + } + }, + "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==", + "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==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "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==", + "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/@lucide/vue": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@lucide/vue/-/vue-1.23.0.tgz", + "integrity": "sha512-9SIYeY5K+R1iv8F8JUKMGSL7Pck/86BJ8djtZz/lnYsoHtKFUj1H2z6+PvKnC/8blZ8tqTDeDXAoTKobuX80hg==", + "license": "ISC", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "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/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "vue": "3.5.39" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", + "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==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "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/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "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/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "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/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "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.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "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": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "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/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/vue": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/API Server/panel/package.json b/API Server/panel/package.json new file mode 100644 index 0000000..0697fa5 --- /dev/null +++ b/API Server/panel/package.json @@ -0,0 +1,21 @@ +{ + "name": "gsmnode-api-panel", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@lucide/vue": "^1.23.0", + "vue": "^3.5.13" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@vitejs/plugin-vue": "^5.2.1", + "tailwindcss": "^4.0.0", + "vite": "^6.0.7" + } +} diff --git a/API Server/panel/public/favicon-32.png b/API Server/panel/public/favicon-32.png new file mode 100644 index 0000000..23301f0 Binary files /dev/null and b/API Server/panel/public/favicon-32.png differ diff --git a/API Server/panel/public/favicon.svg b/API Server/panel/public/favicon.svg new file mode 100644 index 0000000..4078054 --- /dev/null +++ b/API Server/panel/public/favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/API Server/panel/public/gsmnode-horizontal-white.png b/API Server/panel/public/gsmnode-horizontal-white.png new file mode 100644 index 0000000..ac100c0 Binary files /dev/null and b/API Server/panel/public/gsmnode-horizontal-white.png differ diff --git a/API Server/panel/public/gsmnode-horizontal.png b/API Server/panel/public/gsmnode-horizontal.png new file mode 100644 index 0000000..bcd2937 Binary files /dev/null and b/API Server/panel/public/gsmnode-horizontal.png differ diff --git a/API Server/panel/src/App.vue b/API Server/panel/src/App.vue new file mode 100644 index 0000000..c8218fc --- /dev/null +++ b/API Server/panel/src/App.vue @@ -0,0 +1,111 @@ + + + diff --git a/API Server/panel/src/components/EndpointTable.vue b/API Server/panel/src/components/EndpointTable.vue new file mode 100644 index 0000000..4dd8fe2 --- /dev/null +++ b/API Server/panel/src/components/EndpointTable.vue @@ -0,0 +1,40 @@ + + + diff --git a/API Server/panel/src/main.js b/API Server/panel/src/main.js new file mode 100644 index 0000000..40dbaaa --- /dev/null +++ b/API Server/panel/src/main.js @@ -0,0 +1,6 @@ +import { createApp } from "vue"; +import App from "./App.vue"; +import "./style.css"; +import "./theme"; + +createApp(App).mount("#app"); diff --git a/API Server/panel/src/style.css b/API Server/panel/src/style.css new file mode 100644 index 0000000..1c712a9 --- /dev/null +++ b/API Server/panel/src/style.css @@ -0,0 +1,336 @@ +/* gsmnode design system — tokens from Design/SMS Gateway logo design/tokens/*, + mapped into Tailwind v4. Signal-green + ink, developer-tool aesthetic. */ +@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap'); +@import "tailwindcss"; + +/* ============================================================ + RAW RAMPS + SEMANTIC ALIASES (light) + ============================================================ */ +:root { + /* Signal green (brand) — shared chroma, stepped lightness */ + --green-100: oklch(0.95 0.04 152); + --green-200: oklch(0.88 0.07 152); + --green-300: oklch(0.78 0.11 152); + --green-500: oklch(0.60 0.14 152); /* primary */ + --green-600: oklch(0.52 0.13 152); + --green-700: oklch(0.44 0.11 152); + --green-on-dark: oklch(0.72 0.14 152); + + /* Ink & paper */ + --ink: #12161C; + --ink-900: #0A0D11; + --ink-800: #1A1F27; + --paper: #FAFAF9; + --white: #FFFFFF; + + /* Cool gray ramp */ + --gray-50: #F4F5F4; + --gray-100: #E9EBEA; + --gray-200: #DCDFDE; + --gray-300: #C2C7C6; + --gray-400: #9AA0A2; + --gray-500: #6B7278; + --gray-600: #4A5157; + --gray-700: #333A40; + + /* Semantic status */ + --success: oklch(0.60 0.14 152); /* green — also "delivered" */ + --warning: oklch(0.72 0.15 75); + --danger: oklch(0.60 0.17 25); + --info: oklch(0.62 0.13 245); + --success-x: oklch(0.95 0.04 152); + --warning-x: oklch(0.95 0.05 80); + --danger-x: oklch(0.95 0.05 25); + --info-x: oklch(0.95 0.04 245); + + /* Semantic aliases — reference these in components */ + --bg-page: var(--paper); + --bg-sunken: var(--gray-50); + --surface-card: var(--white); + --surface-raised: var(--white); + --surface-inverse:var(--ink); + --surface-code: var(--ink-900); + + --border-subtle: rgba(18, 22, 28, 0.08); + --border-strong: rgba(18, 22, 28, 0.20); + --border-focus: var(--green-500); + + --text-primary: var(--ink); + --text-secondary: var(--gray-700); + --text-muted: var(--gray-500); + --text-inverse: #F5F6F4; + --text-brand: var(--green-600); + --text-link: var(--green-600); + + --brand: var(--green-500); + --brand-hover: var(--green-600); + --brand-active: var(--green-700); + --brand-tint: var(--green-100); + --brand-contrast: var(--white); + --brand-on-dark: var(--green-on-dark); + + --success-tint: var(--success-x); + --warning-tint: var(--warning-x); + --danger-tint: var(--danger-x); + --info-tint: var(--info-x); + + --ring-focus: 0 0 0 3px oklch(0.60 0.14 152 / 0.45); + --ring-danger: 0 0 0 3px oklch(0.60 0.17 25 / 0.35); + + /* Flat, low-contrast elevation */ + --sh-xs: 0 1px 2px rgba(18, 22, 28, 0.04); + --sh-sm: 0 1px 2px rgba(18, 22, 28, 0.06), 0 1px 1px rgba(18, 22, 28, 0.04); + --sh-md: 0 4px 12px rgba(18, 22, 28, 0.08); + --sh-lg: 0 8px 24px rgba(18, 22, 28, 0.14); + --sh-xl: 0 16px 40px rgba(18, 22, 28, 0.18); + + --dur-instant: 100ms; + --dur-fast: 120ms; + --dur-base: 180ms; + --dur-slow: 260ms; + --ease-standard: cubic-bezier(0.2, 0, 0, 1); + --ease-entrance: cubic-bezier(0.16, 1, 0.3, 1); +} + +/* ============================================================ + DARK THEME — only the semantic layer remaps. + Namespaced data-gsm-theme so it matches the design system. + ============================================================ */ +[data-gsm-theme="dark"] { + --bg-page: var(--ink-900); + --bg-sunken: #0E1216; + --surface-card: #14191F; + --surface-raised: #1A2027; + --surface-inverse:var(--white); + --surface-code: #05070A; + + --border-subtle: rgba(255, 255, 255, 0.07); + --border-strong: rgba(255, 255, 255, 0.18); + --border-focus: var(--green-on-dark); + + --text-primary: #F5F6F4; + --text-secondary: var(--gray-200); + --text-muted: var(--gray-400); + --text-inverse: var(--ink); + --text-brand: var(--green-on-dark); + --text-link: var(--green-on-dark); + + --brand: var(--green-500); + --brand-hover: var(--green-on-dark); + --brand-active: var(--green-600); + --brand-tint: oklch(0.60 0.14 152 / 0.16); + --brand-contrast: var(--white); + + --success: var(--green-300); + --warning: oklch(0.80 0.14 80); + --danger: oklch(0.72 0.16 25); + --info: oklch(0.72 0.12 245); + --success-tint: oklch(0.60 0.14 152 / 0.16); + --warning-tint: oklch(0.72 0.15 75 / 0.16); + --danger-tint: oklch(0.60 0.17 25 / 0.18); + --info-tint: oklch(0.62 0.13 245 / 0.16); + + --ring-focus: 0 0 0 3px oklch(0.72 0.14 152 / 0.45); + + --sh-xs: 0 1px 2px rgba(0, 0, 0, 0.4); + --sh-sm: 0 1px 3px rgba(0, 0, 0, 0.5), 0 1px 2px rgba(0, 0, 0, 0.4); + --sh-md: 0 4px 12px rgba(0, 0, 0, 0.5), 0 2px 4px rgba(0, 0, 0, 0.4); + --sh-lg: 0 8px 24px rgba(0, 0, 0, 0.6), 0 4px 8px rgba(0, 0, 0, 0.4); + --sh-xl: 0 16px 40px rgba(0, 0, 0, 0.7); + + color-scheme: dark; +} + +/* ============================================================ + TAILWIND THEME — utilities resolve to the semantic vars, + so everything flips automatically under data-gsm-theme="dark". + ============================================================ */ +@theme inline { + --color-*: initial; + + --color-page: var(--bg-page); + --color-sunken: var(--bg-sunken); + --color-card: var(--surface-card); + --color-raised: var(--surface-raised); + --color-inverse: var(--surface-inverse); + --color-code: var(--surface-code); + + --color-subtle: var(--border-subtle); + --color-strong: var(--border-strong); + + --color-primary: var(--text-primary); + --color-secondary: var(--text-secondary); + --color-muted: var(--text-muted); + --color-on-brand: var(--brand-contrast); + --color-link: var(--text-link); + + --color-brand: var(--brand); + --color-brand-hover: var(--brand-hover); + --color-brand-active: var(--brand-active); + --color-brand-tint: var(--brand-tint); + --color-brand-text: var(--text-brand); + + --color-success: var(--success); + --color-success-tint: var(--success-tint); + --color-warning: var(--warning); + --color-warning-tint: var(--warning-tint); + --color-danger: var(--danger); + --color-danger-tint: var(--danger-tint); + --color-info: var(--info); + --color-info-tint: var(--info-tint); + + --color-white: #FFFFFF; + --color-ink: var(--ink); + --color-green: var(--green-500); + + --font-display: 'Space Grotesk', ui-sans-serif, system-ui, 'Segoe UI', sans-serif; + --font-sans: 'IBM Plex Sans', ui-sans-serif, system-ui, 'Segoe UI', sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace; + + --radius-*: initial; + --radius-xs: 4px; + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 15px; + --radius-2xl: 18px; + --radius-full: 9999px; + + --shadow-*: initial; + --shadow-xs: var(--sh-xs); + --shadow-sm: var(--sh-sm); + --shadow-md: var(--sh-md); + --shadow-lg: var(--sh-lg); + --shadow-xl: var(--sh-xl); + --shadow-ring: var(--ring-focus); +} + +/* ============================================================ + BASE + ============================================================ */ +html, +body, +#app { + height: 100%; +} + +body { + font-family: var(--font-sans); + background: var(--bg-page); + color: var(--text-primary); + -webkit-font-smoothing: antialiased; +} + +/* Display face for titles & metrics */ +h1, h2, h3 { + font-family: var(--font-display); + letter-spacing: -0.02em; +} + +/* Shared control styles (inputs, selects, textareas) */ +@utility gn-input { + width: 100%; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--surface-card); + color: var(--text-primary); + padding: 0 12px; + height: 40px; + font-size: 0.875rem; + font-family: var(--font-sans); + transition: border-color var(--dur-fast) var(--ease-standard), + box-shadow var(--dur-fast) var(--ease-standard); +} + +@utility gn-textarea { + width: 100%; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--surface-card); + color: var(--text-primary); + padding: 10px 12px; + font-size: 0.875rem; + font-family: var(--font-sans); + resize: vertical; + transition: border-color var(--dur-fast) var(--ease-standard), + box-shadow var(--dur-fast) var(--ease-standard); +} + +.gn-input:focus, +.gn-textarea:focus { + outline: none; + border-color: var(--border-focus); + box-shadow: var(--ring-focus); +} + +.gn-input::placeholder, +.gn-textarea::placeholder { + color: var(--text-muted); +} + +/* Primary button — flat signal-green, no glow (infrastructure) */ +@utility gn-btn-pri { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 40px; + padding: 0 16px; + border-radius: var(--radius-md); + border: 1px solid transparent; + background: var(--brand); + color: var(--brand-contrast); + font-family: var(--font-sans); + font-weight: 600; + font-size: 0.875rem; + cursor: pointer; + transition: background-color var(--dur-fast) var(--ease-standard), + transform var(--dur-fast) var(--ease-standard); +} + +.gn-btn-pri:hover:not(:disabled) { background: var(--brand-hover); } +.gn-btn-pri:active:not(:disabled) { background: var(--brand-active); transform: translateY(1px); } +.gn-btn-pri:focus-visible { outline: none; box-shadow: var(--ring-focus); } +.gn-btn-pri:disabled { opacity: 0.45; cursor: not-allowed; } + +/* Secondary button */ +@utility gn-btn-sec { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 40px; + padding: 0 16px; + border-radius: var(--radius-md); + border: 1px solid var(--border-strong); + background: var(--surface-card); + color: var(--text-primary); + font-family: var(--font-sans); + font-weight: 600; + font-size: 0.875rem; + cursor: pointer; + transition: background-color var(--dur-fast) var(--ease-standard), + transform var(--dur-fast) var(--ease-standard); +} + +.gn-btn-sec:hover:not(:disabled) { background: var(--bg-sunken); } +.gn-btn-sec:active:not(:disabled) { transform: translateY(1px); } +.gn-btn-sec:focus-visible { outline: none; box-shadow: var(--ring-focus); } +.gn-btn-sec:disabled { opacity: 0.45; cursor: not-allowed; } + +/* Small button size modifier */ +@utility gn-btn-sm { + height: 32px; + padding: 0 12px; + font-size: 0.75rem; + border-radius: var(--radius-sm); +} + +/* Mono eyebrow — uppercase, tracked out */ +@utility gn-eyebrow { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--text-muted); +} diff --git a/API Server/panel/src/theme.js b/API Server/panel/src/theme.js new file mode 100644 index 0000000..b8dae4d --- /dev/null +++ b/API Server/panel/src/theme.js @@ -0,0 +1,30 @@ +import { ref } from "vue"; + +// Persisted light/dark theme, shared key with the design-system kits. +const KEY = "gsmnode-theme"; + +function initial() { + try { + return localStorage.getItem(KEY) === "dark" ? "dark" : "light"; + } catch { + return "light"; + } +} + +export const theme = ref(initial()); + +export function applyTheme(t) { + theme.value = t; + document.documentElement.setAttribute("data-gsm-theme", t); + try { + localStorage.setItem(KEY, t); + } catch { + /* private mode — theme just won't persist */ + } +} + +export function toggleTheme() { + applyTheme(theme.value === "dark" ? "light" : "dark"); +} + +applyTheme(theme.value); diff --git a/API Server/panel/vite.config.js b/API Server/panel/vite.config.js new file mode 100644 index 0000000..d5478de --- /dev/null +++ b/API Server/panel/vite.config.js @@ -0,0 +1,20 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import tailwindcss from "@tailwindcss/vite"; + +// Builds into internal/api/dist, which the Go server embeds via go:embed +// and serves at the server root. +export default defineConfig({ + plugins: [vue(), tailwindcss()], + build: { + outDir: "../internal/api/dist", + emptyOutDir: true, + }, + server: { + port: 5174, + proxy: { + // Dev-mode proxy to a locally running API Server. + "/api": "http://localhost:8080", + }, + }, +}); diff --git a/API Server/scripts/Run-ApiServer.ps1 b/API Server/scripts/Run-ApiServer.ps1 new file mode 100644 index 0000000..4c1adae --- /dev/null +++ b/API Server/scripts/Run-ApiServer.ps1 @@ -0,0 +1,25 @@ +# Runs the SMS Gateway API Server. +# Loads .env (if present), then starts the Go server. +# +# ./scripts/Run-ApiServer.ps1 + +$ErrorActionPreference = "Stop" +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$root = Split-Path -Parent $here # the "API Server" folder + +Push-Location $root +try { + # Ensure Go is on PATH for this session. + $goBin = "C:\Program Files\Go\bin" + if (Test-Path $goBin) { $env:Path = "$goBin;$env:Path" } + + if (-not (Get-Command go -ErrorAction SilentlyContinue)) { + throw "Go is not installed or not on PATH." + } + + Write-Host "Starting API Server (Ctrl+C to stop)..." -ForegroundColor Cyan + go run ./cmd/server +} +finally { + Pop-Location +} diff --git a/API Server/scripts/create-user.mjs b/API Server/scripts/create-user.mjs new file mode 100644 index 0000000..2d6ecfe --- /dev/null +++ b/API Server/scripts/create-user.mjs @@ -0,0 +1,50 @@ +// Creates a user in the PocketBase "users" collection to log in with. +// +// Usage (PowerShell): +// $env:POCKETBASE_URL="http://10.2.1.10:8028" +// $env:PB_ADMIN_EMAIL="admin@example.com" +// $env:PB_ADMIN_PASSWORD="admin-password" +// node scripts/create-user.mjs user@example.com "user-password" "Display Name" + +const BASE = (process.env.POCKETBASE_URL || "http://10.2.1.10:8028").replace(/\/$/, ""); +const EMAIL = process.env.PB_ADMIN_EMAIL; +const PASSWORD = process.env.PB_ADMIN_PASSWORD; + +const [, , userEmail, userPassword, userName] = process.argv; + +if (!EMAIL || !PASSWORD) { + console.error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD environment variables."); + process.exit(1); +} +if (!userEmail || !userPassword) { + console.error('Usage: node scripts/create-user.mjs ["name"]'); + process.exit(1); +} + +async function api(method, path, body, token) { + const res = await fetch(BASE + path, { + method, + headers: { "Content-Type": "application/json", ...(token ? { Authorization: token } : {}) }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + const json = text ? JSON.parse(text) : null; + if (!res.ok) throw new Error(`${method} ${path} -> ${res.status}: ${json?.message || text}`); + return json; +} + +const auth = await api("POST", "/api/collections/_superusers/auth-with-password", { + identity: EMAIL, + password: PASSWORD, +}); + +const user = await api("POST", "/api/collections/users/records", { + email: userEmail, + password: userPassword, + passwordConfirm: userPassword, + name: userName || "", + emailVisibility: true, + verified: true, +}, auth.token); + +console.log(`Created user ${user.email} (id: ${user.id}).`); diff --git a/API Server/scripts/setup-pocketbase.mjs b/API Server/scripts/setup-pocketbase.mjs new file mode 100644 index 0000000..b93e667 --- /dev/null +++ b/API Server/scripts/setup-pocketbase.mjs @@ -0,0 +1,194 @@ +// Sets up the PocketBase collections the API Server expects. +// +// Usage (PowerShell): +// $env:POCKETBASE_URL="http://10.2.1.10:8028" +// $env:PB_ADMIN_EMAIL="you@example.com" +// $env:PB_ADMIN_PASSWORD="your-password" +// node scripts/setup-pocketbase.mjs +// +// It is idempotent: existing collections are updated, missing ones created. +// Requires Node 18+ (uses global fetch). No npm install needed. + +const BASE = (process.env.POCKETBASE_URL || "http://10.2.1.10:8028").replace(/\/$/, ""); +const EMAIL = process.env.PB_ADMIN_EMAIL; +const PASSWORD = process.env.PB_ADMIN_PASSWORD; + +if (!EMAIL || !PASSWORD) { + console.error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD environment variables."); + process.exit(1); +} + +let token = ""; + +async function api(method, path, body) { + const res = await fetch(BASE + path, { + method, + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: token } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let json = null; + try { json = text ? JSON.parse(text) : null; } catch { /* non-JSON */ } + if (!res.ok) { + const msg = json?.message || text || res.statusText; + throw new Error(`${method} ${path} -> ${res.status}: ${msg}`); + } + return json; +} + +async function authenticate() { + const res = await api("POST", "/api/collections/_superusers/auth-with-password", { + identity: EMAIL, + password: PASSWORD, + }); + token = res.token; + console.log("Authenticated as superuser."); +} + +async function getCollections() { + const res = await api("GET", "/api/collections?perPage=200"); + const byName = {}; + for (const c of res.items) byName[c.name] = c; + return byName; +} + +// Field builders for PocketBase v0.23+ (collections use a `fields` array). +const f = { + text: (name, opts = {}) => ({ name, type: "text", required: false, ...opts }), + number: (name, opts = {}) => ({ name, type: "number", required: false, ...opts }), + date: (name, opts = {}) => ({ name, type: "date", required: false, ...opts }), + json: (name, opts = {}) => ({ name, type: "json", required: false, maxSize: 2000000, ...opts }), + url: (name, opts = {}) => ({ name, type: "url", required: false, ...opts }), + select: (name, values, opts = {}) => ({ + name, type: "select", required: false, maxSelect: 1, values, ...opts, + }), + relation: (name, collectionId, opts = {}) => ({ + name, type: "relation", required: false, collectionId, + cascadeDelete: false, maxSelect: 1, minSelect: 0, ...opts, + }), + autodate: (name, opts) => ({ name, type: "autodate", onCreate: true, onUpdate: false, ...opts }), +}; + +// Superuser-only API rules: only the API Server (acting as superuser) reads or +// writes these collections. Clients never touch PocketBase directly. +const LOCKED = { listRule: null, viewRule: null, createRule: null, updateRule: null, deleteRule: null }; + +function definitions(ids) { + return [ + { + name: "devices", + type: "base", + ...LOCKED, + fields: [ + f.text("device_id", { required: true }), + f.text("name"), + f.text("platform"), + f.text("app_version"), + f.text("push_token"), + f.text("auth_token", { required: true }), + f.select("status", ["online", "offline"]), + f.date("last_seen_at"), + f.relation("owner", ids.users, { required: true, cascadeDelete: true }), + f.autodate("created", { onCreate: true, onUpdate: false }), + f.autodate("updated", { onCreate: true, onUpdate: true }), + ], + indexes: [ + "CREATE UNIQUE INDEX idx_devices_auth_token ON devices (auth_token)", + "CREATE UNIQUE INDEX idx_devices_owner_device ON devices (owner, device_id)", + ], + }, + { + name: "messages", + type: "base", + ...LOCKED, + fields: [ + f.json("phone_numbers", { required: true }), + f.text("text_message"), + f.select("type", ["sms", "call"]), + f.number("sim_number"), + f.select("status", ["Pending", "Processed", "Sent", "Delivered", "Failed"]), + f.text("error"), + f.date("schedule_at"), + f.date("sent_at"), + f.date("delivered_at"), + f.relation("device", ids.devices, { cascadeDelete: false }), + f.relation("owner", ids.users, { required: true, cascadeDelete: true }), + f.autodate("created", { onCreate: true, onUpdate: false }), + f.autodate("updated", { onCreate: true, onUpdate: true }), + ], + indexes: [ + "CREATE INDEX idx_messages_device_status ON messages (device, status)", + "CREATE INDEX idx_messages_owner ON messages (owner)", + ], + }, + { + name: "inbox", + type: "base", + ...LOCKED, + fields: [ + f.text("phone_number", { required: true }), + f.text("message"), + f.date("received_at"), + f.relation("device", ids.devices, { cascadeDelete: false }), + f.relation("owner", ids.users, { required: true, cascadeDelete: true }), + f.autodate("created", { onCreate: true, onUpdate: false }), + ], + indexes: ["CREATE INDEX idx_inbox_owner ON inbox (owner)"], + }, + { + name: "webhooks", + type: "base", + ...LOCKED, + fields: [ + f.select("event", ["sms:received", "sms:sent", "sms:delivered", "sms:failed"]), + f.url("url", { required: true }), + f.relation("device", ids.devices, { cascadeDelete: false }), + f.relation("owner", ids.users, { required: true, cascadeDelete: true }), + f.autodate("created", { onCreate: true, onUpdate: false }), + ], + indexes: ["CREATE INDEX idx_webhooks_owner_event ON webhooks (owner, event)"], + }, + ]; +} + +async function main() { + await authenticate(); + + let collections = await getCollections(); + if (!collections.users) { + throw new Error('The default "users" auth collection was not found in PocketBase.'); + } + + // Resolve collection ids needed for relation fields. devices must exist before + // messages/inbox/webhooks reference it, so create in order, refreshing ids. + const order = ["devices", "messages", "inbox", "webhooks"]; + for (const name of order) { + const ids = { + users: collections.users.id, + devices: collections.devices?.id, + }; + const def = definitions(ids).find((d) => d.name === name); + + if (collections[name]) { + await api("PATCH", `/api/collections/${collections[name].id}`, def); + console.log(`Updated collection: ${name}`); + } else { + await api("POST", "/api/collections", def); + console.log(`Created collection: ${name}`); + } + collections = await getCollections(); // refresh so later relations resolve + } + + console.log("\nPocketBase setup complete."); + console.log("Collections: users (existing), devices, messages, inbox, webhooks."); + console.log("\nNext: create a user to log in with, e.g. via the PocketBase admin UI,"); + console.log("or run scripts/create-user.mjs."); +} + +main().catch((err) => { + console.error("\nSetup failed:", err.message); + process.exit(1); +}); diff --git a/Home Assistant Plugin/README.md b/Home Assistant Plugin/README.md new file mode 100644 index 0000000..4e86f9a --- /dev/null +++ b/Home Assistant Plugin/README.md @@ -0,0 +1,133 @@ +# gsmnode — Home Assistant Integration + +A custom Home Assistant integration that sends SMS **and places phone calls** +through the gsmnode **API Server**. It can be added and configured entirely +from the Home Assistant UI. + +``` +Home Assistant ──► API Server (/api/messages, /api/calls) ──► your phone ──► SMS / call +``` + +Like every other client, it talks **only** to the API Server (never PocketBase). + +## What you get + +- **UI setup** (config flow) — add it under *Settings → Devices & Services*. +- **Services** — `gsmnode.send_sms` and `gsmnode.call`, with field + pickers in the automation editor and *Developer Tools → Actions*. +- **Sensor** — `binary_sensor` "API Server" (connectivity) so you can see and + automate on the gateway being up/down. + +## Install + +1. Copy the integration folder into your Home Assistant config directory so it + lands at: + + ``` + /custom_components/gsmnode/ + ``` + + (Copy this repo's `custom_components/gsmnode/` next to your + `configuration.yaml`. Use the Samba / File editor / SSH add-on, or the + mapped volume for Docker.) + +2. **Restart Home Assistant** (Settings → System → Restart). + +## Add it from the UI + +1. **Settings → Devices & Services → Add Integration**. +2. Search for **gsmnode**. +3. Fill in the form: + - **API Server URL** — e.g. `http://10.2.1.101:8080` (must be reachable from HA) + - **Email** / **Password** — a gateway user (create one with + `node "API Server/scripts/create-user.mjs" ha@local "pass" "Home Assistant"`) + - **Default device ID** *(optional)* — pin sends/calls to a specific phone + + The form validates by logging in; you'll get *Invalid auth* or *Cannot + connect* if something's wrong. + +A **gsmnode** device appears with an **API Server** connectivity sensor. + +## Use it + +### Send an SMS + +```yaml +action: gsmnode.send_sms +data: + phone_numbers: ["+15551234567"] + message: "Hello from Home Assistant" + device_id: my-phone # optional + sim_number: 1 # optional (dual-SIM) +``` + +### Place a call + +```yaml +action: gsmnode.call +data: + phone_number: "+15551234567" + device_id: my-phone # optional +``` + +### Example automation + +```yaml +automation: + - alias: "Water leak — text then call" + trigger: + - platform: state + entity_id: binary_sensor.basement_leak + to: "on" + action: + - action: gsmnode.send_sms + data: + phone_numbers: ["+15551234567"] + message: "Water leak detected in the basement!" + - action: gsmnode.call + data: + phone_number: "+15551234567" +``` + +### React to the gateway going offline + +```yaml +automation: + - alias: "Alert if SMS gateway API is down" + trigger: + - platform: state + entity_id: binary_sensor.gsmnode_api_server + to: "off" + for: "00:02:00" + action: + - action: persistent_notification.create + data: + title: "gsmnode" + message: "The API Server is unreachable." +``` + +## Receiving SMS in Home Assistant + +To **receive** incoming texts, register the API Server's `sms:received` webhook +against a HA webhook trigger. A ready-to-use snippet is in +[`configuration.example.yaml`](configuration.example.yaml). + +## Legacy YAML `notify` platform (optional) + +A `notify.gsmnode` service is still available for those who prefer YAML +(`notify.py`). It's independent of the UI integration — see +[`configuration.example.yaml`](configuration.example.yaml). For new setups, the +UI integration above is recommended. + +## How it works + +- On first send it logs in (`POST /api/auth/login`) and caches the JWT; on a + `401` it re-logs in once and retries. +- `send_sms` → `POST /api/messages`; `call` → `POST /api/calls`. +- The sensor polls `GET /api/health` every 30s. +- All HTTP uses Home Assistant's shared aiohttp session (fully async). + +## Notes / limitations + +- The API Server must be reachable from the Home Assistant host. +- No external dependencies (`requirements: []`). diff --git a/Home Assistant Plugin/configuration.example.yaml b/Home Assistant Plugin/configuration.example.yaml new file mode 100644 index 0000000..f45cc44 --- /dev/null +++ b/Home Assistant Plugin/configuration.example.yaml @@ -0,0 +1,49 @@ +# RECOMMENDED: add this integration from the UI instead +# (Settings -> Devices & Services -> Add Integration -> gsmnode). +# The blocks below are only for the OPTIONAL legacy `notify.gsmnode` +# YAML service and for receiving incoming SMS via a webhook. + +# --- Optional: legacy notify.gsmnode YAML service --------------------- +notify: + - platform: gsmnode + name: gsmnode # service becomes notify.gsmnode + api_base: http://10.2.1.10:8080 # the gsmnode API Server (reachable from HA) + email: !secret gsmnode_email + password: !secret gsmnode_password + device_id: my-phone # optional: pin to a specific device + +# secrets.yaml: +# gsmnode_email: you@example.com +# gsmnode_password: your-password + +# --- Usage ---------------------------------------------------------------- +# Send an SMS: +# service: notify.gsmnode +# data: { message: "Hello", target: ["+15551234567"] } +# +# Place a phone call (gateway phone dials the number; message is ignored): +# service: notify.gsmnode +# data: { message: "", target: ["+15551234567"], data: { type: call } } + +# --- Optional: receive incoming SMS as Home Assistant events --------------- +# The API Server can POST the `sms:received` webhook to a HA webhook trigger. +# +# 1. Create an automation with a webhook trigger (gives you a webhook_id): +# +# automation: +# - alias: "gsmnode - incoming SMS" +# trigger: +# - platform: webhook +# webhook_id: gsmnode_incoming +# allowed_methods: [POST] +# local_only: true +# action: +# - service: persistent_notification.create +# data: +# title: "SMS from {{ trigger.json.payload.phone_number }}" +# message: "{{ trigger.json.payload.message }}" +# +# 2. Register the webhook with the API Server (one-time), pointing at: +# http://:8123/api/webhook/gsmnode_incoming +# e.g. via the Web App "Webhooks" page or: +# POST /api/webhooks {"event":"sms:received","url":""} diff --git a/Home Assistant Plugin/custom_components/gsmnode/__init__.py b/Home Assistant Plugin/custom_components/gsmnode/__init__.py new file mode 100644 index 0000000..68986d8 --- /dev/null +++ b/Home Assistant Plugin/custom_components/gsmnode/__init__.py @@ -0,0 +1,105 @@ +"""The gsmnode integration. + +Sends SMS and places phone calls through the gsmnode API Server. Configured +from the UI (Settings → Devices & Services → Add Integration → gsmnode). + +Exposes two services — `gsmnode.send_sms` and `gsmnode.call` — and an +"API Server" connectivity binary sensor. A legacy `notify.gsmnode` platform +(YAML) is also available for backward compatibility (see notify.py / README). +""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv + +from .client import GsmNodeClient, GsmNodeConnectionError +from .const import ( + CONF_API_BASE, + CONF_DEVICE_ID, + DOMAIN, + SERVICE_CALL, + SERVICE_SEND_SMS, +) + +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] + +SEND_SMS_SCHEMA = vol.Schema( + { + vol.Required("phone_numbers"): vol.All(cv.ensure_list, [cv.string]), + vol.Required("message"): cv.string, + vol.Optional("device_id"): cv.string, + vol.Optional("sim_number"): vol.Coerce(int), + } +) + +CALL_SCHEMA = vol.Schema( + { + vol.Required("phone_number"): cv.string, + vol.Optional("device_id"): cv.string, + } +) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up gsmnode from a config entry.""" + client = GsmNodeClient( + hass, + entry.data[CONF_API_BASE], + entry.data[CONF_EMAIL], + entry.data[CONF_PASSWORD], + entry.data.get(CONF_DEVICE_ID), + ) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + _async_register_services(hass) + return True + + +def _async_register_services(hass: HomeAssistant) -> None: + """Register the send_sms / call services (once).""" + if hass.services.has_service(DOMAIN, SERVICE_SEND_SMS): + return + + def _first_client() -> GsmNodeClient | None: + for value in hass.data.get(DOMAIN, {}).values(): + if isinstance(value, GsmNodeClient): + return value + return None + + async def handle_send_sms(call: ServiceCall) -> None: + client = _first_client() + if client is None: + raise GsmNodeConnectionError("no gsmnode configured") + await client.send_sms( + call.data["phone_numbers"], + call.data["message"], + call.data.get("device_id"), + call.data.get("sim_number"), + ) + + async def handle_call(call: ServiceCall) -> None: + client = _first_client() + if client is None: + raise GsmNodeConnectionError("no gsmnode configured") + await client.place_call(call.data["phone_number"], call.data.get("device_id")) + + hass.services.async_register( + DOMAIN, SERVICE_SEND_SMS, handle_send_sms, schema=SEND_SMS_SCHEMA + ) + hass.services.async_register(DOMAIN, SERVICE_CALL, handle_call, schema=CALL_SCHEMA) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id, None) + if not hass.data[DOMAIN]: + hass.services.async_remove(DOMAIN, SERVICE_SEND_SMS) + hass.services.async_remove(DOMAIN, SERVICE_CALL) + return unload_ok diff --git a/Home Assistant Plugin/custom_components/gsmnode/__pycache__/__init__.cpython-313.pyc b/Home Assistant Plugin/custom_components/gsmnode/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..d345992 Binary files /dev/null and b/Home Assistant Plugin/custom_components/gsmnode/__pycache__/__init__.cpython-313.pyc differ diff --git a/Home Assistant Plugin/custom_components/gsmnode/__pycache__/binary_sensor.cpython-313.pyc b/Home Assistant Plugin/custom_components/gsmnode/__pycache__/binary_sensor.cpython-313.pyc new file mode 100644 index 0000000..22894fd Binary files /dev/null and b/Home Assistant Plugin/custom_components/gsmnode/__pycache__/binary_sensor.cpython-313.pyc differ diff --git a/Home Assistant Plugin/custom_components/gsmnode/__pycache__/client.cpython-313.pyc b/Home Assistant Plugin/custom_components/gsmnode/__pycache__/client.cpython-313.pyc new file mode 100644 index 0000000..0526ba3 Binary files /dev/null and b/Home Assistant Plugin/custom_components/gsmnode/__pycache__/client.cpython-313.pyc differ diff --git a/Home Assistant Plugin/custom_components/gsmnode/__pycache__/config_flow.cpython-313.pyc b/Home Assistant Plugin/custom_components/gsmnode/__pycache__/config_flow.cpython-313.pyc new file mode 100644 index 0000000..e2e4fa2 Binary files /dev/null and b/Home Assistant Plugin/custom_components/gsmnode/__pycache__/config_flow.cpython-313.pyc differ diff --git a/Home Assistant Plugin/custom_components/gsmnode/__pycache__/const.cpython-313.pyc b/Home Assistant Plugin/custom_components/gsmnode/__pycache__/const.cpython-313.pyc new file mode 100644 index 0000000..9007f2d Binary files /dev/null and b/Home Assistant Plugin/custom_components/gsmnode/__pycache__/const.cpython-313.pyc differ diff --git a/Home Assistant Plugin/custom_components/gsmnode/__pycache__/notify.cpython-313.pyc b/Home Assistant Plugin/custom_components/gsmnode/__pycache__/notify.cpython-313.pyc new file mode 100644 index 0000000..52bda9d Binary files /dev/null and b/Home Assistant Plugin/custom_components/gsmnode/__pycache__/notify.cpython-313.pyc differ diff --git a/Home Assistant Plugin/custom_components/gsmnode/binary_sensor.py b/Home Assistant Plugin/custom_components/gsmnode/binary_sensor.py new file mode 100644 index 0000000..944bf49 --- /dev/null +++ b/Home Assistant Plugin/custom_components/gsmnode/binary_sensor.py @@ -0,0 +1,66 @@ +"""API Server connectivity binary sensor.""" +from __future__ import annotations + +import logging +from datetime import timedelta + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .client import GsmNodeClient +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=30) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the connectivity sensor for a config entry.""" + client: GsmNodeClient = hass.data[DOMAIN][entry.entry_id] + + coordinator: DataUpdateCoordinator[bool] = DataUpdateCoordinator( + hass, + _LOGGER, + name="gsmnode_health", + update_method=client.health, + update_interval=SCAN_INTERVAL, + ) + await coordinator.async_config_entry_first_refresh() + + async_add_entities([GsmNodeHealthSensor(coordinator, entry)]) + + +class GsmNodeHealthSensor(CoordinatorEntity[DataUpdateCoordinator[bool]], BinarySensorEntity): + """Reports whether the API Server is reachable.""" + + _attr_has_entity_name = True + _attr_name = "API Server" + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + + def __init__(self, coordinator: DataUpdateCoordinator[bool], entry: ConfigEntry) -> None: + super().__init__(coordinator) + self._attr_unique_id = f"{entry.entry_id}_api_health" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name="gsmnode", + manufacturer="gsmnode", + ) + + @property + def is_on(self) -> bool: + """True when the API Server responded OK on the last check.""" + return bool(self.coordinator.data) diff --git a/Home Assistant Plugin/custom_components/gsmnode/client.py b/Home Assistant Plugin/custom_components/gsmnode/client.py new file mode 100644 index 0000000..5e1e82a --- /dev/null +++ b/Home Assistant Plugin/custom_components/gsmnode/client.py @@ -0,0 +1,105 @@ +"""Async client for the gsmnode API Server (shared by the UI integration).""" +from __future__ import annotations + +import aiohttp + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + + +class GsmNodeAuthError(Exception): + """Raised when the API Server rejects the credentials.""" + + +class GsmNodeConnectionError(Exception): + """Raised when the API Server can't be reached or returns an error.""" + + +class GsmNodeClient: + """Talks to the API Server: login, send SMS, place calls, health.""" + + def __init__( + self, + hass: HomeAssistant, + api_base: str, + email: str, + password: str, + device_id: str | None = None, + ) -> None: + self._session = async_get_clientsession(hass) + self._api_base = api_base.rstrip("/") + self._email = email + self._password = password + self.device_id = device_id + self._token: str | None = None + + async def login(self) -> None: + """Authenticate and cache the JWT.""" + try: + async with self._session.post( + f"{self._api_base}/api/auth/login", + json={"email": self._email, "password": self._password}, + ) as resp: + if resp.status == 401: + raise GsmNodeAuthError("invalid credentials") + if resp.status != 200: + raise GsmNodeConnectionError(f"HTTP {resp.status}") + data = await resp.json() + self._token = data.get("access_token") + if not self._token: + raise GsmNodeAuthError("no token in response") + except aiohttp.ClientError as err: + raise GsmNodeConnectionError(str(err)) from err + + async def _post(self, path: str, payload: dict) -> int: + headers = {"Authorization": f"Bearer {self._token}"} if self._token else {} + async with self._session.post( + f"{self._api_base}{path}", json=payload, headers=headers + ) as resp: + return resp.status + + async def _send(self, path: str, payload: dict) -> None: + """POST with auth, re-logging in once on a 401.""" + try: + if not self._token: + await self.login() + status = await self._post(path, payload) + if status == 401: + await self.login() + status = await self._post(path, payload) + except aiohttp.ClientError as err: + raise GsmNodeConnectionError(str(err)) from err + if status not in (200, 201, 202): + raise GsmNodeConnectionError(f"{path} -> HTTP {status}") + + async def send_sms( + self, + phone_numbers: list[str], + message: str, + device_id: str | None = None, + sim_number: int | None = None, + ) -> None: + """Queue an outbound SMS.""" + payload: dict = {"phone_numbers": phone_numbers, "text_message": message} + dev = device_id or self.device_id + if dev: + payload["device_id"] = dev + if sim_number is not None: + payload["sim_number"] = sim_number + await self._send("/api/messages", payload) + + async def place_call(self, phone_number: str, device_id: str | None = None) -> None: + """Queue an outbound phone call.""" + payload: dict = {"phone_number": phone_number} + dev = device_id or self.device_id + if dev: + payload["device_id"] = dev + await self._send("/api/calls", payload) + + async def health(self) -> bool: + """Return True if the API Server's health endpoint responds OK.""" + try: + async with self._session.get(f"{self._api_base}/api/health") as resp: + return resp.status == 200 + except aiohttp.ClientError: + return False diff --git a/Home Assistant Plugin/custom_components/gsmnode/config_flow.py b/Home Assistant Plugin/custom_components/gsmnode/config_flow.py new file mode 100644 index 0000000..15ab690 --- /dev/null +++ b/Home Assistant Plugin/custom_components/gsmnode/config_flow.py @@ -0,0 +1,67 @@ +"""Config flow for the gsmnode integration (UI setup).""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers import selector + +from .client import ( + GsmNodeAuthError, + GsmNodeClient, + GsmNodeConnectionError, +) +from .const import CONF_API_BASE, CONF_DEVICE_ID, DEFAULT_API_BASE, DOMAIN + +STEP_USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_BASE, default=DEFAULT_API_BASE): str, + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), + vol.Optional(CONF_DEVICE_ID): str, + } +) + + +class GsmNodeConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle the UI configuration flow.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + client = GsmNodeClient( + self.hass, + user_input[CONF_API_BASE], + user_input[CONF_EMAIL], + user_input[CONF_PASSWORD], + user_input.get(CONF_DEVICE_ID), + ) + try: + await client.login() + except GsmNodeAuthError: + errors["base"] = "invalid_auth" + except GsmNodeConnectionError: + errors["base"] = "cannot_connect" + else: + api_base = user_input[CONF_API_BASE].rstrip("/") + await self.async_set_unique_id(f"{api_base}::{user_input[CONF_EMAIL]}") + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"{user_input[CONF_EMAIL]} ({api_base})", + data=user_input, + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_SCHEMA, errors=errors + ) diff --git a/Home Assistant Plugin/custom_components/gsmnode/const.py b/Home Assistant Plugin/custom_components/gsmnode/const.py new file mode 100644 index 0000000..7dda479 --- /dev/null +++ b/Home Assistant Plugin/custom_components/gsmnode/const.py @@ -0,0 +1,13 @@ +"""Constants for the gsmnode integration.""" + +DOMAIN = "gsmnode" + +CONF_API_BASE = "api_base" +CONF_DEVICE_ID = "device_id" + +DEFAULT_API_BASE = "http://localhost:8080" +DEFAULT_NAME = "gsmnode" + +# Service names registered by the integration. +SERVICE_SEND_SMS = "send_sms" +SERVICE_CALL = "call" diff --git a/Home Assistant Plugin/custom_components/gsmnode/manifest.json b/Home Assistant Plugin/custom_components/gsmnode/manifest.json new file mode 100644 index 0000000..0f4ebb5 --- /dev/null +++ b/Home Assistant Plugin/custom_components/gsmnode/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "gsmnode", + "name": "gsmnode", + "version": "1.2.0", + "config_flow": true, + "documentation": "https://github.com/your-org/sms-gateway", + "issue_tracker": "https://github.com/your-org/sms-gateway/issues", + "dependencies": [], + "codeowners": [], + "requirements": [], + "integration_type": "service", + "iot_class": "local_polling" +} diff --git a/Home Assistant Plugin/custom_components/gsmnode/notify.py b/Home Assistant Plugin/custom_components/gsmnode/notify.py new file mode 100644 index 0000000..cf8c3a3 --- /dev/null +++ b/Home Assistant Plugin/custom_components/gsmnode/notify.py @@ -0,0 +1,150 @@ +"""gsmnode notify platform. + +Sends SMS through the gsmnode API Server's `/api/messages` endpoint. The +API Server is the only thing that talks to PocketBase, so this integration only +needs the API Server's URL and a user login. +""" +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components.notify import ( + ATTR_DATA, + ATTR_TARGET, + PLATFORM_SCHEMA, + BaseNotificationService, +) +from homeassistant.const import CONF_EMAIL, CONF_NAME, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from .const import ( + CONF_API_BASE, + CONF_DEVICE_ID, + DEFAULT_API_BASE, + DEFAULT_NAME, +) + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_API_BASE, default=DEFAULT_API_BASE): cv.string, + vol.Required(CONF_EMAIL): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_DEVICE_ID): cv.string, + } +) + + +async def async_get_service( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> "GsmNodeNotificationService": + """Return the gsmnode notification service.""" + return GsmNodeNotificationService( + hass, + config[CONF_API_BASE], + config[CONF_EMAIL], + config[CONF_PASSWORD], + config.get(CONF_DEVICE_ID), + ) + + +class GsmNodeNotificationService(BaseNotificationService): + """Implement the notification service for gsmnode.""" + + def __init__( + self, + hass: HomeAssistant, + api_base: str, + email: str, + password: str, + device_id: str | None, + ) -> None: + """Initialize the service.""" + self._hass = hass + self._api_base = api_base.rstrip("/") + self._email = email + self._password = password + self._device_id = device_id + self._token: str | None = None + + @property + def _session(self): + return async_get_clientsession(self._hass) + + async def _login(self) -> None: + """Authenticate against the API Server and cache the JWT.""" + url = f"{self._api_base}/api/auth/login" + async with self._session.post( + url, json={"email": self._email, "password": self._password} + ) as resp: + if resp.status != 200: + raise RuntimeError(f"login failed: HTTP {resp.status}") + data = await resp.json() + self._token = data.get("access_token") + if not self._token: + raise RuntimeError("login response did not contain a token") + + async def _post(self, path: str, payload: dict[str, Any]) -> int: + """POST to an API path; returns the HTTP status code.""" + url = f"{self._api_base}{path}" + headers = {"Authorization": f"Bearer {self._token}"} + async with self._session.post(url, json=payload, headers=headers) as resp: + return resp.status + + async def _send(self, path: str, payload: dict[str, Any]) -> None: + """Authenticate (if needed) and POST, retrying once on a 401.""" + try: + if not self._token: + await self._login() + + status = await self._post(path, payload) + if status == 401: # token expired — re-login once and retry + await self._login() + status = await self._post(path, payload) + + if status not in (200, 201, 202): + _LOGGER.error("gsmnode: %s failed with HTTP %s", path, status) + except Exception as err: # noqa: BLE001 - surface any transport error + _LOGGER.error("gsmnode: error calling %s: %s", path, err) + + async def async_send_message(self, message: str = "", **kwargs: Any) -> None: + """Send an SMS, or place a phone call when `data.type` is `call`. + + Recipient numbers come from the `target` field. Optional data overrides: + `device_id` (which device), `sim_number` (SMS only), and `type: call` to + dial the target(s) instead of texting. + """ + targets = kwargs.get(ATTR_TARGET) + if not targets: + _LOGGER.error("gsmnode: no target phone number(s) provided") + return + + data = kwargs.get(ATTR_DATA) or {} + device_id = data.get("device_id", self._device_id) + is_call = str(data.get("type", "")).lower() == "call" + + if is_call: + # One call per target number (a call has a single recipient). + for number in targets: + payload: dict[str, Any] = {"phone_number": number} + if device_id: + payload["device_id"] = device_id + await self._send("/api/calls", payload) + return + + payload = {"phone_numbers": list(targets), "text_message": message} + if device_id: + payload["device_id"] = device_id + if "sim_number" in data: + payload["sim_number"] = data["sim_number"] + await self._send("/api/messages", payload) diff --git a/Home Assistant Plugin/custom_components/gsmnode/services.yaml b/Home Assistant Plugin/custom_components/gsmnode/services.yaml new file mode 100644 index 0000000..59427d8 --- /dev/null +++ b/Home Assistant Plugin/custom_components/gsmnode/services.yaml @@ -0,0 +1,53 @@ +send_sms: + name: Send SMS + description: Send an SMS through the gateway. + fields: + phone_numbers: + name: Phone numbers + description: One or more recipient phone numbers. + required: true + example: '["+15551234567"]' + selector: + text: + multiple: true + message: + name: Message + description: The text to send. + required: true + example: Hello from Home Assistant + selector: + text: + multiline: true + device_id: + name: Device + description: Device to send from (defaults to the configured / most recent device). + example: my-phone + selector: + text: + sim_number: + name: SIM number + description: Which SIM to use on a dual-SIM phone. + example: 1 + selector: + number: + min: 1 + max: 4 + mode: box + +call: + name: Place call + description: Tell a gateway device to place an outbound phone call. + fields: + phone_number: + name: Phone number + description: The number to call. + required: true + example: "+15551234567" + selector: + text: + device_id: + name: Device + description: Device to call from (defaults to the configured / most recent device). + example: my-phone + selector: + text: diff --git a/Home Assistant Plugin/custom_components/gsmnode/strings.json b/Home Assistant Plugin/custom_components/gsmnode/strings.json new file mode 100644 index 0000000..b11d469 --- /dev/null +++ b/Home Assistant Plugin/custom_components/gsmnode/strings.json @@ -0,0 +1,43 @@ +{ + "config": { + "step": { + "user": { + "title": "gsmnode", + "description": "Connect to your gsmnode API Server.", + "data": { + "api_base": "API Server URL", + "email": "Email", + "password": "Password", + "device_id": "Default device ID (optional)" + } + } + }, + "error": { + "cannot_connect": "Failed to connect to the API Server. Check the URL and that it is reachable.", + "invalid_auth": "Invalid email or password." + }, + "abort": { + "already_configured": "This gateway is already configured." + } + }, + "services": { + "send_sms": { + "name": "Send SMS", + "description": "Send an SMS through the gateway.", + "fields": { + "phone_numbers": { "name": "Phone numbers", "description": "One or more recipient phone numbers." }, + "message": { "name": "Message", "description": "The text to send." }, + "device_id": { "name": "Device", "description": "Device to send from." }, + "sim_number": { "name": "SIM number", "description": "Which SIM to use on a dual-SIM phone." } + } + }, + "call": { + "name": "Place call", + "description": "Tell a gateway device to place an outbound phone call.", + "fields": { + "phone_number": { "name": "Phone number", "description": "The number to call." }, + "device_id": { "name": "Device", "description": "Device to call from." } + } + } + } +} diff --git a/Home Assistant Plugin/custom_components/gsmnode/translations/en.json b/Home Assistant Plugin/custom_components/gsmnode/translations/en.json new file mode 100644 index 0000000..b11d469 --- /dev/null +++ b/Home Assistant Plugin/custom_components/gsmnode/translations/en.json @@ -0,0 +1,43 @@ +{ + "config": { + "step": { + "user": { + "title": "gsmnode", + "description": "Connect to your gsmnode API Server.", + "data": { + "api_base": "API Server URL", + "email": "Email", + "password": "Password", + "device_id": "Default device ID (optional)" + } + } + }, + "error": { + "cannot_connect": "Failed to connect to the API Server. Check the URL and that it is reachable.", + "invalid_auth": "Invalid email or password." + }, + "abort": { + "already_configured": "This gateway is already configured." + } + }, + "services": { + "send_sms": { + "name": "Send SMS", + "description": "Send an SMS through the gateway.", + "fields": { + "phone_numbers": { "name": "Phone numbers", "description": "One or more recipient phone numbers." }, + "message": { "name": "Message", "description": "The text to send." }, + "device_id": { "name": "Device", "description": "Device to send from." }, + "sim_number": { "name": "SIM number", "description": "Which SIM to use on a dual-SIM phone." } + } + }, + "call": { + "name": "Place call", + "description": "Tell a gateway device to place an outbound phone call.", + "fields": { + "phone_number": { "name": "Phone number", "description": "The number to call." }, + "device_id": { "name": "Device", "description": "Device to call from." } + } + } + } +} diff --git a/Phone App/.gitignore b/Phone App/.gitignore new file mode 100644 index 0000000..740ffb4 --- /dev/null +++ b/Phone App/.gitignore @@ -0,0 +1,19 @@ +# Flutter / Dart +.dart_tool/ +.packages +build/ +.flutter-plugins +.flutter-plugins-dependencies +pubspec.lock + +# Android +android/.gradle/ +android/local.properties +android/key.properties +*.keystore +*.jks + +# IDE +.idea/ +*.iml +.vscode/ diff --git a/Phone App/.metadata b/Phone App/.metadata new file mode 100644 index 0000000..9c3a509 --- /dev/null +++ b/Phone App/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "d8a9f9a52e5af486f80d932e838ee93861ffd863" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + - platform: android + create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/Phone App/README.md b/Phone App/README.md new file mode 100644 index 0000000..a32df2c --- /dev/null +++ b/Phone App/README.md @@ -0,0 +1,124 @@ +# gsmnode — Phone App (Flutter / Android) + +Turns an Android phone into the SMS gateway endpoint. It registers with the API +Server, polls for pending outbound messages, sends them over the radio, reports +delivery state, and forwards incoming SMS to the server's inbox. + +``` +API Server ──(pending messages)──► Phone App ──► SmsManager (send) + ▲ │ + └──(status reports / inbox)──────────┘ ◄── BroadcastReceiver (incoming) +``` + +> The Phone App talks **only** to the API Server (never to PocketBase directly), +> using the device token issued at registration. + +## Status: source scaffold + +This folder contains the complete Dart app and the native Android (Kotlin) SMS +bridge, but **not** the generated Gradle/platform scaffolding. You generate that +once with `flutter create` after installing the toolchain (below). + +## Prerequisites + +1. **Flutter SDK** (stable) — https://docs.flutter.dev/get-started/install/windows +2. **JDK 17** (this machine currently has only JDK 8 — Android Gradle needs 17+) +3. **Android SDK** (via Android Studio or `flutter doctor --android-licenses`) +4. A **physical Android phone** with a SIM (the emulator can't send real SMS) + +Verify with `flutter doctor` — resolve anything it flags before continuing. + +## Generate platform scaffolding & wire in the native code + +From this `Phone App/` folder: + +```powershell +# 1. Generate the android/ Gradle project (keeps lib/ and pubspec.yaml) +flutter create . --org app.smsgateway --project-name sms_gateway_phone --platforms=android + +# 2. Overlay the SMS-enabled manifest + Kotlin (overwrites the generated stubs) +Copy-Item -Recurse -Force android_overlay/* android/ + +# 3. Fetch packages +flutter pub get +``` + +`android_overlay/` mirrors the real `android/` paths, so step 2 drops: +- `app/src/main/AndroidManifest.xml` — SMS permissions + the `SmsReceiver` +- `app/src/main/kotlin/app/smsgateway/sms_gateway_phone/MainActivity.kt` — send bridge +- `app/src/main/kotlin/app/smsgateway/sms_gateway_phone/SmsReceiver.kt` — incoming bridge + +If Gradle complains about SDK levels, set `minSdkVersion 23` (or higher) in +`android/app/build.gradle`. + +## Run + +```powershell +flutter devices # confirm your phone is listed +flutter run # build & install on the connected phone +``` + +1. On first launch, enter the **API Server URL**, your **email/password**, and a + **device name**, then tap *Sign in & register device*. + - Emulator → host: use `http://10.0.2.2:8080`. + - Physical phone → use the host's LAN IP, e.g. `http://10.2.1.x:8080` + (the same network as the phone; make sure the API Server is reachable). +2. On the home screen, **grant SMS & phone permissions**, then **Start gateway**. +3. Send a test message from the Web App → it appears in the activity log and is + delivered via the phone. Texts received by the phone show up in the Web App + **Inbox**. + +## How it maps to the API + +| Action | Endpoint | +|---|---| +| Login | `POST /api/auth/login` (JWT) | +| Register device | `POST /api/mobile/v1/device` → device token | +| Poll pending | `GET /api/mobile/v1/messages` (marks them `Processed`) | +| Report state | `PATCH /api/mobile/v1/messages/{id}` (`Sent`/`Failed`) | +| Incoming SMS | `POST /api/mobile/v1/inbox` | +| Heartbeat | `POST /api/mobile/v1/ping` | + +Pulled items carry a `type` of `sms` or `call`. For `call` the app places a +native phone call via `TelecomManager.placeCall` (needs `CALL_PHONE` — covered by +the phone permission) instead of sending SMS, then reports `Sent`. +`TelecomManager.placeCall` is used rather than `startActivity(ACTION_CALL)` so the +call still goes through when the screen is locked / the app is backgrounded +(Android blocks background activity starts, but not telecom-routed calls). + +## Code layout + +``` +lib/ + main.dart entry, bootstraps services, picks first screen + config.dart default API base + poll/ping intervals + models/message.dart outbound message model + services/ + storage.dart persisted settings/tokens (shared_preferences) + api_client.dart API Server HTTP client + sms_service.dart platform-channel bridge (send + incoming stream) + gateway_service.dart the poll → send → report loop + inbox forwarding + screens/ + login_screen.dart login + device registration + home_screen.dart start/stop, permissions, activity log +android_overlay/ native Android files to copy after `flutter create` +``` + +## Background & delivery reports (implemented) + +- **Foreground service** (`GatewayForegroundService.kt`): starting the gateway + launches an ongoing-notification foreground service + partial wakelock, so the + poll/send loop keeps running while the screen is off or the app is backgrounded. +- **Delivery reports**: `MainActivity.sendSms` attaches `sent`/`delivered` + PendingIntents; `SmsStatusReceiver` forwards the outcome (tagged with the + message id) to Dart, which reports `Delivered`/`Failed` to the API Server. + +## Known next steps (not yet implemented) + +- **Survive full task removal / long Doze**: the foreground service covers + screen-lock, but surviving the user swiping the app away or hours of Doze would + need a dedicated background Dart isolate (e.g. `flutter_background_service`). +- **Push wake-up (FCM)**: register an FCM token at registration so the server can + wake the device instead of polling. Requires a Firebase project + + `google-services.json` + server-side FCM sending in the API Server. +- **MMS / data SMS**: only text SMS is implemented. diff --git a/Phone App/analysis_options.yaml b/Phone App/analysis_options.yaml new file mode 100644 index 0000000..c4d5f50 --- /dev/null +++ b/Phone App/analysis_options.yaml @@ -0,0 +1,6 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + prefer_const_constructors: true + avoid_print: false diff --git a/Phone App/android/.gitignore b/Phone App/android/.gitignore new file mode 100644 index 0000000..55afd91 --- /dev/null +++ b/Phone App/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/Phone App/android/app/build.gradle b/Phone App/android/app/build.gradle new file mode 100644 index 0000000..6545a7c --- /dev/null +++ b/Phone App/android/app/build.gradle @@ -0,0 +1,43 @@ +plugins { + id "com.android.application" + id "kotlin-android" + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id "dev.flutter.flutter-gradle-plugin" +} + +android { + namespace = "app.gsmnode.phone" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8 + } + + defaultConfig { + applicationId = "app.gsmnode.phone" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.debug + } + } +} + +flutter { + source = "../.." +} diff --git a/Phone App/android/app/src/debug/AndroidManifest.xml b/Phone App/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/Phone App/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/Phone App/android/app/src/main/AndroidManifest.xml b/Phone App/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..dabd850 --- /dev/null +++ b/Phone App/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Phone App/android/app/src/main/kotlin/app/gsmnode/phone/GatewayForegroundService.kt b/Phone App/android/app/src/main/kotlin/app/gsmnode/phone/GatewayForegroundService.kt new file mode 100644 index 0000000..fa22bb6 --- /dev/null +++ b/Phone App/android/app/src/main/kotlin/app/gsmnode/phone/GatewayForegroundService.kt @@ -0,0 +1,99 @@ +package app.gsmnode.phone + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.os.PowerManager +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat + +/// Foreground service that keeps the app process alive (and the CPU awake via a +/// partial wakelock) so the Dart gateway loop keeps polling/sending while the +/// screen is off or the app is in the background. +/// +/// Note: this closes the "survives screen lock" gap. Surviving a full task +/// removal or Doze for hours would additionally need a background Dart isolate +/// (e.g. flutter_background_service) — documented as a further step. +class GatewayForegroundService : Service() { + + private var wakeLock: PowerManager.WakeLock? = null + + companion object { + private const val CHANNEL_ID = "sms_gateway_service" + private const val NOTIFICATION_ID = 4711 + + fun start(context: Context) { + val intent = Intent(context, GatewayForegroundService::class.java) + ContextCompat.startForegroundService(context, intent) + } + + fun stop(context: Context) { + context.stopService(Intent(context, GatewayForegroundService::class.java)) + } + } + + override fun onCreate() { + super.onCreate() + createChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + startForegroundCompat() + acquireWakeLock() + // Restart if the system kills the service. + return START_STICKY + } + + private fun startForegroundCompat() { + val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("gsmnode") + .setContentText("Gateway is running") + .setSmallIcon(android.R.drawable.stat_sys_upload) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground( + NOTIFICATION_ID, notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + + @Suppress("WakelockTimeout") + private fun acquireWakeLock() { + if (wakeLock?.isHeld == true) return + val pm = getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = pm.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, "gsmnode:gateway" + ).also { it.acquire() } + } + + private fun createChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, "gsmnode", + NotificationManager.IMPORTANCE_LOW + ).apply { description = "Keeps the SMS gateway running" } + getSystemService(NotificationManager::class.java) + .createNotificationChannel(channel) + } + } + + override fun onDestroy() { + wakeLock?.let { if (it.isHeld) it.release() } + wakeLock = null + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null +} diff --git a/Phone App/android/app/src/main/kotlin/app/gsmnode/phone/MainActivity.kt b/Phone App/android/app/src/main/kotlin/app/gsmnode/phone/MainActivity.kt new file mode 100644 index 0000000..f3e1ecb --- /dev/null +++ b/Phone App/android/app/src/main/kotlin/app/gsmnode/phone/MainActivity.kt @@ -0,0 +1,187 @@ +package app.gsmnode.phone + +import android.Manifest +import android.app.PendingIntent +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.telecom.TelecomManager +import android.telephony.SmsManager +import android.telephony.SubscriptionManager +import androidx.core.content.ContextCompat +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodChannel + +class MainActivity : FlutterActivity() { + + companion object { + const val METHOD_CHANNEL = "app.gsmnode/sms" + const val EVENT_CHANNEL = "app.gsmnode/sms_incoming" + const val STATUS_CHANNEL = "app.gsmnode/sms_status" + const val STATUS_ACTION = "app.gsmnode.SMS_STATUS" + + // Sinks used by the broadcast receivers to push events into Dart. + @Volatile var incomingSink: EventChannel.EventSink? = null + @Volatile var statusSink: EventChannel.EventSink? = null + } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + val messenger = flutterEngine.dartExecutor.binaryMessenger + + MethodChannel(messenger, METHOD_CHANNEL).setMethodCallHandler { call, result -> + when (call.method) { + "sendSms" -> { + val phone = call.argument("phone") + val message = call.argument("message") + val simSlot = call.argument("simSlot") + val messageId = call.argument("messageId") + if (phone.isNullOrBlank() || message == null) { + result.error("BAD_ARGS", "phone and message are required", null) + return@setMethodCallHandler + } + try { + sendSms(phone, message, simSlot, messageId) + result.success(true) + } catch (e: Exception) { + result.error("SEND_FAILED", e.message, null) + } + } + "placeCall" -> { + val phone = call.argument("phone") + if (phone.isNullOrBlank()) { + result.error("BAD_ARGS", "phone is required", null) + return@setMethodCallHandler + } + try { + placeCall(phone) + result.success(true) + } catch (e: Exception) { + result.error("CALL_FAILED", e.message, null) + } + } + "startService" -> { + GatewayForegroundService.start(this) + result.success(true) + } + "stopService" -> { + GatewayForegroundService.stop(this) + result.success(true) + } + else -> result.notImplemented() + } + } + + EventChannel(messenger, EVENT_CHANNEL).setStreamHandler( + object : EventChannel.StreamHandler { + override fun onListen(args: Any?, sink: EventChannel.EventSink?) { + incomingSink = sink + } + override fun onCancel(args: Any?) { + incomingSink = null + } + }) + + EventChannel(messenger, STATUS_CHANNEL).setStreamHandler( + object : EventChannel.StreamHandler { + override fun onListen(args: Any?, sink: EventChannel.EventSink?) { + statusSink = sink + } + override fun onCancel(args: Any?) { + statusSink = null + } + }) + } + + private fun smsManagerFor(simSlot: Int?): SmsManager { + if (simSlot == null) { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + getSystemService(SmsManager::class.java) + else + @Suppress("DEPRECATION") SmsManager.getDefault() + } + val hasPerm = ContextCompat.checkSelfPermission( + this, Manifest.permission.READ_PHONE_STATE + ) == PackageManager.PERMISSION_GRANTED + if (hasPerm) { + val sm = getSystemService(SubscriptionManager::class.java) + val info = sm?.getActiveSubscriptionInfoForSimSlotIndex(simSlot) + if (info != null) { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + getSystemService(SmsManager::class.java) + .createForSubscriptionId(info.subscriptionId) + else + @Suppress("DEPRECATION") + SmsManager.getSmsManagerForSubscriptionId(info.subscriptionId) + } + } + return smsManagerFor(null) + } + + /// Builds a PendingIntent that, when fired by the radio, broadcasts the + /// send/delivery outcome to SmsStatusReceiver (tagged with the message id). + private fun statusPendingIntent(messageId: String?, phone: String, kind: String): PendingIntent { + val intent = Intent(this, SmsStatusReceiver::class.java).apply { + action = STATUS_ACTION + putExtra("messageId", messageId) + putExtra("phone", phone) + putExtra("kind", kind) + } + val requestCode = (messageId.orEmpty() + phone + kind).hashCode() + return PendingIntent.getBroadcast( + this, requestCode, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + /// Places a phone call. Requires the CALL_PHONE permission (requested in Dart + /// via the phone permission group). + /// + /// Uses TelecomManager.placeCall, which routes through the system telecom + /// service rather than starting the dialer activity ourselves. This is what + /// lets the call go through when the screen is locked / the app is in the + /// background — a plain startActivity(ACTION_CALL) is blocked by Android's + /// background-activity-start restrictions in that state. Falls back to + /// ACTION_CALL only on very old devices without TelecomManager.placeCall. + private fun placeCall(phone: String) { + val uri = Uri.fromParts("tel", phone, null) + val hasPerm = ContextCompat.checkSelfPermission( + this, Manifest.permission.CALL_PHONE + ) == PackageManager.PERMISSION_GRANTED + if (!hasPerm) { + throw SecurityException("CALL_PHONE permission not granted") + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val telecom = getSystemService(TelecomManager::class.java) + if (telecom != null) { + telecom.placeCall(uri, Bundle()) + return + } + } + val intent = Intent(Intent.ACTION_CALL, uri) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + } + + private fun sendSms(phone: String, message: String, simSlot: Int?, messageId: String?) { + val sms = smsManagerFor(simSlot) + val parts = sms.divideMessage(message) + val sentPI = statusPendingIntent(messageId, phone, "sent") + val deliveredPI = statusPendingIntent(messageId, phone, "delivered") + if (parts.size > 1) { + val sentList = ArrayList(parts.size) + val deliveredList = ArrayList(parts.size) + for (i in parts.indices) { + sentList.add(sentPI) + deliveredList.add(deliveredPI) + } + sms.sendMultipartTextMessage(phone, null, parts, sentList, deliveredList) + } else { + sms.sendTextMessage(phone, null, message, sentPI, deliveredPI) + } + } +} diff --git a/Phone App/android/app/src/main/kotlin/app/gsmnode/phone/SmsReceiver.kt b/Phone App/android/app/src/main/kotlin/app/gsmnode/phone/SmsReceiver.kt new file mode 100644 index 0000000..c3df656 --- /dev/null +++ b/Phone App/android/app/src/main/kotlin/app/gsmnode/phone/SmsReceiver.kt @@ -0,0 +1,40 @@ +package app.gsmnode.phone + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Handler +import android.os.Looper +import android.provider.Telephony + +/// Receives incoming SMS and forwards each message to Dart via the EventChannel +/// sink held by MainActivity. Works while the app process is alive. +class SmsReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return + + val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent) ?: return + if (messages.isEmpty()) return + + // Multipart SMS arrive as several PDUs from the same sender; concatenate. + val from = messages[0].displayOriginatingAddress ?: "" + val body = StringBuilder() + var timestamp = System.currentTimeMillis() + for (m in messages) { + body.append(m.displayMessageBody ?: "") + timestamp = m.timestampMillis + } + + val payload = mapOf( + "from" to from, + "body" to body.toString(), + "timestamp" to timestamp, + ) + + // EventSink must be touched on the main thread. + Handler(Looper.getMainLooper()).post { + MainActivity.incomingSink?.success(payload) + } + } +} diff --git a/Phone App/android/app/src/main/kotlin/app/gsmnode/phone/SmsStatusReceiver.kt b/Phone App/android/app/src/main/kotlin/app/gsmnode/phone/SmsStatusReceiver.kt new file mode 100644 index 0000000..0d87a2d --- /dev/null +++ b/Phone App/android/app/src/main/kotlin/app/gsmnode/phone/SmsStatusReceiver.kt @@ -0,0 +1,33 @@ +package app.gsmnode.phone + +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Handler +import android.os.Looper + +/// Receives SMS send/delivery outcomes from the PendingIntents created in +/// MainActivity.sendSms and forwards them to Dart, tagged with the message id +/// so the gateway loop can report Sent / Delivered / Failed to the API Server. +class SmsStatusReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val messageId = intent.getStringExtra("messageId") + val kind = intent.getStringExtra("kind") ?: return + // For "sent": RESULT_OK means the radio accepted the message. + // For "delivered": RESULT_OK means a positive delivery report arrived. + val success = resultCode == Activity.RESULT_OK + + val payload = mapOf( + "messageId" to messageId, + "kind" to kind, + "success" to success, + "resultCode" to resultCode, + ) + + Handler(Looper.getMainLooper()).post { + MainActivity.statusSink?.success(payload) + } + } +} diff --git a/Phone App/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/Phone App/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..5b271c0 Binary files /dev/null and b/Phone App/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/Phone App/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/Phone App/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..c517daf Binary files /dev/null and b/Phone App/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/Phone App/android/app/src/main/res/drawable-v21/launch_background.xml b/Phone App/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/Phone App/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/Phone App/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/Phone App/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..77557e1 Binary files /dev/null and b/Phone App/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/Phone App/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/Phone App/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..445ba82 Binary files /dev/null and b/Phone App/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/Phone App/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/Phone App/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..f96aeb6 Binary files /dev/null and b/Phone App/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/Phone App/android/app/src/main/res/drawable/launch_background.xml b/Phone App/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/Phone App/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/Phone App/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Phone App/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c79c58a --- /dev/null +++ b/Phone App/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/Phone App/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/Phone App/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..69a543e Binary files /dev/null and b/Phone App/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/Phone App/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/Phone App/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..4541dbc Binary files /dev/null and b/Phone App/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/Phone App/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/Phone App/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..1798428 Binary files /dev/null and b/Phone App/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/Phone App/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/Phone App/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..ae9b99b Binary files /dev/null and b/Phone App/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/Phone App/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Phone App/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..ccdff6c Binary files /dev/null and b/Phone App/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/Phone App/android/app/src/main/res/values-night/styles.xml b/Phone App/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/Phone App/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/Phone App/android/app/src/main/res/values/colors.xml b/Phone App/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..9a9a847 --- /dev/null +++ b/Phone App/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #2E9E6B + \ No newline at end of file diff --git a/Phone App/android/app/src/main/res/values/styles.xml b/Phone App/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/Phone App/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/Phone App/android/app/src/profile/AndroidManifest.xml b/Phone App/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/Phone App/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/Phone App/android/build.gradle b/Phone App/android/build.gradle new file mode 100644 index 0000000..d2ffbff --- /dev/null +++ b/Phone App/android/build.gradle @@ -0,0 +1,18 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = "../build" +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/Phone App/android/gradle.properties b/Phone App/android/gradle.properties new file mode 100644 index 0000000..4147ba3 --- /dev/null +++ b/Phone App/android/gradle.properties @@ -0,0 +1,7 @@ +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/Phone App/android/gradle/wrapper/gradle-wrapper.properties b/Phone App/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..3c85cfe --- /dev/null +++ b/Phone App/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip diff --git a/Phone App/android/settings.gradle b/Phone App/android/settings.gradle new file mode 100644 index 0000000..10fd726 --- /dev/null +++ b/Phone App/android/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.6.0" apply false + id "org.jetbrains.kotlin.android" version "1.8.22" apply false +} + +include ":app" diff --git a/Phone App/android_overlay/app/src/main/AndroidManifest.xml b/Phone App/android_overlay/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..dabd850 --- /dev/null +++ b/Phone App/android_overlay/app/src/main/AndroidManifest.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Phone App/android_overlay/app/src/main/kotlin/app/gsmnode/phone/GatewayForegroundService.kt b/Phone App/android_overlay/app/src/main/kotlin/app/gsmnode/phone/GatewayForegroundService.kt new file mode 100644 index 0000000..fa22bb6 --- /dev/null +++ b/Phone App/android_overlay/app/src/main/kotlin/app/gsmnode/phone/GatewayForegroundService.kt @@ -0,0 +1,99 @@ +package app.gsmnode.phone + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.os.PowerManager +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat + +/// Foreground service that keeps the app process alive (and the CPU awake via a +/// partial wakelock) so the Dart gateway loop keeps polling/sending while the +/// screen is off or the app is in the background. +/// +/// Note: this closes the "survives screen lock" gap. Surviving a full task +/// removal or Doze for hours would additionally need a background Dart isolate +/// (e.g. flutter_background_service) — documented as a further step. +class GatewayForegroundService : Service() { + + private var wakeLock: PowerManager.WakeLock? = null + + companion object { + private const val CHANNEL_ID = "sms_gateway_service" + private const val NOTIFICATION_ID = 4711 + + fun start(context: Context) { + val intent = Intent(context, GatewayForegroundService::class.java) + ContextCompat.startForegroundService(context, intent) + } + + fun stop(context: Context) { + context.stopService(Intent(context, GatewayForegroundService::class.java)) + } + } + + override fun onCreate() { + super.onCreate() + createChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + startForegroundCompat() + acquireWakeLock() + // Restart if the system kills the service. + return START_STICKY + } + + private fun startForegroundCompat() { + val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("gsmnode") + .setContentText("Gateway is running") + .setSmallIcon(android.R.drawable.stat_sys_upload) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground( + NOTIFICATION_ID, notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + + @Suppress("WakelockTimeout") + private fun acquireWakeLock() { + if (wakeLock?.isHeld == true) return + val pm = getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = pm.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, "gsmnode:gateway" + ).also { it.acquire() } + } + + private fun createChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, "gsmnode", + NotificationManager.IMPORTANCE_LOW + ).apply { description = "Keeps the SMS gateway running" } + getSystemService(NotificationManager::class.java) + .createNotificationChannel(channel) + } + } + + override fun onDestroy() { + wakeLock?.let { if (it.isHeld) it.release() } + wakeLock = null + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null +} diff --git a/Phone App/android_overlay/app/src/main/kotlin/app/gsmnode/phone/MainActivity.kt b/Phone App/android_overlay/app/src/main/kotlin/app/gsmnode/phone/MainActivity.kt new file mode 100644 index 0000000..f3e1ecb --- /dev/null +++ b/Phone App/android_overlay/app/src/main/kotlin/app/gsmnode/phone/MainActivity.kt @@ -0,0 +1,187 @@ +package app.gsmnode.phone + +import android.Manifest +import android.app.PendingIntent +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.telecom.TelecomManager +import android.telephony.SmsManager +import android.telephony.SubscriptionManager +import androidx.core.content.ContextCompat +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodChannel + +class MainActivity : FlutterActivity() { + + companion object { + const val METHOD_CHANNEL = "app.gsmnode/sms" + const val EVENT_CHANNEL = "app.gsmnode/sms_incoming" + const val STATUS_CHANNEL = "app.gsmnode/sms_status" + const val STATUS_ACTION = "app.gsmnode.SMS_STATUS" + + // Sinks used by the broadcast receivers to push events into Dart. + @Volatile var incomingSink: EventChannel.EventSink? = null + @Volatile var statusSink: EventChannel.EventSink? = null + } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + val messenger = flutterEngine.dartExecutor.binaryMessenger + + MethodChannel(messenger, METHOD_CHANNEL).setMethodCallHandler { call, result -> + when (call.method) { + "sendSms" -> { + val phone = call.argument("phone") + val message = call.argument("message") + val simSlot = call.argument("simSlot") + val messageId = call.argument("messageId") + if (phone.isNullOrBlank() || message == null) { + result.error("BAD_ARGS", "phone and message are required", null) + return@setMethodCallHandler + } + try { + sendSms(phone, message, simSlot, messageId) + result.success(true) + } catch (e: Exception) { + result.error("SEND_FAILED", e.message, null) + } + } + "placeCall" -> { + val phone = call.argument("phone") + if (phone.isNullOrBlank()) { + result.error("BAD_ARGS", "phone is required", null) + return@setMethodCallHandler + } + try { + placeCall(phone) + result.success(true) + } catch (e: Exception) { + result.error("CALL_FAILED", e.message, null) + } + } + "startService" -> { + GatewayForegroundService.start(this) + result.success(true) + } + "stopService" -> { + GatewayForegroundService.stop(this) + result.success(true) + } + else -> result.notImplemented() + } + } + + EventChannel(messenger, EVENT_CHANNEL).setStreamHandler( + object : EventChannel.StreamHandler { + override fun onListen(args: Any?, sink: EventChannel.EventSink?) { + incomingSink = sink + } + override fun onCancel(args: Any?) { + incomingSink = null + } + }) + + EventChannel(messenger, STATUS_CHANNEL).setStreamHandler( + object : EventChannel.StreamHandler { + override fun onListen(args: Any?, sink: EventChannel.EventSink?) { + statusSink = sink + } + override fun onCancel(args: Any?) { + statusSink = null + } + }) + } + + private fun smsManagerFor(simSlot: Int?): SmsManager { + if (simSlot == null) { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + getSystemService(SmsManager::class.java) + else + @Suppress("DEPRECATION") SmsManager.getDefault() + } + val hasPerm = ContextCompat.checkSelfPermission( + this, Manifest.permission.READ_PHONE_STATE + ) == PackageManager.PERMISSION_GRANTED + if (hasPerm) { + val sm = getSystemService(SubscriptionManager::class.java) + val info = sm?.getActiveSubscriptionInfoForSimSlotIndex(simSlot) + if (info != null) { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + getSystemService(SmsManager::class.java) + .createForSubscriptionId(info.subscriptionId) + else + @Suppress("DEPRECATION") + SmsManager.getSmsManagerForSubscriptionId(info.subscriptionId) + } + } + return smsManagerFor(null) + } + + /// Builds a PendingIntent that, when fired by the radio, broadcasts the + /// send/delivery outcome to SmsStatusReceiver (tagged with the message id). + private fun statusPendingIntent(messageId: String?, phone: String, kind: String): PendingIntent { + val intent = Intent(this, SmsStatusReceiver::class.java).apply { + action = STATUS_ACTION + putExtra("messageId", messageId) + putExtra("phone", phone) + putExtra("kind", kind) + } + val requestCode = (messageId.orEmpty() + phone + kind).hashCode() + return PendingIntent.getBroadcast( + this, requestCode, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + /// Places a phone call. Requires the CALL_PHONE permission (requested in Dart + /// via the phone permission group). + /// + /// Uses TelecomManager.placeCall, which routes through the system telecom + /// service rather than starting the dialer activity ourselves. This is what + /// lets the call go through when the screen is locked / the app is in the + /// background — a plain startActivity(ACTION_CALL) is blocked by Android's + /// background-activity-start restrictions in that state. Falls back to + /// ACTION_CALL only on very old devices without TelecomManager.placeCall. + private fun placeCall(phone: String) { + val uri = Uri.fromParts("tel", phone, null) + val hasPerm = ContextCompat.checkSelfPermission( + this, Manifest.permission.CALL_PHONE + ) == PackageManager.PERMISSION_GRANTED + if (!hasPerm) { + throw SecurityException("CALL_PHONE permission not granted") + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val telecom = getSystemService(TelecomManager::class.java) + if (telecom != null) { + telecom.placeCall(uri, Bundle()) + return + } + } + val intent = Intent(Intent.ACTION_CALL, uri) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + } + + private fun sendSms(phone: String, message: String, simSlot: Int?, messageId: String?) { + val sms = smsManagerFor(simSlot) + val parts = sms.divideMessage(message) + val sentPI = statusPendingIntent(messageId, phone, "sent") + val deliveredPI = statusPendingIntent(messageId, phone, "delivered") + if (parts.size > 1) { + val sentList = ArrayList(parts.size) + val deliveredList = ArrayList(parts.size) + for (i in parts.indices) { + sentList.add(sentPI) + deliveredList.add(deliveredPI) + } + sms.sendMultipartTextMessage(phone, null, parts, sentList, deliveredList) + } else { + sms.sendTextMessage(phone, null, message, sentPI, deliveredPI) + } + } +} diff --git a/Phone App/android_overlay/app/src/main/kotlin/app/gsmnode/phone/SmsReceiver.kt b/Phone App/android_overlay/app/src/main/kotlin/app/gsmnode/phone/SmsReceiver.kt new file mode 100644 index 0000000..c3df656 --- /dev/null +++ b/Phone App/android_overlay/app/src/main/kotlin/app/gsmnode/phone/SmsReceiver.kt @@ -0,0 +1,40 @@ +package app.gsmnode.phone + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Handler +import android.os.Looper +import android.provider.Telephony + +/// Receives incoming SMS and forwards each message to Dart via the EventChannel +/// sink held by MainActivity. Works while the app process is alive. +class SmsReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return + + val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent) ?: return + if (messages.isEmpty()) return + + // Multipart SMS arrive as several PDUs from the same sender; concatenate. + val from = messages[0].displayOriginatingAddress ?: "" + val body = StringBuilder() + var timestamp = System.currentTimeMillis() + for (m in messages) { + body.append(m.displayMessageBody ?: "") + timestamp = m.timestampMillis + } + + val payload = mapOf( + "from" to from, + "body" to body.toString(), + "timestamp" to timestamp, + ) + + // EventSink must be touched on the main thread. + Handler(Looper.getMainLooper()).post { + MainActivity.incomingSink?.success(payload) + } + } +} diff --git a/Phone App/android_overlay/app/src/main/kotlin/app/gsmnode/phone/SmsStatusReceiver.kt b/Phone App/android_overlay/app/src/main/kotlin/app/gsmnode/phone/SmsStatusReceiver.kt new file mode 100644 index 0000000..0d87a2d --- /dev/null +++ b/Phone App/android_overlay/app/src/main/kotlin/app/gsmnode/phone/SmsStatusReceiver.kt @@ -0,0 +1,33 @@ +package app.gsmnode.phone + +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Handler +import android.os.Looper + +/// Receives SMS send/delivery outcomes from the PendingIntents created in +/// MainActivity.sendSms and forwards them to Dart, tagged with the message id +/// so the gateway loop can report Sent / Delivered / Failed to the API Server. +class SmsStatusReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val messageId = intent.getStringExtra("messageId") + val kind = intent.getStringExtra("kind") ?: return + // For "sent": RESULT_OK means the radio accepted the message. + // For "delivered": RESULT_OK means a positive delivery report arrived. + val success = resultCode == Activity.RESULT_OK + + val payload = mapOf( + "messageId" to messageId, + "kind" to kind, + "success" to success, + "resultCode" to resultCode, + ) + + Handler(Looper.getMainLooper()).post { + MainActivity.statusSink?.success(payload) + } + } +} diff --git a/Phone App/assets/icon/adaptive-foreground.png b/Phone App/assets/icon/adaptive-foreground.png new file mode 100644 index 0000000..2423b2c Binary files /dev/null and b/Phone App/assets/icon/adaptive-foreground.png differ diff --git a/Phone App/assets/icon/app-icon.png b/Phone App/assets/icon/app-icon.png new file mode 100644 index 0000000..115a1c7 Binary files /dev/null and b/Phone App/assets/icon/app-icon.png differ diff --git a/Phone App/lib/config.dart b/Phone App/lib/config.dart new file mode 100644 index 0000000..749bd22 --- /dev/null +++ b/Phone App/lib/config.dart @@ -0,0 +1,15 @@ +/// Compile-time defaults. The API base URL is also user-editable on the login +/// screen and persisted via [Storage]. +class AppConfig { + /// Default API Server base URL. Use the LAN IP of the machine running the + /// API Server (not localhost — that points at the phone itself). + /// + /// 10.0.2.2 is the Android emulator's alias for the host machine. + static const String defaultApiBase = 'http://10.0.2.2:8080'; + + /// How often the gateway polls the API Server for pending messages. + static const Duration pollInterval = Duration(seconds: 10); + + /// How often the device sends a heartbeat ping. + static const Duration pingInterval = Duration(minutes: 1); +} diff --git a/Phone App/lib/main.dart b/Phone App/lib/main.dart new file mode 100644 index 0000000..76bad57 --- /dev/null +++ b/Phone App/lib/main.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +import 'config.dart'; +import 'services/api_client.dart'; +import 'services/gateway_service.dart'; +import 'services/sms_service.dart'; +import 'services/storage.dart'; +import 'screens/login_screen.dart'; +import 'screens/home_screen.dart'; +import 'theme.dart'; + +late Storage storage; +late ApiClient apiClient; +late GatewayService gateway; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + storage = await Storage.create(); + storage.apiBase ??= AppConfig.defaultApiBase; + + apiClient = ApiClient(storage); + gateway = GatewayService(apiClient, SmsService()); + + runApp(const GsmNodeApp()); +} + +class GsmNodeApp extends StatelessWidget { + const GsmNodeApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'gsmnode', + debugShowCheckedModeBanner: false, + theme: gsmnodeLightTheme(), + darkTheme: gsmnodeDarkTheme(), + themeMode: ThemeMode.system, + home: storage.isRegistered ? const HomeScreen() : const LoginScreen(), + ); + } +} diff --git a/Phone App/lib/models/message.dart b/Phone App/lib/models/message.dart new file mode 100644 index 0000000..597e8c5 --- /dev/null +++ b/Phone App/lib/models/message.dart @@ -0,0 +1,33 @@ +/// An outbound message handed to the device by the API Server. +class GatewayMessage { + final String id; + final String type; // 'sms' or 'call' + final List phoneNumbers; + final String textMessage; + final int? simNumber; + final String status; + + GatewayMessage({ + required this.id, + required this.type, + required this.phoneNumbers, + required this.textMessage, + this.simNumber, + required this.status, + }); + + bool get isCall => type == 'call'; + + factory GatewayMessage.fromJson(Map json) { + return GatewayMessage( + id: json['id'] as String? ?? '', + type: json['type'] as String? ?? 'sms', + phoneNumbers: (json['phone_numbers'] as List? ?? []) + .map((e) => e.toString()) + .toList(), + textMessage: json['text_message'] as String? ?? '', + simNumber: json['sim_number'] as int?, + status: json['status'] as String? ?? '', + ); + } +} diff --git a/Phone App/lib/screens/home_screen.dart b/Phone App/lib/screens/home_screen.dart new file mode 100644 index 0000000..c6e446a --- /dev/null +++ b/Phone App/lib/screens/home_screen.dart @@ -0,0 +1,298 @@ +import 'package:flutter/material.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../main.dart'; +import '../theme.dart'; +import '../widgets/gsmnode_mark.dart'; +import 'login_screen.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + bool _permsGranted = false; + + @override + void initState() { + super.initState(); + gateway.addListener(_onChange); + _checkPermissions(); + } + + @override + void dispose() { + gateway.removeListener(_onChange); + super.dispose(); + } + + void _onChange() => setState(() {}); + + Future _checkPermissions() async { + final sms = await Permission.sms.status; + final phone = await Permission.phone.status; + setState(() => _permsGranted = sms.isGranted && phone.isGranted); + } + + Future _requestPermissions() async { + // SMS + phone gate the gateway; notification lets the foreground service + // post its ongoing notification on Android 13+. + await [Permission.sms, Permission.phone, Permission.notification].request(); + await _checkPermissions(); + } + + void _toggle() { + if (gateway.running) { + gateway.stop(); + } else { + gateway.start(); + } + } + + Future _logout() async { + gateway.stop(); + await storage.clearSession(); + if (!mounted) return; + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const LoginScreen()), + ); + } + + @override + Widget build(BuildContext context) { + final running = gateway.running; + final cg = context.cg; + return Scaffold( + appBar: AppBar( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + GsmNodeMark(size: 22), + SizedBox(width: 8), + GsmNodeWordmark(size: 17), + ], + ), + actions: [ + IconButton( + onPressed: _logout, + icon: const Icon(Icons.logout), + tooltip: 'Sign out', + ), + ], + ), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _heroCard(running), + const SizedBox(height: 12), + if (!_permsGranted) ...[ + _permissionCard(), + const SizedBox(height: 12), + ], + _infoCard(), + const SizedBox(height: 16), + Text( + 'ACTIVITY', + style: gsmMono(size: 10, color: cg.textMuted, letterSpacing: 1.4), + ), + const SizedBox(height: 8), + Expanded(child: _activityLog()), + ], + ), + ), + ); + } + + /// Hero per the mobile UI kit: brand ring + mark when routing, ink when + /// paused. The primary action carries the brand glow. + Widget _heroCard(bool running) { + final cg = context.cg; + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: running ? cg.brandTint : cg.sunkenBg, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: running + ? GsmColors.green500.withValues(alpha: 0.22) + : cg.borderSubtle, + ), + ), + child: Column( + children: [ + Container( + width: 84, + height: 84, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: running ? GsmColors.green500 : cg.textMuted, + boxShadow: running + ? [ + BoxShadow( + color: GsmColors.green500.withValues(alpha: 0.28), + blurRadius: 18, + offset: const Offset(0, 6), + ), + ] + : null, + ), + child: const Center( + child: GsmNodeMark(size: 44, color: Colors.white), + ), + ), + const SizedBox(height: 14), + Text( + running ? 'Gateway active' : 'Gateway paused', + style: gsmDisplay(size: 20, color: cg.textPrimary), + ), + const SizedBox(height: 4), + Text( + running + ? 'routing sms · mms · calls' + : 'not routing — messages will queue', + style: gsmMono( + size: 12, + color: running ? GsmColors.green500 : cg.textMuted, + ), + ), + const SizedBox(height: 18), + FilledButton.icon( + onPressed: _permsGranted ? _toggle : null, + icon: Icon(running ? Icons.stop : Icons.play_arrow), + label: Text(running ? 'Stop gateway' : 'Start gateway'), + style: running + ? FilledButton.styleFrom( + backgroundColor: cg.danger, + minimumSize: const Size.fromHeight(48), + ) + : null, + ), + ], + ), + ); + } + + Widget _permissionCard() { + final cg = context.cg; + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: cg.warningTint, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Icon(Icons.warning_amber_rounded, color: cg.warning), + const SizedBox(width: 12), + const Expanded( + child: Text('SMS & phone permissions are required to send and ' + 'receive messages.'), + ), + TextButton( + onPressed: _requestPermissions, + child: const Text('Grant'), + ), + ], + ), + ); + } + + Widget _infoCard() { + final cg = context.cg; + return Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + children: [ + _infoRow('DEVICE', storage.deviceName ?? '—'), + Divider(height: 16, color: cg.borderSubtle), + _infoRow('ACCOUNT', storage.userEmail ?? '—'), + Divider(height: 16, color: cg.borderSubtle), + _infoRow('SERVER', storage.apiBase ?? '—'), + ], + ), + ), + ); + } + + Widget _infoRow(String label, String value) { + final cg = context.cg; + return Row( + children: [ + SizedBox( + width: 76, + child: Text( + label, + style: gsmMono(size: 9, color: cg.textMuted, letterSpacing: 1.3), + ), + ), + Expanded( + child: Text( + value, + style: gsmMono(size: 12, color: cg.textPrimary), + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + ), + ), + ], + ); + } + + Widget _activityLog() { + final cg = context.cg; + final entries = gateway.log; + if (entries.isEmpty) { + return Center( + child: Text( + 'No activity yet.', + style: TextStyle(color: cg.textMuted), + ), + ); + } + return ListView.separated( + itemCount: entries.length, + separatorBuilder: (_, __) => const SizedBox(height: 6), + itemBuilder: (_, i) { + final e = entries[i]; + final t = e.time; + final hh = t.hour.toString().padLeft(2, '0'); + final mm = t.minute.toString().padLeft(2, '0'); + final ss = t.second.toString().padLeft(2, '0'); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: cg.card, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: cg.borderSubtle), + ), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: e.error ? cg.danger : cg.success, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text(e.text, style: const TextStyle(fontSize: 13)), + ), + const SizedBox(width: 10), + Text( + '$hh:$mm:$ss', + style: gsmMono(size: 10, color: cg.textMuted), + ), + ], + ), + ); + }, + ); + } +} diff --git a/Phone App/lib/screens/login_screen.dart b/Phone App/lib/screens/login_screen.dart new file mode 100644 index 0000000..b77eade --- /dev/null +++ b/Phone App/lib/screens/login_screen.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; + +import '../main.dart'; +import '../services/api_client.dart'; +import '../theme.dart'; +import '../widgets/gsmnode_mark.dart'; +import 'home_screen.dart'; + +/// Login + device registration. The user authenticates against the API Server, +/// then this phone is registered as a gateway device. +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _apiBase = TextEditingController(text: storage.apiBase ?? ''); + final _email = TextEditingController(text: storage.userEmail ?? ''); + final _password = TextEditingController(); + final _deviceName = TextEditingController(text: storage.deviceName ?? 'My Phone'); + + bool _busy = false; + String? _error; + + @override + void dispose() { + _apiBase.dispose(); + _email.dispose(); + _password.dispose(); + _deviceName.dispose(); + super.dispose(); + } + + Future _submit() async { + setState(() { + _busy = true; + _error = null; + }); + try { + storage.apiBase = _apiBase.text.trim(); + await apiClient.login(_email.text.trim(), _password.text); + + final deviceId = storage.deviceId ?? + 'android-${DateTime.now().millisecondsSinceEpoch}'; + await apiClient.registerDevice( + deviceId: deviceId, + name: _deviceName.text.trim().isEmpty ? 'My Phone' : _deviceName.text.trim(), + ); + + if (!mounted) return; + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const HomeScreen()), + ); + } on ApiException catch (e) { + setState(() => _error = + e.status == 401 ? 'Invalid email or password.' : e.message); + } catch (e) { + setState(() => _error = 'Could not connect: $e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final cg = context.cg; + return Scaffold( + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Center(child: GsmNodeMark(size: 56)), + const SizedBox(height: 14), + const Center(child: GsmNodeWordmark(size: 26)), + const SizedBox(height: 8), + Center( + child: Text( + 'CONNECT THIS PHONE TO YOUR GATEWAY', + textAlign: TextAlign.center, + style: gsmMono( + size: 10, + color: cg.textMuted, + letterSpacing: 1.4, + ), + ), + ), + const SizedBox(height: 28), + TextField( + controller: _apiBase, + decoration: const InputDecoration( + labelText: 'API Server URL', + hintText: 'http://10.0.2.2:8080', + ), + style: gsmMono(size: 14, color: cg.textPrimary), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 12), + TextField( + controller: _email, + decoration: const InputDecoration(labelText: 'Email'), + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 12), + TextField( + controller: _password, + decoration: const InputDecoration(labelText: 'Password'), + obscureText: true, + ), + const SizedBox(height: 12), + TextField( + controller: _deviceName, + decoration: const InputDecoration(labelText: 'Device name'), + ), + const SizedBox(height: 16), + if (_error != null) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: cg.dangerTint, + borderRadius: BorderRadius.circular(10), + ), + child: Text(_error!, style: TextStyle(color: cg.danger)), + ), + const SizedBox(height: 12), + FilledButton( + onPressed: _busy ? null : _submit, + child: _busy + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text('Sign in & register device'), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/Phone App/lib/services/api_client.dart b/Phone App/lib/services/api_client.dart new file mode 100644 index 0000000..0228fc0 --- /dev/null +++ b/Phone App/lib/services/api_client.dart @@ -0,0 +1,130 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +import '../models/message.dart'; +import 'storage.dart'; + +class ApiException implements Exception { + final int status; + final String message; + ApiException(this.status, this.message); + @override + String toString() => 'ApiException($status): $message'; +} + +/// Client for the API Server. Uses the user JWT for login/registration and the +/// opaque device token for the mobile gateway endpoints. +class ApiClient { + final Storage storage; + final http.Client _http; + + ApiClient(this.storage, {http.Client? client}) + : _http = client ?? http.Client(); + + String get _base => (storage.apiBase ?? '').replaceAll(RegExp(r'/$'), ''); + + Uri _uri(String path) => Uri.parse('$_base$path'); + + Map _headers(String? token) => { + 'Content-Type': 'application/json', + if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token', + }; + + dynamic _decode(http.Response res) { + final body = res.body.isNotEmpty ? jsonDecode(res.body) : null; + if (res.statusCode < 200 || res.statusCode >= 300) { + final msg = (body is Map && body['error'] != null) + ? body['error'].toString() + : 'HTTP ${res.statusCode}'; + throw ApiException(res.statusCode, msg); + } + return body; + } + + /// Authenticates a user and stores the JWT. Returns the user email. + Future login(String email, String password) async { + final res = await _http.post( + _uri('/api/auth/login'), + headers: _headers(null), + body: jsonEncode({'email': email, 'password': password}), + ); + final data = _decode(res) as Map; + storage.jwt = data['access_token'] as String?; + final user = data['user'] as Map?; + storage.userEmail = user?['email'] as String?; + return storage.userEmail ?? email; + } + + /// Registers this device and stores the returned device token. + Future registerDevice({ + required String deviceId, + required String name, + String platform = 'android', + String appVersion = '1.0.0', + String? pushToken, + }) async { + final res = await _http.post( + _uri('/api/mobile/v1/device'), + headers: _headers(storage.jwt), + body: jsonEncode({ + 'device_id': deviceId, + 'name': name, + 'platform': platform, + 'app_version': appVersion, + if (pushToken != null) 'push_token': pushToken, + }), + ); + final data = _decode(res) as Map; + storage.deviceId = deviceId; + storage.deviceName = name; + storage.deviceToken = data['auth_token'] as String?; + } + + /// Pulls pending messages for this device (the server marks them Processed). + Future> pullMessages() async { + final res = await _http.get( + _uri('/api/mobile/v1/messages'), + headers: _headers(storage.deviceToken), + ); + final data = _decode(res) as Map; + final items = (data['items'] as List? ?? []); + return items + .map((e) => GatewayMessage.fromJson(e as Map)) + .toList(); + } + + /// Reports a message's delivery state back to the server. + Future reportMessage(String id, String status, {String? error}) async { + final res = await _http.patch( + _uri('/api/mobile/v1/messages/$id'), + headers: _headers(storage.deviceToken), + body: jsonEncode({'status': status, if (error != null) 'error': error}), + ); + _decode(res); + } + + /// Posts an incoming SMS to the server's inbox. + Future postInbox(String phoneNumber, String message, + {DateTime? receivedAt}) async { + final res = await _http.post( + _uri('/api/mobile/v1/inbox'), + headers: _headers(storage.deviceToken), + body: jsonEncode({ + 'phone_number': phoneNumber, + 'message': message, + if (receivedAt != null) + 'received_at': receivedAt.toUtc().toIso8601String(), + }), + ); + _decode(res); + } + + /// Sends a heartbeat ping. + Future ping() async { + final res = await _http.post( + _uri('/api/mobile/v1/ping'), + headers: _headers(storage.deviceToken), + ); + _decode(res); + } +} diff --git a/Phone App/lib/services/gateway_service.dart b/Phone App/lib/services/gateway_service.dart new file mode 100644 index 0000000..61a77ce --- /dev/null +++ b/Phone App/lib/services/gateway_service.dart @@ -0,0 +1,165 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../config.dart'; +import 'api_client.dart'; +import 'sms_service.dart'; + +/// A single line in the on-screen activity log. +class LogEntry { + final DateTime time; + final String text; + final bool error; + LogEntry(this.text, {this.error = false}) : time = DateTime.now(); +} + +/// Orchestrates the gateway loop: polls the API Server for pending messages, +/// sends them over the radio, reports delivery state, forwards incoming SMS to +/// the inbox, and sends heartbeats. +/// +/// This runs in the foreground while the app is open. Moving the loop to a +/// persistent background/foreground service (WorkManager) is a documented next +/// step — see README. +class GatewayService extends ChangeNotifier { + final ApiClient api; + final SmsService sms; + + GatewayService(this.api, this.sms); + + bool _running = false; + bool get running => _running; + + final List _log = []; + List get log => List.unmodifiable(_log); + + Timer? _pollTimer; + Timer? _pingTimer; + StreamSubscription? _incomingSub; + StreamSubscription? _statusSub; + bool _polling = false; + + void start() { + if (_running) return; + _running = true; + _log0('Gateway started'); + + // Keep the process alive while the screen is off / app backgrounded. + sms.startBackgroundService().catchError( + (e) => _log0('Foreground service failed to start: $e', error: true)); + + _incomingSub = sms.incomingSms().listen(_onIncoming, onError: (e) { + _log0('Incoming SMS stream error: $e', error: true); + }); + _statusSub = sms.smsStatus().listen(_onStatus, onError: (e) { + _log0('Status stream error: $e', error: true); + }); + + _pollTimer = Timer.periodic(AppConfig.pollInterval, (_) => _poll()); + _pingTimer = Timer.periodic(AppConfig.pingInterval, (_) => _ping()); + _poll(); + _ping(); + notifyListeners(); + } + + void stop() { + _running = false; + _pollTimer?.cancel(); + _pingTimer?.cancel(); + _incomingSub?.cancel(); + _statusSub?.cancel(); + sms.stopBackgroundService().catchError((_) {}); + _log0('Gateway stopped'); + notifyListeners(); + } + + Future _poll() async { + if (_polling || !_running) return; + _polling = true; + try { + final messages = await api.pullMessages(); + for (final m in messages) { + var handedOff = true; + for (final phone in m.phoneNumbers) { + try { + if (m.isCall) { + await sms.placeCall(phone); + _log0('Calling $phone'); + } else { + await sms.sendSms(phone, m.textMessage, + simSlot: m.simNumber, messageId: m.id); + _log0('Sending to $phone: "${_short(m.textMessage)}"'); + } + } catch (e) { + handedOff = false; + await api.reportMessage(m.id, 'Failed', error: e.toString()); + _log0('${m.isCall ? "Call" : "Send"} to $phone failed: $e', + error: true); + break; + } + } + // Report Sent once handed off. For SMS the native send/delivery + // PendingIntents then refine this to Delivered (or Failed) via _onStatus; + // a call has no delivery report, so it stays Sent. + if (handedOff) await api.reportMessage(m.id, 'Sent'); + } + } catch (e) { + _log0('Poll error: $e', error: true); + } finally { + _polling = false; + } + } + + Future _ping() async { + if (!_running) return; + try { + await api.ping(); + } catch (e) { + _log0('Ping failed: $e', error: true); + } + } + + Future _onStatus(SmsStatus s) async { + final id = s.messageId; + if (id == null || id.isEmpty) return; + try { + if (s.kind == 'delivered') { + if (s.success) { + await api.reportMessage(id, 'Delivered'); + _log0('Delivered: $id'); + } else { + await api.reportMessage(id, 'Failed', error: 'delivery failed'); + _log0('Delivery failed: $id', error: true); + } + } else if (s.kind == 'sent' && !s.success) { + await api.reportMessage(id, 'Failed', error: 'radio rejected message'); + _log0('Send rejected by radio: $id', error: true); + } + } catch (e) { + _log0('Report status failed: $e', error: true); + } + } + + Future _onIncoming(IncomingSms msg) async { + _log0('Received from ${msg.from}: "${_short(msg.body)}"'); + try { + await api.postInbox(msg.from, msg.body, receivedAt: msg.timestamp); + } catch (e) { + _log0('Forward inbox failed: $e', error: true); + } + } + + void _log0(String text, {bool error = false}) { + _log.insert(0, LogEntry(text, error: error)); + if (_log.length > 100) _log.removeLast(); + notifyListeners(); + } + + String _short(String s) => s.length > 40 ? '${s.substring(0, 40)}…' : s; + + @override + void dispose() { + stop(); + super.dispose(); + } +} diff --git a/Phone App/lib/services/sms_service.dart b/Phone App/lib/services/sms_service.dart new file mode 100644 index 0000000..642ec41 --- /dev/null +++ b/Phone App/lib/services/sms_service.dart @@ -0,0 +1,76 @@ +import 'package:flutter/services.dart'; + +/// An incoming SMS delivered from the native side. +class IncomingSms { + final String from; + final String body; + final DateTime timestamp; + IncomingSms(this.from, this.body, this.timestamp); +} + +/// A send/delivery status report for an outbound message. +class SmsStatus { + final String? messageId; + final String kind; // 'sent' or 'delivered' + final bool success; + SmsStatus(this.messageId, this.kind, this.success); +} + +/// Bridges to the native Android side: SmsManager (sending), a BroadcastReceiver +/// (incoming), send/delivery PendingIntents (status), and the foreground service +/// that keeps the gateway alive. See android_overlay/ for the Kotlin side. +class SmsService { + static const _method = MethodChannel('app.gsmnode/sms'); + static const _events = EventChannel('app.gsmnode/sms_incoming'); + static const _status = EventChannel('app.gsmnode/sms_status'); + + /// Sends an SMS via the device radio. [simSlot] is 0-based; null = default SIM. + /// [messageId] is echoed back on the status stream for correlation. + /// Throws [PlatformException] on failure. + Future sendSms(String phoneNumber, String message, + {int? simSlot, String? messageId}) async { + await _method.invokeMethod('sendSms', { + 'phone': phoneNumber, + 'message': message, + 'simSlot': simSlot, + 'messageId': messageId, + }); + } + + /// Places a phone call to [phoneNumber] via the native dialer (ACTION_CALL). + /// Requires the CALL_PHONE permission. Throws [PlatformException] on failure. + Future placeCall(String phoneNumber) async { + await _method.invokeMethod('placeCall', {'phone': phoneNumber}); + } + + /// Starts the foreground service so the gateway loop survives screen-off. + Future startBackgroundService() => _method.invokeMethod('startService'); + + /// Stops the foreground service. + Future stopBackgroundService() => _method.invokeMethod('stopService'); + + /// Stream of incoming SMS messages (active while the app process is alive). + Stream incomingSms() { + return _events.receiveBroadcastStream().map((event) { + final map = Map.from(event as Map); + final ts = map['timestamp'] as int?; + return IncomingSms( + map['from'] as String? ?? '', + map['body'] as String? ?? '', + ts != null ? DateTime.fromMillisecondsSinceEpoch(ts) : DateTime.now(), + ); + }); + } + + /// Stream of send/delivery status reports for outbound messages. + Stream smsStatus() { + return _status.receiveBroadcastStream().map((event) { + final map = Map.from(event as Map); + return SmsStatus( + map['messageId'] as String?, + map['kind'] as String? ?? '', + map['success'] == true, + ); + }); + } +} diff --git a/Phone App/lib/services/storage.dart b/Phone App/lib/services/storage.dart new file mode 100644 index 0000000..be68d61 --- /dev/null +++ b/Phone App/lib/services/storage.dart @@ -0,0 +1,54 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// Persists connection settings and credentials across app launches. +class Storage { + static const _kApiBase = 'api_base'; + static const _kJwt = 'jwt'; + static const _kDeviceToken = 'device_token'; + static const _kDeviceId = 'device_id'; + static const _kDeviceName = 'device_name'; + static const _kUserEmail = 'user_email'; + + final SharedPreferences _prefs; + Storage(this._prefs); + + static Future create() async { + return Storage(await SharedPreferences.getInstance()); + } + + String? get apiBase => _prefs.getString(_kApiBase); + set apiBase(String? v) => _set(_kApiBase, v); + + String? get jwt => _prefs.getString(_kJwt); + set jwt(String? v) => _set(_kJwt, v); + + String? get deviceToken => _prefs.getString(_kDeviceToken); + set deviceToken(String? v) => _set(_kDeviceToken, v); + + String? get deviceId => _prefs.getString(_kDeviceId); + set deviceId(String? v) => _set(_kDeviceId, v); + + String? get deviceName => _prefs.getString(_kDeviceName); + set deviceName(String? v) => _set(_kDeviceName, v); + + String? get userEmail => _prefs.getString(_kUserEmail); + set userEmail(String? v) => _set(_kUserEmail, v); + + bool get isRegistered => (deviceToken ?? '').isNotEmpty; + + Future clearSession() async { + await _prefs.remove(_kJwt); + await _prefs.remove(_kDeviceToken); + await _prefs.remove(_kUserEmail); + // Keep device_id stable across logout so re-registering updates the same + // device record instead of creating a new (phantom) one. + } + + void _set(String key, String? v) { + if (v == null) { + _prefs.remove(key); + } else { + _prefs.setString(key, v); + } + } +} diff --git a/Phone App/lib/theme.dart b/Phone App/lib/theme.dart new file mode 100644 index 0000000..b875d4d --- /dev/null +++ b/Phone App/lib/theme.dart @@ -0,0 +1,292 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// gsmnode design tokens (see Design/SMS Gateway logo design/tokens in the +/// project root). Brand is signal-green #2E9E6B on an ink/paper cool scale. +abstract class GsmColors { + // Signal green (brand) ramp + static const green100 = Color(0xFFE4F4EC); + static const green200 = Color(0xFFBEE6D0); + static const green300 = Color(0xFF6BC79A); // lifted text on dark + static const green500 = Color(0xFF2E9E6B); // primary + static const green600 = Color(0xFF278A5C); // hover + static const green700 = Color(0xFF1F6E49); // active + static const greenOnDark = Color(0xFF4FB985); // accent on ink surfaces + + // Ink & paper + static const ink = Color(0xFF12161C); // primary text / dark surface + static const ink900 = Color(0xFF0A0D11); // deepest surface + static const ink800 = Color(0xFF1A1F27); + static const paper = Color(0xFFFAFAF9); // page bg + + // Cool gray ramp + static const gray50 = Color(0xFFF4F5F4); + static const gray100 = Color(0xFFE9EBEA); + static const gray200 = Color(0xFFDCDFDE); // subtle border + static const gray300 = Color(0xFFC2C7C6); // strong border + static const gray400 = Color(0xFF9AA0A2); // muted text + static const gray500 = Color(0xFF6B7278); + static const gray600 = Color(0xFF4A5157); + static const gray700 = Color(0xFF333A40); // secondary text + + // Semantic (light) + static const warning = Color(0xFFC68A2E); + static const warningTint = Color(0xFFF6EAD6); + static const danger = Color(0xFFC64A3E); + static const dangerTint = Color(0xFFF6E1DC); + + // Dark surfaces + static const dBase = Color(0xFF0A0D11); + static const dSunken = Color(0xFF0E1216); + static const dCard = Color(0xFF14191F); + static const dRaised = Color(0xFF1A2027); + static const dBorderSubtle = Color(0xFF262B33); + static const dBorderStrong = Color(0xFF363C46); + static const dTextPrimary = Color(0xFFF5F6F4); + static const dTextSecondary = Color(0xFFC2C7C6); + static const dTextMuted = Color(0xFF9AA0A2); + + // Semantic (dark — lifted for contrast) + static const dWarning = Color(0xFFE0A84E); + static const dDanger = Color(0xFFE0655B); +} + +/// Semantic role colors that flip between light and dark, exposed as a +/// ThemeExtension so screens theme through roles, not raw ramp values. +class GsmSemantic extends ThemeExtension { + const GsmSemantic({ + required this.pageBg, + required this.sunkenBg, + required this.card, + required this.borderSubtle, + required this.borderStrong, + required this.textPrimary, + required this.textSecondary, + required this.textMuted, + required this.brandTint, + required this.success, + required this.successTint, + required this.warning, + required this.warningTint, + required this.danger, + required this.dangerTint, + }); + + final Color pageBg; + final Color sunkenBg; + final Color card; + final Color borderSubtle; + final Color borderStrong; + final Color textPrimary; + final Color textSecondary; + final Color textMuted; + final Color brandTint; + final Color success; + final Color successTint; + final Color warning; + final Color warningTint; + final Color danger; + final Color dangerTint; + + static const light = GsmSemantic( + pageBg: GsmColors.paper, + sunkenBg: GsmColors.gray50, + card: Colors.white, + borderSubtle: GsmColors.gray200, + borderStrong: GsmColors.gray300, + textPrimary: GsmColors.ink, + textSecondary: GsmColors.gray700, + textMuted: GsmColors.gray500, + brandTint: GsmColors.green100, + success: GsmColors.green500, + successTint: GsmColors.green100, + warning: GsmColors.warning, + warningTint: GsmColors.warningTint, + danger: GsmColors.danger, + dangerTint: GsmColors.dangerTint, + ); + + static const dark = GsmSemantic( + pageBg: GsmColors.dBase, + sunkenBg: GsmColors.dSunken, + card: GsmColors.dCard, + borderSubtle: GsmColors.dBorderSubtle, + borderStrong: GsmColors.dBorderStrong, + textPrimary: GsmColors.dTextPrimary, + textSecondary: GsmColors.dTextSecondary, + textMuted: GsmColors.dTextMuted, + brandTint: Color(0x292E9E6B), // green-500 @ 16% + success: GsmColors.green300, + successTint: Color(0x2E2E9E6B), + warning: GsmColors.dWarning, + warningTint: Color(0x2EC68A2E), + danger: GsmColors.dDanger, + dangerTint: Color(0x2EC64A3E), + ); + + @override + GsmSemantic copyWith({Color? pageBg}) => this; + + @override + GsmSemantic lerp(ThemeExtension? other, double t) => this; +} + +extension GsmThemeX on BuildContext { + GsmSemantic get cg => Theme.of(this).extension()!; +} + +/// A mono (JetBrains Mono) style for IDs, numbers, timestamps and eyebrows. +TextStyle gsmMono({ + double size = 12, + Color? color, + FontWeight weight = FontWeight.w500, + double letterSpacing = 0, +}) => + GoogleFonts.jetBrainsMono( + fontSize: size, + color: color, + fontWeight: weight, + letterSpacing: letterSpacing, + ); + +/// Display (Space Grotesk) style for titles & metrics. +TextStyle gsmDisplay({ + double size = 20, + Color? color, + FontWeight weight = FontWeight.w700, +}) => + GoogleFonts.spaceGrotesk( + fontSize: size, + color: color, + fontWeight: weight, + letterSpacing: -0.02 * size, + ); + +ThemeData _base(Brightness brightness, GsmSemantic s) { + final isDark = brightness == Brightness.dark; + final scheme = ColorScheme( + brightness: brightness, + primary: GsmColors.green500, + onPrimary: Colors.white, + secondary: isDark ? GsmColors.greenOnDark : GsmColors.green600, + onSecondary: Colors.white, + error: s.danger, + onError: Colors.white, + surface: s.card, + onSurface: s.textPrimary, + outline: s.borderStrong, + outlineVariant: s.borderSubtle, + surfaceContainerLowest: s.sunkenBg, + surfaceContainerLow: s.pageBg, + surfaceContainer: s.card, + ); + + // Body & UI in IBM Plex Sans; titles/metrics reach for Space Grotesk. + final textTheme = GoogleFonts.ibmPlexSansTextTheme( + (isDark ? ThemeData.dark() : ThemeData.light()).textTheme, + ).apply(bodyColor: s.textPrimary, displayColor: s.textPrimary); + + const controlRadius = BorderRadius.all(Radius.circular(8)); + const cardRadius = BorderRadius.all(Radius.circular(18)); + + return ThemeData( + useMaterial3: true, + colorScheme: scheme, + scaffoldBackgroundColor: s.pageBg, + textTheme: textTheme, + extensions: [s], + appBarTheme: AppBarTheme( + backgroundColor: s.card, + foregroundColor: s.textPrimary, + elevation: 0, + scrolledUnderElevation: 0, + shape: Border(bottom: BorderSide(color: s.borderSubtle)), + titleTextStyle: GoogleFonts.spaceGrotesk( + fontSize: 17, + fontWeight: FontWeight.w700, + color: s.textPrimary, + letterSpacing: -0.2, + ), + ), + cardTheme: CardThemeData( + color: s.card, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: cardRadius, + side: BorderSide(color: s.borderSubtle), + ), + margin: EdgeInsets.zero, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: GsmColors.green500, + foregroundColor: Colors.white, + elevation: 0, + minimumSize: const Size.fromHeight(48), + shape: const RoundedRectangleBorder(borderRadius: controlRadius), + textStyle: GoogleFonts.ibmPlexSans( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ).copyWith( + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return GsmColors.green500.withValues(alpha: 0.45); + } + if (states.contains(WidgetState.pressed)) return GsmColors.green700; + if (states.contains(WidgetState.hovered)) return GsmColors.green600; + return GsmColors.green500; + }), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: s.textPrimary, + side: BorderSide(color: s.borderStrong), + shape: const RoundedRectangleBorder(borderRadius: controlRadius), + textStyle: GoogleFonts.ibmPlexSans( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: isDark ? GsmColors.greenOnDark : GsmColors.green600, + textStyle: GoogleFonts.ibmPlexSans( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: s.card, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + border: OutlineInputBorder( + borderRadius: controlRadius, + borderSide: BorderSide(color: s.borderStrong), + ), + enabledBorder: OutlineInputBorder( + borderRadius: controlRadius, + borderSide: BorderSide(color: s.borderStrong), + ), + focusedBorder: const OutlineInputBorder( + borderRadius: controlRadius, + borderSide: BorderSide(color: GsmColors.green500, width: 2), + ), + labelStyle: TextStyle(color: s.textSecondary), + hintStyle: TextStyle(color: s.textMuted), + ), + dividerTheme: DividerThemeData(color: s.borderSubtle, thickness: 1), + snackBarTheme: SnackBarThemeData( + backgroundColor: isDark ? GsmColors.dRaised : GsmColors.ink, + contentTextStyle: GoogleFonts.ibmPlexSans(color: Colors.white), + shape: const RoundedRectangleBorder(borderRadius: controlRadius), + behavior: SnackBarBehavior.floating, + ), + ); +} + +ThemeData gsmnodeLightTheme() => _base(Brightness.light, GsmSemantic.light); +ThemeData gsmnodeDarkTheme() => _base(Brightness.dark, GsmSemantic.dark); diff --git a/Phone App/lib/widgets/gsmnode_mark.dart b/Phone App/lib/widgets/gsmnode_mark.dart new file mode 100644 index 0000000..5e852d2 --- /dev/null +++ b/Phone App/lib/widgets/gsmnode_mark.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../theme.dart'; + +/// The gsmnode routing mark — two stacked arrows: the top (ink) routes right, +/// the bottom (green) routes left. Geometry matches assets/svg/mark-color.svg +/// (2.6px stroke on a 40px grid, rounded terminals). +/// +/// When [color] is given the whole mark is drawn in that single color +/// (e.g. white on a filled tile); otherwise the top arrow takes the surface +/// ink color and the bottom arrow the signal green. +class GsmNodeMark extends StatelessWidget { + const GsmNodeMark({super.key, this.size = 32, this.color}); + + final double size; + final Color? color; + + @override + Widget build(BuildContext context) { + final ink = color ?? Theme.of(context).colorScheme.onSurface; + final green = color ?? GsmColors.green500; + return CustomPaint( + size: Size.square(size), + painter: _MarkPainter(ink: ink, green: green), + ); + } +} + +class _MarkPainter extends CustomPainter { + _MarkPainter({required this.ink, required this.green}); + + final Color ink; + final Color green; + + @override + void paint(Canvas canvas, Size size) { + final k = size.width / 40; // scale from the 40px design grid + Paint stroke(Color c) => Paint() + ..color = c + ..style = PaintingStyle.stroke + ..strokeWidth = 2.6 * k + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + + final inkPaint = stroke(ink); + final greenPaint = stroke(green); + + // Top arrow — routes right (ink). + canvas.drawLine(Offset(7 * k, 15 * k), Offset(31 * k, 15 * k), inkPaint); + canvas.drawPath( + Path() + ..moveTo(26 * k, 10 * k) + ..lineTo(32 * k, 15 * k) + ..lineTo(26 * k, 20 * k), + inkPaint, + ); + + // Bottom arrow — routes left (green). + canvas.drawLine(Offset(9 * k, 25 * k), Offset(33 * k, 25 * k), greenPaint); + canvas.drawPath( + Path() + ..moveTo(14 * k, 20 * k) + ..lineTo(8 * k, 25 * k) + ..lineTo(14 * k, 30 * k), + greenPaint, + ); + } + + @override + bool shouldRepaint(_MarkPainter old) => old.ink != ink || old.green != green; +} + +/// The gsmnode wordmark: lowercase `gsm` in ink + `node` in green, monospace. +/// On dark surfaces `node` lifts to the on-dark green. Pass [color] to render +/// the whole wordmark in a single color. +class GsmNodeWordmark extends StatelessWidget { + const GsmNodeWordmark({super.key, this.size = 24, this.color}); + + final double size; + final Color? color; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final ink = color ?? Theme.of(context).colorScheme.onSurface; + final green = + color ?? (isDark ? GsmColors.greenOnDark : GsmColors.green500); + return Text.rich( + TextSpan(children: [ + TextSpan(text: 'gsm', style: TextStyle(color: ink)), + TextSpan(text: 'node', style: TextStyle(color: green)), + ]), + style: GoogleFonts.jetBrainsMono( + fontSize: size, + fontWeight: FontWeight.w600, + letterSpacing: -0.01 * size, + height: 1, + ), + ); + } +} diff --git a/Phone App/pubspec.yaml b/Phone App/pubspec.yaml new file mode 100644 index 0000000..d4cffee --- /dev/null +++ b/Phone App/pubspec.yaml @@ -0,0 +1,31 @@ +name: sms_gateway_phone +description: gsmnode phone client — turns an Android phone into an SMS gateway controlled via the API Server. +publish_to: "none" +version: 1.0.0+1 + +environment: + sdk: ">=3.4.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + http: ^1.2.2 + shared_preferences: ^2.3.2 + permission_handler: ^11.3.1 + google_fonts: ^8.1.0 + +dev_dependencies: + flutter_lints: ^4.0.0 + flutter_launcher_icons: ^0.14.1 + +flutter: + uses-material-design: true + +# App launcher icons — generated from the gsmnode design-system app icon. +# Regenerate with: dart run flutter_launcher_icons +flutter_launcher_icons: + android: true + image_path: "assets/icon/app-icon.png" + min_sdk_android: 21 + adaptive_icon_background: "#2E9E6B" + adaptive_icon_foreground: "assets/icon/adaptive-foreground.png" diff --git a/README.md b/README.md new file mode 100644 index 0000000..326b179 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# gsmnode + +Turn Android phones into a programmable SMS gateway, controlled through a web UI +and a REST API — inspired by [android-sms-gateway](https://docs.sms-gate.app/). + +Brand + design system live in +[`Design/SMS Gateway logo design/`](Design/SMS%20Gateway%20logo%20design/) — +signal-green `#2E9E6B` on ink, Space Grotesk (display) · IBM Plex Sans (body) · +JetBrains Mono (code/labels), the lowercase `gsm`+`node` wordmark, and the +two-arrow routing mark. All three UI surfaces implement it (the Web App and API +panel share a persisted light/dark toggle, `localStorage` key `gsmnode-theme`, +`data-gsm-theme` attribute; the Phone App follows the system theme). + +Three application surfaces sit in front of a shared PocketBase. **The API Server +is the only component that talks to PocketBase**; the Web App and Phone App talk +only to the API Server. + +``` +┌────────────┐ ┌──────────────────┐ ┌──────────────┐ +│ Web App │───────►│ │───────►│ │ +│ (Vue/Go) │ │ API Server │ │ PocketBase │ +├────────────┤ │ (Go) │ │ 10.2.1.10: │ +│ Phone App │───────►│ │───────►│ 8028 │ +│ (Flutter) │ └──────────────────┘ └──────────────┘ +└────────────┘ +``` + +## Surfaces + +| Folder | Stack | Port | Status | +|---|---|---|---| +| [`API Server/`](API%20Server/) | Go | `:8080` | ✅ Built & verified (live E2E) | +| [`Web App/`](Web%20App/) | Go BFF + Vue 3 + Tailwind | `:8090` | ✅ Built & verified | +| [`Phone App/`](Phone%20App/) | Flutter (Android) | — | ✅ Built & run on a real device; foreground service + delivery reports | +| [`Home Assistant Plugin/`](Home%20Assistant%20Plugin/) | HA custom component (Python) | — | ✅ `notify.gsmnode` service; flow validated | + +## PocketBase collections + +Managed by `API Server/scripts/setup-pocketbase.mjs` (idempotent): + +- `users` — auth (existing default collection) +- `devices` — `device_id, name, platform, app_version, push_token, auth_token, status, last_seen_at, owner` +- `messages` — `phone_numbers, text_message, sim_number, status, error, schedule_at, sent_at, delivered_at, device, owner` +- `inbox` — `phone_number, message, received_at, device, owner` +- `webhooks` — `event, url, device, owner` + +Collections are locked to superuser access; the API Server enforces per-user +ownership in application logic. + +## Run order + +1. **PocketBase** — already running at `http://10.2.1.10:8028`. +2. **API Server** (`:8080`): + ```powershell + cd "API Server" + Copy-Item .env.example .env # fill in PB_ADMIN_* and JWT_SECRET + node scripts/setup-pocketbase.mjs # one-time schema setup + node scripts/create-user.mjs you@example.com "password" "Your Name" + ./scripts/Run-ApiServer.ps1 + ``` +3. **Web App** (`:8090`): + ```powershell + cd "Web App"; ./server/Run-WebApp.ps1 + ``` + Open http://localhost:8090 and sign in. +4. **Phone App** — see [`Phone App/README.md`](Phone%20App/README.md) (install + Flutter + JDK 17, `flutter create`, copy `android_overlay/`, `flutter run`). + +## Message lifecycle + +`Pending` → (device pulls) `Processed` → `Sent` → `Delivered` · or `Failed`. + +## Webhooks + +Events `sms:received`, `sms:sent`, `sms:delivered`, `sms:failed` are POSTed to +registered URLs as `{event, device_id, payload, created_at}`. + +## Per-surface docs + +- [API Server README](API%20Server/README.md) — full endpoint reference, setup +- [Web App README](Web%20App/README.md) — dev/build, pages +- [Phone App README](Phone%20App/README.md) — Flutter build + native SMS wiring diff --git a/Web App/README.md b/Web App/README.md new file mode 100644 index 0000000..1f8ca49 --- /dev/null +++ b/Web App/README.md @@ -0,0 +1,82 @@ +# gsmnode — Web App + +Browser console for the gateway: a **Vue 3 + Tailwind** SPA served by a small **Go +backend-for-frontend (BFF)**. Styled with the gsmnode design system +(`../Design/SMS Gateway logo design/`): signal-green `#2E9E6B` on ink, Space +Grotesk (display) · IBM Plex Sans (body) · JetBrains Mono (code/labels), Lucide +icons, and a persisted light/dark toggle (`localStorage` key `gsmnode-theme`, +`data-gsm-theme` attribute). The BFF serves the built SPA and reverse-proxies +`/api/*` to the API Server, so the browser is always same-origin and all data +access still flows through the API Server. + +``` +Browser ─► Web App BFF (:8090) ──/api/*──► API Server (:8080) ─► PocketBase + └── serves embedded Vue SPA +``` + +## Layout + +``` +server/ Go BFF: embeds web/dist, proxies /api -> API_BASE + main.go + .env.example + dist/ built SPA (generated; embedded at compile time) +web/ Vue 3 + Vite + Tailwind v4 source + src/ + api.js fetch wrapper (bearer token, same-origin /api) + store/auth.js login state + router.js routes + auth guard + views/ Login, Devices, Send, Messages, Inbox, Webhooks +``` + +## Requirements + +- Node 18+ and Go 1.26+ +- A running **API Server** (see `../API Server`) + +## Develop + +Two terminals: + +```powershell +# terminal 1 — API Server (see ../API Server/README.md) +cd "../API Server"; ./scripts/Run-ApiServer.ps1 + +# terminal 2 — Vite dev server with hot reload (proxies /api -> :8080) +cd web; npm install; npm run dev # http://localhost:5173 +``` + +## Build & run (production-style) + +```powershell +./server/Run-WebApp.ps1 # builds frontend, then serves on :8090 +# or manually: +cd web; npm run build # outputs to ../server/dist +cd ../server; go run . # http://localhost:8090 +``` + +Config (`server/.env`, copy from `.env.example`): + +| Variable | Purpose | Default | +|---|---|---| +| `WEB_ADDR` | Listen address | `:8090` | +| `API_BASE` | API Server base URL | `http://localhost:8080` | + +## Pages + +- **Login** — authenticates via `/api/auth/login`, stores the JWT. Includes an + editable **Server settings** section (like the phone app): enter an API Server + URL to point the browser directly at any server (uses the server's CORS), or + leave it blank to use this site's built-in BFF proxy. The choice is remembered + in `localStorage`. +- **Devices** — list/remove your registered phones, see online status & last seen. +- **Send SMS** — queue an outbound message (multiple numbers, pick device/SIM). +- **Call** — remotely tell a device to place an outbound phone call. +- **Messages** — outbound history with live status (`Pending`→…→`Delivered`/`Failed`), filterable. +- **Inbox** — incoming messages received by your devices. +- **Webhooks** — register/delete callbacks for `sms:received|sent|delivered|failed`. + +The header shows a live **API Server status** indicator (green/red dot) that polls +`/api/health` every 10s against the configured server, with latency on hover. + +Log in with the user you created via `../API Server/scripts/create-user.mjs`. diff --git a/Web App/server/.env.example b/Web App/server/.env.example new file mode 100644 index 0000000..4a29e16 --- /dev/null +++ b/Web App/server/.env.example @@ -0,0 +1,8 @@ +# Web App (BFF) configuration + +# Address the Web App listens on +WEB_ADDR=:8090 + +# Base URL of the API Server. The BFF reverse-proxies /api/* to this host, so +# the browser only ever talks same-origin to the Web App. +API_BASE=http://localhost:8080 diff --git a/Web App/server/Run-WebApp.ps1 b/Web App/server/Run-WebApp.ps1 new file mode 100644 index 0000000..1d530ab --- /dev/null +++ b/Web App/server/Run-WebApp.ps1 @@ -0,0 +1,29 @@ +# Builds the Vue frontend (if needed) and runs the Web App BFF. +# +# ./Run-WebApp.ps1 # build frontend then serve +# ./Run-WebApp.ps1 -SkipBuild # serve existing dist only + +param([switch]$SkipBuild) + +$ErrorActionPreference = "Stop" +$serverDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$root = Split-Path -Parent $serverDir # the "Web App" folder +$webDir = Join-Path $root "web" + +$goBin = "C:\Program Files\Go\bin" +if (Test-Path $goBin) { $env:Path = "$goBin;$env:Path" } + +if (-not $SkipBuild) { + Write-Host "Building frontend..." -ForegroundColor Cyan + Push-Location $webDir + try { + if (-not (Test-Path "node_modules")) { npm install } + npm run build + } finally { Pop-Location } +} + +Push-Location $serverDir +try { + Write-Host "Starting Web App on :8090 (Ctrl+C to stop)..." -ForegroundColor Cyan + go run . +} finally { Pop-Location } diff --git a/Web App/server/dist/apple-touch-icon.png b/Web App/server/dist/apple-touch-icon.png new file mode 100644 index 0000000..80236eb Binary files /dev/null and b/Web App/server/dist/apple-touch-icon.png differ diff --git a/Web App/server/dist/assets/index-Ab7NGFI_.css b/Web App/server/dist/assets/index-Ab7NGFI_.css new file mode 100644 index 0000000..cb055a9 --- /dev/null +++ b/Web App/server/dist/assets/index-Ab7NGFI_.css @@ -0,0 +1 @@ +@import"https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap";/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:"IBM Plex Sans", ui-sans-serif, system-ui, "Segoe UI", sans-serif;--font-mono:"JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-2xl:42rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:"IBM Plex Sans", ui-sans-serif, system-ui, "Segoe UI", sans-serif;--default-mono-font-family:"JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;--font-display:"Space Grotesk", ui-sans-serif, system-ui, "Segoe UI", sans-serif;--radius-sm:6px;--radius-md:8px}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.absolute{position:absolute}.relative{position:relative}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-7{margin-bottom:calc(var(--spacing) * 7)}.gn-btn-pri{border-radius:var(--radius-md);background:var(--brand);height:40px;color:var(--brand-contrast);font-family:var(--font-sans);cursor:pointer;transition:background-color var(--dur-fast) var(--ease-standard),transform var(--dur-fast) var(--ease-standard);border:1px solid #0000;justify-content:center;align-items:center;gap:8px;padding:0 16px;font-size:.875rem;font-weight:600;display:inline-flex}.gn-btn-sec{border-radius:var(--radius-md);border:1px solid var(--border-strong);background:var(--surface-card);height:40px;color:var(--text-primary);font-family:var(--font-sans);cursor:pointer;transition:background-color var(--dur-fast) var(--ease-standard),transform var(--dur-fast) var(--ease-standard);justify-content:center;align-items:center;gap:8px;padding:0 16px;font-size:.875rem;font-weight:600;display:inline-flex}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-flex{display:inline-flex}.gn-input{border:1px solid var(--border-strong);border-radius:var(--radius-md);background:var(--surface-card);width:100%;color:var(--text-primary);height:40px;font-size:.875rem;font-family:var(--font-sans);transition:border-color var(--dur-fast) var(--ease-standard),box-shadow var(--dur-fast) var(--ease-standard);padding:0 12px}.gn-btn-sm{border-radius:var(--radius-sm);height:32px;padding:0 12px;font-size:.75rem}.\!h-8{height:calc(var(--spacing) * 8)!important}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-16{height:calc(var(--spacing) * 16)}.h-\[18px\]{height:18px}.h-full{height:100%}.min-h-screen{min-height:100vh}.gn-textarea{border:1px solid var(--border-strong);border-radius:var(--radius-md);background:var(--surface-card);width:100%;color:var(--text-primary);font-size:.875rem;font-family:var(--font-sans);resize:vertical;transition:border-color var(--dur-fast) var(--ease-standard),box-shadow var(--dur-fast) var(--ease-standard);padding:10px 12px}.\!w-auto{width:auto!important}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-8{width:calc(var(--spacing) * 8)}.w-60{width:calc(var(--spacing) * 60)}.w-\[18px\]{width:18px}.w-full{width:100%}.w-px{width:1px}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.min-w-0{min-width:0}.flex-1{flex:1}.shrink-0{flex-shrink:0}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-ping{animation:var(--animate-ping)}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:12px}.rounded-md{border-radius:8px}.rounded-sm{border-radius:6px}.rounded-xl{border-radius:15px}.rounded-xs{border-radius:4px}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-strong{border-color:var(--border-strong)}.border-subtle{border-color:var(--border-subtle)}.\!bg-brand-tint{background-color:var(--brand-tint)!important}.bg-brand-tint{background-color:var(--brand-tint)}.bg-card{background-color:var(--surface-card)}.bg-current{background-color:currentColor}.bg-danger-tint{background-color:var(--danger-tint)}.bg-page{background-color:var(--bg-page)}.bg-subtle{background-color:var(--border-subtle)}.bg-success-tint{background-color:var(--success-tint)}.bg-sunken{background-color:var(--bg-sunken)}.bg-warning-tint{background-color:var(--warning-tint)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-7{padding:calc(var(--spacing) * 7)}.p-8{padding:calc(var(--spacing) * 8)}.p-10{padding:calc(var(--spacing) * 10)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-10{padding-block:calc(var(--spacing) * 10)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-top{vertical-align:top}.gn-eyebrow{font-family:var(--font-mono);letter-spacing:.16em;text-transform:uppercase;color:var(--text-muted);font-size:11px}.font-mono{font-family:JetBrains Mono,ui-monospace,SFMono-Regular,Menlo,monospace}.\!text-xs{font-size:var(--text-xs)!important;line-height:var(--tw-leading,var(--text-xs--line-height))!important}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[11px\]{font-size:11px}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[-0\.02em\]{--tw-tracking:-.02em;letter-spacing:-.02em}.whitespace-nowrap{white-space:nowrap}.\!text-brand-active{color:var(--brand-active)!important}.\!text-secondary{color:var(--text-secondary)!important}.text-brand-active{color:var(--brand-active)}.text-danger{color:var(--danger)}.text-muted{color:var(--text-muted)}.text-primary{color:var(--text-primary)}.text-secondary{color:var(--text-secondary)}.text-success{color:var(--success)}.text-warning{color:var(--warning)}.opacity-60{opacity:.6}.shadow-lg{--tw-shadow:var(--sh-lg);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:var(--sh-sm);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:var(--sh-xs);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media(hover:hover){.hover\:bg-sunken:hover{background-color:var(--bg-sunken)}.hover\:text-primary:hover{color:var(--text-primary)}.hover\:underline:hover{text-decoration-line:underline}}@media(min-width:40rem){.sm\:block{display:block}.sm\:flex{display:flex}.sm\:hidden{display:none}}@media(min-width:48rem){.md\:inline{display:inline}}}:root{--green-100:oklch(95% .04 152);--green-200:oklch(88% .07 152);--green-300:oklch(78% .11 152);--green-500:oklch(60% .14 152);--green-600:oklch(52% .13 152);--green-700:oklch(44% .11 152);--green-on-dark:oklch(72% .14 152);--ink:#12161c;--ink-900:#0a0d11;--ink-800:#1a1f27;--paper:#fafaf9;--white:#fff;--gray-50:#f4f5f4;--gray-100:#e9ebea;--gray-200:#dcdfde;--gray-300:#c2c7c6;--gray-400:#9aa0a2;--gray-500:#6b7278;--gray-600:#4a5157;--gray-700:#333a40;--success:oklch(60% .14 152);--warning:oklch(72% .15 75);--danger:oklch(60% .17 25);--info:oklch(62% .13 245);--success-x:oklch(95% .04 152);--warning-x:oklch(95% .05 80);--danger-x:oklch(95% .05 25);--info-x:oklch(95% .04 245);--bg-page:var(--paper);--bg-sunken:var(--gray-50);--surface-card:var(--white);--surface-raised:var(--white);--surface-inverse:var(--ink);--surface-code:var(--ink-900);--border-subtle:#12161c14;--border-strong:#12161c33;--border-focus:var(--green-500);--text-primary:var(--ink);--text-secondary:var(--gray-700);--text-muted:var(--gray-500);--text-inverse:#f5f6f4;--text-brand:var(--green-600);--text-link:var(--green-600);--brand:var(--green-500);--brand-hover:var(--green-600);--brand-active:var(--green-700);--brand-tint:var(--green-100);--brand-contrast:var(--white);--brand-on-dark:var(--green-on-dark);--success-tint:var(--success-x);--warning-tint:var(--warning-x);--danger-tint:var(--danger-x);--info-tint:var(--info-x);--ring-focus:0 0 0 3px oklch(60% .14 152/.45);--ring-danger:0 0 0 3px oklch(60% .17 25/.35);--sh-xs:0 1px 2px #12161c0a;--sh-sm:0 1px 2px #12161c0f, 0 1px 1px #12161c0a;--sh-md:0 4px 12px #12161c14;--sh-lg:0 8px 24px #12161c24;--sh-xl:0 16px 40px #12161c2e;--dur-instant:.1s;--dur-fast:.12s;--dur-base:.18s;--dur-slow:.26s;--ease-standard:cubic-bezier(.2, 0, 0, 1);--ease-entrance:cubic-bezier(.16, 1, .3, 1)}[data-gsm-theme=dark]{--bg-page:var(--ink-900);--bg-sunken:#0e1216;--surface-card:#14191f;--surface-raised:#1a2027;--surface-inverse:var(--white);--surface-code:#05070a;--border-subtle:#ffffff12;--border-strong:#ffffff2e;--border-focus:var(--green-on-dark);--text-primary:#f5f6f4;--text-secondary:var(--gray-200);--text-muted:var(--gray-400);--text-inverse:var(--ink);--text-brand:var(--green-on-dark);--text-link:var(--green-on-dark);--brand:var(--green-500);--brand-hover:var(--green-on-dark);--brand-active:var(--green-600);--brand-tint:oklch(60% .14 152/.16);--brand-contrast:var(--white);--success:var(--green-300);--warning:oklch(80% .14 80);--danger:oklch(72% .16 25);--info:oklch(72% .12 245);--success-tint:oklch(60% .14 152/.16);--warning-tint:oklch(72% .15 75/.16);--danger-tint:oklch(60% .17 25/.18);--info-tint:oklch(62% .13 245/.16);--ring-focus:0 0 0 3px oklch(72% .14 152/.45);--sh-xs:0 1px 2px #0006;--sh-sm:0 1px 3px #00000080, 0 1px 2px #0006;--sh-md:0 4px 12px #00000080, 0 2px 4px #0006;--sh-lg:0 8px 24px #0009, 0 4px 8px #0006;--sh-xl:0 16px 40px #000000b3;color-scheme:dark}html,body,#app{height:100%}body{font-family:var(--font-sans);background:var(--bg-page);color:var(--text-primary);-webkit-font-smoothing:antialiased}h1,h2,h3{font-family:var(--font-display);letter-spacing:-.02em}.gn-input:focus,.gn-textarea:focus{border-color:var(--border-focus);box-shadow:var(--ring-focus);outline:none}.gn-input::placeholder,.gn-textarea::placeholder{color:var(--text-muted)}.gn-btn-pri:hover:not(:disabled){background:var(--brand-hover)}.gn-btn-pri:active:not(:disabled){background:var(--brand-active);transform:translateY(1px)}.gn-btn-pri:focus-visible{box-shadow:var(--ring-focus);outline:none}.gn-btn-pri:disabled{opacity:.45;cursor:not-allowed}.gn-btn-sec:hover:not(:disabled){background:var(--bg-sunken)}.gn-btn-sec:active:not(:disabled){transform:translateY(1px)}.gn-btn-sec:focus-visible{box-shadow:var(--ring-focus);outline:none}.gn-btn-sec:disabled{opacity:.45;cursor:not-allowed}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@keyframes ping{75%,to{opacity:0;transform:scale(2)}} diff --git a/Web App/server/dist/assets/index-B7DZHD5s.js b/Web App/server/dist/assets/index-B7DZHD5s.js new file mode 100644 index 0000000..a6e5075 --- /dev/null +++ b/Web App/server/dist/assets/index-B7DZHD5s.js @@ -0,0 +1,150 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))s(r);new MutationObserver(r=>{for(const o of r)if(o.type==="childList")for(const i of o.addedNodes)i.tagName==="LINK"&&i.rel==="modulepreload"&&s(i)}).observe(document,{childList:!0,subtree:!0});function n(r){const o={};return r.integrity&&(o.integrity=r.integrity),r.referrerPolicy&&(o.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?o.credentials="include":r.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function s(r){if(r.ep)return;r.ep=!0;const o=n(r);fetch(r.href,o)}})();/** +* @vue/shared v3.5.39 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function Ys(e){const t=Object.create(null);for(const n of e.split(","))t[n]=1;return n=>n in t}const oe={},Kt=[],nt=()=>{},vo=()=>!1,Zn=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),es=e=>e.startsWith("onUpdate:"),_e=Object.assign,Xs=(e,t)=>{const n=e.indexOf(t);n>-1&&e.splice(n,1)},qi=Object.prototype.hasOwnProperty,X=(e,t)=>qi.call(e,t),U=Array.isArray,Gt=e=>Cn(e)==="[object Map]",ts=e=>Cn(e)==="[object Set]",_r=e=>Cn(e)==="[object Date]",B=e=>typeof e=="function",ae=e=>typeof e=="string",je=e=>typeof e=="symbol",ee=e=>e!==null&&typeof e=="object",bo=e=>(ee(e)||B(e))&&B(e.then)&&B(e.catch),_o=Object.prototype.toString,Cn=e=>_o.call(e),zi=e=>Cn(e).slice(8,-1),yo=e=>Cn(e)==="[object Object]",Zs=e=>ae(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,fn=Ys(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),ns=e=>{const t=Object.create(null);return(n=>t[n]||(t[n]=e(n)))},Ji=/-\w/g,Oe=ns(e=>e.replace(Ji,t=>t.slice(1).toUpperCase())),Qi=/\B([A-Z])/g,Dt=ns(e=>e.replace(Qi,"-$1").toLowerCase()),ss=ns(e=>e.charAt(0).toUpperCase()+e.slice(1)),vs=ns(e=>e?`on${ss(e)}`:""),tt=(e,t)=>!Object.is(e,t),Ln=(e,...t)=>{for(let n=0;n{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:s,value:n})},rs=e=>{const t=parseFloat(e);return isNaN(t)?e:t};let yr;const os=()=>yr||(yr=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function is(e){if(U(e)){const t={};for(let n=0;n{if(n){const s=n.split(Xi);s.length>1&&(t[s[0].trim()]=s[1].trim())}}),t}function On(e){let t="";if(ae(e))t=e;else if(U(e))for(let n=0;nPn(n,t))}const So=e=>!!(e&&e.__v_isRef===!0),K=e=>ae(e)?e:e==null?"":U(e)||ee(e)&&(e.toString===_o||!B(e.toString))?So(e)?K(e.value):JSON.stringify(e,Eo,2):String(e),Eo=(e,t)=>So(t)?Eo(e,t.value):Gt(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((n,[s,r],o)=>(n[bs(s,o)+" =>"]=r,n),{})}:ts(t)?{[`Set(${t.size})`]:[...t.values()].map(n=>bs(n))}:je(t)?bs(t):ee(t)&&!U(t)&&!yo(t)?String(t):t,bs=(e,t="")=>{var n;return je(e)?`Symbol(${(n=e.description)!=null?n:t})`:e};/** +* @vue/reactivity v3.5.39 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let ve;class ol{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!t&&ve&&(ve.active?(this.parent=ve,this.index=(ve.scopes||(ve.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,n;if(this.scopes)for(t=0,n=this.scopes.length;t0&&--this._on===0){if(ve===this)ve=this.prevScope;else{let t=ve;for(;t;){if(t.prevScope===this){t.prevScope=this.prevScope;break}t=t.prevScope}}this.prevScope=void 0}}stop(t){if(this._active){this._active=!1;let n,s;for(n=0,s=this.effects.length;n0)return;if(pn){let t=pn;for(pn=void 0;t;){const n=t.next;t.next=void 0,t.flags&=-9,t=n}}let e;for(;dn;){let t=dn;for(dn=void 0;t;){const n=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(s){e||(e=s)}t=n}}if(e)throw e}function Oo(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function Po(e){let t,n=e.depsTail,s=n;for(;s;){const r=s.prevDep;s.version===-1?(s===n&&(n=r),nr(s),ll(s)):t=s,s.dep.activeLink=s.prevActiveLink,s.prevActiveLink=void 0,s=r}e.deps=t,e.depsTail=n}function Ns(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(Io(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function Io(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===bn)||(e.globalVersion=bn,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!Ns(e))))return;e.flags|=2;const t=e.dep,n=ie,s=Fe;ie=e,Fe=!0;try{Oo(e);const r=e.fn(e._value);(t.version===0||tt(r,e._value))&&(e.flags|=128,e._value=r,t.version++)}catch(r){throw t.version++,r}finally{ie=n,Fe=s,Po(e),e.flags&=-3}}function nr(e,t=!1){const{dep:n,prevSub:s,nextSub:r}=e;if(s&&(s.nextSub=r,e.prevSub=void 0),r&&(r.prevSub=s,e.nextSub=void 0),n.subs===e&&(n.subs=s,!s&&n.computed)){n.computed.flags&=-5;for(let o=n.computed.deps;o;o=o.nextDep)nr(o,!0)}!t&&!--n.sc&&n.map&&n.map.delete(n.key)}function ll(e){const{prevDep:t,nextDep:n}=e;t&&(t.nextDep=n,e.prevDep=void 0),n&&(n.prevDep=t,e.nextDep=void 0)}let Fe=!0;const To=[];function st(){To.push(Fe),Fe=!1}function rt(){const e=To.pop();Fe=e===void 0?!0:e}function xr(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const n=ie;ie=void 0;try{t()}finally{ie=n}}}let bn=0;class cl{constructor(t,n){this.sub=t,this.dep=n,this.version=n.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class sr{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!ie||!Fe||ie===this.computed)return;let n=this.activeLink;if(n===void 0||n.sub!==ie)n=this.activeLink=new cl(ie,this),ie.deps?(n.prevDep=ie.depsTail,ie.depsTail.nextDep=n,ie.depsTail=n):ie.deps=ie.depsTail=n,No(n);else if(n.version===-1&&(n.version=this.version,n.nextDep)){const s=n.nextDep;s.prevDep=n.prevDep,n.prevDep&&(n.prevDep.nextDep=s),n.prevDep=ie.depsTail,n.nextDep=void 0,ie.depsTail.nextDep=n,ie.depsTail=n,ie.deps===n&&(ie.deps=s)}return n}trigger(t){this.version++,bn++,this.notify(t)}notify(t){er();try{for(let n=this.subs;n;n=n.prevSub)n.sub.notify()&&n.sub.dep.notify()}finally{tr()}}}function No(e){if(e.dep.sc++,e.sub.flags&4){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let s=t.deps;s;s=s.nextDep)No(s)}const n=e.dep.subs;n!==e&&(e.prevSub=n,n&&(n.nextSub=e)),e.dep.subs=e}}const ks=new WeakMap,Nt=Symbol(""),Ms=Symbol(""),_n=Symbol("");function ye(e,t,n){if(Fe&&ie){let s=ks.get(e);s||ks.set(e,s=new Map);let r=s.get(n);r||(s.set(n,r=new sr),r.map=s,r.key=n),r.track()}}function ft(e,t,n,s,r,o){const i=ks.get(e);if(!i){bn++;return}const l=c=>{c&&c.trigger()};if(er(),t==="clear")i.forEach(l);else{const c=U(e),u=c&&Zs(n);if(c&&n==="length"){const a=Number(s);i.forEach((f,h)=>{(h==="length"||h===_n||!je(h)&&h>=a)&&l(f)})}else switch((n!==void 0||i.has(void 0))&&l(i.get(n)),u&&l(i.get(_n)),t){case"add":c?u&&l(i.get("length")):(l(i.get(Nt)),Gt(e)&&l(i.get(Ms)));break;case"delete":c||(l(i.get(Nt)),Gt(e)&&l(i.get(Ms)));break;case"set":Gt(e)&&l(i.get(Nt));break}}tr()}function Ft(e){const t=Y(e);return t===e?t:(ye(t,"iterate",_n),Le(e)?t:t.map(Ue))}function ls(e){return ye(e=Y(e),"iterate",_n),e}function Ze(e,t){return ht(e)?Qt(kt(e)?Ue(t):t):Ue(t)}const al={__proto__:null,[Symbol.iterator](){return ys(this,Symbol.iterator,e=>Ze(this,e))},concat(...e){return Ft(this).concat(...e.map(t=>U(t)?Ft(t):t))},entries(){return ys(this,"entries",e=>(e[1]=Ze(this,e[1]),e))},every(e,t){return lt(this,"every",e,t,void 0,arguments)},filter(e,t){return lt(this,"filter",e,t,n=>n.map(s=>Ze(this,s)),arguments)},find(e,t){return lt(this,"find",e,t,n=>Ze(this,n),arguments)},findIndex(e,t){return lt(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return lt(this,"findLast",e,t,n=>Ze(this,n),arguments)},findLastIndex(e,t){return lt(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return lt(this,"forEach",e,t,void 0,arguments)},includes(...e){return xs(this,"includes",e)},indexOf(...e){return xs(this,"indexOf",e)},join(e){return Ft(this).join(e)},lastIndexOf(...e){return xs(this,"lastIndexOf",e)},map(e,t){return lt(this,"map",e,t,void 0,arguments)},pop(){return rn(this,"pop")},push(...e){return rn(this,"push",e)},reduce(e,...t){return wr(this,"reduce",e,t)},reduceRight(e,...t){return wr(this,"reduceRight",e,t)},shift(){return rn(this,"shift")},some(e,t){return lt(this,"some",e,t,void 0,arguments)},splice(...e){return rn(this,"splice",e)},toReversed(){return Ft(this).toReversed()},toSorted(e){return Ft(this).toSorted(e)},toSpliced(...e){return Ft(this).toSpliced(...e)},unshift(...e){return rn(this,"unshift",e)},values(){return ys(this,"values",e=>Ze(this,e))}};function ys(e,t,n){const s=ls(e),r=s[t]();return s!==e&&!Le(e)&&(r._next=r.next,r.next=()=>{const o=r._next();return o.done||(o.value=n(o.value)),o}),r}const ul=Array.prototype;function lt(e,t,n,s,r,o){const i=ls(e),l=i!==e&&!Le(e),c=i[t];if(c!==ul[t]){const f=c.apply(e,o);return l?Ue(f):f}let u=n;i!==e&&(l?u=function(f,h){return n.call(this,Ze(e,f),h,e)}:n.length>2&&(u=function(f,h){return n.call(this,f,h,e)}));const a=c.call(i,u,s);return l&&r?r(a):a}function wr(e,t,n,s){const r=ls(e),o=r!==e&&!Le(e);let i=n,l=!1;r!==e&&(o?(l=s.length===0,i=function(u,a,f){return l&&(l=!1,u=Ze(e,u)),n.call(this,u,Ze(e,a),f,e)}):n.length>3&&(i=function(u,a,f){return n.call(this,u,a,f,e)}));const c=r[t](i,...s);return l?Ze(e,c):c}function xs(e,t,n){const s=Y(e);ye(s,"iterate",_n);const r=s[t](...n);return(r===-1||r===!1)&&ir(n[0])?(n[0]=Y(n[0]),s[t](...n)):r}function rn(e,t,n=[]){st(),er();const s=Y(e)[t].apply(e,n);return tr(),rt(),s}const fl=Ys("__proto__,__v_isRef,__isVue"),ko=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(je));function dl(e){je(e)||(e=String(e));const t=Y(this);return ye(t,"has",e),t.hasOwnProperty(e)}class Mo{constructor(t=!1,n=!1){this._isReadonly=t,this._isShallow=n}get(t,n,s){if(n==="__v_skip")return t.__v_skip;const r=this._isReadonly,o=this._isShallow;if(n==="__v_isReactive")return!r;if(n==="__v_isReadonly")return r;if(n==="__v_isShallow")return o;if(n==="__v_raw")return s===(r?o?wl:Vo:o?Lo:$o).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(s)?t:void 0;const i=U(t);if(!r){let c;if(i&&(c=al[n]))return c;if(n==="hasOwnProperty")return dl}const l=Reflect.get(t,n,we(t)?t:s);if((je(n)?ko.has(n):fl(n))||(r||ye(t,"get",n),o))return l;if(we(l)){const c=i&&Zs(n)?l:l.value;return r&&ee(c)?Un(c):c}return ee(l)?r?Un(l):In(l):l}}class Do extends Mo{constructor(t=!1){super(!1,t)}set(t,n,s,r){let o=t[n];const i=U(t)&&Zs(n);if(!this._isShallow){const u=ht(o);if(!Le(s)&&!ht(s)&&(o=Y(o),s=Y(s)),!i&&we(o)&&!we(s))return u||(o.value=s),!0}const l=i?Number(n)e,kn=e=>Reflect.getPrototypeOf(e);function vl(e,t,n){return function(...s){const r=this.__v_raw,o=Y(r),i=Gt(o),l=e==="entries"||e===Symbol.iterator&&i,c=e==="keys"&&i,u=r[e](...s),a=n?Ds:t?Qt:Ue;return!t&&ye(o,"iterate",c?Ms:Nt),_e(Object.create(u),{next(){const{value:f,done:h}=u.next();return h?{value:f,done:h}:{value:l?[a(f[0]),a(f[1])]:a(f),done:h}}})}}function Mn(e){return function(...t){return e==="delete"?!1:e==="clear"?void 0:this}}function bl(e,t){const n={get(r){const o=this.__v_raw,i=Y(o),l=Y(r);e||(tt(r,l)&&ye(i,"get",r),ye(i,"get",l));const{has:c}=kn(i),u=t?Ds:e?Qt:Ue;if(c.call(i,r))return u(o.get(r));if(c.call(i,l))return u(o.get(l));o!==i&&o.get(r)},get size(){const r=this.__v_raw;return!e&&ye(Y(r),"iterate",Nt),r.size},has(r){const o=this.__v_raw,i=Y(o),l=Y(r);return e||(tt(r,l)&&ye(i,"has",r),ye(i,"has",l)),r===l?o.has(r):o.has(r)||o.has(l)},forEach(r,o){const i=this,l=i.__v_raw,c=Y(l),u=t?Ds:e?Qt:Ue;return!e&&ye(c,"iterate",Nt),l.forEach((a,f)=>r.call(o,u(a),u(f),i))}};return _e(n,e?{add:Mn("add"),set:Mn("set"),delete:Mn("delete"),clear:Mn("clear")}:{add(r){const o=Y(this),i=kn(o),l=Y(r),c=!t&&!Le(r)&&!ht(r)?l:r;return i.has.call(o,c)||tt(r,c)&&i.has.call(o,r)||tt(l,c)&&i.has.call(o,l)||(o.add(c),ft(o,"add",c,c)),this},set(r,o){!t&&!Le(o)&&!ht(o)&&(o=Y(o));const i=Y(this),{has:l,get:c}=kn(i);let u=l.call(i,r);u||(r=Y(r),u=l.call(i,r));const a=c.call(i,r);return i.set(r,o),u?tt(o,a)&&ft(i,"set",r,o):ft(i,"add",r,o),this},delete(r){const o=Y(this),{has:i,get:l}=kn(o);let c=i.call(o,r);c||(r=Y(r),c=i.call(o,r)),l&&l.call(o,r);const u=o.delete(r);return c&&ft(o,"delete",r,void 0),u},clear(){const r=Y(this),o=r.size!==0,i=r.clear();return o&&ft(r,"clear",void 0,void 0),i}}),["keys","values","entries",Symbol.iterator].forEach(r=>{n[r]=vl(r,e,t)}),n}function rr(e,t){const n=bl(e,t);return(s,r,o)=>r==="__v_isReactive"?!e:r==="__v_isReadonly"?e:r==="__v_raw"?s:Reflect.get(X(n,r)&&r in s?n:s,r,o)}const _l={get:rr(!1,!1)},yl={get:rr(!1,!0)},xl={get:rr(!0,!1)};const $o=new WeakMap,Lo=new WeakMap,Vo=new WeakMap,wl=new WeakMap;function Sl(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function In(e){return ht(e)?e:or(e,!1,hl,_l,$o)}function Fo(e){return or(e,!1,gl,yl,Lo)}function Un(e){return or(e,!0,ml,xl,Vo)}function or(e,t,n,s,r){if(!ee(e)||e.__v_raw&&!(t&&e.__v_isReactive)||e.__v_skip||!Object.isExtensible(e))return e;const o=r.get(e);if(o)return o;const i=Sl(zi(e));if(i===0)return e;const l=new Proxy(e,i===2?s:n);return r.set(e,l),l}function kt(e){return ht(e)?kt(e.__v_raw):!!(e&&e.__v_isReactive)}function ht(e){return!!(e&&e.__v_isReadonly)}function Le(e){return!!(e&&e.__v_isShallow)}function ir(e){return e?!!e.__v_raw:!1}function Y(e){const t=e&&e.__v_raw;return t?Y(t):e}function El(e){return!X(e,"__v_skip")&&Object.isExtensible(e)&&xo(e,"__v_skip",!0),e}const Ue=e=>ee(e)?In(e):e,Qt=e=>ee(e)?Un(e):e;function we(e){return e?e.__v_isRef===!0:!1}function G(e){return jo(e,!1)}function Al(e){return jo(e,!0)}function jo(e,t){return we(e)?e:new Rl(e,t)}class Rl{constructor(t,n){this.dep=new sr,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=n?t:Y(t),this._value=n?t:Ue(t),this.__v_isShallow=n}get value(){return this.dep.track(),this._value}set value(t){const n=this._rawValue,s=this.__v_isShallow||Le(t)||ht(t);t=s?t:Y(t),tt(t,n)&&(this._rawValue=t,this._value=s?t:Ue(t),this.dep.trigger())}}function Z(e){return we(e)?e.value:e}const Cl={get:(e,t,n)=>t==="__v_raw"?e:Z(Reflect.get(e,t,n)),set:(e,t,n,s)=>{const r=e[t];return we(r)&&!we(n)?(r.value=n,!0):Reflect.set(e,t,n,s)}};function Uo(e){return kt(e)?e:new Proxy(e,Cl)}class Ol{constructor(t,n,s){this.fn=t,this.setter=n,this._value=void 0,this.dep=new sr(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=bn-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!n,this.isSSR=s}notify(){if(this.flags|=16,!(this.flags&8)&&ie!==this)return Co(this,!0),!0}get value(){const t=this.dep.track();return Io(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter&&this.setter(t)}}function Pl(e,t,n=!1){let s,r;return B(e)?s=e:(s=e.get,r=e.set),new Ol(s,r,n)}const Dn={},Hn=new WeakMap;let Pt;function Il(e,t=!1,n=Pt){if(n){let s=Hn.get(n);s||Hn.set(n,s=[]),s.push(e)}}function Tl(e,t,n=oe){const{immediate:s,deep:r,once:o,scheduler:i,augmentJob:l,call:c}=n,u=k=>r?k:Le(k)||r===!1||r===0?dt(k,1):dt(k);let a,f,h,v,T=!1,A=!1;if(we(e)?(f=()=>e.value,T=Le(e)):kt(e)?(f=()=>u(e),T=!0):U(e)?(A=!0,T=e.some(k=>kt(k)||Le(k)),f=()=>e.map(k=>{if(we(k))return k.value;if(kt(k))return u(k);if(B(k))return c?c(k,2):k()})):B(e)?t?f=c?()=>c(e,2):e:f=()=>{if(h){st();try{h()}finally{rt()}}const k=Pt;Pt=a;try{return c?c(e,3,[v]):e(v)}finally{Pt=k}}:f=nt,t&&r){const k=f,se=r===!0?1/0:r;f=()=>dt(k(),se)}const j=il(),V=()=>{a.stop(),j&&j.active&&Xs(j.effects,a)};if(o&&t){const k=t;t=(...se)=>{const pe=k(...se);return V(),pe}}let P=A?new Array(e.length).fill(Dn):Dn;const M=k=>{if(!(!(a.flags&1)||!a.dirty&&!k))if(t){const se=a.run();if(k||r||T||(A?se.some((pe,le)=>tt(pe,P[le])):tt(se,P))){h&&h();const pe=Pt;Pt=a;try{const le=[se,P===Dn?void 0:A&&P[0]===Dn?[]:P,v];P=se,c?c(t,3,le):t(...le)}finally{Pt=pe}}}else a.run()};return l&&l(M),a=new Ao(f),a.scheduler=i?()=>i(M,!1):M,v=k=>Il(k,!1,a),h=a.onStop=()=>{const k=Hn.get(a);if(k){if(c)c(k,4);else for(const se of k)se();Hn.delete(a)}},t?s?M(!0):P=a.run():i?i(M.bind(null,!0),!0):a.run(),V.pause=a.pause.bind(a),V.resume=a.resume.bind(a),V.stop=V,V}function dt(e,t=1/0,n){if(t<=0||!ee(e)||e.__v_skip||(n=n||new Map,(n.get(e)||0)>=t))return e;if(n.set(e,t),t--,we(e))dt(e.value,t,n);else if(U(e))for(let s=0;s{dt(s,t,n)});else if(yo(e)){for(const s in e)dt(e[s],t,n);for(const s of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,s)&&dt(e[s],t,n)}return e}/** +* @vue/runtime-core v3.5.39 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function Tn(e,t,n,s){try{return s?e(...s):e()}catch(r){cs(r,t,n)}}function He(e,t,n,s){if(B(e)){const r=Tn(e,t,n,s);return r&&bo(r)&&r.catch(o=>{cs(o,t,n)}),r}if(U(e)){const r=[];for(let o=0;o>>1,r=Ce[s],o=yn(r);o=yn(n)?Ce.push(e):Ce.splice(kl(t),0,e),e.flags|=1,Bo()}}function Bo(){Bn||(Bn=Ho.then(Go))}function Ml(e){U(e)?Wt.push(...e):xt&&e.id===-1?xt.splice(Ht+1,0,e):e.flags&1||(Wt.push(e),e.flags|=1),Bo()}function Sr(e,t,n=Xe+1){for(;nyn(n)-yn(s));if(Wt.length=0,xt){xt.push(...t);return}for(xt=t,Ht=0;Hte.id==null?e.flags&2?-1:1/0:e.id;function Go(e){try{for(Xe=0;Xe{s._d&&qn(-1);const o=Kn(t);let i;try{i=e(...r)}finally{Kn(o),s._d&&qn(1)}return i};return s._n=!0,s._c=!0,s._d=!0,s}function Ne(e,t){if(be===null)return e;const n=ds(be),s=e.dirs||(e.dirs=[]);for(let r=0;r1)return n&&B(t)?t.call(s&&s.proxy):t}}const Dl=Symbol.for("v-scx"),$l=()=>Ve(Dl);function Fn(e,t,n){return qo(e,t,n)}function qo(e,t,n=oe){const{immediate:s,deep:r,flush:o,once:i}=n,l=_e({},n),c=t&&s||!t&&o!=="post";let u;if(Sn){if(o==="sync"){const v=$l();u=v.__watcherHandles||(v.__watcherHandles=[])}else if(!c){const v=()=>{};return v.stop=nt,v.resume=nt,v.pause=nt,v}}const a=xe;l.call=(v,T,A)=>He(v,a,T,A);let f=!1;o==="post"?l.scheduler=v=>{Ie(v,a&&a.suspense)}:o!=="sync"&&(f=!0,l.scheduler=(v,T)=>{T?v():cr(v)}),l.augmentJob=v=>{t&&(v.flags|=4),f&&(v.flags|=2,a&&(v.id=a.uid,v.i=a))};const h=Tl(e,t,l);return Sn&&(u?u.push(h):c&&h()),h}function Ll(e,t,n){const s=this.proxy,r=ae(e)?e.includes(".")?zo(s,e):()=>s[e]:e.bind(s,s);let o;B(t)?o=t:(o=t.handler,n=t);const i=Nn(this),l=qo(r,o.bind(s),n);return i(),l}function zo(e,t){const n=t.split(".");return()=>{let s=e;for(let r=0;re.__isTeleport,ws=Symbol("_leaveCb");function ar(e,t){e.shapeFlag&6&&e.component?(e.transition=t,ar(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function Jo(e,t){return B(e)?_e({name:e.name},t,{setup:e}):e}function Qo(e){e.ids=[e.ids[0]+e.ids[2]+++"-",0,0]}function Er(e,t){let n;return!!((n=Object.getOwnPropertyDescriptor(e,t))&&!n.configurable)}const Gn=new WeakMap;function hn(e,t,n,s,r=!1){if(U(e)){e.forEach((A,j)=>hn(A,t&&(U(t)?t[j]:t),n,s,r));return}if(qt(s)&&!r){s.shapeFlag&512&&s.type.__asyncResolved&&s.component.subTree.component&&hn(e,t,n,s.component.subTree);return}const o=s.shapeFlag&4?ds(s.component):s.el,i=r?null:o,{i:l,r:c}=e,u=t&&t.r,a=l.refs===oe?l.refs={}:l.refs,f=l.setupState,h=Y(f),v=f===oe?vo:A=>Er(a,A)?!1:X(h,A),T=(A,j)=>!(j&&Er(a,j));if(u!=null&&u!==c){if(Ar(t),ae(u))a[u]=null,v(u)&&(f[u]=null);else if(we(u)){const A=t;T(u,A.k)&&(u.value=null),A.k&&(a[A.k]=null)}}if(B(c)){st();try{Tn(c,l,12,[i,a])}finally{rt()}}else{const A=ae(c),j=we(c);if(A||j){const V=()=>{if(e.f){const P=A?v(c)?f[c]:a[c]:T()||!e.k?c.value:a[e.k];if(r)U(P)&&Xs(P,o);else if(U(P))P.includes(o)||P.push(o);else if(A)a[c]=[o],v(c)&&(f[c]=a[c]);else{const M=[o];T(c,e.k)&&(c.value=M),e.k&&(a[e.k]=M)}}else A?(a[c]=i,v(c)&&(f[c]=i)):j&&(T(c,e.k)&&(c.value=i),e.k&&(a[e.k]=i))};if(i){const P=()=>{V(),Gn.delete(e)};P.id=-1,Gn.set(e,P),Ie(P,n)}else Ar(e),V()}}}function Ar(e){const t=Gn.get(e);t&&(t.flags|=8,Gn.delete(e))}os().requestIdleCallback;os().cancelIdleCallback;const qt=e=>!!e.type.__asyncLoader,Yo=e=>e.type.__isKeepAlive;function jl(e,t){Xo(e,"a",t)}function Ul(e,t){Xo(e,"da",t)}function Xo(e,t,n=xe){const s=e.__wdc||(e.__wdc=()=>{let r=n;for(;r;){if(r.isDeactivated)return;r=r.parent}return e()});if(as(t,s,n),n){let r=n.parent;for(;r&&r.parent;)Yo(r.parent.vnode)&&Hl(s,t,n,r),r=r.parent}}function Hl(e,t,n,s){const r=as(t,e,s,!0);ur(()=>{Xs(s[t],r)},n)}function as(e,t,n=xe,s=!1){if(n){const r=n[e]||(n[e]=[]),o=t.__weh||(t.__weh=(...i)=>{st();const l=Nn(n),c=He(t,n,e,i);return l(),rt(),c});return s?r.unshift(o):r.push(o),o}}const gt=e=>(t,n=xe)=>{(!Sn||e==="sp")&&as(e,(...s)=>t(...s),n)},Bl=gt("bm"),Et=gt("m"),Kl=gt("bu"),Gl=gt("u"),Wl=gt("bum"),ur=gt("um"),ql=gt("sp"),zl=gt("rtg"),Jl=gt("rtc");function Ql(e,t=xe){as("ec",e,t)}const Zo="components";function Yl(e,t){return ti(Zo,e,!0,t)||e}const ei=Symbol.for("v-ndc");function Xl(e){return ae(e)?ti(Zo,e,!1)||e:e||ei}function ti(e,t,n=!0,s=!1){const r=be||xe;if(r){const o=r.type;{const l=Vc(o,!1);if(l&&(l===t||l===Oe(t)||l===ss(Oe(t))))return o}const i=Rr(r[e]||o[e],t)||Rr(r.appContext[e],t);return!i&&s?o:i}}function Rr(e,t){return e&&(e[t]||e[Oe(t)]||e[ss(Oe(t))])}function ot(e,t,n,s){let r;const o=n,i=U(e);if(i||ae(e)){const l=i&&kt(e);let c=!1,u=!1;l&&(c=!Le(e),u=ht(e),e=ls(e)),r=new Array(e.length);for(let a=0,f=e.length;at(l,c,void 0,o));else{const l=Object.keys(e);r=new Array(l.length);for(let c=0,u=l.length;c0;return n.name=t,N(),pt(ce,null,[W("slot",n,s)],u?-2:64)}let o=e[t];o&&o._c&&(o._d=!1),N();const i=o&&ni(o(n)),l=n.key||i&&i.key,c=pt(ce,{key:(l&&!je(l)?l:`_${t}`)+(!i&&s?"_fb":"")},i||[],i&&e._===1?64:-2);return c.scopeId&&(c.slotScopeIds=[c.scopeId+"-s"]),o&&o._c&&(o._d=!0),c}function ni(e){return e.some(t=>wn(t)?!(t.type===mt||t.type===ce&&!ni(t.children)):!0)?e:null}const $s=e=>e?wi(e)?ds(e):$s(e.parent):null,mn=_e(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>$s(e.parent),$root:e=>$s(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>ri(e),$forceUpdate:e=>e.f||(e.f=()=>{cr(e.update)}),$nextTick:e=>e.n||(e.n=lr.bind(e.proxy)),$watch:e=>Ll.bind(e)}),Ss=(e,t)=>e!==oe&&!e.__isScriptSetup&&X(e,t),ec={get({_:e},t){if(t==="__v_skip")return!0;const{ctx:n,setupState:s,data:r,props:o,accessCache:i,type:l,appContext:c}=e;if(t[0]!=="$"){const h=i[t];if(h!==void 0)switch(h){case 1:return s[t];case 2:return r[t];case 4:return n[t];case 3:return o[t]}else{if(Ss(s,t))return i[t]=1,s[t];if(r!==oe&&X(r,t))return i[t]=2,r[t];if(X(o,t))return i[t]=3,o[t];if(n!==oe&&X(n,t))return i[t]=4,n[t];Ls&&(i[t]=0)}}const u=mn[t];let a,f;if(u)return t==="$attrs"&&ye(e.attrs,"get",""),u(e);if((a=l.__cssModules)&&(a=a[t]))return a;if(n!==oe&&X(n,t))return i[t]=4,n[t];if(f=c.config.globalProperties,X(f,t))return f[t]},set({_:e},t,n){const{data:s,setupState:r,ctx:o}=e;return Ss(r,t)?(r[t]=n,!0):s!==oe&&X(s,t)?(s[t]=n,!0):X(e.props,t)||t[0]==="$"&&t.slice(1)in e?!1:(o[t]=n,!0)},has({_:{data:e,setupState:t,accessCache:n,ctx:s,appContext:r,props:o,type:i}},l){let c;return!!(n[l]||e!==oe&&l[0]!=="$"&&X(e,l)||Ss(t,l)||X(o,l)||X(s,l)||X(mn,l)||X(r.config.globalProperties,l)||(c=i.__cssModules)&&c[l])},defineProperty(e,t,n){return n.get!=null?e._.accessCache[t]=0:X(n,"value")&&this.set(e,t,n.value,null),Reflect.defineProperty(e,t,n)}};function Cr(e){return U(e)?e.reduce((t,n)=>(t[n]=null,t),{}):e}let Ls=!0;function tc(e){const t=ri(e),n=e.proxy,s=e.ctx;Ls=!1,t.beforeCreate&&Or(t.beforeCreate,e,"bc");const{data:r,computed:o,methods:i,watch:l,provide:c,inject:u,created:a,beforeMount:f,mounted:h,beforeUpdate:v,updated:T,activated:A,deactivated:j,beforeDestroy:V,beforeUnmount:P,destroyed:M,unmounted:k,render:se,renderTracked:pe,renderTriggered:le,errorCaptured:Ke,serverPrefetch:vt,expose:Ge,inheritAttrs:bt,components:At,directives:We,filters:nn}=t;if(u&&nc(u,s,null),i)for(const te in i){const J=i[te];B(J)&&(s[te]=J.bind(n))}if(r){const te=r.call(n,n);ee(te)&&(e.data=In(te))}if(Ls=!0,o)for(const te in o){const J=o[te],it=B(J)?J.bind(n,n):B(J.get)?J.get.bind(n,n):nt,_t=!B(J)&&B(J.set)?J.set.bind(n):nt,qe=$e({get:it,set:_t});Object.defineProperty(s,te,{enumerable:!0,configurable:!0,get:()=>qe.value,set:Pe=>qe.value=Pe})}if(l)for(const te in l)si(l[te],s,n,te);if(c){const te=B(c)?c.call(n):c;Reflect.ownKeys(te).forEach(J=>{Vn(J,te[J])})}a&&Or(a,e,"c");function me(te,J){U(J)?J.forEach(it=>te(it.bind(n))):J&&te(J.bind(n))}if(me(Bl,f),me(Et,h),me(Kl,v),me(Gl,T),me(jl,A),me(Ul,j),me(Ql,Ke),me(Jl,pe),me(zl,le),me(Wl,P),me(ur,k),me(ql,vt),U(Ge))if(Ge.length){const te=e.exposed||(e.exposed={});Ge.forEach(J=>{Object.defineProperty(te,J,{get:()=>n[J],set:it=>n[J]=it,enumerable:!0})})}else e.exposed||(e.exposed={});se&&e.render===nt&&(e.render=se),bt!=null&&(e.inheritAttrs=bt),At&&(e.components=At),We&&(e.directives=We),vt&&Qo(e)}function nc(e,t,n=nt){U(e)&&(e=Vs(e));for(const s in e){const r=e[s];let o;ee(r)?"default"in r?o=Ve(r.from||s,r.default,!0):o=Ve(r.from||s):o=Ve(r),we(o)?Object.defineProperty(t,s,{enumerable:!0,configurable:!0,get:()=>o.value,set:i=>o.value=i}):t[s]=o}}function Or(e,t,n){He(U(e)?e.map(s=>s.bind(t.proxy)):e.bind(t.proxy),t,n)}function si(e,t,n,s){let r=s.includes(".")?zo(n,s):()=>n[s];if(ae(e)){const o=t[e];B(o)&&Fn(r,o)}else if(B(e))Fn(r,e.bind(n));else if(ee(e))if(U(e))e.forEach(o=>si(o,t,n,s));else{const o=B(e.handler)?e.handler.bind(n):t[e.handler];B(o)&&Fn(r,o,e)}}function ri(e){const t=e.type,{mixins:n,extends:s}=t,{mixins:r,optionsCache:o,config:{optionMergeStrategies:i}}=e.appContext,l=o.get(t);let c;return l?c=l:!r.length&&!n&&!s?c=t:(c={},r.length&&r.forEach(u=>Wn(c,u,i,!0)),Wn(c,t,i)),ee(t)&&o.set(t,c),c}function Wn(e,t,n,s=!1){const{mixins:r,extends:o}=t;o&&Wn(e,o,n,!0),r&&r.forEach(i=>Wn(e,i,n,!0));for(const i in t)if(!(s&&i==="expose")){const l=sc[i]||n&&n[i];e[i]=l?l(e[i],t[i]):t[i]}return e}const sc={data:Pr,props:Ir,emits:Ir,methods:an,computed:an,beforeCreate:Ae,created:Ae,beforeMount:Ae,mounted:Ae,beforeUpdate:Ae,updated:Ae,beforeDestroy:Ae,beforeUnmount:Ae,destroyed:Ae,unmounted:Ae,activated:Ae,deactivated:Ae,errorCaptured:Ae,serverPrefetch:Ae,components:an,directives:an,watch:oc,provide:Pr,inject:rc};function Pr(e,t){return t?e?function(){return _e(B(e)?e.call(this,this):e,B(t)?t.call(this,this):t)}:t:e}function rc(e,t){return an(Vs(e),Vs(t))}function Vs(e){if(U(e)){const t={};for(let n=0;nt==="modelValue"||t==="model-value"?e.modelModifiers:e[`${t}Modifiers`]||e[`${Oe(t)}Modifiers`]||e[`${Dt(t)}Modifiers`];function ac(e,t,...n){if(e.isUnmounted)return;const s=e.vnode.props||oe;let r=n;const o=t.startsWith("update:"),i=o&&cc(s,t.slice(7));i&&(i.trim&&(r=n.map(a=>ae(a)?a.trim():a)),i.number&&(r=n.map(rs)));let l,c=s[l=vs(t)]||s[l=vs(Oe(t))];!c&&o&&(c=s[l=vs(Dt(t))]),c&&He(c,e,6,r);const u=s[l+"Once"];if(u){if(!e.emitted)e.emitted={};else if(e.emitted[l])return;e.emitted[l]=!0,He(u,e,6,r)}}const uc=new WeakMap;function ii(e,t,n=!1){const s=n?uc:t.emitsCache,r=s.get(e);if(r!==void 0)return r;const o=e.emits;let i={},l=!1;if(!B(e)){const c=u=>{const a=ii(u,t,!0);a&&(l=!0,_e(i,a))};!n&&t.mixins.length&&t.mixins.forEach(c),e.extends&&c(e.extends),e.mixins&&e.mixins.forEach(c)}return!o&&!l?(ee(e)&&s.set(e,null),null):(U(o)?o.forEach(c=>i[c]=null):_e(i,o),ee(e)&&s.set(e,i),i)}function us(e,t){return!e||!Zn(t)?!1:(t=t.slice(2),t=t==="Once"?t:t.replace(/Once$/,""),X(e,t[0].toLowerCase()+t.slice(1))||X(e,Dt(t))||X(e,t))}function Tr(e){const{type:t,vnode:n,proxy:s,withProxy:r,propsOptions:[o],slots:i,attrs:l,emit:c,render:u,renderCache:a,props:f,data:h,setupState:v,ctx:T,inheritAttrs:A}=e,j=Kn(e);let V,P;try{if(n.shapeFlag&4){const k=r||s,se=k;V=et(u.call(se,k,a,f,v,h,T)),P=l}else{const k=t;V=et(k.length>1?k(f,{attrs:l,slots:i,emit:c}):k(f,null)),P=t.props?l:fc(l)}}catch(k){gn.length=0,cs(k,e,1),V=W(mt)}let M=V;if(P&&A!==!1){const k=Object.keys(P),{shapeFlag:se}=M;k.length&&se&7&&(o&&k.some(es)&&(P=dc(P,o)),M=Xt(M,P,!1,!0))}return n.dirs&&(M=Xt(M,null,!1,!0),M.dirs=M.dirs?M.dirs.concat(n.dirs):n.dirs),n.transition&&ar(M,n.transition),V=M,Kn(j),V}const fc=e=>{let t;for(const n in e)(n==="class"||n==="style"||Zn(n))&&((t||(t={}))[n]=e[n]);return t},dc=(e,t)=>{const n={};for(const s in e)(!es(s)||!(s.slice(9)in t))&&(n[s]=e[s]);return n};function pc(e,t,n){const{props:s,children:r,component:o}=e,{props:i,children:l,patchFlag:c}=t,u=o.emitsOptions;if(t.dirs||t.transition)return!0;if(n&&c>=0){if(c&1024)return!0;if(c&16)return s?Nr(s,i,u):!!i;if(c&8){const a=t.dynamicProps;for(let f=0;fObject.create(ci),ui=e=>Object.getPrototypeOf(e)===ci;function mc(e,t,n,s=!1){const r={},o=ai();e.propsDefaults=Object.create(null),fi(e,t,r,o);for(const i in e.propsOptions[0])i in r||(r[i]=void 0);n?e.props=s?r:Fo(r):e.type.props?e.props=r:e.props=o,e.attrs=o}function gc(e,t,n,s){const{props:r,attrs:o,vnode:{patchFlag:i}}=e,l=Y(r),[c]=e.propsOptions;let u=!1;if((s||i>0)&&!(i&16)){if(i&8){const a=e.vnode.dynamicProps;for(let f=0;f{c=!0;const[h,v]=di(f,t,!0);_e(i,h),v&&l.push(...v)};!n&&t.mixins.length&&t.mixins.forEach(a),e.extends&&a(e.extends),e.mixins&&e.mixins.forEach(a)}if(!o&&!c)return ee(e)&&s.set(e,Kt),Kt;if(U(o))for(let a=0;ae==="_"||e==="_ctx"||e==="$stable",dr=e=>U(e)?e.map(et):[et(e)],bc=(e,t,n)=>{if(t._n)return t;const s=Yt((...r)=>dr(t(...r)),n);return s._c=!1,s},pi=(e,t,n)=>{const s=e._ctx;for(const r in e){if(fr(r))continue;const o=e[r];if(B(o))t[r]=bc(r,o,s);else if(o!=null){const i=dr(o);t[r]=()=>i}}},hi=(e,t)=>{const n=dr(t);e.slots.default=()=>n},mi=(e,t,n)=>{for(const s in t)(n||!fr(s))&&(e[s]=t[s])},_c=(e,t,n)=>{const s=e.slots=ai();if(e.vnode.shapeFlag&32){const r=t._;r?(mi(s,t,n),n&&xo(s,"_",r,!0)):pi(t,s)}else t&&hi(e,t)},yc=(e,t,n)=>{const{vnode:s,slots:r}=e;let o=!0,i=oe;if(s.shapeFlag&32){const l=t._;l?n&&l===1?o=!1:mi(r,t,n):(o=!t.$stable,pi(t,r)),i=t}else t&&(hi(e,t),i={default:1});if(o)for(const l in r)!fr(l)&&i[l]==null&&delete r[l]},Ie=Ac;function xc(e){return wc(e)}function wc(e,t){const n=os();n.__VUE__=!0;const{insert:s,remove:r,patchProp:o,createElement:i,createText:l,createComment:c,setText:u,setElementText:a,parentNode:f,nextSibling:h,setScopeId:v=nt,insertStaticContent:T}=e,A=(d,p,m,_=null,x=null,b=null,R=void 0,E=null,S=!!p.dynamicChildren)=>{if(d===p)return;d&&!on(d,p)&&(_=y(d),Pe(d,x,b,!0),d=null),p.patchFlag===-2&&(S=!1,p.dynamicChildren=null);const{type:w,ref:F,shapeFlag:O}=p;switch(w){case fs:j(d,p,m,_);break;case mt:V(d,p,m,_);break;case As:d==null&&P(p,m,_,R);break;case ce:At(d,p,m,_,x,b,R,E,S);break;default:O&1?se(d,p,m,_,x,b,R,E,S):O&6?We(d,p,m,_,x,b,R,E,S):(O&64||O&128)&&w.process(d,p,m,_,x,b,R,E,S,D)}F!=null&&x?hn(F,d&&d.ref,b,p||d,!p):F==null&&d&&d.ref!=null&&hn(d.ref,null,b,d,!0)},j=(d,p,m,_)=>{if(d==null)s(p.el=l(p.children),m,_);else{const x=p.el=d.el;p.children!==d.children&&u(x,p.children)}},V=(d,p,m,_)=>{d==null?s(p.el=c(p.children||""),m,_):p.el=d.el},P=(d,p,m,_)=>{[d.el,d.anchor]=T(d.children,p,m,_,d.el,d.anchor)},M=({el:d,anchor:p},m,_)=>{let x;for(;d&&d!==p;)x=h(d),s(d,m,_),d=x;s(p,m,_)},k=({el:d,anchor:p})=>{let m;for(;d&&d!==p;)m=h(d),r(d),d=m;r(p)},se=(d,p,m,_,x,b,R,E,S)=>{if(p.type==="svg"?R="svg":p.type==="math"&&(R="mathml"),d==null)pe(p,m,_,x,b,R,E,S);else{const w=d.el&&d.el._isVueCE?d.el:null;try{w&&w._beginPatch(),vt(d,p,x,b,R,E,S)}finally{w&&w._endPatch()}}},pe=(d,p,m,_,x,b,R,E)=>{let S,w;const{props:F,shapeFlag:O,transition:$,dirs:H}=d;if(S=d.el=i(d.type,b,F&&F.is,F),O&8?a(S,d.children):O&16&&Ke(d.children,S,null,_,x,Es(d,b),R,E),H&&Ct(d,null,_,"created"),le(S,d,d.scopeId,R,_),F){for(const re in F)re!=="value"&&!fn(re)&&o(S,re,null,F[re],b,_);"value"in F&&o(S,"value",null,F.value,b),(w=F.onVnodeBeforeMount)&&Ye(w,_,d)}H&&Ct(d,null,_,"beforeMount");const z=Sc(x,$);z&&$.beforeEnter(S),s(S,p,m),((w=F&&F.onVnodeMounted)||z||H)&&Ie(()=>{try{w&&Ye(w,_,d),z&&$.enter(S),H&&Ct(d,null,_,"mounted")}finally{}},x)},le=(d,p,m,_,x)=>{if(m&&v(d,m),_)for(let b=0;b<_.length;b++)v(d,_[b]);if(x){let b=x.subTree;if(p===b||_i(b.type)&&(b.ssContent===p||b.ssFallback===p)){const R=x.vnode;le(d,R,R.scopeId,R.slotScopeIds,x.parent)}}},Ke=(d,p,m,_,x,b,R,E,S=0)=>{for(let w=S;w{const E=p.el=d.el;let{patchFlag:S,dynamicChildren:w,dirs:F}=p;S|=d.patchFlag&16;const O=d.props||oe,$=p.props||oe;let H;if(m&&Ot(m,!1),(H=$.onVnodeBeforeUpdate)&&Ye(H,m,p,d),F&&Ct(p,d,m,"beforeUpdate"),m&&Ot(m,!0),w&&(!d.dynamicChildren||d.dynamicChildren.length!==w.length)&&(S=0,R=!1,w=null),(O.innerHTML&&$.innerHTML==null||O.textContent&&$.textContent==null)&&a(E,""),w?Ge(d.dynamicChildren,w,E,m,_,Es(p,x),b):R||J(d,p,E,null,m,_,Es(p,x),b,!1),S>0){if(S&16)bt(E,O,$,m,x);else if(S&2&&O.class!==$.class&&o(E,"class",null,$.class,x),S&4&&o(E,"style",O.style,$.style,x),S&8){const z=p.dynamicProps;for(let re=0;re{H&&Ye(H,m,p,d),F&&Ct(p,d,m,"updated")},_)},Ge=(d,p,m,_,x,b,R)=>{for(let E=0;E{if(p!==m){if(p!==oe)for(const b in p)!fn(b)&&!(b in m)&&o(d,b,p[b],null,x,_);for(const b in m){if(fn(b))continue;const R=m[b],E=p[b];R!==E&&b!=="value"&&o(d,b,E,R,x,_)}"value"in m&&o(d,"value",p.value,m.value,x)}},At=(d,p,m,_,x,b,R,E,S)=>{const w=p.el=d?d.el:l(""),F=p.anchor=d?d.anchor:l("");let{patchFlag:O,dynamicChildren:$,slotScopeIds:H}=p;H&&(E=E?E.concat(H):H),d==null?(s(w,m,_),s(F,m,_),Ke(p.children||[],m,F,x,b,R,E,S)):O>0&&O&64&&$&&d.dynamicChildren&&d.dynamicChildren.length===$.length?(Ge(d.dynamicChildren,$,m,x,b,R,E),(p.key!=null||x&&p===x.subTree)&&gi(d,p,!0)):J(d,p,m,F,x,b,R,E,S)},We=(d,p,m,_,x,b,R,E,S)=>{p.slotScopeIds=E,d==null?p.shapeFlag&512?x.ctx.activate(p,m,_,R,S):nn(p,m,_,x,b,R,S):$t(d,p,S)},nn=(d,p,m,_,x,b,R)=>{const E=d.component=Nc(d,_,x);if(Yo(d)&&(E.ctx.renderer=D),Mc(E,!1,R),E.asyncDep){if(x&&x.registerDep(E,me,R),!d.el){const S=E.subTree=W(mt);V(null,S,p,m),d.placeholder=S.el}}else me(E,d,p,m,x,b,R)},$t=(d,p,m)=>{const _=p.component=d.component;if(pc(d,p,m))if(_.asyncDep&&!_.asyncResolved){te(_,p,m);return}else _.next=p,_.update();else p.el=d.el,_.vnode=p},me=(d,p,m,_,x,b,R)=>{const E=()=>{if(d.isMounted){let{next:O,bu:$,u:H,parent:z,vnode:re}=d;{const Je=vi(d);if(Je){O&&(O.el=re.el,te(d,O,R)),Je.asyncDep.then(()=>{Ie(()=>{d.isUnmounted||w()},x)});return}}let ne=O,fe;Ot(d,!1),O?(O.el=re.el,te(d,O,R)):O=re,$&&Ln($),(fe=O.props&&O.props.onVnodeBeforeUpdate)&&Ye(fe,z,O,re),Ot(d,!0);const ge=Tr(d),ze=d.subTree;d.subTree=ge,A(ze,ge,f(ze.el),y(ze),d,x,b),O.el=ge.el,ne===null&&hc(d,ge.el),H&&Ie(H,x),(fe=O.props&&O.props.onVnodeUpdated)&&Ie(()=>Ye(fe,z,O,re),x)}else{let O;const{el:$,props:H}=p,{bm:z,m:re,parent:ne,root:fe,type:ge}=d,ze=qt(p);Ot(d,!1),z&&Ln(z),!ze&&(O=H&&H.onVnodeBeforeMount)&&Ye(O,ne,p),Ot(d,!0);{fe.ce&&fe.ce._hasShadowRoot()&&fe.ce._injectChildStyle(ge,d.parent?d.parent.type:void 0);const Je=d.subTree=Tr(d);A(null,Je,m,_,d,x,b),p.el=Je.el}if(re&&Ie(re,x),!ze&&(O=H&&H.onVnodeMounted)){const Je=p;Ie(()=>Ye(O,ne,Je),x)}(p.shapeFlag&256||ne&&qt(ne.vnode)&&ne.vnode.shapeFlag&256)&&d.a&&Ie(d.a,x),d.isMounted=!0,p=m=_=null}};d.scope.on();const S=d.effect=new Ao(E);d.scope.off();const w=d.update=S.run.bind(S),F=d.job=S.runIfDirty.bind(S);F.i=d,F.id=d.uid,S.scheduler=()=>cr(F),Ot(d,!0),w()},te=(d,p,m)=>{p.component=d;const _=d.vnode.props;d.vnode=p,d.next=null,gc(d,p.props,_,m),yc(d,p.children,m),st(),Sr(d),rt()},J=(d,p,m,_,x,b,R,E,S=!1)=>{const w=d&&d.children,F=d?d.shapeFlag:0,O=p.children,{patchFlag:$,shapeFlag:H}=p;if($>0){if($&128){_t(w,O,m,_,x,b,R,E,S);return}else if($&256){it(w,O,m,_,x,b,R,E,S);return}}H&8?(F&16&&De(w,x,b),O!==w&&a(m,O)):F&16?H&16?_t(w,O,m,_,x,b,R,E,S):De(w,x,b,!0):(F&8&&a(m,""),H&16&&Ke(O,m,_,x,b,R,E,S))},it=(d,p,m,_,x,b,R,E,S)=>{d=d||Kt,p=p||Kt;const w=d.length,F=p.length,O=Math.min(w,F);let $;for($=0;$F?De(d,x,b,!0,!1,O):Ke(p,m,_,x,b,R,E,S,O)},_t=(d,p,m,_,x,b,R,E,S)=>{let w=0;const F=p.length;let O=d.length-1,$=F-1;for(;w<=O&&w<=$;){const H=d[w],z=p[w]=S?ut(p[w]):et(p[w]);if(on(H,z))A(H,z,m,null,x,b,R,E,S);else break;w++}for(;w<=O&&w<=$;){const H=d[O],z=p[$]=S?ut(p[$]):et(p[$]);if(on(H,z))A(H,z,m,null,x,b,R,E,S);else break;O--,$--}if(w>O){if(w<=$){const H=$+1,z=H$)for(;w<=O;)Pe(d[w],x,b,!0),w++;else{const H=w,z=w,re=new Map;for(w=z;w<=$;w++){const Te=p[w]=S?ut(p[w]):et(p[w]);Te.key!=null&&re.set(Te.key,w)}let ne,fe=0;const ge=$-z+1;let ze=!1,Je=0;const sn=new Array(ge);for(w=0;w=ge){Pe(Te,x,b,!0);continue}let Qe;if(Te.key!=null)Qe=re.get(Te.key);else for(ne=z;ne<=$;ne++)if(sn[ne-z]===0&&on(Te,p[ne])){Qe=ne;break}Qe===void 0?Pe(Te,x,b,!0):(sn[Qe-z]=w+1,Qe>=Je?Je=Qe:ze=!0,A(Te,p[Qe],m,null,x,b,R,E,S),fe++)}const gr=ze?Ec(sn):Kt;for(ne=gr.length-1,w=ge-1;w>=0;w--){const Te=z+w,Qe=p[Te],vr=p[Te+1],br=Te+1{const{el:b,type:R,transition:E,children:S,shapeFlag:w}=d;if(w&6){qe(d.component.subTree,p,m,_);return}if(w&128){d.suspense.move(p,m,_);return}if(w&64){R.move(d,p,m,D);return}if(R===ce){s(b,p,m);for(let O=0;OE.enter(b),x));else{const{leave:O,delayLeave:$,afterLeave:H}=E,z=()=>{d.ctx.isUnmounted?r(b):s(b,p,m)},re=()=>{const ne=b._isLeaving||!!b[ws];b._isLeaving&&b[ws](!0),E.persisted&&!ne?z():O(b,()=>{z(),H&&H()})};$?$(b,z,re):re()}else s(b,p,m)},Pe=(d,p,m,_=!1,x=!1)=>{const{type:b,props:R,ref:E,children:S,dynamicChildren:w,shapeFlag:F,patchFlag:O,dirs:$,cacheIndex:H,memo:z}=d;if(O===-2&&(x=!1),E!=null&&(st(),hn(E,null,m,d,!0),rt()),H!=null&&(p.renderCache[H]=void 0),F&256){p.ctx.deactivate(d);return}const re=F&1&&$,ne=!qt(d);let fe;if(ne&&(fe=R&&R.onVnodeBeforeUnmount)&&Ye(fe,p,d),F&6)Rt(d.component,m,_);else{if(F&128){d.suspense.unmount(m,_);return}re&&Ct(d,null,p,"beforeUnmount"),F&64?d.type.remove(d,p,m,D,_):w&&!w.hasOnce&&(b!==ce||O>0&&O&64)?De(w,p,m,!1,!0):(b===ce&&O&384||!x&&F&16)&&De(S,p,m),_&&Lt(d)}const ge=z!=null&&H==null;(ne&&(fe=R&&R.onVnodeUnmounted)||re||ge)&&Ie(()=>{fe&&Ye(fe,p,d),re&&Ct(d,null,p,"unmounted"),ge&&(d.el=null)},m)},Lt=d=>{const{type:p,el:m,anchor:_,transition:x}=d;if(p===ce){Vt(m,_);return}if(p===As){k(d);return}const b=()=>{r(m),x&&!x.persisted&&x.afterLeave&&x.afterLeave()};if(d.shapeFlag&1&&x&&!x.persisted){const{leave:R,delayLeave:E}=x,S=()=>R(m,b);E?E(d.el,b,S):S()}else b()},Vt=(d,p)=>{let m;for(;d!==p;)m=h(d),r(d),d=m;r(p)},Rt=(d,p,m)=>{const{bum:_,scope:x,job:b,subTree:R,um:E,m:S,a:w}=d;Mr(S),Mr(w),_&&Ln(_),x.stop(),b&&(b.flags|=8,Pe(R,d,p,m)),E&&Ie(E,p),Ie(()=>{d.isUnmounted=!0},p)},De=(d,p,m,_=!1,x=!1,b=0)=>{for(let R=b;R{if(d.shapeFlag&6)return y(d.component.subTree);if(d.shapeFlag&128)return d.suspense.next();const p=h(d.anchor||d.el),m=p&&p[Vl];return m?h(m):p};let I=!1;const C=(d,p,m)=>{let _;d==null?p._vnode&&(Pe(p._vnode,null,null,!0),_=p._vnode.component):A(p._vnode||null,d,p,null,null,null,m),p._vnode=d,I||(I=!0,Sr(_),Ko(),I=!1)},D={p:A,um:Pe,m:qe,r:Lt,mt:nn,mc:Ke,pc:J,pbc:Ge,n:y,o:e};return{render:C,hydrate:void 0,createApp:lc(C)}}function Es({type:e,props:t},n){return n==="svg"&&e==="foreignObject"||n==="mathml"&&e==="annotation-xml"&&t&&t.encoding&&t.encoding.includes("html")?void 0:n}function Ot({effect:e,job:t},n){n?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function Sc(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function gi(e,t,n=!1){const s=e.children,r=t.children;if(U(s)&&U(r))for(let o=0;o>1,e[n[l]]0&&(t[s]=n[o-1]),n[o]=s)}}for(o=n.length,i=n[o-1];o-- >0;)n[o]=i,i=t[i];return n}function vi(e){const t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:vi(t)}function Mr(e){if(e)for(let t=0;te.__isSuspense;function Ac(e,t){t&&t.pendingBranch?U(e)?t.effects.push(...e):t.effects.push(e):Ml(e)}const ce=Symbol.for("v-fgt"),fs=Symbol.for("v-txt"),mt=Symbol.for("v-cmt"),As=Symbol.for("v-stc"),gn=[];let ke=null;function N(e=!1){gn.push(ke=e?null:[])}function Rc(){gn.pop(),ke=gn[gn.length-1]||null}let xn=1;function qn(e,t=!1){xn+=e,e<0&&ke&&t&&(ke.hasOnce=!0)}function yi(e){return e.dynamicChildren=xn>0?ke||Kt:null,Rc(),xn>0&&ke&&ke.push(e),e}function L(e,t,n,s,r,o){return yi(g(e,t,n,s,r,o,!0))}function pt(e,t,n,s,r){return yi(W(e,t,n,s,r,!0))}function wn(e){return e?e.__v_isVNode===!0:!1}function on(e,t){return e.type===t.type&&e.key===t.key}const xi=({key:e})=>e??null,jn=({ref:e,ref_key:t,ref_for:n})=>(typeof e=="number"&&(e=""+e),e!=null?ae(e)||we(e)||B(e)?{i:be,r:e,k:t,f:!!n}:e:null);function g(e,t=null,n=null,s=0,r=null,o=e===ce?0:1,i=!1,l=!1){const c={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&xi(t),ref:t&&jn(t),scopeId:Wo,slotScopeIds:null,children:n,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:o,patchFlag:s,dynamicProps:r,dynamicChildren:null,appContext:null,ctx:be};return l?(zn(c,n),o&128&&e.normalize(c)):n&&(c.shapeFlag|=ae(n)?8:16),xn>0&&!i&&ke&&(c.patchFlag>0||o&6)&&c.patchFlag!==32&&ke.push(c),c}const W=Cc;function Cc(e,t=null,n=null,s=0,r=null,o=!1){if((!e||e===ei)&&(e=mt),wn(e)){const l=Xt(e,t,!0);return n&&zn(l,n),xn>0&&!o&&ke&&(l.shapeFlag&6?ke[ke.indexOf(e)]=l:ke.push(l)),l.patchFlag=-2,l}if(Fc(e)&&(e=e.__vccOpts),t){t=Oc(t);let{class:l,style:c}=t;l&&!ae(l)&&(t.class=On(l)),ee(c)&&(ir(c)&&!U(c)&&(c=_e({},c)),t.style=is(c))}const i=ae(e)?1:_i(e)?128:Fl(e)?64:ee(e)?4:B(e)?2:0;return g(e,t,n,s,r,i,o,!0)}function Oc(e){return e?ir(e)||ui(e)?_e({},e):e:null}function Xt(e,t,n=!1,s=!1){const{props:r,ref:o,patchFlag:i,children:l,transition:c}=e,u=t?Pc(r||{},t):r,a={__v_isVNode:!0,__v_skip:!0,type:e.type,props:u,key:u&&xi(u),ref:t&&t.ref?n&&o?U(o)?o.concat(jn(t)):[o,jn(t)]:jn(t):o,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:l,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==ce?i===-1?16:i|16:i,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:c,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&Xt(e.ssContent),ssFallback:e.ssFallback&&Xt(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return c&&s&&ar(a,c.clone(a)),a}function de(e=" ",t=0){return W(fs,null,e,t)}function Se(e="",t=!1){return t?(N(),pt(mt,null,e)):W(mt,null,e)}function et(e){return e==null||typeof e=="boolean"?W(mt):U(e)?W(ce,null,e.slice()):wn(e)?ut(e):W(fs,null,String(e))}function ut(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:Xt(e)}function zn(e,t){let n=0;const{shapeFlag:s}=e;if(t==null)t=null;else if(U(t))n=16;else if(typeof t=="object")if(s&65){const r=t.default;r&&(r._c&&(r._d=!1),zn(e,r()),r._c&&(r._d=!0));return}else{n=32;const r=t._;!r&&!ui(t)?t._ctx=be:r===3&&be&&(be.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else if(B(t)){if(s&65){zn(e,{default:t});return}t={default:t,_ctx:be},n=32}else t=String(t),s&64?(n=16,t=[de(t)]):n=8;e.children=t,e.shapeFlag|=n}function Pc(...e){const t={};for(let n=0;nxe||be;let Jn,js;{const e=os(),t=(n,s)=>{let r;return(r=e[n])||(r=e[n]=[]),r.push(s),o=>{r.length>1?r.forEach(i=>i(o)):r[0](o)}};Jn=t("__VUE_INSTANCE_SETTERS__",n=>xe=n),js=t("__VUE_SSR_SETTERS__",n=>Sn=n)}const Nn=e=>{const t=xe;return Jn(e),e.scope.on(),()=>{e.scope.off(),Jn(t)}},Dr=()=>{xe&&xe.scope.off(),Jn(null)};function wi(e){return e.vnode.shapeFlag&4}let Sn=!1;function Mc(e,t=!1,n=!1){t&&js(t);const{props:s,children:r}=e.vnode,o=wi(e);mc(e,s,o,t),_c(e,r,n||t);const i=o?Dc(e,t):void 0;return t&&js(!1),i}function Dc(e,t){const n=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,ec);const{setup:s}=n;if(s){st();const r=e.setupContext=s.length>1?Lc(e):null,o=Nn(e),i=Tn(s,e,0,[e.props,r]),l=bo(i);if(rt(),o(),(l||e.sp)&&!qt(e)&&Qo(e),l){if(i.then(Dr,Dr),t)return i.then(c=>{$r(e,c)}).catch(c=>{cs(c,e,0)});e.asyncDep=i}else $r(e,i)}else Si(e)}function $r(e,t,n){B(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:ee(t)&&(e.setupState=Uo(t)),Si(e)}function Si(e,t,n){const s=e.type;e.render||(e.render=s.render||nt);{const r=Nn(e);st();try{tc(e)}finally{rt(),r()}}}const $c={get(e,t){return ye(e,"get",""),e[t]}};function Lc(e){const t=n=>{e.exposed=n||{}};return{attrs:new Proxy(e.attrs,$c),slots:e.slots,emit:e.emit,expose:t}}function ds(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy(Uo(El(e.exposed)),{get(t,n){if(n in t)return t[n];if(n in mn)return mn[n](e)},has(t,n){return n in t||n in mn}})):e.proxy}function Vc(e,t=!0){return B(e)?e.displayName||e.name:e.name||t&&e.__name}function Fc(e){return B(e)&&"__vccOpts"in e}const $e=(e,t)=>Pl(e,t,Sn);function En(e,t,n){try{qn(-1);const s=arguments.length;return s===2?ee(t)&&!U(t)?wn(t)?W(e,null,[t]):W(e,t):W(e,null,t):(s>3?n=Array.prototype.slice.call(arguments,2):s===3&&wn(n)&&(n=[n]),W(e,t,n))}finally{qn(1)}}const jc="3.5.39";/** +* @vue/runtime-dom v3.5.39 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let Us;const Lr=typeof window<"u"&&window.trustedTypes;if(Lr)try{Us=Lr.createPolicy("vue",{createHTML:e=>e})}catch{}const Ei=Us?e=>Us.createHTML(e):e=>e,Uc="http://www.w3.org/2000/svg",Hc="http://www.w3.org/1998/Math/MathML",at=typeof document<"u"?document:null,Vr=at&&at.createElement("template"),Bc={insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,s)=>{const r=t==="svg"?at.createElementNS(Uc,e):t==="mathml"?at.createElementNS(Hc,e):n?at.createElement(e,{is:n}):at.createElement(e);return e==="select"&&s&&s.multiple!=null&&r.setAttribute("multiple",s.multiple),r},createText:e=>at.createTextNode(e),createComment:e=>at.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>at.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,n,s,r,o){const i=n?n.previousSibling:t.lastChild;if(r&&(r===o||r.nextSibling))for(;t.insertBefore(r.cloneNode(!0),n),!(r===o||!(r=r.nextSibling)););else{Vr.innerHTML=Ei(s==="svg"?`${e}`:s==="mathml"?`${e}`:e);const l=Vr.content;if(s==="svg"||s==="mathml"){const c=l.firstChild;for(;c.firstChild;)l.appendChild(c.firstChild);l.removeChild(c)}t.insertBefore(l,n)}return[i?i.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}},Kc=Symbol("_vtc");function Gc(e,t,n){const s=e[Kc];s&&(t=(t?[t,...s]:[...s]).join(" ")),t==null?e.removeAttribute("class"):n?e.setAttribute("class",t):e.className=t}const Qn=Symbol("_vod"),Ai=Symbol("_vsh"),Wc={name:"show",beforeMount(e,{value:t},{transition:n}){e[Qn]=e.style.display==="none"?"":e.style.display,n&&t?n.beforeEnter(e):ln(e,t)},mounted(e,{value:t},{transition:n}){n&&t&&n.enter(e)},updated(e,{value:t,oldValue:n},{transition:s}){!t!=!n&&(s?t?(s.beforeEnter(e),ln(e,!0),s.enter(e)):s.leave(e,()=>{ln(e,!1)}):ln(e,t))},beforeUnmount(e,{value:t}){ln(e,t)}};function ln(e,t){e.style.display=t?e[Qn]:"none",e[Ai]=!t}const qc=Symbol(""),zc=/(?:^|;)\s*display\s*:/;function Jc(e,t,n){const s=e.style,r=ae(n);let o=!1;if(n&&!r){if(t)if(ae(t))for(const i of t.split(";")){const l=i.slice(0,i.indexOf(":")).trim();n[l]==null&&un(s,l,"")}else for(const i in t)n[i]==null&&un(s,i,"");for(const i in n){i==="display"&&(o=!0);const l=n[i];l!=null?Yc(e,i,!ae(t)&&t?t[i]:void 0,l)||un(s,i,l):un(s,i,"")}}else if(r){if(t!==n){const i=s[qc];i&&(n+=";"+i),s.cssText=n,o=zc.test(n)}}else t&&e.removeAttribute("style");Qn in e&&(e[Qn]=o?s.display:"",e[Ai]&&(s.display="none"))}const Fr=/\s*!important$/;function un(e,t,n){if(U(n))n.forEach(s=>un(e,t,s));else if(n==null&&(n=""),t.startsWith("--"))e.setProperty(t,n);else{const s=Qc(e,t);Fr.test(n)?e.setProperty(Dt(s),n.replace(Fr,""),"important"):e[s]=n}}const jr=["Webkit","Moz","ms"],Rs={};function Qc(e,t){const n=Rs[t];if(n)return n;let s=Oe(t);if(s!=="filter"&&s in e)return Rs[t]=s;s=ss(s);for(let r=0;rCs||(sa.then(()=>Cs=0),Cs=Date.now());function oa(e,t){const n=s=>{if(!s._vts)s._vts=Date.now();else if(s._vts<=n.attached)return;const r=n.value;if(U(r)){const o=s.stopImmediatePropagation;s.stopImmediatePropagation=()=>{o.call(s),s._stopped=!0};const i=r.slice(),l=[s];for(let c=0;ce.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,ia=(e,t,n,s,r,o)=>{const i=r==="svg";t==="class"?Gc(e,s,i):t==="style"?Jc(e,n,s):Zn(t)?es(t)||Zc(e,t,n,s,o):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):la(e,t,s,i))?(Br(e,t,s),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&Hr(e,t,s,i,o,t!=="value")):e._isVueCE&&(ca(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!ae(s)))?Br(e,Oe(t),s,o,t):(t==="true-value"?e._trueValue=s:t==="false-value"&&(e._falseValue=s),Hr(e,t,s,i))};function la(e,t,n,s){if(s)return!!(t==="innerHTML"||t==="textContent"||t in e&&Gr(t)&&B(n));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const r=e.tagName;if(r==="IMG"||r==="VIDEO"||r==="CANVAS"||r==="SOURCE")return!1}return Gr(t)&&ae(n)?!1:t in e}function ca(e,t){const n=e._def.props;if(!n)return!1;const s=Oe(t);return Array.isArray(n)?n.some(r=>Oe(r)===s):Object.keys(n).some(r=>Oe(r)===s)}const Yn=e=>{const t=e.props["onUpdate:modelValue"]||!1;return U(t)?n=>Ln(t,n):t};function aa(e){e.target.composing=!0}function Wr(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const Jt=Symbol("_assign");function qr(e,t,n){return t&&(e=e.trim()),n&&(e=rs(e)),e}const St={created(e,{modifiers:{lazy:t,trim:n,number:s}},r){e[Jt]=Yn(r);const o=s||r.props&&r.props.type==="number";It(e,t?"change":"input",i=>{i.target.composing||e[Jt](qr(e.value,n,o))}),(n||o)&&It(e,"change",()=>{e.value=qr(e.value,n,o)}),t||(It(e,"compositionstart",aa),It(e,"compositionend",Wr),It(e,"change",Wr))},mounted(e,{value:t}){e.value=t??""},beforeUpdate(e,{value:t,oldValue:n,modifiers:{lazy:s,trim:r,number:o}},i){if(e[Jt]=Yn(i),e.composing)return;const l=(o||e.type==="number")&&!/^0\d/.test(e.value)?rs(e.value):e.value,c=t??"";if(l===c)return;const u=e.getRootNode();(u instanceof Document||u instanceof ShadowRoot)&&u.activeElement===e&&e.type!=="range"&&(s&&t===n||r&&e.value.trim()===c)||(e.value=c)}},ps={deep:!0,created(e,{value:t,modifiers:{number:n}},s){const r=ts(t);It(e,"change",()=>{const o=Array.prototype.filter.call(e.options,i=>i.selected).map(i=>n?rs(Xn(i)):Xn(i));e[Jt](e.multiple?r?new Set(o):o:o[0]),e._assigning=!0,lr(()=>{e._assigning=!1})}),e[Jt]=Yn(s)},mounted(e,{value:t}){zr(e,t)},beforeUpdate(e,t,n){e[Jt]=Yn(n)},updated(e,{value:t}){e._assigning||zr(e,t)}};function zr(e,t){const n=e.multiple,s=U(t);if(!(n&&!s&&!ts(t))){for(let r=0,o=e.options.length;rString(u)===String(l)):i.selected=rl(t,l)>-1}else i.selected=t.has(l);else if(Pn(Xn(i),t)){e.selectedIndex!==r&&(e.selectedIndex=r);return}}!n&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function Xn(e){return"_value"in e?e._value:e.value}const ua=["ctrl","shift","alt","meta"],fa={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>ua.some(n=>e[`${n}Key`]&&!t.includes(n))},hs=(e,t)=>{if(!e)return e;const n=e._withMods||(e._withMods={}),s=t.join(".");return n[s]||(n[s]=((r,...o)=>{for(let i=0;i{const t=pa().createApp(...e),{mount:n}=t;return t.mount=s=>{const r=ga(s);if(!r)return;const o=t._component;!B(o)&&!o.render&&!o.template&&(o.template=r.innerHTML),r.nodeType===1&&(r.textContent="");const i=n(r,!1,ma(r));return r instanceof Element&&(r.removeAttribute("v-cloak"),r.setAttribute("data-v-app","")),i},t});function ma(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function ga(e){return ae(e)?document.querySelector(e):e}const va=(e,t)=>{const n=e.__vccOpts||e;for(const[s,r]of t)n[s]=r;return n},ba={};function _a(e,t){const n=Yl("router-view");return N(),pt(n)}const ya=va(ba,[["render",_a]]);/*! + * vue-router v4.6.4 + * (c) 2025 Eduardo San Martin Morote + * @license MIT + */const Bt=typeof document<"u";function Ri(e){return typeof e=="object"||"displayName"in e||"props"in e||"__vccOpts"in e}function xa(e){return e.__esModule||e[Symbol.toStringTag]==="Module"||e.default&&Ri(e.default)}const Q=Object.assign;function Os(e,t){const n={};for(const s in t){const r=t[s];n[s]=Be(r)?r.map(e):e(r)}return n}const vn=()=>{},Be=Array.isArray;function Qr(e,t){const n={};for(const s in e)n[s]=s in t?t[s]:e[s];return n}const Ci=/#/g,wa=/&/g,Sa=/\//g,Ea=/=/g,Aa=/\?/g,Oi=/\+/g,Ra=/%5B/g,Ca=/%5D/g,Pi=/%5E/g,Oa=/%60/g,Ii=/%7B/g,Pa=/%7C/g,Ti=/%7D/g,Ia=/%20/g;function pr(e){return e==null?"":encodeURI(""+e).replace(Pa,"|").replace(Ra,"[").replace(Ca,"]")}function Ta(e){return pr(e).replace(Ii,"{").replace(Ti,"}").replace(Pi,"^")}function Hs(e){return pr(e).replace(Oi,"%2B").replace(Ia,"+").replace(Ci,"%23").replace(wa,"%26").replace(Oa,"`").replace(Ii,"{").replace(Ti,"}").replace(Pi,"^")}function Na(e){return Hs(e).replace(Ea,"%3D")}function ka(e){return pr(e).replace(Ci,"%23").replace(Aa,"%3F")}function Ma(e){return ka(e).replace(Sa,"%2F")}function An(e){if(e==null)return null;try{return decodeURIComponent(""+e)}catch{}return""+e}const Da=/\/$/,$a=e=>e.replace(Da,"");function Ps(e,t,n="/"){let s,r={},o="",i="";const l=t.indexOf("#");let c=t.indexOf("?");return c=l>=0&&c>l?-1:c,c>=0&&(s=t.slice(0,c),o=t.slice(c,l>0?l:t.length),r=e(o.slice(1))),l>=0&&(s=s||t.slice(0,l),i=t.slice(l,t.length)),s=ja(s??t,n),{fullPath:s+o+i,path:s,query:r,hash:An(i)}}function La(e,t){const n=t.query?e(t.query):"";return t.path+(n&&"?")+n+(t.hash||"")}function Yr(e,t){return!t||!e.toLowerCase().startsWith(t.toLowerCase())?e:e.slice(t.length)||"/"}function Va(e,t,n){const s=t.matched.length-1,r=n.matched.length-1;return s>-1&&s===r&&Zt(t.matched[s],n.matched[r])&&Ni(t.params,n.params)&&e(t.query)===e(n.query)&&t.hash===n.hash}function Zt(e,t){return(e.aliasOf||e)===(t.aliasOf||t)}function Ni(e,t){if(Object.keys(e).length!==Object.keys(t).length)return!1;for(var n in e)if(!Fa(e[n],t[n]))return!1;return!0}function Fa(e,t){return Be(e)?Xr(e,t):Be(t)?Xr(t,e):(e==null?void 0:e.valueOf())===(t==null?void 0:t.valueOf())}function Xr(e,t){return Be(t)?e.length===t.length&&e.every((n,s)=>n===t[s]):e.length===1&&e[0]===t}function ja(e,t){if(e.startsWith("/"))return e;if(!e)return t;const n=t.split("/"),s=e.split("/"),r=s[s.length-1];(r===".."||r===".")&&s.push("");let o=n.length-1,i,l;for(i=0;i1&&o--;else break;return n.slice(0,o).join("/")+"/"+s.slice(i).join("/")}const yt={path:"/",name:void 0,params:{},query:{},hash:"",fullPath:"/",matched:[],meta:{},redirectedFrom:void 0};let Bs=(function(e){return e.pop="pop",e.push="push",e})({}),Is=(function(e){return e.back="back",e.forward="forward",e.unknown="",e})({});function Ua(e){if(!e)if(Bt){const t=document.querySelector("base");e=t&&t.getAttribute("href")||"/",e=e.replace(/^\w+:\/\/[^\/]+/,"")}else e="/";return e[0]!=="/"&&e[0]!=="#"&&(e="/"+e),$a(e)}const Ha=/^[^#]+#/;function Ba(e,t){return e.replace(Ha,"#")+t}function Ka(e,t){const n=document.documentElement.getBoundingClientRect(),s=e.getBoundingClientRect();return{behavior:t.behavior,left:s.left-n.left-(t.left||0),top:s.top-n.top-(t.top||0)}}const ms=()=>({left:window.scrollX,top:window.scrollY});function Ga(e){let t;if("el"in e){const n=e.el,s=typeof n=="string"&&n.startsWith("#"),r=typeof n=="string"?s?document.getElementById(n.slice(1)):document.querySelector(n):n;if(!r)return;t=Ka(r,e)}else t=e;"scrollBehavior"in document.documentElement.style?window.scrollTo(t):window.scrollTo(t.left!=null?t.left:window.scrollX,t.top!=null?t.top:window.scrollY)}function Zr(e,t){return(history.state?history.state.position-t:-1)+e}const Ks=new Map;function Wa(e,t){Ks.set(e,t)}function qa(e){const t=Ks.get(e);return Ks.delete(e),t}function za(e){return typeof e=="string"||e&&typeof e=="object"}function ki(e){return typeof e=="string"||typeof e=="symbol"}let ue=(function(e){return e[e.MATCHER_NOT_FOUND=1]="MATCHER_NOT_FOUND",e[e.NAVIGATION_GUARD_REDIRECT=2]="NAVIGATION_GUARD_REDIRECT",e[e.NAVIGATION_ABORTED=4]="NAVIGATION_ABORTED",e[e.NAVIGATION_CANCELLED=8]="NAVIGATION_CANCELLED",e[e.NAVIGATION_DUPLICATED=16]="NAVIGATION_DUPLICATED",e})({});const Mi=Symbol("");ue.MATCHER_NOT_FOUND+"",ue.NAVIGATION_GUARD_REDIRECT+"",ue.NAVIGATION_ABORTED+"",ue.NAVIGATION_CANCELLED+"",ue.NAVIGATION_DUPLICATED+"";function en(e,t){return Q(new Error,{type:e,[Mi]:!0},t)}function ct(e,t){return e instanceof Error&&Mi in e&&(t==null||!!(e.type&t))}const Ja=["params","query","hash"];function Qa(e){if(typeof e=="string")return e;if(e.path!=null)return e.path;const t={};for(const n of Ja)n in e&&(t[n]=e[n]);return JSON.stringify(t,null,2)}function Ya(e){const t={};if(e===""||e==="?")return t;const n=(e[0]==="?"?e.slice(1):e).split("&");for(let s=0;sr&&Hs(r)):[s&&Hs(s)]).forEach(r=>{r!==void 0&&(t+=(t.length?"&":"")+n,r!=null&&(t+="="+r))})}return t}function Xa(e){const t={};for(const n in e){const s=e[n];s!==void 0&&(t[n]=Be(s)?s.map(r=>r==null?null:""+r):s==null?s:""+s)}return t}const Za=Symbol(""),to=Symbol(""),gs=Symbol(""),hr=Symbol(""),Gs=Symbol("");function cn(){let e=[];function t(s){return e.push(s),()=>{const r=e.indexOf(s);r>-1&&e.splice(r,1)}}function n(){e=[]}return{add:t,list:()=>e.slice(),reset:n}}function wt(e,t,n,s,r,o=i=>i()){const i=s&&(s.enterCallbacks[r]=s.enterCallbacks[r]||[]);return()=>new Promise((l,c)=>{const u=h=>{h===!1?c(en(ue.NAVIGATION_ABORTED,{from:n,to:t})):h instanceof Error?c(h):za(h)?c(en(ue.NAVIGATION_GUARD_REDIRECT,{from:t,to:h})):(i&&s.enterCallbacks[r]===i&&typeof h=="function"&&i.push(h),l())},a=o(()=>e.call(s&&s.instances[r],t,n,u));let f=Promise.resolve(a);e.length<3&&(f=f.then(u)),f.catch(h=>c(h))})}function Ts(e,t,n,s,r=o=>o()){const o=[];for(const i of e)for(const l in i.components){let c=i.components[l];if(!(t!=="beforeRouteEnter"&&!i.instances[l]))if(Ri(c)){const u=(c.__vccOpts||c)[t];u&&o.push(wt(u,n,s,i,l,r))}else{let u=c();o.push(()=>u.then(a=>{if(!a)throw new Error(`Couldn't resolve component "${l}" at "${i.path}"`);const f=xa(a)?a.default:a;i.mods[l]=a,i.components[l]=f;const h=(f.__vccOpts||f)[t];return h&&wt(h,n,s,i,l,r)()}))}}return o}function eu(e,t){const n=[],s=[],r=[],o=Math.max(t.matched.length,e.matched.length);for(let i=0;iZt(u,l))?s.push(l):n.push(l));const c=e.matched[i];c&&(t.matched.find(u=>Zt(u,c))||r.push(c))}return[n,s,r]}/*! + * vue-router v4.6.4 + * (c) 2025 Eduardo San Martin Morote + * @license MIT + */let tu=()=>location.protocol+"//"+location.host;function Di(e,t){const{pathname:n,search:s,hash:r}=t,o=e.indexOf("#");if(o>-1){let i=r.includes(e.slice(o))?e.slice(o).length:1,l=r.slice(i);return l[0]!=="/"&&(l="/"+l),Yr(l,"")}return Yr(n,e)+s+r}function nu(e,t,n,s){let r=[],o=[],i=null;const l=({state:h})=>{const v=Di(e,location),T=n.value,A=t.value;let j=0;if(h){if(n.value=v,t.value=h,i&&i===T){i=null;return}j=A?h.position-A.position:0}else s(v);r.forEach(V=>{V(n.value,T,{delta:j,type:Bs.pop,direction:j?j>0?Is.forward:Is.back:Is.unknown})})};function c(){i=n.value}function u(h){r.push(h);const v=()=>{const T=r.indexOf(h);T>-1&&r.splice(T,1)};return o.push(v),v}function a(){if(document.visibilityState==="hidden"){const{history:h}=window;if(!h.state)return;h.replaceState(Q({},h.state,{scroll:ms()}),"")}}function f(){for(const h of o)h();o=[],window.removeEventListener("popstate",l),window.removeEventListener("pagehide",a),document.removeEventListener("visibilitychange",a)}return window.addEventListener("popstate",l),window.addEventListener("pagehide",a),document.addEventListener("visibilitychange",a),{pauseListeners:c,listen:u,destroy:f}}function no(e,t,n,s=!1,r=!1){return{back:e,current:t,forward:n,replaced:s,position:window.history.length,scroll:r?ms():null}}function su(e){const{history:t,location:n}=window,s={value:Di(e,n)},r={value:t.state};r.value||o(s.value,{back:null,current:s.value,forward:null,position:t.length-1,replaced:!0,scroll:null},!0);function o(c,u,a){const f=e.indexOf("#"),h=f>-1?(n.host&&document.querySelector("base")?e:e.slice(f))+c:tu()+e+c;try{t[a?"replaceState":"pushState"](u,"",h),r.value=u}catch(v){console.error(v),n[a?"replace":"assign"](h)}}function i(c,u){o(c,Q({},t.state,no(r.value.back,c,r.value.forward,!0),u,{position:r.value.position}),!0),s.value=c}function l(c,u){const a=Q({},r.value,t.state,{forward:c,scroll:ms()});o(a.current,a,!0),o(c,Q({},no(s.value,c,null),{position:a.position+1},u),!1),s.value=c}return{location:s,state:r,push:l,replace:i}}function ru(e){e=Ua(e);const t=su(e),n=nu(e,t.state,t.location,t.replace);function s(o,i=!0){i||n.pauseListeners(),history.go(o)}const r=Q({location:"",base:e,go:s,createHref:Ba.bind(null,e)},t,n);return Object.defineProperty(r,"location",{enumerable:!0,get:()=>t.location.value}),Object.defineProperty(r,"state",{enumerable:!0,get:()=>t.state.value}),r}let Tt=(function(e){return e[e.Static=0]="Static",e[e.Param=1]="Param",e[e.Group=2]="Group",e})({});var he=(function(e){return e[e.Static=0]="Static",e[e.Param=1]="Param",e[e.ParamRegExp=2]="ParamRegExp",e[e.ParamRegExpEnd=3]="ParamRegExpEnd",e[e.EscapeNext=4]="EscapeNext",e})(he||{});const ou={type:Tt.Static,value:""},iu=/[a-zA-Z0-9_]/;function lu(e){if(!e)return[[]];if(e==="/")return[[ou]];if(!e.startsWith("/"))throw new Error(`Invalid path "${e}"`);function t(v){throw new Error(`ERR (${n})/"${u}": ${v}`)}let n=he.Static,s=n;const r=[];let o;function i(){o&&r.push(o),o=[]}let l=0,c,u="",a="";function f(){u&&(n===he.Static?o.push({type:Tt.Static,value:u}):n===he.Param||n===he.ParamRegExp||n===he.ParamRegExpEnd?(o.length>1&&(c==="*"||c==="+")&&t(`A repeatable param (${u}) must be alone in its segment. eg: '/:ids+.`),o.push({type:Tt.Param,value:u,regexp:a,repeatable:c==="*"||c==="+",optional:c==="*"||c==="?"})):t("Invalid state to consume buffer"),u="")}function h(){u+=c}for(;lt.length?t.length===1&&t[0]===Re.Static+Re.Segment?1:-1:0}function $i(e,t){let n=0;const s=e.score,r=t.score;for(;n0&&t[t.length-1]<0}const du={strict:!1,end:!0,sensitive:!1};function pu(e,t,n){const s=uu(lu(e.path),n),r=Q(s,{record:e,parent:t,children:[],alias:[]});return t&&!r.record.aliasOf==!t.record.aliasOf&&t.children.push(r),r}function hu(e,t){const n=[],s=new Map;t=Qr(du,t);function r(f){return s.get(f)}function o(f,h,v){const T=!v,A=io(f);A.aliasOf=v&&v.record;const j=Qr(t,f),V=[A];if("alias"in f){const k=typeof f.alias=="string"?[f.alias]:f.alias;for(const se of k)V.push(io(Q({},A,{components:v?v.record.components:A.components,path:se,aliasOf:v?v.record:A})))}let P,M;for(const k of V){const{path:se}=k;if(h&&se[0]!=="/"){const pe=h.record.path,le=pe[pe.length-1]==="/"?"":"/";k.path=h.record.path+(se&&le+se)}if(P=pu(k,h,j),v?v.alias.push(P):(M=M||P,M!==P&&M.alias.push(P),T&&f.name&&!lo(P)&&i(f.name)),Li(P)&&c(P),A.children){const pe=A.children;for(let le=0;le{i(M)}:vn}function i(f){if(ki(f)){const h=s.get(f);h&&(s.delete(f),n.splice(n.indexOf(h),1),h.children.forEach(i),h.alias.forEach(i))}else{const h=n.indexOf(f);h>-1&&(n.splice(h,1),f.record.name&&s.delete(f.record.name),f.children.forEach(i),f.alias.forEach(i))}}function l(){return n}function c(f){const h=vu(f,n);n.splice(h,0,f),f.record.name&&!lo(f)&&s.set(f.record.name,f)}function u(f,h){let v,T={},A,j;if("name"in f&&f.name){if(v=s.get(f.name),!v)throw en(ue.MATCHER_NOT_FOUND,{location:f});j=v.record.name,T=Q(oo(h.params,v.keys.filter(M=>!M.optional).concat(v.parent?v.parent.keys.filter(M=>M.optional):[]).map(M=>M.name)),f.params&&oo(f.params,v.keys.map(M=>M.name))),A=v.stringify(T)}else if(f.path!=null)A=f.path,v=n.find(M=>M.re.test(A)),v&&(T=v.parse(A),j=v.record.name);else{if(v=h.name?s.get(h.name):n.find(M=>M.re.test(h.path)),!v)throw en(ue.MATCHER_NOT_FOUND,{location:f,currentLocation:h});j=v.record.name,T=Q({},h.params,f.params),A=v.stringify(T)}const V=[];let P=v;for(;P;)V.unshift(P.record),P=P.parent;return{name:j,path:A,params:T,matched:V,meta:gu(V)}}e.forEach(f=>o(f));function a(){n.length=0,s.clear()}return{addRoute:o,resolve:u,removeRoute:i,clearRoutes:a,getRoutes:l,getRecordMatcher:r}}function oo(e,t){const n={};for(const s of t)s in e&&(n[s]=e[s]);return n}function io(e){const t={path:e.path,redirect:e.redirect,name:e.name,meta:e.meta||{},aliasOf:e.aliasOf,beforeEnter:e.beforeEnter,props:mu(e),children:e.children||[],instances:{},leaveGuards:new Set,updateGuards:new Set,enterCallbacks:{},components:"components"in e?e.components||null:e.component&&{default:e.component}};return Object.defineProperty(t,"mods",{value:{}}),t}function mu(e){const t={},n=e.props||!1;if("component"in e)t.default=n;else for(const s in e.components)t[s]=typeof n=="object"?n[s]:n;return t}function lo(e){for(;e;){if(e.record.aliasOf)return!0;e=e.parent}return!1}function gu(e){return e.reduce((t,n)=>Q(t,n.meta),{})}function vu(e,t){let n=0,s=t.length;for(;n!==s;){const o=n+s>>1;$i(e,t[o])<0?s=o:n=o+1}const r=bu(e);return r&&(s=t.lastIndexOf(r,s-1)),s}function bu(e){let t=e;for(;t=t.parent;)if(Li(t)&&$i(e,t)===0)return t}function Li({record:e}){return!!(e.name||e.components&&Object.keys(e.components).length||e.redirect)}function co(e){const t=Ve(gs),n=Ve(hr),s=$e(()=>{const c=Z(e.to);return t.resolve(c)}),r=$e(()=>{const{matched:c}=s.value,{length:u}=c,a=c[u-1],f=n.matched;if(!a||!f.length)return-1;const h=f.findIndex(Zt.bind(null,a));if(h>-1)return h;const v=ao(c[u-2]);return u>1&&ao(a)===v&&f[f.length-1].path!==v?f.findIndex(Zt.bind(null,c[u-2])):h}),o=$e(()=>r.value>-1&&wu(n.params,s.value.params)),i=$e(()=>r.value>-1&&r.value===n.matched.length-1&&Ni(n.params,s.value.params));function l(c={}){if(xu(c)){const u=t[Z(e.replace)?"replace":"push"](Z(e.to)).catch(vn);return e.viewTransition&&typeof document<"u"&&"startViewTransition"in document&&document.startViewTransition(()=>u),u}return Promise.resolve()}return{route:s,href:$e(()=>s.value.href),isActive:o,isExactActive:i,navigate:l}}function _u(e){return e.length===1?e[0]:e}const yu=Jo({name:"RouterLink",compatConfig:{MODE:3},props:{to:{type:[String,Object],required:!0},replace:Boolean,activeClass:String,exactActiveClass:String,custom:Boolean,ariaCurrentValue:{type:String,default:"page"},viewTransition:Boolean},useLink:co,setup(e,{slots:t}){const n=In(co(e)),{options:s}=Ve(gs),r=$e(()=>({[uo(e.activeClass,s.linkActiveClass,"router-link-active")]:n.isActive,[uo(e.exactActiveClass,s.linkExactActiveClass,"router-link-exact-active")]:n.isExactActive}));return()=>{const o=t.default&&_u(t.default(n));return e.custom?o:En("a",{"aria-current":n.isExactActive?e.ariaCurrentValue:null,href:n.href,onClick:n.navigate,class:r.value},o)}}}),Ws=yu;function xu(e){if(!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)&&!e.defaultPrevented&&!(e.button!==void 0&&e.button!==0)){if(e.currentTarget&&e.currentTarget.getAttribute){const t=e.currentTarget.getAttribute("target");if(/\b_blank\b/i.test(t))return}return e.preventDefault&&e.preventDefault(),!0}}function wu(e,t){for(const n in t){const s=t[n],r=e[n];if(typeof s=="string"){if(s!==r)return!1}else if(!Be(r)||r.length!==s.length||s.some((o,i)=>o.valueOf()!==r[i].valueOf()))return!1}return!0}function ao(e){return e?e.aliasOf?e.aliasOf.path:e.path:""}const uo=(e,t,n)=>e??t??n,Su=Jo({name:"RouterView",inheritAttrs:!1,props:{name:{type:String,default:"default"},route:Object},compatConfig:{MODE:3},setup(e,{attrs:t,slots:n}){const s=Ve(Gs),r=$e(()=>e.route||s.value),o=Ve(to,0),i=$e(()=>{let u=Z(o);const{matched:a}=r.value;let f;for(;(f=a[u])&&!f.components;)u++;return u}),l=$e(()=>r.value.matched[i.value]);Vn(to,$e(()=>i.value+1)),Vn(Za,l),Vn(Gs,r);const c=G();return Fn(()=>[c.value,l.value,e.name],([u,a,f],[h,v,T])=>{a&&(a.instances[f]=u,v&&v!==a&&u&&u===h&&(a.leaveGuards.size||(a.leaveGuards=v.leaveGuards),a.updateGuards.size||(a.updateGuards=v.updateGuards))),u&&a&&(!v||!Zt(a,v)||!h)&&(a.enterCallbacks[f]||[]).forEach(A=>A(u))},{flush:"post"}),()=>{const u=r.value,a=e.name,f=l.value,h=f&&f.components[a];if(!h)return fo(n.default,{Component:h,route:u});const v=f.props[a],T=v?v===!0?u.params:typeof v=="function"?v(u):v:null,j=En(h,Q({},T,t,{onVnodeUnmounted:V=>{V.component.isUnmounted&&(f.instances[a]=null)},ref:c}));return fo(n.default,{Component:j,route:u})||j}}});function fo(e,t){if(!e)return null;const n=e(t);return n.length===1?n[0]:n}const Vi=Su;function Eu(e){const t=hu(e.routes,e),n=e.parseQuery||Ya,s=e.stringifyQuery||eo,r=e.history,o=cn(),i=cn(),l=cn(),c=Al(yt);let u=yt;Bt&&e.scrollBehavior&&"scrollRestoration"in history&&(history.scrollRestoration="manual");const a=Os.bind(null,y=>""+y),f=Os.bind(null,Ma),h=Os.bind(null,An);function v(y,I){let C,D;return ki(y)?(C=t.getRecordMatcher(y),D=I):D=y,t.addRoute(D,C)}function T(y){const I=t.getRecordMatcher(y);I&&t.removeRoute(I)}function A(){return t.getRoutes().map(y=>y.record)}function j(y){return!!t.getRecordMatcher(y)}function V(y,I){if(I=Q({},I||c.value),typeof y=="string"){const m=Ps(n,y,I.path),_=t.resolve({path:m.path},I),x=r.createHref(m.fullPath);return Q(m,_,{params:h(_.params),hash:An(m.hash),redirectedFrom:void 0,href:x})}let C;if(y.path!=null)C=Q({},y,{path:Ps(n,y.path,I.path).path});else{const m=Q({},y.params);for(const _ in m)m[_]==null&&delete m[_];C=Q({},y,{params:f(m)}),I.params=f(I.params)}const D=t.resolve(C,I),q=y.hash||"";D.params=a(h(D.params));const d=La(s,Q({},y,{hash:Ta(q),path:D.path})),p=r.createHref(d);return Q({fullPath:d,hash:q,query:s===eo?Xa(y.query):y.query||{}},D,{redirectedFrom:void 0,href:p})}function P(y){return typeof y=="string"?Ps(n,y,c.value.path):Q({},y)}function M(y,I){if(u!==y)return en(ue.NAVIGATION_CANCELLED,{from:I,to:y})}function k(y){return le(y)}function se(y){return k(Q(P(y),{replace:!0}))}function pe(y,I){const C=y.matched[y.matched.length-1];if(C&&C.redirect){const{redirect:D}=C;let q=typeof D=="function"?D(y,I):D;return typeof q=="string"&&(q=q.includes("?")||q.includes("#")?q=P(q):{path:q},q.params={}),Q({query:y.query,hash:y.hash,params:q.path!=null?{}:y.params},q)}}function le(y,I){const C=u=V(y),D=c.value,q=y.state,d=y.force,p=y.replace===!0,m=pe(C,D);if(m)return le(Q(P(m),{state:typeof m=="object"?Q({},q,m.state):q,force:d,replace:p}),I||C);const _=C;_.redirectedFrom=I;let x;return!d&&Va(s,D,C)&&(x=en(ue.NAVIGATION_DUPLICATED,{to:_,from:D}),qe(D,D,!0,!1)),(x?Promise.resolve(x):Ge(_,D)).catch(b=>ct(b)?ct(b,ue.NAVIGATION_GUARD_REDIRECT)?b:_t(b):J(b,_,D)).then(b=>{if(b){if(ct(b,ue.NAVIGATION_GUARD_REDIRECT))return le(Q({replace:p},P(b.to),{state:typeof b.to=="object"?Q({},q,b.to.state):q,force:d}),I||_)}else b=At(_,D,!0,p,q);return bt(_,D,b),b})}function Ke(y,I){const C=M(y,I);return C?Promise.reject(C):Promise.resolve()}function vt(y){const I=Vt.values().next().value;return I&&typeof I.runWithContext=="function"?I.runWithContext(y):y()}function Ge(y,I){let C;const[D,q,d]=eu(y,I);C=Ts(D.reverse(),"beforeRouteLeave",y,I);for(const m of D)m.leaveGuards.forEach(_=>{C.push(wt(_,y,I))});const p=Ke.bind(null,y,I);return C.push(p),De(C).then(()=>{C=[];for(const m of o.list())C.push(wt(m,y,I));return C.push(p),De(C)}).then(()=>{C=Ts(q,"beforeRouteUpdate",y,I);for(const m of q)m.updateGuards.forEach(_=>{C.push(wt(_,y,I))});return C.push(p),De(C)}).then(()=>{C=[];for(const m of d)if(m.beforeEnter)if(Be(m.beforeEnter))for(const _ of m.beforeEnter)C.push(wt(_,y,I));else C.push(wt(m.beforeEnter,y,I));return C.push(p),De(C)}).then(()=>(y.matched.forEach(m=>m.enterCallbacks={}),C=Ts(d,"beforeRouteEnter",y,I,vt),C.push(p),De(C))).then(()=>{C=[];for(const m of i.list())C.push(wt(m,y,I));return C.push(p),De(C)}).catch(m=>ct(m,ue.NAVIGATION_CANCELLED)?m:Promise.reject(m))}function bt(y,I,C){l.list().forEach(D=>vt(()=>D(y,I,C)))}function At(y,I,C,D,q){const d=M(y,I);if(d)return d;const p=I===yt,m=Bt?history.state:{};C&&(D||p?r.replace(y.fullPath,Q({scroll:p&&m&&m.scroll},q)):r.push(y.fullPath,q)),c.value=y,qe(y,I,C,p),_t()}let We;function nn(){We||(We=r.listen((y,I,C)=>{if(!Rt.listening)return;const D=V(y),q=pe(D,Rt.currentRoute.value);if(q){le(Q(q,{replace:!0,force:!0}),D).catch(vn);return}u=D;const d=c.value;Bt&&Wa(Zr(d.fullPath,C.delta),ms()),Ge(D,d).catch(p=>ct(p,ue.NAVIGATION_ABORTED|ue.NAVIGATION_CANCELLED)?p:ct(p,ue.NAVIGATION_GUARD_REDIRECT)?(le(Q(P(p.to),{force:!0}),D).then(m=>{ct(m,ue.NAVIGATION_ABORTED|ue.NAVIGATION_DUPLICATED)&&!C.delta&&C.type===Bs.pop&&r.go(-1,!1)}).catch(vn),Promise.reject()):(C.delta&&r.go(-C.delta,!1),J(p,D,d))).then(p=>{p=p||At(D,d,!1),p&&(C.delta&&!ct(p,ue.NAVIGATION_CANCELLED)?r.go(-C.delta,!1):C.type===Bs.pop&&ct(p,ue.NAVIGATION_ABORTED|ue.NAVIGATION_DUPLICATED)&&r.go(-1,!1)),bt(D,d,p)}).catch(vn)}))}let $t=cn(),me=cn(),te;function J(y,I,C){_t(y);const D=me.list();return D.length?D.forEach(q=>q(y,I,C)):console.error(y),Promise.reject(y)}function it(){return te&&c.value!==yt?Promise.resolve():new Promise((y,I)=>{$t.add([y,I])})}function _t(y){return te||(te=!y,nn(),$t.list().forEach(([I,C])=>y?C(y):I()),$t.reset()),y}function qe(y,I,C,D){const{scrollBehavior:q}=e;if(!Bt||!q)return Promise.resolve();const d=!C&&qa(Zr(y.fullPath,0))||(D||!C)&&history.state&&history.state.scroll||null;return lr().then(()=>q(y,I,d)).then(p=>p&&Ga(p)).catch(p=>J(p,y,I))}const Pe=y=>r.go(y);let Lt;const Vt=new Set,Rt={currentRoute:c,listening:!0,addRoute:v,removeRoute:T,clearRoutes:t.clearRoutes,hasRoute:j,getRoutes:A,resolve:V,options:e,push:k,replace:se,go:Pe,back:()=>Pe(-1),forward:()=>Pe(1),beforeEach:o.add,beforeResolve:i.add,afterEach:l.add,onError:me.add,isReady:it,install(y){y.component("RouterLink",Ws),y.component("RouterView",Vi),y.config.globalProperties.$router=Rt,Object.defineProperty(y.config.globalProperties,"$route",{enumerable:!0,get:()=>Z(c)}),Bt&&!Lt&&c.value===yt&&(Lt=!0,k(r.location).catch(D=>{}));const I={};for(const D in yt)Object.defineProperty(I,D,{get:()=>c.value[D],enumerable:!0});y.provide(gs,Rt),y.provide(hr,Fo(I)),y.provide(Gs,c);const C=y.unmount;Vt.add(y),y.unmount=function(){Vt.delete(y),Vt.size<1&&(u=yt,We&&We(),We=null,c.value=yt,Lt=!1,te=!1),C()}}};function De(y){return y.reduce((I,C)=>I.then(()=>vt(C)),Promise.resolve())}return Rt}function Fi(){return Ve(gs)}function Au(e){return Ve(hr)}const qs="sms_gw_token",zs="sms_gw_api_base";function ji(){return localStorage.getItem(qs)||""}function po(e){e?localStorage.setItem(qs,e):localStorage.removeItem(qs)}function Js(){return localStorage.getItem(zs)||""}function Ru(e){const t=(e||"").trim().replace(/\/+$/,"");t?localStorage.setItem(zs,t):localStorage.removeItem(zs)}class Cu extends Error{constructor(t,n){super(n),this.status=t}}async function $n(e,t,n){const s={},r=ji();r&&(s.Authorization="Bearer "+r),n!==void 0&&(s["Content-Type"]="application/json");const o=await fetch(Js()+"/api"+t,{method:e,headers:s,body:n!==void 0?JSON.stringify(n):void 0}),i=await o.text();let l=null;try{l=i?JSON.parse(i):null}catch{l=i}if(!o.ok){const c=l&&l.error||o.statusText||"Request failed";throw new Cu(o.status,c)}return l}const Me={get:e=>$n("GET",e),post:(e,t)=>$n("POST",e,t),patch:(e,t)=>$n("PATCH",e,t),del:e=>$n("DELETE",e)},Qs="sms_gw_user",jt=In({user:JSON.parse(localStorage.getItem(Qs)||"null"),token:ji()}),Rn={state:Un(jt),isAuthenticated(){return!!jt.token},async login(e,t){const n=await Me.post("/auth/login",{email:e,password:t});return jt.token=n.access_token,jt.user=n.user,po(n.access_token),localStorage.setItem(Qs,JSON.stringify(n.user)),n.user},logout(){jt.token="",jt.user=null,po(""),localStorage.removeItem(Qs)}};/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const ho=e=>e==="";/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ou=(...e)=>e.filter((t,n,s)=>!!t&&t.trim()!==""&&s.indexOf(t)===n).join(" ").trim();/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const mo=e=>e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase();/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Pu=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(t,n,s)=>s?s.toUpperCase():n.toLowerCase());/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Iu=e=>{const t=Pu(e);return t.charAt(0).toUpperCase()+t.slice(1)};/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */var Ut={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":2,"stroke-linecap":"round","stroke-linejoin":"round"};/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Tu=Symbol("lucide-icons");function Nu(){return Ve(Tu,{})}/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const ku=({name:e,iconNode:t,absoluteStrokeWidth:n,"absolute-stroke-width":s,strokeWidth:r,"stroke-width":o,size:i,color:l,...c},{slots:u})=>{const{size:a,color:f,strokeWidth:h=2,absoluteStrokeWidth:v=!1,class:T=""}=Nu(),A=$e(()=>{const j=ho(n)||ho(s)||n===!0||s===!0||v===!0,V=r||o||h||Ut["stroke-width"];return j?Number(V)*24/Number(i??a??Ut.width):V});return En("svg",{...Ut,...c,width:i??a??Ut.width,height:i??a??Ut.height,stroke:l??f??Ut.stroke,"stroke-width":A.value,class:Ou("lucide",T,...e?[`lucide-${mo(Iu(e))}-icon`,`lucide-${mo(e)}`]:["lucide-icon"])},[...t.map(j=>En(...j)),...u.default?[u.default()]:[]])};/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ee=(e,t)=>(n,{slots:s,attrs:r})=>En(ku,{...r,...n,iconNode:t,name:e},s.default?{default:s.default}:void 0);/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Mu=[["path",{d:"M17 7 7 17",key:"15tmo1"}],["path",{d:"M17 17H7V7",key:"1org7z"}]],Du=Ee("arrow-down-left",Mu);/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const $u=[["path",{d:"m6 9 6 6 6-6",key:"qrunsl"}]],Lu=Ee("chevron-down",$u);/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Vu=[["path",{d:"m18 15-6-6-6 6",key:"153udz"}]],Fu=Ee("chevron-up",Vu);/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const ju=[["polyline",{points:"22 12 16 12 14 15 10 15 8 12 2 12",key:"o97t9d"}],["path",{d:"M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z",key:"oot6mr"}]],Uu=Ee("inbox",ju);/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Hu=[["path",{d:"m16 17 5-5-5-5",key:"1bji2h"}],["path",{d:"M21 12H9",key:"dn1m92"}],["path",{d:"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4",key:"1uf3rs"}]],Bu=Ee("log-out",Hu);/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ku=[["path",{d:"M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z",key:"18887p"}]],Gu=Ee("message-square",Ku);/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Wu=[["path",{d:"M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401",key:"kfwtm"}]],qu=Ee("moon",Wu);/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const zu=[["path",{d:"m16 8 6-6",key:"oawc05"}],["path",{d:"M22 8V2h-6",key:"oqy2zc"}],["path",{d:"M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384",key:"9njp5v"}]],Ju=Ee("phone-outgoing",zu);/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Qu=[["path",{d:"M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384",key:"9njp5v"}]],Ui=Ee("phone",Qu);/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Yu=[["path",{d:"M5 12h14",key:"1ays0h"}],["path",{d:"M12 5v14",key:"s699le"}]],Xu=Ee("plus",Yu);/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Zu=[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8",key:"v9h5vc"}],["path",{d:"M21 3v5h-5",key:"1q7to0"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16",key:"3uifl3"}],["path",{d:"M8 16H3v5",key:"1cv678"}]],mr=Ee("refresh-cw",Zu);/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const ef=[["path",{d:"M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z",key:"1ffxy3"}],["path",{d:"m21.854 2.147-10.94 10.939",key:"12cjpa"}]],Hi=Ee("send",ef);/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const tf=[["path",{d:"M14 17H5",key:"gfn3mx"}],["path",{d:"M19 7h-9",key:"6i9tg"}],["circle",{cx:"17",cy:"17",r:"3",key:"18b49y"}],["circle",{cx:"7",cy:"7",r:"3",key:"dfmy0x"}]],nf=Ee("settings-2",tf);/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const sf=[["rect",{width:"14",height:"20",x:"5",y:"2",rx:"2",ry:"2",key:"1yt0o3"}],["path",{d:"M12 18h.01",key:"mhygvu"}]],rf=Ee("smartphone",sf);/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const of=[["circle",{cx:"12",cy:"12",r:"4",key:"4exip2"}],["path",{d:"M12 2v2",key:"tus03m"}],["path",{d:"M12 20v2",key:"1lh1kg"}],["path",{d:"m4.93 4.93 1.41 1.41",key:"149t6j"}],["path",{d:"m17.66 17.66 1.41 1.41",key:"ptbguv"}],["path",{d:"M2 12h2",key:"1t8f8n"}],["path",{d:"M20 12h2",key:"1q8mjw"}],["path",{d:"m6.34 17.66-1.41 1.41",key:"1m8zz5"}],["path",{d:"m19.07 4.93-1.41 1.41",key:"1shlcs"}]],lf=Ee("sun",of);/** + * @license @lucide/vue v1.23.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const cf=[["path",{d:"M18 16.98h-5.99c-1.1 0-1.95.94-2.48 1.9A4 4 0 0 1 2 17c.01-.7.2-1.4.57-2",key:"q3hayz"}],["path",{d:"m6 17 3.13-5.78c.53-.97.1-2.18-.5-3.1a4 4 0 1 1 6.89-4.06",key:"1go1hn"}],["path",{d:"m12 6 3.13 5.73C15.66 12.7 16.9 13 18 13a4 4 0 0 1 0 8",key:"qlwsc0"}]],af=Ee("webhook",cf),Bi="gsmnode-theme";function uf(){try{return localStorage.getItem(Bi)==="dark"?"dark":"light"}catch{return"light"}}const Mt=G(uf());function Ki(e){Mt.value=e,document.documentElement.setAttribute("data-gsm-theme",e);try{localStorage.setItem(Bi,e)}catch{}}function go(){Ki(Mt.value==="dark"?"light":"dark")}Ki(Mt.value);const ff={class:"flex min-h-screen items-center justify-center bg-page p-4"},df={class:"w-full max-w-sm rounded-xl border border-subtle bg-card p-8 shadow-lg"},pf={class:"mb-7 text-center"},hf=["src"],mf={class:"rounded-md border border-subtle"},gf={class:"flex items-center gap-2"},vf={class:"border-t border-subtle p-3"},bf={key:0,class:"rounded-md bg-danger-tint px-3 py-2 text-sm text-danger"},_f=["disabled"],yf={__name:"Login",setup(e){const t=Fi(),n=Au(),s=G(""),r=G(""),o=G(""),i=G(!1),l=G(Js()),c=G(!!Js());async function u(){o.value="",i.value=!0;try{Ru(l.value),await Rn.login(s.value.trim(),r.value),t.push(n.query.redirect||"/")}catch(a){a.status===401?o.value="Invalid email or password.":a.status===void 0?o.value="Cannot reach the API Server. Check the server URL.":o.value=a.message}finally{i.value=!1}}return(a,f)=>(N(),L("div",ff,[g("div",df,[g("div",pf,[g("img",{src:Z(Mt)==="dark"?"/gsmnode-horizontal-white.png":"/gsmnode-horizontal.png",alt:"gsmnode",class:"mx-auto h-8"},null,8,hf),f[4]||(f[4]=g("p",{class:"gn-eyebrow mt-4"},"Sign in to your gateway",-1))]),g("form",{class:"space-y-4",onSubmit:hs(u,["prevent"])},[g("div",null,[f[5]||(f[5]=g("label",{class:"mb-1.5 block text-sm font-medium text-primary"},"Email",-1)),Ne(g("input",{"onUpdate:modelValue":f[0]||(f[0]=h=>s.value=h),type:"email",required:"",autocomplete:"username",class:"gn-input",placeholder:"you@example.com"},null,512),[[St,s.value]])]),g("div",null,[f[6]||(f[6]=g("label",{class:"mb-1.5 block text-sm font-medium text-primary"},"Password",-1)),Ne(g("input",{"onUpdate:modelValue":f[1]||(f[1]=h=>r.value=h),type:"password",required:"",autocomplete:"current-password",class:"gn-input",placeholder:"••••••••"},null,512),[[St,r.value]])]),g("div",mf,[g("button",{type:"button",class:"flex w-full items-center justify-between rounded-md px-3 py-2 text-sm font-medium text-secondary transition-colors hover:bg-sunken",onClick:f[2]||(f[2]=h=>c.value=!c.value)},[g("span",gf,[W(Z(nf),{class:"h-4 w-4"}),f[7]||(f[7]=de("Server settings ",-1))]),c.value?(N(),pt(Z(Fu),{key:0,class:"h-4 w-4 text-muted"})):(N(),pt(Z(Lu),{key:1,class:"h-4 w-4 text-muted"}))]),Ne(g("div",vf,[f[8]||(f[8]=g("label",{class:"mb-1.5 block text-sm font-medium text-primary"},"API Server URL",-1)),Ne(g("input",{"onUpdate:modelValue":f[3]||(f[3]=h=>l.value=h),type:"url",autocomplete:"off",class:"gn-input font-mono !text-xs",placeholder:"http://10.2.1.101:8080"},null,512),[[St,l.value]]),f[9]||(f[9]=g("p",{class:"mt-1.5 text-xs text-muted"}," Leave blank to use this site's built-in server. ",-1))],512),[[Wc,c.value]])]),o.value?(N(),L("p",bf,K(o.value),1)):Se("",!0),g("button",{type:"submit",disabled:i.value,class:"gn-btn-pri w-full"},K(i.value?"Signing in…":"Sign in"),9,_f)],32)])]))}},xf=["title"],wf={class:"relative flex h-2 w-2"},Sf={key:0,class:"absolute inline-flex h-full w-full animate-ping rounded-full opacity-60",style:{background:"var(--success)"}},Ef={__name:"ApiStatus",setup(e){const t=G("checking"),n=G(null);let s=null;async function r(){const l=performance.now();try{await Me.get("/health"),n.value=Math.round(performance.now()-l),t.value="online"}catch{n.value=null,t.value="offline"}}Et(()=>{r(),s=setInterval(r,1e4)}),ur(()=>clearInterval(s));const o={checking:"background: var(--gray-300)",online:"background: var(--success)",offline:"background: var(--danger)"},i={checking:"Checking…",online:"API online",offline:"API offline"};return(l,c)=>(N(),L("div",{class:"flex items-center gap-2",title:t.value==="online"?`API Server reachable · ${n.value} ms`:t.value==="offline"?"API Server unreachable":"Checking API Server"},[g("span",wf,[t.value==="online"?(N(),L("span",Sf)):Se("",!0),g("span",{class:"relative inline-flex h-2 w-2 rounded-full",style:is(o[t.value])},null,4)]),g("span",{class:On(["font-mono text-xs",t.value==="offline"?"text-danger":"text-secondary"])},[de(K(i[t.value]),1),n.value!==null?(N(),L(ce,{key:0},[de(" · "+K(n.value)+"ms",1)],64)):Se("",!0)],2)],8,xf))}},Af={class:"flex min-h-screen"},Rf={class:"hidden w-60 shrink-0 flex-col border-r border-subtle bg-card sm:flex"},Cf={class:"px-5 pb-4 pt-5"},Of=["src"],Pf={class:"flex-1 space-y-0.5 px-3"},If={class:"border-t border-subtle px-5 py-4"},Tf={class:"flex min-w-0 flex-1 flex-col"},Nf={class:"flex h-16 items-center justify-between border-b border-subtle bg-card px-6"},kf={class:"flex gap-1 overflow-x-auto sm:hidden"},Mf={class:"flex items-center gap-3 text-sm"},Df={class:"hidden font-mono text-xs text-secondary md:inline"},$f=["title"],Lf={class:"flex-1 p-7"},Vf={__name:"Layout",setup(e){const t=Fi(),n=[{name:"devices",label:"Devices",icon:rf},{name:"send",label:"Send SMS",icon:Hi},{name:"call",label:"Call",icon:Ui},{name:"messages",label:"Messages",icon:Gu},{name:"inbox",label:"Inbox",icon:Uu},{name:"webhooks",label:"Webhooks",icon:af}];function s(){Rn.logout(),t.push({name:"login"})}return(r,o)=>{var i;return N(),L("div",Af,[g("aside",Rf,[g("div",Cf,[g("img",{src:Z(Mt)==="dark"?"/gsmnode-horizontal-white.png":"/gsmnode-horizontal.png",alt:"gsmnode",class:"h-6"},null,8,Of)]),o[1]||(o[1]=g("div",{class:"gn-eyebrow px-5 pb-2"},"Gateway",-1)),g("nav",Pf,[(N(),L(ce,null,ot(n,l=>W(Z(Ws),{key:l.name,to:{name:l.name},class:"flex items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium text-secondary transition-colors hover:bg-sunken hover:text-primary","active-class":"!bg-brand-tint !text-brand-active"},{default:Yt(()=>[(N(),pt(Xl(l.icon),{class:"h-[18px] w-[18px]"})),de(K(l.label),1)]),_:2},1032,["to"])),64))]),g("div",If,[W(Ef)])]),g("div",Tf,[g("header",Nf,[g("nav",kf,[(N(),L(ce,null,ot(n,l=>W(Z(Ws),{key:l.name,to:{name:l.name},class:"rounded-sm px-2 py-1 text-xs font-medium whitespace-nowrap text-secondary","active-class":"!bg-brand-tint !text-brand-active"},{default:Yt(()=>[de(K(l.label),1)]),_:2},1032,["to"])),64))]),o[4]||(o[4]=g("div",{class:"hidden sm:block"},null,-1)),g("div",Mf,[g("span",Df,K((i=Z(Rn).state.user)==null?void 0:i.email),1),o[3]||(o[3]=g("span",{class:"hidden h-4 w-px bg-subtle sm:block"},null,-1)),g("button",{class:"gn-btn-sec gn-btn-sm",title:Z(Mt)==="dark"?"Switch to light theme":"Switch to dark theme",onClick:o[0]||(o[0]=(...l)=>Z(go)&&Z(go)(...l))},[Z(Mt)==="dark"?(N(),pt(Z(lf),{key:0,class:"h-4 w-4"})):(N(),pt(Z(qu),{key:1,class:"h-4 w-4"}))],8,$f),g("button",{class:"gn-btn-sec gn-btn-sm",onClick:s},[W(Z(Bu),{class:"h-4 w-4"}),o[2]||(o[2]=de("Sign out ",-1))])])]),g("main",Lf,[W(Z(Vi))])])])}}},Ff={class:"mb-6 flex items-end justify-between gap-4"},jf={class:"text-2xl font-semibold tracking-[-0.02em] text-primary"},Uf={key:0,class:"mt-0.5 text-sm text-secondary"},tn={__name:"PageHeader",props:{title:String,subtitle:String},setup(e){return(t,n)=>(N(),L("div",Ff,[g("div",null,[g("h2",jf,K(e.title),1),e.subtitle?(N(),L("p",Uf,K(e.subtitle),1)):Se("",!0)]),Zl(t.$slots,"actions")]))}},Gi={__name:"StatusBadge",props:{status:String},setup(e){const t=e,n={Pending:"bg-subtle text-secondary",Processed:"bg-brand-tint text-brand-active",Sent:"bg-warning-tint text-warning",Delivered:"bg-success-tint text-success",Failed:"bg-danger-tint text-danger",online:"bg-success-tint text-success",offline:"bg-subtle text-muted"};return(s,r)=>(N(),L("span",{class:On(["inline-flex items-center gap-1.5 rounded-sm px-2 py-0.5 font-mono text-[11px] font-medium",n[t.status]||"bg-subtle text-secondary"])},[r[0]||(r[0]=g("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),de(" "+K(e.status||"—"),1)],2))}},Hf={key:0,class:"mb-4 rounded-md bg-danger-tint px-3 py-2 text-sm text-danger"},Bf={class:"overflow-hidden rounded-lg border border-subtle bg-card shadow-xs"},Kf={class:"w-full text-left text-sm"},Gf={key:0},Wf={key:1},qf={class:"border-t border-subtle px-5 py-3 font-medium text-primary"},zf={class:"border-t border-subtle px-5 py-3 font-mono text-xs text-secondary"},Jf={class:"border-t border-subtle px-5 py-3 text-secondary"},Qf={class:"border-t border-subtle px-5 py-3"},Yf={class:"border-t border-subtle px-5 py-3 font-mono text-xs text-secondary"},Xf={class:"border-t border-subtle px-5 py-3 text-right"},Zf=["onClick"],ed={__name:"Devices",setup(e){const t=G([]),n=G(!0),s=G("");async function r(){n.value=!0,s.value="";try{const l=await Me.get("/devices");t.value=l.items||[]}catch(l){s.value=l.message}finally{n.value=!1}}async function o(l){if(confirm(`Remove device "${l.name||l.device_id}"?`))try{await Me.del("/devices/"+l.id),t.value=t.value.filter(c=>c.id!==l.id)}catch(c){alert("Could not remove device: "+c.message)}}function i(l){return l?new Date(l).toLocaleString():"—"}return Et(r),(l,c)=>(N(),L("div",null,[W(tn,{title:"Devices",subtitle:"Phones connected to your gateway"},{actions:Yt(()=>[g("button",{class:"gn-btn-sec gn-btn-sm",onClick:r},[W(Z(mr),{class:"h-3.5 w-3.5"}),c[0]||(c[0]=de("Refresh ",-1))])]),_:1}),s.value?(N(),L("p",Hf,K(s.value),1)):Se("",!0),g("div",Bf,[g("table",Kf,[c[3]||(c[3]=g("thead",null,[g("tr",{class:"gn-eyebrow"},[g("th",{class:"px-5 py-3 font-medium"},"Name"),g("th",{class:"px-5 py-3 font-medium"},"Device ID"),g("th",{class:"px-5 py-3 font-medium"},"Platform"),g("th",{class:"px-5 py-3 font-medium"},"Status"),g("th",{class:"px-5 py-3 font-medium"},"Last seen"),g("th",{class:"px-5 py-3"})])],-1)),g("tbody",null,[n.value?(N(),L("tr",Gf,[...c[1]||(c[1]=[g("td",{colspan:"6",class:"border-t border-subtle px-5 py-10 text-center text-sm text-muted"},"Loading…",-1)])])):t.value.length?Se("",!0):(N(),L("tr",Wf,[...c[2]||(c[2]=[g("td",{colspan:"6",class:"border-t border-subtle px-5 py-10 text-center text-sm text-muted"}," No devices yet. Register one from the phone app. ",-1)])])),(N(!0),L(ce,null,ot(t.value,u=>(N(),L("tr",{key:u.id,class:"transition-colors hover:bg-sunken"},[g("td",qf,K(u.name||"—"),1),g("td",zf,K(u.device_id),1),g("td",Jf,K(u.platform)+" "+K(u.app_version),1),g("td",Qf,[W(Gi,{status:u.status},null,8,["status"])]),g("td",Yf,K(i(u.last_seen_at)),1),g("td",Xf,[g("button",{class:"text-sm font-medium text-danger hover:underline",onClick:a=>o(u)},"Remove",8,Zf)])]))),128))])])])]))}},td={class:"max-w-2xl"},nd={class:"space-y-5 p-6"},sd={class:"grid grid-cols-2 gap-4"},rd=["value"],od={key:0,class:"rounded-md bg-danger-tint px-3 py-2 text-sm text-danger"},id={key:1,class:"rounded-md bg-success-tint px-3 py-2 text-sm text-success"},ld={class:"font-mono text-xs"},cd=["disabled"],ad={__name:"Send",setup(e){const t=G([]),n=G(""),s=G(""),r=G(""),o=G(""),i=G(!1),l=G(null),c=G("");Et(async()=>{try{const a=await Me.get("/devices");t.value=a.items||[]}catch{}});async function u(){c.value="",l.value=null;const a=n.value.split(/[\n,;]+/).map(f=>f.trim()).filter(Boolean);if(!a.length){c.value="Enter at least one phone number.";return}if(!s.value.trim()){c.value="Message text is required.";return}i.value=!0;try{const f={phone_numbers:a,text_message:s.value};r.value&&(f.device_id=r.value),o.value&&(f.sim_number=Number(o.value)),l.value=await Me.post("/messages",f),s.value=""}catch(f){c.value=f.message}finally{i.value=!1}}return(a,f)=>(N(),L("div",td,[W(tn,{title:"Send SMS",subtitle:"Queue an outbound message for a device"}),g("form",{class:"rounded-lg border border-subtle bg-card shadow-sm",onSubmit:hs(u,["prevent"])},[f[11]||(f[11]=g("div",{class:"border-b border-subtle px-6 py-4"},[g("div",{class:"text-base font-semibold text-primary"},"New message"),g("div",{class:"mt-0.5 font-mono text-[11px] text-muted"},"POST /api/messages")],-1)),g("div",nd,[g("div",null,[f[4]||(f[4]=g("label",{class:"mb-1.5 block text-sm font-medium text-primary"},"Phone numbers",-1)),Ne(g("textarea",{"onUpdate:modelValue":f[0]||(f[0]=h=>n.value=h),rows:"2",class:"gn-textarea font-mono !text-xs",placeholder:"+15551234567, +15559876543"},null,512),[[St,n.value]]),f[5]||(f[5]=g("p",{class:"mt-1.5 text-xs text-muted"},"Separate multiple numbers with commas or new lines.",-1))]),g("div",null,[f[6]||(f[6]=g("label",{class:"mb-1.5 block text-sm font-medium text-primary"},"Message",-1)),Ne(g("textarea",{"onUpdate:modelValue":f[1]||(f[1]=h=>s.value=h),rows:"4",class:"gn-textarea",placeholder:"Your message…"},null,512),[[St,s.value]])]),g("div",sd,[g("div",null,[f[8]||(f[8]=g("label",{class:"mb-1.5 block text-sm font-medium text-primary"},"Device",-1)),Ne(g("select",{"onUpdate:modelValue":f[2]||(f[2]=h=>r.value=h),class:"gn-input"},[f[7]||(f[7]=g("option",{value:""},"Auto (most recent)",-1)),(N(!0),L(ce,null,ot(t.value,h=>(N(),L("option",{key:h.id,value:h.device_id},K(h.name||h.device_id),9,rd))),128))],512),[[ps,r.value]])]),g("div",null,[f[9]||(f[9]=g("label",{class:"mb-1.5 block text-sm font-medium text-primary"},"SIM number (optional)",-1)),Ne(g("input",{"onUpdate:modelValue":f[3]||(f[3]=h=>o.value=h),type:"number",min:"1",class:"gn-input",placeholder:"1"},null,512),[[St,o.value]])])]),c.value?(N(),L("p",od,K(c.value),1)):Se("",!0),l.value?(N(),L("p",id,[f[10]||(f[10]=de(" Queued — message ",-1)),g("span",ld,K(l.value.id),1),de(" ("+K(l.value.status)+") ",1)])):Se("",!0),g("button",{type:"submit",disabled:i.value,class:"gn-btn-pri"},[W(Z(Hi),{class:"h-4 w-4"}),de(K(i.value?"Queuing…":"Send message"),1)],8,cd)])],32)]))}},ud={class:"max-w-2xl"},fd={class:"space-y-5 p-6"},dd=["value"],pd={key:0,class:"rounded-md bg-danger-tint px-3 py-2 text-sm text-danger"},hd={key:1,class:"rounded-md bg-success-tint px-3 py-2 text-sm text-success"},md={class:"font-mono text-xs"},gd=["disabled"],vd={__name:"Call",setup(e){const t=G([]),n=G(""),s=G(""),r=G(!1),o=G(null),i=G("");Et(async()=>{try{const c=await Me.get("/devices");t.value=c.items||[]}catch{}});async function l(){i.value="",o.value=null;const c=n.value.trim();if(!c){i.value="Enter a phone number to call.";return}r.value=!0;try{const u={phone_number:c};s.value&&(u.device_id=s.value),o.value=await Me.post("/calls",u),n.value=""}catch(u){i.value=u.message}finally{r.value=!1}}return(c,u)=>(N(),L("div",ud,[W(tn,{title:"Make a call",subtitle:"Tell a device to place an outbound phone call"}),g("form",{class:"rounded-lg border border-subtle bg-card shadow-sm",onSubmit:hs(l,["prevent"])},[u[7]||(u[7]=g("div",{class:"border-b border-subtle px-6 py-4"},[g("div",{class:"text-base font-semibold text-primary"},"New call"),g("div",{class:"mt-0.5 font-mono text-[11px] text-muted"},"POST /api/calls")],-1)),g("div",fd,[g("div",null,[u[2]||(u[2]=g("label",{class:"mb-1.5 block text-sm font-medium text-primary"},"Phone number",-1)),Ne(g("input",{"onUpdate:modelValue":u[0]||(u[0]=a=>n.value=a),type:"tel",class:"gn-input font-mono !text-xs",placeholder:"+15551234567"},null,512),[[St,n.value]])]),g("div",null,[u[4]||(u[4]=g("label",{class:"mb-1.5 block text-sm font-medium text-primary"},"Device",-1)),Ne(g("select",{"onUpdate:modelValue":u[1]||(u[1]=a=>s.value=a),class:"gn-input"},[u[3]||(u[3]=g("option",{value:""},"Auto (most recent)",-1)),(N(!0),L(ce,null,ot(t.value,a=>(N(),L("option",{key:a.id,value:a.device_id},K(a.name||a.device_id),9,dd))),128))],512),[[ps,s.value]])]),i.value?(N(),L("p",pd,K(i.value),1)):Se("",!0),o.value?(N(),L("p",hd,[u[5]||(u[5]=de(" Queued — call ",-1)),g("span",md,K(o.value.id),1),de(" ("+K(o.value.status)+") ",1)])):Se("",!0),g("button",{type:"submit",disabled:r.value,class:"gn-btn-pri"},[W(Z(Ui),{class:"h-4 w-4"}),de(K(r.value?"Queuing…":"Place call"),1)],8,gd),u[6]||(u[6]=g("p",{class:"text-xs text-muted"}," The selected device must be online with the gateway running and granted phone-call permission. The call is placed from that phone. ",-1))])],32)]))}},bd={class:"flex items-center gap-2"},_d=["value"],yd={key:0,class:"mb-4 rounded-md bg-danger-tint px-3 py-2 text-sm text-danger"},xd={class:"overflow-hidden rounded-lg border border-subtle bg-card shadow-xs"},wd={class:"w-full text-left text-sm"},Sd={key:0},Ed={key:1},Ad={class:"border-t border-subtle px-5 py-3 font-mono text-xs text-primary"},Rd={class:"border-t border-subtle px-5 py-3 text-secondary"},Cd={key:0,class:"flex items-center gap-1.5"},Od={key:1,class:"max-w-md truncate"},Pd={key:2,class:"mt-0.5 text-xs text-danger"},Id={class:"border-t border-subtle px-5 py-3"},Td={class:"border-t border-subtle px-5 py-3 font-mono text-xs text-secondary"},Nd={__name:"Messages",setup(e){const t=G([]),n=G(!0),s=G(""),r=G(""),o=["","Pending","Processed","Sent","Delivered","Failed"];async function i(){n.value=!0,s.value="";try{const c=r.value?"?status="+encodeURIComponent(r.value):"",u=await Me.get("/messages"+c);t.value=u.items||[]}catch(c){s.value=c.message}finally{n.value=!1}}function l(c){return c?new Date(c).toLocaleString():"—"}return Et(i),(c,u)=>(N(),L("div",null,[W(tn,{title:"Messages",subtitle:"Outbound message history"},{actions:Yt(()=>[g("div",bd,[Ne(g("select",{"onUpdate:modelValue":u[0]||(u[0]=a=>r.value=a),class:"gn-input !h-8 !w-auto !text-xs",onChange:i},[(N(),L(ce,null,ot(o,a=>g("option",{key:a,value:a},K(a||"All statuses"),9,_d)),64))],544),[[ps,r.value]]),g("button",{class:"gn-btn-sec gn-btn-sm",onClick:i},[W(Z(mr),{class:"h-3.5 w-3.5"}),u[1]||(u[1]=de("Refresh ",-1))])])]),_:1}),s.value?(N(),L("p",yd,K(s.value),1)):Se("",!0),g("div",xd,[g("table",wd,[u[5]||(u[5]=g("thead",null,[g("tr",{class:"gn-eyebrow"},[g("th",{class:"px-5 py-3 font-medium"},"To"),g("th",{class:"px-5 py-3 font-medium"},"Message"),g("th",{class:"px-5 py-3 font-medium"},"Status"),g("th",{class:"px-5 py-3 font-medium"},"Created")])],-1)),g("tbody",null,[n.value?(N(),L("tr",Sd,[...u[2]||(u[2]=[g("td",{colspan:"4",class:"border-t border-subtle px-5 py-10 text-center text-sm text-muted"},"Loading…",-1)])])):t.value.length?Se("",!0):(N(),L("tr",Ed,[...u[3]||(u[3]=[g("td",{colspan:"4",class:"border-t border-subtle px-5 py-10 text-center text-sm text-muted"},[de(" No messages yet. Send your first with a POST to "),g("span",{class:"rounded-xs bg-sunken px-1.5 py-0.5 font-mono text-xs text-secondary"},"/api/messages"),de(". ")],-1)])])),(N(!0),L(ce,null,ot(t.value,a=>(N(),L("tr",{key:a.id,class:"align-top transition-colors hover:bg-sunken"},[g("td",Ad,K((a.phone_numbers||[]).join(", ")),1),g("td",Rd,[a.type==="call"?(N(),L("div",Cd,[W(Z(Ju),{class:"h-3.5 w-3.5"}),u[4]||(u[4]=g("span",{class:"gn-eyebrow !text-secondary"},"Voice call",-1))])):(N(),L("div",Od,K(a.text_message),1)),a.error?(N(),L("div",Pd,K(a.error),1)):Se("",!0)]),g("td",Id,[W(Gi,{status:a.status},null,8,["status"])]),g("td",Td,K(l(a.created_at)),1)]))),128))])])])]))}},kd={key:0,class:"mb-4 rounded-md bg-danger-tint px-3 py-2 text-sm text-danger"},Md={key:1,class:"text-sm text-muted"},Dd={key:2,class:"rounded-lg border border-dashed border-strong p-10 text-center text-sm text-muted"},$d={key:3,class:"space-y-3"},Ld={class:"flex h-8 w-8 shrink-0 items-center justify-center rounded-sm bg-success-tint text-success"},Vd={class:"min-w-0 flex-1"},Fd={class:"flex items-baseline justify-between gap-3"},jd={class:"font-mono text-sm font-medium text-primary"},Ud={class:"shrink-0 font-mono text-[11px] text-muted"},Hd={class:"mt-1 text-sm text-secondary"},Bd={__name:"Inbox",setup(e){const t=G([]),n=G(!0),s=G("");async function r(){n.value=!0,s.value="";try{const i=await Me.get("/inbox");t.value=i.items||[]}catch(i){s.value=i.message}finally{n.value=!1}}function o(i){return i?new Date(i).toLocaleString():"—"}return Et(r),(i,l)=>(N(),L("div",null,[W(tn,{title:"Inbox",subtitle:"Incoming messages received by your devices"},{actions:Yt(()=>[g("button",{class:"gn-btn-sec gn-btn-sm",onClick:r},[W(Z(mr),{class:"h-3.5 w-3.5"}),l[0]||(l[0]=de("Refresh ",-1))])]),_:1}),s.value?(N(),L("p",kd,K(s.value),1)):Se("",!0),n.value?(N(),L("div",Md,"Loading…")):t.value.length?(N(),L("div",$d,[(N(!0),L(ce,null,ot(t.value,c=>(N(),L("div",{key:c.id,class:"flex items-start gap-3 rounded-lg border border-subtle bg-card p-4 shadow-xs"},[g("span",Ld,[W(Z(Du),{class:"h-4 w-4"})]),g("div",Vd,[g("div",Fd,[g("span",jd,K(c.phone_number),1),g("span",Ud,K(o(c.received_at)),1)]),g("p",Hd,K(c.message),1)])]))),128))])):(N(),L("div",Dd," No incoming messages yet. "))]))}},Kd={class:"max-w-3xl"},Gd=["value"],Wd={class:"flex-1"},qd=["disabled"],zd={key:0,class:"mb-4 rounded-md bg-danger-tint px-3 py-2 text-sm text-danger"},Jd={class:"overflow-hidden rounded-lg border border-subtle bg-card shadow-xs"},Qd={class:"w-full text-left text-sm"},Yd={key:0},Xd={key:1},Zd={class:"border-t border-subtle px-5 py-3"},ep={class:"rounded-sm bg-brand-tint px-2 py-0.5 font-mono text-xs text-brand-active"},tp={class:"border-t border-subtle px-5 py-3 font-mono text-xs text-secondary"},np={class:"border-t border-subtle px-5 py-3 text-right"},sp=["onClick"],rp={__name:"Webhooks",setup(e){const t=G([]),n=G(!0),s=G(""),r=G("sms:received"),o=G(""),i=G(!1),l=["sms:received","sms:sent","sms:delivered","sms:failed"];async function c(){n.value=!0,s.value="";try{const f=await Me.get("/webhooks");t.value=f.items||[]}catch(f){s.value=f.message}finally{n.value=!1}}async function u(){if(s.value="",!o.value.trim()){s.value="URL is required.";return}i.value=!0;try{const f=await Me.post("/webhooks",{event:r.value,url:o.value.trim()});t.value.unshift(f),o.value=""}catch(f){s.value=f.message}finally{i.value=!1}}async function a(f){if(confirm("Delete this webhook?"))try{await Me.del("/webhooks/"+f.id),t.value=t.value.filter(h=>h.id!==f.id)}catch(h){alert("Could not delete: "+h.message)}}return Et(c),(f,h)=>(N(),L("div",Kd,[W(tn,{title:"Webhooks",subtitle:"Get notified when messages change state or arrive"}),g("form",{class:"mb-6 flex flex-wrap items-end gap-3 rounded-lg border border-subtle bg-card p-4 shadow-xs",onSubmit:hs(u,["prevent"])},[g("div",null,[h[2]||(h[2]=g("label",{class:"gn-eyebrow mb-1.5 block"},"Event",-1)),Ne(g("select",{"onUpdate:modelValue":h[0]||(h[0]=v=>r.value=v),class:"gn-input !w-auto font-mono !text-xs"},[(N(),L(ce,null,ot(l,v=>g("option",{key:v,value:v},K(v),9,Gd)),64))],512),[[ps,r.value]])]),g("div",Wd,[h[3]||(h[3]=g("label",{class:"gn-eyebrow mb-1.5 block"},"Target URL",-1)),Ne(g("input",{"onUpdate:modelValue":h[1]||(h[1]=v=>o.value=v),type:"url",placeholder:"https://example.com/hook",class:"gn-input font-mono !text-xs"},null,512),[[St,o.value]])]),g("button",{type:"submit",disabled:i.value,class:"gn-btn-pri"},[W(Z(Xu),{class:"h-4 w-4"}),h[4]||(h[4]=de("Add webhook ",-1))],8,qd)],32),s.value?(N(),L("p",zd,K(s.value),1)):Se("",!0),g("div",Jd,[g("table",Qd,[h[7]||(h[7]=g("thead",null,[g("tr",{class:"gn-eyebrow"},[g("th",{class:"px-5 py-3 font-medium"},"Event"),g("th",{class:"px-5 py-3 font-medium"},"URL"),g("th",{class:"px-5 py-3"})])],-1)),g("tbody",null,[n.value?(N(),L("tr",Yd,[...h[5]||(h[5]=[g("td",{colspan:"3",class:"border-t border-subtle px-5 py-10 text-center text-sm text-muted"},"Loading…",-1)])])):t.value.length?Se("",!0):(N(),L("tr",Xd,[...h[6]||(h[6]=[g("td",{colspan:"3",class:"border-t border-subtle px-5 py-10 text-center text-sm text-muted"},"No webhooks.",-1)])])),(N(!0),L(ce,null,ot(t.value,v=>(N(),L("tr",{key:v.id,class:"transition-colors hover:bg-sunken"},[g("td",Zd,[g("span",ep,K(v.event),1)]),g("td",tp,K(v.url),1),g("td",np,[g("button",{class:"text-sm font-medium text-danger hover:underline",onClick:T=>a(v)},"Delete",8,sp)])]))),128))])])])]))}},op=[{path:"/login",name:"login",component:yf},{path:"/",component:Vf,meta:{requiresAuth:!0},children:[{path:"",redirect:{name:"devices"}},{path:"devices",name:"devices",component:ed},{path:"send",name:"send",component:ad},{path:"call",name:"call",component:vd},{path:"messages",name:"messages",component:Nd},{path:"inbox",name:"inbox",component:Bd},{path:"webhooks",name:"webhooks",component:rp}]},{path:"/:pathMatch(.*)*",redirect:"/"}],Wi=Eu({history:ru(),routes:op});Wi.beforeEach(e=>{if(e.meta.requiresAuth&&!Rn.isAuthenticated())return{name:"login",query:{redirect:e.fullPath}};if(e.name==="login"&&Rn.isAuthenticated())return{path:"/"}});ha(ya).use(Wi).mount("#app"); diff --git a/Web App/server/dist/favicon-32.png b/Web App/server/dist/favicon-32.png new file mode 100644 index 0000000..23301f0 Binary files /dev/null and b/Web App/server/dist/favicon-32.png differ diff --git a/Web App/server/dist/favicon.svg b/Web App/server/dist/favicon.svg new file mode 100644 index 0000000..4078054 --- /dev/null +++ b/Web App/server/dist/favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/Web App/server/dist/gsmnode-horizontal-white.png b/Web App/server/dist/gsmnode-horizontal-white.png new file mode 100644 index 0000000..ac100c0 Binary files /dev/null and b/Web App/server/dist/gsmnode-horizontal-white.png differ diff --git a/Web App/server/dist/gsmnode-horizontal.png b/Web App/server/dist/gsmnode-horizontal.png new file mode 100644 index 0000000..bcd2937 Binary files /dev/null and b/Web App/server/dist/gsmnode-horizontal.png differ diff --git a/Web App/server/dist/gsmnode-mark.svg b/Web App/server/dist/gsmnode-mark.svg new file mode 100644 index 0000000..d8de483 --- /dev/null +++ b/Web App/server/dist/gsmnode-mark.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Web App/server/dist/icon-192.png b/Web App/server/dist/icon-192.png new file mode 100644 index 0000000..616b0e0 Binary files /dev/null and b/Web App/server/dist/icon-192.png differ diff --git a/Web App/server/dist/icon-512.png b/Web App/server/dist/icon-512.png new file mode 100644 index 0000000..2d94601 Binary files /dev/null and b/Web App/server/dist/icon-512.png differ diff --git a/Web App/server/dist/index.html b/Web App/server/dist/index.html new file mode 100644 index 0000000..0a80044 --- /dev/null +++ b/Web App/server/dist/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + gsmnode + + + + +
+ + diff --git a/Web App/server/go.mod b/Web App/server/go.mod new file mode 100644 index 0000000..36864ba --- /dev/null +++ b/Web App/server/go.mod @@ -0,0 +1,3 @@ +module smsgateway/webapp + +go 1.26 diff --git a/Web App/server/main.go b/Web App/server/main.go new file mode 100644 index 0000000..355fa2c --- /dev/null +++ b/Web App/server/main.go @@ -0,0 +1,113 @@ +// Command webapp is the Web App backend-for-frontend. It serves the embedded +// Vue single-page app and reverse-proxies /api/* to the API Server, so the +// browser only ever talks to this server (same-origin) and all data access +// still flows through the API Server. +package main + +import ( + "embed" + "io/fs" + "log" + "net/http" + "net/http/httputil" + "net/url" + "os" + "strings" + "time" +) + +//go:embed all:dist +var distFS embed.FS + +func getenv(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func main() { + log.SetFlags(log.LstdFlags | log.Lmsgprefix) + log.SetPrefix("[web] ") + + loadDotEnv(".env") + addr := getenv("WEB_ADDR", ":8090") + apiBase := strings.TrimRight(getenv("API_BASE", "http://localhost:8080"), "/") + + apiURL, err := url.Parse(apiBase) + if err != nil { + log.Fatalf("invalid API_BASE %q: %v", apiBase, err) + } + + // Reverse proxy: /api/* -> API Server (path preserved). + proxy := httputil.NewSingleHostReverseProxy(apiURL) + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) { + log.Printf("proxy error for %s: %v", r.URL.Path, e) + http.Error(w, `{"error":"api server unavailable"}`, http.StatusBadGateway) + } + + // Embedded SPA file server. + sub, err := fs.Sub(distFS, "dist") + if err != nil { + log.Fatalf("embed dist: %v", err) + } + spa := http.FileServer(http.FS(sub)) + + mux := http.NewServeMux() + mux.Handle("/api/", proxy) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // Serve static assets when they exist; otherwise fall back to index.html + // so client-side routing works on deep links. + if r.URL.Path != "/" { + if f, err := sub.Open(strings.TrimPrefix(r.URL.Path, "/")); err == nil { + f.Close() + spa.ServeHTTP(w, r) + return + } + } + r2 := r.Clone(r.Context()) + r2.URL.Path = "/" + spa.ServeHTTP(w, r2) + }) + + srv := &http.Server{ + Addr: addr, + Handler: logRequests(mux), + ReadHeaderTimeout: 10 * time.Second, + } + log.Printf("listening on %s (proxying /api -> %s)", addr, apiBase) + if err := srv.ListenAndServe(); err != nil { + log.Fatalf("server error: %v", err) + } +} + +func logRequests(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + next.ServeHTTP(w, r) + log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond)) + }) +} + +// loadDotEnv loads KEY=VALUE pairs from a .env file if present. +func loadDotEnv(path string) { + data, err := os.ReadFile(path) + if err != nil { + return + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + k = strings.TrimSpace(k) + v = strings.Trim(strings.TrimSpace(v), `"'`) + if _, exists := os.LookupEnv(k); !exists { + _ = os.Setenv(k, v) + } + } +} diff --git a/Web App/web/.gitignore b/Web App/web/.gitignore new file mode 100644 index 0000000..aa0926a --- /dev/null +++ b/Web App/web/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +*.log diff --git a/Web App/web/index.html b/Web App/web/index.html new file mode 100644 index 0000000..072f71f --- /dev/null +++ b/Web App/web/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + gsmnode + + +
+ + + diff --git a/Web App/web/package-lock.json b/Web App/web/package-lock.json new file mode 100644 index 0000000..b09cbc9 --- /dev/null +++ b/Web App/web/package-lock.json @@ -0,0 +1,1996 @@ +{ + "name": "sms-gateway-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sms-gateway-web", + "version": "1.0.0", + "dependencies": { + "@lucide/vue": "^1.23.0", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@vitejs/plugin-vue": "^5.2.1", + "tailwindcss": "^4.0.0", + "vite": "^6.0.7" + } + }, + "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==", + "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==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "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==", + "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/@lucide/vue": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@lucide/vue/-/vue-1.23.0.tgz", + "integrity": "sha512-9SIYeY5K+R1iv8F8JUKMGSL7Pck/86BJ8djtZz/lnYsoHtKFUj1H2z6+PvKnC/8blZ8tqTDeDXAoTKobuX80hg==", + "license": "ISC", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "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/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "vue": "3.5.39" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", + "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==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "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/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "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/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "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/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "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": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "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/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/vue": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + } + } +} diff --git a/Web App/web/package.json b/Web App/web/package.json new file mode 100644 index 0000000..1157fd3 --- /dev/null +++ b/Web App/web/package.json @@ -0,0 +1,22 @@ +{ + "name": "gsmnode-web", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@lucide/vue": "^1.23.0", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@vitejs/plugin-vue": "^5.2.1", + "tailwindcss": "^4.0.0", + "vite": "^6.0.7" + } +} diff --git a/Web App/web/public/apple-touch-icon.png b/Web App/web/public/apple-touch-icon.png new file mode 100644 index 0000000..80236eb Binary files /dev/null and b/Web App/web/public/apple-touch-icon.png differ diff --git a/Web App/web/public/favicon-32.png b/Web App/web/public/favicon-32.png new file mode 100644 index 0000000..23301f0 Binary files /dev/null and b/Web App/web/public/favicon-32.png differ diff --git a/Web App/web/public/favicon.svg b/Web App/web/public/favicon.svg new file mode 100644 index 0000000..4078054 --- /dev/null +++ b/Web App/web/public/favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/Web App/web/public/gsmnode-horizontal-white.png b/Web App/web/public/gsmnode-horizontal-white.png new file mode 100644 index 0000000..ac100c0 Binary files /dev/null and b/Web App/web/public/gsmnode-horizontal-white.png differ diff --git a/Web App/web/public/gsmnode-horizontal.png b/Web App/web/public/gsmnode-horizontal.png new file mode 100644 index 0000000..bcd2937 Binary files /dev/null and b/Web App/web/public/gsmnode-horizontal.png differ diff --git a/Web App/web/public/gsmnode-mark.svg b/Web App/web/public/gsmnode-mark.svg new file mode 100644 index 0000000..d8de483 --- /dev/null +++ b/Web App/web/public/gsmnode-mark.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Web App/web/public/icon-192.png b/Web App/web/public/icon-192.png new file mode 100644 index 0000000..616b0e0 Binary files /dev/null and b/Web App/web/public/icon-192.png differ diff --git a/Web App/web/public/icon-512.png b/Web App/web/public/icon-512.png new file mode 100644 index 0000000..2d94601 Binary files /dev/null and b/Web App/web/public/icon-512.png differ diff --git a/Web App/web/src/App.vue b/Web App/web/src/App.vue new file mode 100644 index 0000000..98240ae --- /dev/null +++ b/Web App/web/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/Web App/web/src/api.js b/Web App/web/src/api.js new file mode 100644 index 0000000..d438201 --- /dev/null +++ b/Web App/web/src/api.js @@ -0,0 +1,68 @@ +// Thin fetch wrapper around the Web App BFF (same-origin /api, proxied to the +// API Server). Handles bearer-token auth and JSON encoding/decoding. + +const TOKEN_KEY = "sms_gw_token"; +const API_BASE_KEY = "sms_gw_api_base"; + +export function getToken() { + return localStorage.getItem(TOKEN_KEY) || ""; +} + +export function setToken(token) { + if (token) localStorage.setItem(TOKEN_KEY, token); + else localStorage.removeItem(TOKEN_KEY); +} + +// Optional API Server base URL. When set, the browser calls that server +// directly (the server sends permissive CORS). When blank, requests go to this +// site's own origin at /api, i.e. through the Web App BFF proxy. +export function getApiBase() { + return localStorage.getItem(API_BASE_KEY) || ""; +} + +export function setApiBase(url) { + const v = (url || "").trim().replace(/\/+$/, ""); + if (v) localStorage.setItem(API_BASE_KEY, v); + else localStorage.removeItem(API_BASE_KEY); +} + +export class ApiError extends Error { + constructor(status, message) { + super(message); + this.status = status; + } +} + +async function request(method, path, body) { + const headers = {}; + const token = getToken(); + if (token) headers.Authorization = "Bearer " + token; + if (body !== undefined) headers["Content-Type"] = "application/json"; + + const res = await fetch(getApiBase() + "/api" + path, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + + const text = await res.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = text; + } + + if (!res.ok) { + const msg = (data && data.error) || res.statusText || "Request failed"; + throw new ApiError(res.status, msg); + } + return data; +} + +export const api = { + get: (p) => request("GET", p), + post: (p, b) => request("POST", p, b), + patch: (p, b) => request("PATCH", p, b), + del: (p) => request("DELETE", p), +}; diff --git a/Web App/web/src/components/ApiStatus.vue b/Web App/web/src/components/ApiStatus.vue new file mode 100644 index 0000000..7e3f519 --- /dev/null +++ b/Web App/web/src/components/ApiStatus.vue @@ -0,0 +1,67 @@ + + + diff --git a/Web App/web/src/components/PageHeader.vue b/Web App/web/src/components/PageHeader.vue new file mode 100644 index 0000000..476d76b --- /dev/null +++ b/Web App/web/src/components/PageHeader.vue @@ -0,0 +1,13 @@ + + + diff --git a/Web App/web/src/components/StatusBadge.vue b/Web App/web/src/components/StatusBadge.vue new file mode 100644 index 0000000..7a82d8a --- /dev/null +++ b/Web App/web/src/components/StatusBadge.vue @@ -0,0 +1,25 @@ + + + diff --git a/Web App/web/src/main.js b/Web App/web/src/main.js new file mode 100644 index 0000000..01b3dd4 --- /dev/null +++ b/Web App/web/src/main.js @@ -0,0 +1,6 @@ +import { createApp } from "vue"; +import App from "./App.vue"; +import router from "./router"; +import "./style.css"; + +createApp(App).use(router).mount("#app"); diff --git a/Web App/web/src/router.js b/Web App/web/src/router.js new file mode 100644 index 0000000..1011cec --- /dev/null +++ b/Web App/web/src/router.js @@ -0,0 +1,46 @@ +import { createRouter, createWebHistory } from "vue-router"; +import { auth } from "./store/auth"; + +import Login from "./views/Login.vue"; +import Layout from "./views/Layout.vue"; +import Devices from "./views/Devices.vue"; +import Send from "./views/Send.vue"; +import Call from "./views/Call.vue"; +import Messages from "./views/Messages.vue"; +import Inbox from "./views/Inbox.vue"; +import Webhooks from "./views/Webhooks.vue"; + +const routes = [ + { path: "/login", name: "login", component: Login }, + { + path: "/", + component: Layout, + meta: { requiresAuth: true }, + children: [ + { path: "", redirect: { name: "devices" } }, + { path: "devices", name: "devices", component: Devices }, + { path: "send", name: "send", component: Send }, + { path: "call", name: "call", component: Call }, + { path: "messages", name: "messages", component: Messages }, + { path: "inbox", name: "inbox", component: Inbox }, + { path: "webhooks", name: "webhooks", component: Webhooks }, + ], + }, + { path: "/:pathMatch(.*)*", redirect: "/" }, +]; + +const router = createRouter({ + history: createWebHistory(), + routes, +}); + +router.beforeEach((to) => { + if (to.meta.requiresAuth && !auth.isAuthenticated()) { + return { name: "login", query: { redirect: to.fullPath } }; + } + if (to.name === "login" && auth.isAuthenticated()) { + return { path: "/" }; + } +}); + +export default router; diff --git a/Web App/web/src/store/auth.js b/Web App/web/src/store/auth.js new file mode 100644 index 0000000..5f48ac7 --- /dev/null +++ b/Web App/web/src/store/auth.js @@ -0,0 +1,33 @@ +import { reactive, readonly } from "vue"; +import { api, setToken, getToken } from "../api"; + +const USER_KEY = "sms_gw_user"; + +const state = reactive({ + user: JSON.parse(localStorage.getItem(USER_KEY) || "null"), + token: getToken(), +}); + +export const auth = { + state: readonly(state), + + isAuthenticated() { + return !!state.token; + }, + + async login(email, password) { + const res = await api.post("/auth/login", { email, password }); + state.token = res.access_token; + state.user = res.user; + setToken(res.access_token); + localStorage.setItem(USER_KEY, JSON.stringify(res.user)); + return res.user; + }, + + logout() { + state.token = ""; + state.user = null; + setToken(""); + localStorage.removeItem(USER_KEY); + }, +}; diff --git a/Web App/web/src/style.css b/Web App/web/src/style.css new file mode 100644 index 0000000..1c712a9 --- /dev/null +++ b/Web App/web/src/style.css @@ -0,0 +1,336 @@ +/* gsmnode design system — tokens from Design/SMS Gateway logo design/tokens/*, + mapped into Tailwind v4. Signal-green + ink, developer-tool aesthetic. */ +@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap'); +@import "tailwindcss"; + +/* ============================================================ + RAW RAMPS + SEMANTIC ALIASES (light) + ============================================================ */ +:root { + /* Signal green (brand) — shared chroma, stepped lightness */ + --green-100: oklch(0.95 0.04 152); + --green-200: oklch(0.88 0.07 152); + --green-300: oklch(0.78 0.11 152); + --green-500: oklch(0.60 0.14 152); /* primary */ + --green-600: oklch(0.52 0.13 152); + --green-700: oklch(0.44 0.11 152); + --green-on-dark: oklch(0.72 0.14 152); + + /* Ink & paper */ + --ink: #12161C; + --ink-900: #0A0D11; + --ink-800: #1A1F27; + --paper: #FAFAF9; + --white: #FFFFFF; + + /* Cool gray ramp */ + --gray-50: #F4F5F4; + --gray-100: #E9EBEA; + --gray-200: #DCDFDE; + --gray-300: #C2C7C6; + --gray-400: #9AA0A2; + --gray-500: #6B7278; + --gray-600: #4A5157; + --gray-700: #333A40; + + /* Semantic status */ + --success: oklch(0.60 0.14 152); /* green — also "delivered" */ + --warning: oklch(0.72 0.15 75); + --danger: oklch(0.60 0.17 25); + --info: oklch(0.62 0.13 245); + --success-x: oklch(0.95 0.04 152); + --warning-x: oklch(0.95 0.05 80); + --danger-x: oklch(0.95 0.05 25); + --info-x: oklch(0.95 0.04 245); + + /* Semantic aliases — reference these in components */ + --bg-page: var(--paper); + --bg-sunken: var(--gray-50); + --surface-card: var(--white); + --surface-raised: var(--white); + --surface-inverse:var(--ink); + --surface-code: var(--ink-900); + + --border-subtle: rgba(18, 22, 28, 0.08); + --border-strong: rgba(18, 22, 28, 0.20); + --border-focus: var(--green-500); + + --text-primary: var(--ink); + --text-secondary: var(--gray-700); + --text-muted: var(--gray-500); + --text-inverse: #F5F6F4; + --text-brand: var(--green-600); + --text-link: var(--green-600); + + --brand: var(--green-500); + --brand-hover: var(--green-600); + --brand-active: var(--green-700); + --brand-tint: var(--green-100); + --brand-contrast: var(--white); + --brand-on-dark: var(--green-on-dark); + + --success-tint: var(--success-x); + --warning-tint: var(--warning-x); + --danger-tint: var(--danger-x); + --info-tint: var(--info-x); + + --ring-focus: 0 0 0 3px oklch(0.60 0.14 152 / 0.45); + --ring-danger: 0 0 0 3px oklch(0.60 0.17 25 / 0.35); + + /* Flat, low-contrast elevation */ + --sh-xs: 0 1px 2px rgba(18, 22, 28, 0.04); + --sh-sm: 0 1px 2px rgba(18, 22, 28, 0.06), 0 1px 1px rgba(18, 22, 28, 0.04); + --sh-md: 0 4px 12px rgba(18, 22, 28, 0.08); + --sh-lg: 0 8px 24px rgba(18, 22, 28, 0.14); + --sh-xl: 0 16px 40px rgba(18, 22, 28, 0.18); + + --dur-instant: 100ms; + --dur-fast: 120ms; + --dur-base: 180ms; + --dur-slow: 260ms; + --ease-standard: cubic-bezier(0.2, 0, 0, 1); + --ease-entrance: cubic-bezier(0.16, 1, 0.3, 1); +} + +/* ============================================================ + DARK THEME — only the semantic layer remaps. + Namespaced data-gsm-theme so it matches the design system. + ============================================================ */ +[data-gsm-theme="dark"] { + --bg-page: var(--ink-900); + --bg-sunken: #0E1216; + --surface-card: #14191F; + --surface-raised: #1A2027; + --surface-inverse:var(--white); + --surface-code: #05070A; + + --border-subtle: rgba(255, 255, 255, 0.07); + --border-strong: rgba(255, 255, 255, 0.18); + --border-focus: var(--green-on-dark); + + --text-primary: #F5F6F4; + --text-secondary: var(--gray-200); + --text-muted: var(--gray-400); + --text-inverse: var(--ink); + --text-brand: var(--green-on-dark); + --text-link: var(--green-on-dark); + + --brand: var(--green-500); + --brand-hover: var(--green-on-dark); + --brand-active: var(--green-600); + --brand-tint: oklch(0.60 0.14 152 / 0.16); + --brand-contrast: var(--white); + + --success: var(--green-300); + --warning: oklch(0.80 0.14 80); + --danger: oklch(0.72 0.16 25); + --info: oklch(0.72 0.12 245); + --success-tint: oklch(0.60 0.14 152 / 0.16); + --warning-tint: oklch(0.72 0.15 75 / 0.16); + --danger-tint: oklch(0.60 0.17 25 / 0.18); + --info-tint: oklch(0.62 0.13 245 / 0.16); + + --ring-focus: 0 0 0 3px oklch(0.72 0.14 152 / 0.45); + + --sh-xs: 0 1px 2px rgba(0, 0, 0, 0.4); + --sh-sm: 0 1px 3px rgba(0, 0, 0, 0.5), 0 1px 2px rgba(0, 0, 0, 0.4); + --sh-md: 0 4px 12px rgba(0, 0, 0, 0.5), 0 2px 4px rgba(0, 0, 0, 0.4); + --sh-lg: 0 8px 24px rgba(0, 0, 0, 0.6), 0 4px 8px rgba(0, 0, 0, 0.4); + --sh-xl: 0 16px 40px rgba(0, 0, 0, 0.7); + + color-scheme: dark; +} + +/* ============================================================ + TAILWIND THEME — utilities resolve to the semantic vars, + so everything flips automatically under data-gsm-theme="dark". + ============================================================ */ +@theme inline { + --color-*: initial; + + --color-page: var(--bg-page); + --color-sunken: var(--bg-sunken); + --color-card: var(--surface-card); + --color-raised: var(--surface-raised); + --color-inverse: var(--surface-inverse); + --color-code: var(--surface-code); + + --color-subtle: var(--border-subtle); + --color-strong: var(--border-strong); + + --color-primary: var(--text-primary); + --color-secondary: var(--text-secondary); + --color-muted: var(--text-muted); + --color-on-brand: var(--brand-contrast); + --color-link: var(--text-link); + + --color-brand: var(--brand); + --color-brand-hover: var(--brand-hover); + --color-brand-active: var(--brand-active); + --color-brand-tint: var(--brand-tint); + --color-brand-text: var(--text-brand); + + --color-success: var(--success); + --color-success-tint: var(--success-tint); + --color-warning: var(--warning); + --color-warning-tint: var(--warning-tint); + --color-danger: var(--danger); + --color-danger-tint: var(--danger-tint); + --color-info: var(--info); + --color-info-tint: var(--info-tint); + + --color-white: #FFFFFF; + --color-ink: var(--ink); + --color-green: var(--green-500); + + --font-display: 'Space Grotesk', ui-sans-serif, system-ui, 'Segoe UI', sans-serif; + --font-sans: 'IBM Plex Sans', ui-sans-serif, system-ui, 'Segoe UI', sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace; + + --radius-*: initial; + --radius-xs: 4px; + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 15px; + --radius-2xl: 18px; + --radius-full: 9999px; + + --shadow-*: initial; + --shadow-xs: var(--sh-xs); + --shadow-sm: var(--sh-sm); + --shadow-md: var(--sh-md); + --shadow-lg: var(--sh-lg); + --shadow-xl: var(--sh-xl); + --shadow-ring: var(--ring-focus); +} + +/* ============================================================ + BASE + ============================================================ */ +html, +body, +#app { + height: 100%; +} + +body { + font-family: var(--font-sans); + background: var(--bg-page); + color: var(--text-primary); + -webkit-font-smoothing: antialiased; +} + +/* Display face for titles & metrics */ +h1, h2, h3 { + font-family: var(--font-display); + letter-spacing: -0.02em; +} + +/* Shared control styles (inputs, selects, textareas) */ +@utility gn-input { + width: 100%; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--surface-card); + color: var(--text-primary); + padding: 0 12px; + height: 40px; + font-size: 0.875rem; + font-family: var(--font-sans); + transition: border-color var(--dur-fast) var(--ease-standard), + box-shadow var(--dur-fast) var(--ease-standard); +} + +@utility gn-textarea { + width: 100%; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--surface-card); + color: var(--text-primary); + padding: 10px 12px; + font-size: 0.875rem; + font-family: var(--font-sans); + resize: vertical; + transition: border-color var(--dur-fast) var(--ease-standard), + box-shadow var(--dur-fast) var(--ease-standard); +} + +.gn-input:focus, +.gn-textarea:focus { + outline: none; + border-color: var(--border-focus); + box-shadow: var(--ring-focus); +} + +.gn-input::placeholder, +.gn-textarea::placeholder { + color: var(--text-muted); +} + +/* Primary button — flat signal-green, no glow (infrastructure) */ +@utility gn-btn-pri { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 40px; + padding: 0 16px; + border-radius: var(--radius-md); + border: 1px solid transparent; + background: var(--brand); + color: var(--brand-contrast); + font-family: var(--font-sans); + font-weight: 600; + font-size: 0.875rem; + cursor: pointer; + transition: background-color var(--dur-fast) var(--ease-standard), + transform var(--dur-fast) var(--ease-standard); +} + +.gn-btn-pri:hover:not(:disabled) { background: var(--brand-hover); } +.gn-btn-pri:active:not(:disabled) { background: var(--brand-active); transform: translateY(1px); } +.gn-btn-pri:focus-visible { outline: none; box-shadow: var(--ring-focus); } +.gn-btn-pri:disabled { opacity: 0.45; cursor: not-allowed; } + +/* Secondary button */ +@utility gn-btn-sec { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 40px; + padding: 0 16px; + border-radius: var(--radius-md); + border: 1px solid var(--border-strong); + background: var(--surface-card); + color: var(--text-primary); + font-family: var(--font-sans); + font-weight: 600; + font-size: 0.875rem; + cursor: pointer; + transition: background-color var(--dur-fast) var(--ease-standard), + transform var(--dur-fast) var(--ease-standard); +} + +.gn-btn-sec:hover:not(:disabled) { background: var(--bg-sunken); } +.gn-btn-sec:active:not(:disabled) { transform: translateY(1px); } +.gn-btn-sec:focus-visible { outline: none; box-shadow: var(--ring-focus); } +.gn-btn-sec:disabled { opacity: 0.45; cursor: not-allowed; } + +/* Small button size modifier */ +@utility gn-btn-sm { + height: 32px; + padding: 0 12px; + font-size: 0.75rem; + border-radius: var(--radius-sm); +} + +/* Mono eyebrow — uppercase, tracked out */ +@utility gn-eyebrow { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--text-muted); +} diff --git a/Web App/web/src/theme.js b/Web App/web/src/theme.js new file mode 100644 index 0000000..b8dae4d --- /dev/null +++ b/Web App/web/src/theme.js @@ -0,0 +1,30 @@ +import { ref } from "vue"; + +// Persisted light/dark theme, shared key with the design-system kits. +const KEY = "gsmnode-theme"; + +function initial() { + try { + return localStorage.getItem(KEY) === "dark" ? "dark" : "light"; + } catch { + return "light"; + } +} + +export const theme = ref(initial()); + +export function applyTheme(t) { + theme.value = t; + document.documentElement.setAttribute("data-gsm-theme", t); + try { + localStorage.setItem(KEY, t); + } catch { + /* private mode — theme just won't persist */ + } +} + +export function toggleTheme() { + applyTheme(theme.value === "dark" ? "light" : "dark"); +} + +applyTheme(theme.value); diff --git a/Web App/web/src/views/Call.vue b/Web App/web/src/views/Call.vue new file mode 100644 index 0000000..84e5196 --- /dev/null +++ b/Web App/web/src/views/Call.vue @@ -0,0 +1,94 @@ + + + diff --git a/Web App/web/src/views/Devices.vue b/Web App/web/src/views/Devices.vue new file mode 100644 index 0000000..5cb67bd --- /dev/null +++ b/Web App/web/src/views/Devices.vue @@ -0,0 +1,90 @@ + + + diff --git a/Web App/web/src/views/Inbox.vue b/Web App/web/src/views/Inbox.vue new file mode 100644 index 0000000..3d6b921 --- /dev/null +++ b/Web App/web/src/views/Inbox.vue @@ -0,0 +1,69 @@ + + + diff --git a/Web App/web/src/views/Layout.vue b/Web App/web/src/views/Layout.vue new file mode 100644 index 0000000..b056fff --- /dev/null +++ b/Web App/web/src/views/Layout.vue @@ -0,0 +1,99 @@ + + + diff --git a/Web App/web/src/views/Login.vue b/Web App/web/src/views/Login.vue new file mode 100644 index 0000000..e850e0e --- /dev/null +++ b/Web App/web/src/views/Login.vue @@ -0,0 +1,115 @@ + + + diff --git a/Web App/web/src/views/Messages.vue b/Web App/web/src/views/Messages.vue new file mode 100644 index 0000000..cd73cd6 --- /dev/null +++ b/Web App/web/src/views/Messages.vue @@ -0,0 +1,89 @@ + + + diff --git a/Web App/web/src/views/Send.vue b/Web App/web/src/views/Send.vue new file mode 100644 index 0000000..e1b9fd0 --- /dev/null +++ b/Web App/web/src/views/Send.vue @@ -0,0 +1,117 @@ + + + diff --git a/Web App/web/src/views/Webhooks.vue b/Web App/web/src/views/Webhooks.vue new file mode 100644 index 0000000..c7371ee --- /dev/null +++ b/Web App/web/src/views/Webhooks.vue @@ -0,0 +1,120 @@ + + + diff --git a/Web App/web/vite.config.js b/Web App/web/vite.config.js new file mode 100644 index 0000000..139e43a --- /dev/null +++ b/Web App/web/vite.config.js @@ -0,0 +1,22 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import tailwindcss from "@tailwindcss/vite"; + +// The production build is written into ../server/dist so the Go BFF can embed it. +// In dev, /api is proxied to the API Server directly. +export default defineConfig({ + plugins: [vue(), tailwindcss()], + build: { + outDir: "../server/dist", + emptyOutDir: true, + }, + server: { + port: 5173, + proxy: { + "/api": { + target: process.env.API_BASE || "http://localhost:8080", + changeOrigin: true, + }, + }, + }, +});