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 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-06 08:53:14 +02:00
commit d6956395c8
166 changed files with 12605 additions and 0 deletions
+82
View File
@@ -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`.
+8
View File
@@ -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
+29
View File
@@ -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 }
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 759 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="7" fill="#2E9E6B"></rect>
<g transform="translate(6.5 6.5) scale(0.475)">
<line x1="9" y1="15" x2="28" y2="15" stroke="#fff" stroke-width="3.4" stroke-linecap="round"></line>
<path d="M25 10.5 L31 15 L25 19.5" stroke="#fff" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"></path>
<line x1="12" y1="25" x2="31" y2="25" stroke="#fff" stroke-width="3.4" stroke-linecap="round"></line>
<path d="M15 20.5 L9 25 L15 29.5" stroke="#fff" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 684 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40" fill="none">
<line x1="7" y1="15" x2="31" y2="15" stroke="#12161C" stroke-width="2.6" stroke-linecap="round"></line>
<path d="M26 10 L32 15 L26 20" stroke="#12161C" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"></path>
<line x1="9" y1="25" x2="33" y2="25" stroke="#2E9E6B" stroke-width="2.6" stroke-linecap="round"></line>
<path d="M14 20 L8 25 L14 30" stroke="#2E9E6B" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 561 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#2E9E6B" />
<title>gsmnode</title>
<script type="module" crossorigin src="/assets/index-B7DZHD5s.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Ab7NGFI_.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
module smsgateway/webapp
go 1.26
+113
View File
@@ -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)
}
}
}
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.log
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#2E9E6B" />
<title>gsmnode</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1996
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 759 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="7" fill="#2E9E6B"></rect>
<g transform="translate(6.5 6.5) scale(0.475)">
<line x1="9" y1="15" x2="28" y2="15" stroke="#fff" stroke-width="3.4" stroke-linecap="round"></line>
<path d="M25 10.5 L31 15 L25 19.5" stroke="#fff" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"></path>
<line x1="12" y1="25" x2="31" y2="25" stroke="#fff" stroke-width="3.4" stroke-linecap="round"></line>
<path d="M15 20.5 L9 25 L15 29.5" stroke="#fff" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 684 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40" fill="none">
<line x1="7" y1="15" x2="31" y2="15" stroke="#12161C" stroke-width="2.6" stroke-linecap="round"></line>
<path d="M26 10 L32 15 L26 20" stroke="#12161C" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"></path>
<line x1="9" y1="25" x2="33" y2="25" stroke="#2E9E6B" stroke-width="2.6" stroke-linecap="round"></line>
<path d="M14 20 L8 25 L14 30" stroke="#2E9E6B" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>

After

Width:  |  Height:  |  Size: 561 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+3
View File
@@ -0,0 +1,3 @@
<template>
<router-view />
</template>
+68
View File
@@ -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),
};
+67
View File
@@ -0,0 +1,67 @@
<script setup>
import { ref, onMounted, onUnmounted } from "vue";
import { api } from "../api";
// Polls the API Server's public /health endpoint (through the configured base
// URL / BFF) and reflects reachability as a colored dot.
const status = ref("checking"); // checking | online | offline
const latency = ref(null);
let timer = null;
async function check() {
const started = performance.now();
try {
await api.get("/health");
latency.value = Math.round(performance.now() - started);
status.value = "online";
} catch {
latency.value = null;
status.value = "offline";
}
}
onMounted(() => {
check();
timer = setInterval(check, 10000);
});
onUnmounted(() => clearInterval(timer));
const dotStyle = {
checking: "background: var(--gray-300)",
online: "background: var(--success)",
offline: "background: var(--danger)",
};
const label = {
checking: "Checking…",
online: "API online",
offline: "API offline",
};
</script>
<template>
<div
class="flex items-center gap-2"
:title="
status === 'online'
? `API Server reachable · ${latency} ms`
: status === 'offline'
? 'API Server unreachable'
: 'Checking API Server'
"
>
<span class="relative flex h-2 w-2">
<span
v-if="status === 'online'"
class="absolute inline-flex h-full w-full animate-ping rounded-full opacity-60"
style="background: var(--success)"
></span>
<span class="relative inline-flex h-2 w-2 rounded-full" :style="dotStyle[status]"></span>
</span>
<span
class="font-mono text-xs"
:class="status === 'offline' ? 'text-danger' : 'text-secondary'"
>
{{ label[status] }}<template v-if="latency !== null"> · {{ latency }}ms</template>
</span>
</div>
</template>
+13
View File
@@ -0,0 +1,13 @@
<script setup>
defineProps({ title: String, subtitle: String });
</script>
<template>
<div class="mb-6 flex items-end justify-between gap-4">
<div>
<h2 class="text-2xl font-semibold tracking-[-0.02em] text-primary">{{ title }}</h2>
<p v-if="subtitle" class="mt-0.5 text-sm text-secondary">{{ subtitle }}</p>
</div>
<slot name="actions" />
</div>
</template>
@@ -0,0 +1,25 @@
<script setup>
// Mono badge with a status dot, per the design system: semantic tints only,
// color carries state — no emoji.
const props = defineProps({ status: String });
const styles = {
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",
};
</script>
<template>
<span
class="inline-flex items-center gap-1.5 rounded-sm px-2 py-0.5 font-mono text-[11px] font-medium"
:class="styles[props.status] || 'bg-subtle text-secondary'"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
{{ status || "—" }}
</span>
</template>
+6
View File
@@ -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");
+46
View File
@@ -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;
+33
View File
@@ -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);
},
};
+336
View File
@@ -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);
}
+30
View File
@@ -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);
+94
View File
@@ -0,0 +1,94 @@
<script setup>
import { ref, onMounted } from "vue";
import { Phone } from "@lucide/vue";
import { api } from "../api";
import PageHeader from "../components/PageHeader.vue";
const devices = ref([]);
const phone = ref("");
const deviceId = ref("");
const calling = ref(false);
const result = ref(null);
const error = ref("");
onMounted(async () => {
try {
const res = await api.get("/devices");
devices.value = res.items || [];
} catch {
/* listing failure is non-fatal for the form */
}
});
async function makeCall() {
error.value = "";
result.value = null;
const number = phone.value.trim();
if (!number) {
error.value = "Enter a phone number to call.";
return;
}
calling.value = true;
try {
const body = { phone_number: number };
if (deviceId.value) body.device_id = deviceId.value;
result.value = await api.post("/calls", body);
phone.value = "";
} catch (e) {
error.value = e.message;
} finally {
calling.value = false;
}
}
</script>
<template>
<div class="max-w-2xl">
<PageHeader title="Make a call" subtitle="Tell a device to place an outbound phone call" />
<form class="rounded-lg border border-subtle bg-card shadow-sm" @submit.prevent="makeCall">
<div class="border-b border-subtle px-6 py-4">
<div class="text-base font-semibold text-primary">New call</div>
<div class="mt-0.5 font-mono text-[11px] text-muted">POST /api/calls</div>
</div>
<div class="space-y-5 p-6">
<div>
<label class="mb-1.5 block text-sm font-medium text-primary">Phone number</label>
<input
v-model="phone"
type="tel"
class="gn-input font-mono !text-xs"
placeholder="+15551234567"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-primary">Device</label>
<select v-model="deviceId" class="gn-input">
<option value="">Auto (most recent)</option>
<option v-for="d in devices" :key="d.id" :value="d.device_id">
{{ d.name || d.device_id }}
</option>
</select>
</div>
<p v-if="error" class="rounded-md bg-danger-tint px-3 py-2 text-sm text-danger">{{ error }}</p>
<p v-if="result" class="rounded-md bg-success-tint px-3 py-2 text-sm text-success">
Queued call <span class="font-mono text-xs">{{ result.id }}</span> ({{ result.status }})
</p>
<button type="submit" :disabled="calling" class="gn-btn-pri">
<Phone class="h-4 w-4" />{{ calling ? "Queuing…" : "Place call" }}
</button>
<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.
</p>
</div>
</form>
</div>
</template>
+90
View File
@@ -0,0 +1,90 @@
<script setup>
import { ref, onMounted } from "vue";
import { RefreshCw } from "@lucide/vue";
import { api } from "../api";
import PageHeader from "../components/PageHeader.vue";
import StatusBadge from "../components/StatusBadge.vue";
const devices = ref([]);
const loading = ref(true);
const error = ref("");
async function load() {
loading.value = true;
error.value = "";
try {
const res = await api.get("/devices");
devices.value = res.items || [];
} catch (e) {
error.value = e.message;
} finally {
loading.value = false;
}
}
async function remove(d) {
if (!confirm(`Remove device "${d.name || d.device_id}"?`)) return;
try {
await api.del("/devices/" + d.id);
devices.value = devices.value.filter((x) => x.id !== d.id);
} catch (e) {
alert("Could not remove device: " + e.message);
}
}
function fmt(ts) {
if (!ts) return "—";
return new Date(ts).toLocaleString();
}
onMounted(load);
</script>
<template>
<div>
<PageHeader title="Devices" subtitle="Phones connected to your gateway">
<template #actions>
<button class="gn-btn-sec gn-btn-sm" @click="load">
<RefreshCw class="h-3.5 w-3.5" />Refresh
</button>
</template>
</PageHeader>
<p v-if="error" class="mb-4 rounded-md bg-danger-tint px-3 py-2 text-sm text-danger">{{ error }}</p>
<div class="overflow-hidden rounded-lg border border-subtle bg-card shadow-xs">
<table class="w-full text-left text-sm">
<thead>
<tr class="gn-eyebrow">
<th class="px-5 py-3 font-medium">Name</th>
<th class="px-5 py-3 font-medium">Device ID</th>
<th class="px-5 py-3 font-medium">Platform</th>
<th class="px-5 py-3 font-medium">Status</th>
<th class="px-5 py-3 font-medium">Last seen</th>
<th class="px-5 py-3"></th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="6" class="border-t border-subtle px-5 py-10 text-center text-sm text-muted">Loading</td>
</tr>
<tr v-else-if="!devices.length">
<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.
</td>
</tr>
<tr v-for="d in devices" :key="d.id" class="transition-colors hover:bg-sunken">
<td class="border-t border-subtle px-5 py-3 font-medium text-primary">{{ d.name || "—" }}</td>
<td class="border-t border-subtle px-5 py-3 font-mono text-xs text-secondary">{{ d.device_id }}</td>
<td class="border-t border-subtle px-5 py-3 text-secondary">{{ d.platform }} {{ d.app_version }}</td>
<td class="border-t border-subtle px-5 py-3"><StatusBadge :status="d.status" /></td>
<td class="border-t border-subtle px-5 py-3 font-mono text-xs text-secondary">{{ fmt(d.last_seen_at) }}</td>
<td class="border-t border-subtle px-5 py-3 text-right">
<button class="text-sm font-medium text-danger hover:underline" @click="remove(d)">Remove</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
+69
View File
@@ -0,0 +1,69 @@
<script setup>
import { ref, onMounted } from "vue";
import { RefreshCw, ArrowDownLeft } from "@lucide/vue";
import { api } from "../api";
import PageHeader from "../components/PageHeader.vue";
const items = ref([]);
const loading = ref(true);
const error = ref("");
async function load() {
loading.value = true;
error.value = "";
try {
const res = await api.get("/inbox");
items.value = res.items || [];
} catch (e) {
error.value = e.message;
} finally {
loading.value = false;
}
}
function fmt(ts) {
return ts ? new Date(ts).toLocaleString() : "—";
}
onMounted(load);
</script>
<template>
<div>
<PageHeader title="Inbox" subtitle="Incoming messages received by your devices">
<template #actions>
<button class="gn-btn-sec gn-btn-sm" @click="load">
<RefreshCw class="h-3.5 w-3.5" />Refresh
</button>
</template>
</PageHeader>
<p v-if="error" class="mb-4 rounded-md bg-danger-tint px-3 py-2 text-sm text-danger">{{ error }}</p>
<div v-if="loading" class="text-sm text-muted">Loading</div>
<div
v-else-if="!items.length"
class="rounded-lg border border-dashed border-strong p-10 text-center text-sm text-muted"
>
No incoming messages yet.
</div>
<div v-else class="space-y-3">
<div
v-for="m in items"
:key="m.id"
class="flex items-start gap-3 rounded-lg border border-subtle bg-card p-4 shadow-xs"
>
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-sm bg-success-tint text-success">
<ArrowDownLeft class="h-4 w-4" />
</span>
<div class="min-w-0 flex-1">
<div class="flex items-baseline justify-between gap-3">
<span class="font-mono text-sm font-medium text-primary">{{ m.phone_number }}</span>
<span class="shrink-0 font-mono text-[11px] text-muted">{{ fmt(m.received_at) }}</span>
</div>
<p class="mt-1 text-sm text-secondary">{{ m.message }}</p>
</div>
</div>
</div>
</div>
</template>
+99
View File
@@ -0,0 +1,99 @@
<script setup>
import { RouterLink, RouterView, useRouter } from "vue-router";
import {
Smartphone,
Send,
Phone,
MessageSquare,
Inbox,
Webhook,
Moon,
Sun,
LogOut,
} from "@lucide/vue";
import { auth } from "../store/auth";
import { theme, toggleTheme } from "../theme";
import ApiStatus from "../components/ApiStatus.vue";
const router = useRouter();
const nav = [
{ name: "devices", label: "Devices", icon: Smartphone },
{ name: "send", label: "Send SMS", icon: Send },
{ name: "call", label: "Call", icon: Phone },
{ name: "messages", label: "Messages", icon: MessageSquare },
{ name: "inbox", label: "Inbox", icon: Inbox },
{ name: "webhooks", label: "Webhooks", icon: Webhook },
];
function logout() {
auth.logout();
router.push({ name: "login" });
}
</script>
<template>
<div class="flex min-h-screen">
<!-- Sidebar -->
<aside class="hidden w-60 shrink-0 flex-col border-r border-subtle bg-card sm:flex">
<div class="px-5 pb-4 pt-5">
<img
:src="theme === 'dark' ? '/gsmnode-horizontal-white.png' : '/gsmnode-horizontal.png'"
alt="gsmnode"
class="h-6"
/>
</div>
<div class="gn-eyebrow px-5 pb-2">Gateway</div>
<nav class="flex-1 space-y-0.5 px-3">
<RouterLink
v-for="item in nav"
:key="item.name"
:to="{ name: item.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"
>
<component :is="item.icon" class="h-[18px] w-[18px]" />{{ item.label }}
</RouterLink>
</nav>
<div class="border-t border-subtle px-5 py-4">
<ApiStatus />
</div>
</aside>
<!-- Main -->
<div class="flex min-w-0 flex-1 flex-col">
<header class="flex h-16 items-center justify-between border-b border-subtle bg-card px-6">
<!-- mobile nav -->
<nav class="flex gap-1 overflow-x-auto sm:hidden">
<RouterLink
v-for="item in nav"
:key="item.name"
:to="{ name: item.name }"
class="rounded-sm px-2 py-1 text-xs font-medium whitespace-nowrap text-secondary"
active-class="!bg-brand-tint !text-brand-active"
>{{ item.label }}</RouterLink>
</nav>
<div class="hidden sm:block"></div>
<div class="flex items-center gap-3 text-sm">
<span class="hidden font-mono text-xs text-secondary md:inline">{{ auth.state.user?.email }}</span>
<span class="hidden h-4 w-px bg-subtle sm:block"></span>
<button
class="gn-btn-sec gn-btn-sm"
:title="theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
@click="toggleTheme"
>
<Sun v-if="theme === 'dark'" class="h-4 w-4" />
<Moon v-else class="h-4 w-4" />
</button>
<button class="gn-btn-sec gn-btn-sm" @click="logout">
<LogOut class="h-4 w-4" />Sign out
</button>
</div>
</header>
<main class="flex-1 p-7">
<RouterView />
</main>
</div>
</div>
</template>
+115
View File
@@ -0,0 +1,115 @@
<script setup>
import { ref } from "vue";
import { useRouter, useRoute } from "vue-router";
import { Settings2, ChevronDown, ChevronUp } from "@lucide/vue";
import { auth } from "../store/auth";
import { getApiBase, setApiBase } from "../api";
import { theme } from "../theme";
const router = useRouter();
const route = useRoute();
const email = ref("");
const password = ref("");
const error = ref("");
const loading = ref(false);
// Server settings — point the app at a specific API Server, like the phone app.
// Blank means "use this site's built-in server" (the Web App BFF proxy).
const serverUrl = ref(getApiBase());
const showServer = ref(!!getApiBase());
async function submit() {
error.value = "";
loading.value = true;
try {
setApiBase(serverUrl.value);
await auth.login(email.value.trim(), password.value);
router.push(route.query.redirect || "/");
} catch (e) {
if (e.status === 401) {
error.value = "Invalid email or password.";
} else if (e.status === undefined) {
error.value = "Cannot reach the API Server. Check the server URL.";
} else {
error.value = e.message;
}
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center bg-page p-4">
<div class="w-full max-w-sm rounded-xl border border-subtle bg-card p-8 shadow-lg">
<div class="mb-7 text-center">
<img
:src="theme === 'dark' ? '/gsmnode-horizontal-white.png' : '/gsmnode-horizontal.png'"
alt="gsmnode"
class="mx-auto h-8"
/>
<p class="gn-eyebrow mt-4">Sign in to your gateway</p>
</div>
<form class="space-y-4" @submit.prevent="submit">
<div>
<label class="mb-1.5 block text-sm font-medium text-primary">Email</label>
<input
v-model="email"
type="email"
required
autocomplete="username"
class="gn-input"
placeholder="you@example.com"
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-primary">Password</label>
<input
v-model="password"
type="password"
required
autocomplete="current-password"
class="gn-input"
placeholder="••••••••"
/>
</div>
<!-- Server settings (editable, like the phone app) -->
<div class="rounded-md border border-subtle">
<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"
@click="showServer = !showServer"
>
<span class="flex items-center gap-2">
<Settings2 class="h-4 w-4" />Server settings
</span>
<ChevronUp v-if="showServer" class="h-4 w-4 text-muted" />
<ChevronDown v-else class="h-4 w-4 text-muted" />
</button>
<div v-show="showServer" class="border-t border-subtle p-3">
<label class="mb-1.5 block text-sm font-medium text-primary">API Server URL</label>
<input
v-model="serverUrl"
type="url"
autocomplete="off"
class="gn-input font-mono !text-xs"
placeholder="http://10.2.1.101:8080"
/>
<p class="mt-1.5 text-xs text-muted">
Leave blank to use this site's built-in server.
</p>
</div>
</div>
<p v-if="error" class="rounded-md bg-danger-tint px-3 py-2 text-sm text-danger">{{ error }}</p>
<button type="submit" :disabled="loading" class="gn-btn-pri w-full">
{{ loading ? "Signing in…" : "Sign in" }}
</button>
</form>
</div>
</div>
</template>
+89
View File
@@ -0,0 +1,89 @@
<script setup>
import { ref, onMounted } from "vue";
import { RefreshCw, PhoneOutgoing } from "@lucide/vue";
import { api } from "../api";
import PageHeader from "../components/PageHeader.vue";
import StatusBadge from "../components/StatusBadge.vue";
const messages = ref([]);
const loading = ref(true);
const error = ref("");
const statusFilter = ref("");
const statuses = ["", "Pending", "Processed", "Sent", "Delivered", "Failed"];
async function load() {
loading.value = true;
error.value = "";
try {
const q = statusFilter.value ? "?status=" + encodeURIComponent(statusFilter.value) : "";
const res = await api.get("/messages" + q);
messages.value = res.items || [];
} catch (e) {
error.value = e.message;
} finally {
loading.value = false;
}
}
function fmt(ts) {
return ts ? new Date(ts).toLocaleString() : "—";
}
onMounted(load);
</script>
<template>
<div>
<PageHeader title="Messages" subtitle="Outbound message history">
<template #actions>
<div class="flex items-center gap-2">
<select v-model="statusFilter" class="gn-input !h-8 !w-auto !text-xs" @change="load">
<option v-for="s in statuses" :key="s" :value="s">{{ s || "All statuses" }}</option>
</select>
<button class="gn-btn-sec gn-btn-sm" @click="load">
<RefreshCw class="h-3.5 w-3.5" />Refresh
</button>
</div>
</template>
</PageHeader>
<p v-if="error" class="mb-4 rounded-md bg-danger-tint px-3 py-2 text-sm text-danger">{{ error }}</p>
<div class="overflow-hidden rounded-lg border border-subtle bg-card shadow-xs">
<table class="w-full text-left text-sm">
<thead>
<tr class="gn-eyebrow">
<th class="px-5 py-3 font-medium">To</th>
<th class="px-5 py-3 font-medium">Message</th>
<th class="px-5 py-3 font-medium">Status</th>
<th class="px-5 py-3 font-medium">Created</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="4" class="border-t border-subtle px-5 py-10 text-center text-sm text-muted">Loading</td>
</tr>
<tr v-else-if="!messages.length">
<td colspan="4" class="border-t border-subtle px-5 py-10 text-center text-sm text-muted">
No messages yet. Send your first with a POST to <span class="rounded-xs bg-sunken px-1.5 py-0.5 font-mono text-xs text-secondary">/api/messages</span>.
</td>
</tr>
<tr v-for="m in messages" :key="m.id" class="align-top transition-colors hover:bg-sunken">
<td class="border-t border-subtle px-5 py-3 font-mono text-xs text-primary">{{ (m.phone_numbers || []).join(", ") }}</td>
<td class="border-t border-subtle px-5 py-3 text-secondary">
<div v-if="m.type === 'call'" class="flex items-center gap-1.5">
<PhoneOutgoing class="h-3.5 w-3.5" />
<span class="gn-eyebrow !text-secondary">Voice call</span>
</div>
<div v-else class="max-w-md truncate">{{ m.text_message }}</div>
<div v-if="m.error" class="mt-0.5 text-xs text-danger">{{ m.error }}</div>
</td>
<td class="border-t border-subtle px-5 py-3"><StatusBadge :status="m.status" /></td>
<td class="border-t border-subtle px-5 py-3 font-mono text-xs text-secondary">{{ fmt(m.created_at) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
+117
View File
@@ -0,0 +1,117 @@
<script setup>
import { ref, onMounted } from "vue";
import { Send } from "@lucide/vue";
import { api } from "../api";
import PageHeader from "../components/PageHeader.vue";
const devices = ref([]);
const phones = ref("");
const text = ref("");
const deviceId = ref("");
const simNumber = ref("");
const sending = ref(false);
const result = ref(null);
const error = ref("");
onMounted(async () => {
try {
const res = await api.get("/devices");
devices.value = res.items || [];
} catch {
/* listing failure is non-fatal for the form */
}
});
async function send() {
error.value = "";
result.value = null;
const phoneList = phones.value
.split(/[\n,;]+/)
.map((p) => p.trim())
.filter(Boolean);
if (!phoneList.length) {
error.value = "Enter at least one phone number.";
return;
}
if (!text.value.trim()) {
error.value = "Message text is required.";
return;
}
sending.value = true;
try {
const body = { phone_numbers: phoneList, text_message: text.value };
if (deviceId.value) body.device_id = deviceId.value;
if (simNumber.value) body.sim_number = Number(simNumber.value);
result.value = await api.post("/messages", body);
text.value = "";
} catch (e) {
error.value = e.message;
} finally {
sending.value = false;
}
}
</script>
<template>
<div class="max-w-2xl">
<PageHeader title="Send SMS" subtitle="Queue an outbound message for a device" />
<form class="rounded-lg border border-subtle bg-card shadow-sm" @submit.prevent="send">
<div class="border-b border-subtle px-6 py-4">
<div class="text-base font-semibold text-primary">New message</div>
<div class="mt-0.5 font-mono text-[11px] text-muted">POST /api/messages</div>
</div>
<div class="space-y-5 p-6">
<div>
<label class="mb-1.5 block text-sm font-medium text-primary">Phone numbers</label>
<textarea
v-model="phones"
rows="2"
class="gn-textarea font-mono !text-xs"
placeholder="+15551234567, +15559876543"
></textarea>
<p class="mt-1.5 text-xs text-muted">Separate multiple numbers with commas or new lines.</p>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-primary">Message</label>
<textarea
v-model="text"
rows="4"
class="gn-textarea"
placeholder="Your message…"
></textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="mb-1.5 block text-sm font-medium text-primary">Device</label>
<select v-model="deviceId" class="gn-input">
<option value="">Auto (most recent)</option>
<option v-for="d in devices" :key="d.id" :value="d.device_id">
{{ d.name || d.device_id }}
</option>
</select>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-primary">SIM number (optional)</label>
<input v-model="simNumber" type="number" min="1" class="gn-input" placeholder="1" />
</div>
</div>
<p v-if="error" class="rounded-md bg-danger-tint px-3 py-2 text-sm text-danger">{{ error }}</p>
<p v-if="result" class="rounded-md bg-success-tint px-3 py-2 text-sm text-success">
Queued message <span class="font-mono text-xs">{{ result.id }}</span> ({{ result.status }})
</p>
<button type="submit" :disabled="sending" class="gn-btn-pri">
<Send class="h-4 w-4" />{{ sending ? "Queuing…" : "Send message" }}
</button>
</div>
</form>
</div>
</template>
+120
View File
@@ -0,0 +1,120 @@
<script setup>
import { ref, onMounted } from "vue";
import { Plus } from "@lucide/vue";
import { api } from "../api";
import PageHeader from "../components/PageHeader.vue";
const hooks = ref([]);
const loading = ref(true);
const error = ref("");
const event = ref("sms:received");
const url = ref("");
const creating = ref(false);
const events = ["sms:received", "sms:sent", "sms:delivered", "sms:failed"];
async function load() {
loading.value = true;
error.value = "";
try {
const res = await api.get("/webhooks");
hooks.value = res.items || [];
} catch (e) {
error.value = e.message;
} finally {
loading.value = false;
}
}
async function create() {
error.value = "";
if (!url.value.trim()) {
error.value = "URL is required.";
return;
}
creating.value = true;
try {
const hook = await api.post("/webhooks", { event: event.value, url: url.value.trim() });
hooks.value.unshift(hook);
url.value = "";
} catch (e) {
error.value = e.message;
} finally {
creating.value = false;
}
}
async function remove(h) {
if (!confirm("Delete this webhook?")) return;
try {
await api.del("/webhooks/" + h.id);
hooks.value = hooks.value.filter((x) => x.id !== h.id);
} catch (e) {
alert("Could not delete: " + e.message);
}
}
onMounted(load);
</script>
<template>
<div class="max-w-3xl">
<PageHeader title="Webhooks" subtitle="Get notified when messages change state or arrive" />
<form
class="mb-6 flex flex-wrap items-end gap-3 rounded-lg border border-subtle bg-card p-4 shadow-xs"
@submit.prevent="create"
>
<div>
<label class="gn-eyebrow mb-1.5 block">Event</label>
<select v-model="event" class="gn-input !w-auto font-mono !text-xs">
<option v-for="e in events" :key="e" :value="e">{{ e }}</option>
</select>
</div>
<div class="flex-1">
<label class="gn-eyebrow mb-1.5 block">Target URL</label>
<input
v-model="url"
type="url"
placeholder="https://example.com/hook"
class="gn-input font-mono !text-xs"
/>
</div>
<button type="submit" :disabled="creating" class="gn-btn-pri">
<Plus class="h-4 w-4" />Add webhook
</button>
</form>
<p v-if="error" class="mb-4 rounded-md bg-danger-tint px-3 py-2 text-sm text-danger">{{ error }}</p>
<div class="overflow-hidden rounded-lg border border-subtle bg-card shadow-xs">
<table class="w-full text-left text-sm">
<thead>
<tr class="gn-eyebrow">
<th class="px-5 py-3 font-medium">Event</th>
<th class="px-5 py-3 font-medium">URL</th>
<th class="px-5 py-3"></th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="3" class="border-t border-subtle px-5 py-10 text-center text-sm text-muted">Loading</td>
</tr>
<tr v-else-if="!hooks.length">
<td colspan="3" class="border-t border-subtle px-5 py-10 text-center text-sm text-muted">No webhooks.</td>
</tr>
<tr v-for="h in hooks" :key="h.id" class="transition-colors hover:bg-sunken">
<td class="border-t border-subtle px-5 py-3">
<span class="rounded-sm bg-brand-tint px-2 py-0.5 font-mono text-xs text-brand-active">{{ h.event }}</span>
</td>
<td class="border-t border-subtle px-5 py-3 font-mono text-xs text-secondary">{{ h.url }}</td>
<td class="border-t border-subtle px-5 py-3 text-right">
<button class="text-sm font-medium text-danger hover:underline" @click="remove(h)">Delete</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
+22
View File
@@ -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,
},
},
},
});