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>
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 759 B |
@@ -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 |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -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 |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||