Initial commit: PilotVault multi-service project
Add API Server (Go/PocketBase), Web App (Go BFF + Vue), Fly App (Flutter/DJI MSDK), Adobe Plugin, and Docker/Docker AIO deployment configs. Design assets and build artifacts are gitignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0F1E3D" />
|
||||
<title>PilotVault · API Server</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1964
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "pilotvault-api-panel",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="48" height="48" rx="11" fill="#0F1E3D" />
|
||||
<g stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<polyline points="10,30 21,17 32,30" stroke="#3D7BF0" />
|
||||
<polyline points="16,33 27,20 38,33" stroke="#F4F7FC" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 371 B |
@@ -0,0 +1,807 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { theme, toggleTheme } from "./theme";
|
||||
import EndpointTable from "./components/EndpointTable.vue";
|
||||
|
||||
// ---- Auth gate: this panel is restricted to superadmins ------------------
|
||||
// The token is kept only for this browser (localStorage). Login proxies through
|
||||
// the API Server to PocketBase; the caller's role is then confirmed via /api/me,
|
||||
// and anyone who is not a superadmin is refused.
|
||||
const TOKEN_KEY = "pv_panel_token";
|
||||
const token = ref(localStorage.getItem(TOKEN_KEY) || "");
|
||||
const me = ref(null); // { email, role } once verified as superadmin
|
||||
const authed = ref(false);
|
||||
const booting = ref(true);
|
||||
|
||||
const form = ref({ email: "", password: "" });
|
||||
const loginErr = ref("");
|
||||
const busy = ref(false);
|
||||
|
||||
// Console tabs.
|
||||
const tab = ref("overview");
|
||||
const TABS = [
|
||||
{ id: "overview", label: "Overview" },
|
||||
{ id: "pocketbase", label: "PocketBase" },
|
||||
{ id: "plugins", label: "Plugins" },
|
||||
];
|
||||
|
||||
// Verify a token resolves to a superadmin. Returns true when access is granted.
|
||||
async function verify(tok) {
|
||||
try {
|
||||
const r = await fetch("/api/me", { headers: { Authorization: tok } });
|
||||
if (!r.ok) return false;
|
||||
const who = await r.json();
|
||||
if (who.role !== "superadmin") return false;
|
||||
me.value = { email: who.email, role: who.role };
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function grant(tok) {
|
||||
token.value = tok;
|
||||
localStorage.setItem(TOKEN_KEY, tok);
|
||||
authed.value = true;
|
||||
startPolling();
|
||||
loadPbConfig();
|
||||
loadPlugins();
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
loginErr.value = "";
|
||||
const email = form.value.email.trim().toLowerCase();
|
||||
if (!email || !form.value.password) {
|
||||
loginErr.value = "Enter your email and password.";
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
try {
|
||||
const r = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password: form.value.password }),
|
||||
});
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !body.token) {
|
||||
loginErr.value = "Invalid email or password.";
|
||||
return;
|
||||
}
|
||||
// Authenticated — now enforce the superadmin-only rule.
|
||||
if (!(await verify(body.token))) {
|
||||
loginErr.value = "Access to this panel is restricted to superadmins.";
|
||||
return;
|
||||
}
|
||||
form.value.password = "";
|
||||
await grant(body.token);
|
||||
} catch {
|
||||
loginErr.value = "Cannot reach the API server.";
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
stopPolling();
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
token.value = "";
|
||||
me.value = null;
|
||||
authed.value = false;
|
||||
}
|
||||
|
||||
// ---- PocketBase connection settings (superadmin) -------------------------
|
||||
const pb = ref(null); // { url, adminEmail, adminConfigured, probe }
|
||||
const pbForm = ref({ url: "", adminEmail: "", adminPassword: "" });
|
||||
const pbProbe = ref(null); // most recent test/save probe result
|
||||
const pbMsg = ref("");
|
||||
const pbErr = ref("");
|
||||
const pbBusy = ref(false);
|
||||
const pbTesting = ref(false);
|
||||
|
||||
function authHeaders(json) {
|
||||
const h = { Authorization: token.value };
|
||||
if (json) h["Content-Type"] = "application/json";
|
||||
return h;
|
||||
}
|
||||
|
||||
async function loadPbConfig() {
|
||||
try {
|
||||
const r = await fetch("/api/admin/pb-config", { headers: authHeaders() });
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
pb.value = d;
|
||||
pbForm.value = { url: d.url || "", adminEmail: d.adminEmail || "", adminPassword: "" };
|
||||
pbProbe.value = d.probe || null;
|
||||
} catch {
|
||||
/* leave settings unloaded; the card shows a retry */
|
||||
}
|
||||
}
|
||||
|
||||
async function testPbConfig() {
|
||||
pbMsg.value = "";
|
||||
pbErr.value = "";
|
||||
pbTesting.value = true;
|
||||
try {
|
||||
const r = await fetch("/api/admin/pb-config/test", {
|
||||
method: "POST",
|
||||
headers: authHeaders(true),
|
||||
body: JSON.stringify(pbForm.value),
|
||||
});
|
||||
pbProbe.value = await r.json();
|
||||
} catch {
|
||||
pbErr.value = "Could not run the test.";
|
||||
} finally {
|
||||
pbTesting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function savePbConfig() {
|
||||
pbMsg.value = "";
|
||||
pbErr.value = "";
|
||||
if (!pbForm.value.url.trim()) {
|
||||
pbErr.value = "A PocketBase URL is required.";
|
||||
return;
|
||||
}
|
||||
pbBusy.value = true;
|
||||
try {
|
||||
const r = await fetch("/api/admin/pb-config", {
|
||||
method: "PUT",
|
||||
headers: authHeaders(true),
|
||||
body: JSON.stringify(pbForm.value),
|
||||
});
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
pbErr.value = d.error || "Could not save the connection.";
|
||||
return;
|
||||
}
|
||||
pb.value = d.config;
|
||||
pbProbe.value = d.config?.probe || null;
|
||||
pbForm.value = { url: d.config.url, adminEmail: d.config.adminEmail, adminPassword: "" };
|
||||
pbMsg.value = d.warning || "Connection saved.";
|
||||
} catch {
|
||||
pbErr.value = "Could not reach the API server.";
|
||||
} finally {
|
||||
pbBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Plugins (external-service integrations, superadmin) ------------------
|
||||
const plugins = ref([]); // [{ name, provider, version, kind, capabilities, authType, configFields, enabled, config, baseURL, health }]
|
||||
const pluginsErr = ref("");
|
||||
const pluginsMsg = ref("");
|
||||
const editingPlugin = ref(""); // name whose config form is expanded
|
||||
const editConfig = ref({}); // working copy of the expanded plugin's config
|
||||
const busyPlugin = ref(""); // name of a plugin with an in-flight action
|
||||
const newExt = ref({ name: "", baseURL: "", provider: "" });
|
||||
const newExtErr = ref("");
|
||||
const newExtBusy = ref(false);
|
||||
|
||||
// Plugins are grouped into category tabs (mirrors the Web App's Integrations tabs).
|
||||
// A plugin's server-side `category` picks its tab; an unset category falls back to APIs — External.
|
||||
const PLUGIN_CATEGORIES = [
|
||||
{ id: "apis-external", label: "APIs — External" },
|
||||
{ id: "drives-external", label: "Drives — External" },
|
||||
{ id: "drives-local", label: "Drives — Local" },
|
||||
];
|
||||
const pluginTab = ref("apis-external");
|
||||
function pluginCategory(p) {
|
||||
return p.category || "apis-external";
|
||||
}
|
||||
function pluginsInCategory(id) {
|
||||
return plugins.value.filter((p) => pluginCategory(p) === id);
|
||||
}
|
||||
const activePlugins = computed(() => pluginsInCategory(pluginTab.value));
|
||||
|
||||
const kindBadge = {
|
||||
builtin: "bg-success-tint text-success",
|
||||
external: "bg-warning-tint text-warning",
|
||||
};
|
||||
const healthBadge = {
|
||||
ok: "bg-success-tint text-success",
|
||||
degraded: "bg-warning-tint text-warning",
|
||||
down: "bg-danger-tint text-danger",
|
||||
};
|
||||
|
||||
async function loadPlugins() {
|
||||
pluginsErr.value = "";
|
||||
try {
|
||||
const r = await fetch("/api/admin/plugins", { headers: authHeaders() });
|
||||
if (!r.ok) {
|
||||
pluginsErr.value = "Could not load plugins.";
|
||||
return;
|
||||
}
|
||||
const d = await r.json();
|
||||
plugins.value = d.plugins || [];
|
||||
// Populate health (and live credit usage) for enabled plugins that don't have
|
||||
// it yet — e.g. right after a server restart. Cached server-side afterwards,
|
||||
// so this probes at most once per plugin per server run.
|
||||
for (const p of plugins.value) {
|
||||
if (p.enabled && !p.health) autoCheckHealth(p.name);
|
||||
}
|
||||
} catch {
|
||||
pluginsErr.value = "Could not reach the API server.";
|
||||
}
|
||||
}
|
||||
|
||||
async function autoCheckHealth(name) {
|
||||
try {
|
||||
const r = await fetch(`/api/admin/plugins/${encodeURIComponent(name)}/health`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
});
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (r.ok && d.health) {
|
||||
const t = pluginBy(name);
|
||||
if (t) t.health = d.health;
|
||||
}
|
||||
} catch {
|
||||
/* leave health unset */
|
||||
}
|
||||
}
|
||||
|
||||
function pluginBy(name) {
|
||||
return plugins.value.find((p) => p.name === name);
|
||||
}
|
||||
|
||||
async function togglePlugin(p) {
|
||||
busyPlugin.value = p.name;
|
||||
pluginsMsg.value = "";
|
||||
try {
|
||||
const r = await fetch(`/api/admin/plugins/${encodeURIComponent(p.name)}`, {
|
||||
method: "PUT",
|
||||
headers: authHeaders(true),
|
||||
body: JSON.stringify({ enabled: !p.enabled }),
|
||||
});
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
pluginsMsg.value = d.error || "Could not update the plugin.";
|
||||
} else if (d.warning) {
|
||||
pluginsMsg.value = `${p.name}: ${d.warning}`;
|
||||
}
|
||||
await loadPlugins();
|
||||
} finally {
|
||||
busyPlugin.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function startPluginEdit(p) {
|
||||
editingPlugin.value = editingPlugin.value === p.name ? "" : p.name;
|
||||
const cfg = { ...(p.config || {}) };
|
||||
// Preselect each field's effective default when nothing is stored yet.
|
||||
for (const f of p.configFields || []) {
|
||||
if ((cfg[f.key] === undefined || cfg[f.key] === "") && f.default) cfg[f.key] = f.default;
|
||||
}
|
||||
editConfig.value = cfg;
|
||||
}
|
||||
|
||||
async function savePluginConfig(p) {
|
||||
busyPlugin.value = p.name;
|
||||
pluginsMsg.value = "";
|
||||
try {
|
||||
const r = await fetch(`/api/admin/plugins/${encodeURIComponent(p.name)}`, {
|
||||
method: "PUT",
|
||||
headers: authHeaders(true),
|
||||
body: JSON.stringify({ config: editConfig.value }),
|
||||
});
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
pluginsMsg.value = d.error || "Could not save the configuration.";
|
||||
} else {
|
||||
pluginsMsg.value = d.warning ? `${p.name}: ${d.warning}` : "Configuration saved.";
|
||||
editingPlugin.value = "";
|
||||
}
|
||||
await loadPlugins();
|
||||
} finally {
|
||||
busyPlugin.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function checkPlugin(p) {
|
||||
busyPlugin.value = p.name;
|
||||
pluginsMsg.value = "";
|
||||
try {
|
||||
const r = await fetch(`/api/admin/plugins/${encodeURIComponent(p.name)}/health`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
});
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (r.ok && d.health) {
|
||||
const t = pluginBy(p.name);
|
||||
if (t) t.health = d.health;
|
||||
} else {
|
||||
pluginsMsg.value = d.error || "Health check failed.";
|
||||
}
|
||||
} finally {
|
||||
busyPlugin.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function removePlugin(p) {
|
||||
if (p.kind !== "external") return;
|
||||
busyPlugin.value = p.name;
|
||||
try {
|
||||
const r = await fetch(`/api/admin/plugins/${encodeURIComponent(p.name)}`, {
|
||||
method: "DELETE",
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
pluginsMsg.value = d.error || "Could not remove the plugin.";
|
||||
}
|
||||
await loadPlugins();
|
||||
} finally {
|
||||
busyPlugin.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function registerExternal() {
|
||||
newExtErr.value = "";
|
||||
if (!newExt.value.name.trim() || !newExt.value.baseURL.trim()) {
|
||||
newExtErr.value = "Name and base URL are required.";
|
||||
return;
|
||||
}
|
||||
newExtBusy.value = true;
|
||||
try {
|
||||
const r = await fetch("/api/admin/plugins", {
|
||||
method: "POST",
|
||||
headers: authHeaders(true),
|
||||
body: JSON.stringify(newExt.value),
|
||||
});
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
newExtErr.value = d.error || "Could not register the plugin.";
|
||||
return;
|
||||
}
|
||||
newExt.value = { name: "", baseURL: "", provider: "" };
|
||||
pluginsMsg.value = "External plugin registered.";
|
||||
await loadPlugins();
|
||||
} catch {
|
||||
newExtErr.value = "Could not reach the API server.";
|
||||
} finally {
|
||||
newExtBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Live health poll against this server's aggregate /api/status, which probes
|
||||
// PocketBase and the Web App server-side (the browser only talks to the API).
|
||||
const checkedAt = ref(null);
|
||||
const devices = ref(null);
|
||||
const svc = ref({
|
||||
apiServer: { status: "checking", detail: "" },
|
||||
pocketBase: { status: "checking", detail: "" },
|
||||
webApp: { status: "checking", detail: "" },
|
||||
});
|
||||
let timer = null;
|
||||
|
||||
const rows = [
|
||||
{ key: "apiServer", label: "API server" },
|
||||
{ key: "pocketBase", label: "PocketBase" },
|
||||
{ key: "webApp", label: "Web App" },
|
||||
];
|
||||
|
||||
const badge = {
|
||||
checking: { label: "checking", cls: "bg-sunken text-secondary" },
|
||||
ok: { label: "operational", cls: "bg-success-tint text-success" },
|
||||
down: { label: "unreachable", cls: "bg-danger-tint text-danger" },
|
||||
unreachable: { label: "unreachable", cls: "bg-danger-tint text-danger" },
|
||||
};
|
||||
|
||||
function meta(h) {
|
||||
const bits = [];
|
||||
if (typeof h.latencyMs === "number") bits.push(h.latencyMs + "ms");
|
||||
if (h.httpStatus) bits.push("HTTP " + h.httpStatus);
|
||||
if (h.url) bits.push(h.url);
|
||||
return bits.join(" · ");
|
||||
}
|
||||
|
||||
async function check() {
|
||||
try {
|
||||
const r = await fetch("/api/status");
|
||||
if (!r.ok) throw new Error("status " + r.status);
|
||||
const b = await r.json();
|
||||
const api = b.apiServer || {};
|
||||
devices.value = api.devices ?? 0;
|
||||
svc.value = {
|
||||
apiServer: { status: "ok", detail: "" },
|
||||
pocketBase: { status: b.pocketBase?.status === "ok" ? "ok" : "down", detail: meta(b.pocketBase || {}) },
|
||||
webApp: { status: b.webApp?.status === "ok" ? "ok" : "down", detail: meta(b.webApp || {}) },
|
||||
};
|
||||
} catch {
|
||||
// The API server itself is unreachable → status of everything is unknown.
|
||||
devices.value = null;
|
||||
svc.value = {
|
||||
apiServer: { status: "unreachable", detail: "" },
|
||||
pocketBase: { status: "unreachable", detail: "" },
|
||||
webApp: { status: "unreachable", detail: "" },
|
||||
};
|
||||
}
|
||||
checkedAt.value = new Date();
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
check();
|
||||
clearInterval(timer);
|
||||
timer = setInterval(check, 10000);
|
||||
}
|
||||
function stopPolling() {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Resume a previous superadmin session if the stored token still checks out.
|
||||
if (token.value && (await verify(token.value))) {
|
||||
authed.value = true;
|
||||
startPolling();
|
||||
loadPbConfig();
|
||||
loadPlugins();
|
||||
} else if (token.value) {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
token.value = "";
|
||||
}
|
||||
booting.value = false;
|
||||
});
|
||||
onUnmounted(() => stopPolling());
|
||||
|
||||
// Client / dashboard API — used by the Web App and the API Web Panel.
|
||||
const clientApi = [
|
||||
{ method: "POST", path: "/api/auth/login", desc: "Exchange email + password for a session (via PocketBase)" },
|
||||
{ method: "GET", path: "/api/auth/validate", desc: "Validate the current session token" },
|
||||
{ method: "GET", path: "/api/me", desc: "Caller's id, email, role, and organization from their token" },
|
||||
{ method: "GET", path: "/api/preferences", desc: "Read the caller's saved settings blob" },
|
||||
{ method: "PUT", path: "/api/preferences", desc: "Persist the caller's settings onto their user record" },
|
||||
{ method: "GET", path: "/api/devices", desc: "List connected devices and last-known state" },
|
||||
{ method: "GET", path: "/api/devices/{id}/track", desc: "GPS track history for a device" },
|
||||
{ method: "POST", path: "/api/devices/{id}/command", desc: "Send a command down to a device" },
|
||||
{ method: "DELETE", path: "/api/devices/{id}", desc: "Forget a device's stored state" },
|
||||
{ method: "GET", path: "/ws/ui", desc: "Live telemetry stream (WebSocket)" },
|
||||
];
|
||||
|
||||
// Management API — user + organization management. Requires a manager
|
||||
// (admin or superadmin) token; admins are scoped to their own organization,
|
||||
// superadmins span all of them.
|
||||
const managementApi = [
|
||||
{ method: "GET", path: "/api/users", desc: "List users (admin: own org · superadmin: all)" },
|
||||
{ method: "POST", path: "/api/users", desc: "Create a user {email, password, role, organization?}" },
|
||||
{ method: "PATCH", path: "/api/users/{id}", desc: "Edit a user (role/org changes are scope-checked)" },
|
||||
{ method: "DELETE", path: "/api/users/{id}", desc: "Delete a user (not self; admins in-org only)" },
|
||||
{ method: "GET", path: "/api/orgs", desc: "List organizations (admin: own · superadmin: all)" },
|
||||
{ method: "POST", path: "/api/orgs", desc: "Create an organization {name} (superadmin)" },
|
||||
{ method: "PATCH", path: "/api/orgs/{id}", desc: "Rename an organization (superadmin)" },
|
||||
{ method: "DELETE", path: "/api/orgs/{id}", desc: "Delete an empty organization (superadmin)" },
|
||||
{ method: "GET", path: "/api/admin/pb-config", desc: "Read the PocketBase connection + live probe (superadmin)" },
|
||||
{ method: "POST", path: "/api/admin/pb-config/test", desc: "Test a candidate connection without applying (superadmin)" },
|
||||
{ method: "PUT", path: "/api/admin/pb-config", desc: "Update + persist the PocketBase connection (superadmin)" },
|
||||
{ method: "GET", path: "/api/admin/plugins", desc: "List plugins + state + last health (superadmin)" },
|
||||
{ method: "POST", path: "/api/admin/plugins", desc: "Register an external plugin {name, baseURL} (superadmin)" },
|
||||
{ method: "PUT", path: "/api/admin/plugins/{name}", desc: "Enable/disable + configure a plugin (superadmin)" },
|
||||
{ method: "DELETE", path: "/api/admin/plugins/{name}", desc: "Remove an external plugin (superadmin)" },
|
||||
{ method: "POST", path: "/api/admin/plugins/{name}/health", desc: "Run a plugin health check (superadmin)" },
|
||||
];
|
||||
|
||||
// Device API — used by the Fly App running on the drone/controller.
|
||||
const deviceApi = [
|
||||
{ method: "GET", path: "/ws/device?id={id}", desc: "Device telemetry uplink (WebSocket)" },
|
||||
{ method: "POST", path: "/api/telemetry?id={id}", desc: "Push a single telemetry event over HTTP" },
|
||||
{ method: "GET", path: "/healthz", desc: "Readiness probe" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto flex max-w-5xl flex-col gap-6 px-6 pt-12 pb-16">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3">
|
||||
<svg width="32" height="32" viewBox="0 0 48 48" fill="none" aria-hidden="true">
|
||||
<g stroke-width="4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="8,30 19,17 30,30" stroke="var(--brand)" />
|
||||
<polyline points="18,33 29,20 40,33" stroke="currentColor" />
|
||||
</g>
|
||||
</svg>
|
||||
<div class="leading-tight text-primary">
|
||||
<div class="font-display text-lg font-bold tracking-tight">PilotVault</div>
|
||||
<span class="pv-eyebrow">API server</span>
|
||||
</div>
|
||||
<div class="flex-1"></div>
|
||||
<span v-if="authed && me" class="pv-eyebrow hidden truncate sm:inline">{{ me.email }}</span>
|
||||
<button v-if="authed" class="pv-btn-sec pv-btn-sm" @click="logout">Sign out</button>
|
||||
<button
|
||||
class="pv-btn-sec pv-btn-sm"
|
||||
:title="theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<!-- sun (shown in dark mode) -->
|
||||
<svg v-if="theme === 'dark'" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
<!-- moon (shown in light mode) -->
|
||||
<svg v-else class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
Theme
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Booting: resolving a stored session -->
|
||||
<div v-if="booting" class="rounded-lg border border-subtle bg-card px-5 py-10 text-center shadow-sm">
|
||||
<span class="pv-eyebrow">Checking session…</span>
|
||||
</div>
|
||||
|
||||
<!-- Login gate — superadmin only -->
|
||||
<div v-else-if="!authed" class="mx-auto w-full max-w-sm rounded-lg border border-subtle bg-card shadow-sm">
|
||||
<div class="border-b border-subtle px-5 py-4">
|
||||
<div class="text-base font-semibold text-primary">Sign in</div>
|
||||
<span class="pv-eyebrow">Superadmin access only</span>
|
||||
</div>
|
||||
<form class="flex flex-col gap-3 px-5 py-5" @submit.prevent="doLogin">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="pv-eyebrow">Email</span>
|
||||
<input
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
autocomplete="username"
|
||||
class="pv-input"
|
||||
placeholder="superadmin@pilotvault.local"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="pv-eyebrow">Password</span>
|
||||
<input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="pv-input"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</label>
|
||||
<p v-if="loginErr" class="rounded-sm bg-danger-tint px-3 py-2 text-xs font-medium text-danger">
|
||||
{{ loginErr }}
|
||||
</p>
|
||||
<button type="submit" class="pv-btn mt-1" :disabled="busy">
|
||||
{{ busy ? "Signing in…" : "Sign in" }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Console — visible only to an authenticated superadmin -->
|
||||
<template v-else>
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-1 self-start rounded-lg border border-subtle bg-card p-1 shadow-sm">
|
||||
<button
|
||||
v-for="t in TABS"
|
||||
:key="t.id"
|
||||
class="rounded-md px-3.5 py-1.5 text-sm font-semibold transition"
|
||||
:class="tab === t.id ? 'bg-brand text-on-brand' : 'text-secondary hover:text-primary'"
|
||||
@click="tab = t.id"
|
||||
>
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Overview tab -->
|
||||
<div v-show="tab === 'overview'" class="flex flex-col gap-6">
|
||||
<!-- Status -->
|
||||
<div class="rounded-lg border border-subtle bg-card shadow-sm">
|
||||
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
|
||||
<div class="text-base font-semibold text-primary">Status</div>
|
||||
<span class="pv-eyebrow">{{ checkedAt ? "checked " + checkedAt.toLocaleTimeString() : "—" }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="row.key"
|
||||
class="flex items-center justify-between gap-3 border-t border-subtle px-5 py-3.5 first:border-t-0"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold text-primary">{{ row.label }}</div>
|
||||
<div v-if="svc[row.key].detail" class="truncate font-mono text-xs text-secondary">{{ svc[row.key].detail }}</div>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-sm px-2.5 py-1 font-mono text-xs font-medium"
|
||||
:class="badge[svc[row.key].status].cls"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
|
||||
{{ badge[svc[row.key].status].label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Devices metric -->
|
||||
<div class="flex items-center justify-between gap-3 border-t border-subtle px-5 py-3.5">
|
||||
<div class="text-sm font-semibold text-primary">Devices</div>
|
||||
<span class="font-mono text-xs text-secondary">
|
||||
{{ devices === null ? "—" : devices + " online" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EndpointTable title="Client API" auth="PocketBase session" :endpoints="clientApi" />
|
||||
<EndpointTable title="Management API" auth="Admin · superadmin" :endpoints="managementApi" />
|
||||
<EndpointTable title="Device API" auth="Device uplink" :endpoints="deviceApi" />
|
||||
</div>
|
||||
|
||||
<!-- PocketBase tab -->
|
||||
<div v-show="tab === 'pocketbase'" class="flex flex-col gap-6">
|
||||
<!-- PocketBase connection settings -->
|
||||
<div class="rounded-lg border border-subtle bg-card shadow-sm">
|
||||
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
|
||||
<div>
|
||||
<div class="text-base font-semibold text-primary">PocketBase connection</div>
|
||||
<span class="pv-eyebrow">Settings</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="pbProbe"
|
||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-sm px-2.5 py-1 font-mono text-xs font-medium"
|
||||
:class="pbProbe.reachable ? (pbProbe.superuser ? 'bg-success-tint text-success' : 'bg-warning-tint text-warning') : 'bg-danger-tint text-danger'"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
|
||||
{{ pbProbe.reachable ? (pbProbe.superuser ? "connected" : "reachable") : "unreachable" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 px-5 py-5">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="pv-eyebrow">PocketBase URL</span>
|
||||
<input v-model="pbForm.url" class="pv-input" placeholder="http://10.2.1.10:8026" spellcheck="false" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="pv-eyebrow">Service account email</span>
|
||||
<input v-model="pbForm.adminEmail" class="pv-input" placeholder="admin@pilotvault.local" autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="pv-eyebrow">Service account password</span>
|
||||
<input
|
||||
v-model="pbForm.adminPassword"
|
||||
type="password"
|
||||
class="pv-input"
|
||||
autocomplete="new-password"
|
||||
:placeholder="pb && pb.adminConfigured ? 'leave blank to keep current' : 'set a password'"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div v-if="pbProbe" class="font-mono text-[11px] text-muted">
|
||||
health: {{ pbProbe.reachable ? "ok" : "down" }}<span v-if="pbProbe.latencyMs"> · {{ pbProbe.latencyMs }}ms</span>
|
||||
· superuser auth: {{ pbProbe.superuser ? "ok" : "—" }}<span v-if="pbProbe.detail"> · {{ pbProbe.detail }}</span>
|
||||
</div>
|
||||
|
||||
<p v-if="pbErr" class="rounded-sm bg-danger-tint px-3 py-2 text-xs font-medium text-danger">{{ pbErr }}</p>
|
||||
<p v-else-if="pbMsg" class="rounded-sm bg-success-tint px-3 py-2 text-xs font-medium text-success">{{ pbMsg }}</p>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button class="pv-btn" :disabled="pbBusy" @click="savePbConfig">{{ pbBusy ? "Saving…" : "Save connection" }}</button>
|
||||
<button class="pv-btn-sec" :disabled="pbTesting" @click="testPbConfig">{{ pbTesting ? "Testing…" : "Test connection" }}</button>
|
||||
</div>
|
||||
<p class="text-[11px] text-muted">
|
||||
The service account is used only for user & organization management. Changing the URL
|
||||
repoints the whole API Server at a new PocketBase and may sign you out.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Plugins tab -->
|
||||
<div v-show="tab === 'plugins'" class="flex flex-col gap-6">
|
||||
<!-- Plugins -->
|
||||
<div class="rounded-lg border border-subtle bg-card shadow-sm">
|
||||
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
|
||||
<div>
|
||||
<div class="text-base font-semibold text-primary">Plugins</div>
|
||||
<span class="pv-eyebrow">External integrations</span>
|
||||
</div>
|
||||
<button class="pv-btn-sec pv-btn-sm" @click="loadPlugins">Refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<p v-if="pluginsErr" class="px-5 py-3 text-xs font-medium text-danger">{{ pluginsErr }}</p>
|
||||
<p v-if="pluginsMsg" class="border-b border-subtle bg-sunken px-5 py-2.5 font-mono text-xs text-secondary">{{ pluginsMsg }}</p>
|
||||
<p v-if="!plugins.length && !pluginsErr" class="px-5 py-6 text-sm text-secondary">No plugins registered yet.</p>
|
||||
|
||||
<!-- category tabs -->
|
||||
<div v-if="plugins.length" class="flex gap-1 overflow-x-auto overflow-y-hidden border-b border-subtle px-3 pt-1">
|
||||
<button
|
||||
v-for="t in PLUGIN_CATEGORIES"
|
||||
:key="t.id"
|
||||
class="-mb-px whitespace-nowrap border-b-2 px-3 py-2 text-sm font-semibold transition"
|
||||
:class="pluginTab === t.id ? 'border-brand text-primary' : 'border-transparent text-secondary hover:text-primary'"
|
||||
@click="pluginTab = t.id"
|
||||
>
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- per-tab empty state -->
|
||||
<p v-if="plugins.length && !activePlugins.length" class="px-5 py-6 text-sm text-secondary">No plugins in this category.</p>
|
||||
|
||||
<!-- plugin rows -->
|
||||
<div v-for="p in activePlugins" :key="p.name" class="border-t border-subtle px-5 py-4 first:border-t-0">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-semibold text-primary">{{ p.name }}</span>
|
||||
<span class="rounded-sm px-1.5 py-0.5 font-mono text-[10px] font-medium uppercase tracking-wider" :class="kindBadge[p.kind]">{{ p.kind }}</span>
|
||||
<span
|
||||
v-if="p.health"
|
||||
class="inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 font-mono text-[10px] font-medium"
|
||||
:class="healthBadge[p.health.status]"
|
||||
:title="p.health.detail || ''"
|
||||
>
|
||||
<span class="h-1 w-1 rounded-full bg-current"></span>{{ p.health.status }}<span v-if="p.health.latencyMs"> · {{ p.health.latencyMs }}ms</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-0.5 font-mono text-xs text-secondary">{{ p.provider }} · v{{ p.version }} · {{ p.authType }}</div>
|
||||
<div v-if="p.health && p.health.detail" class="mt-0.5 text-[11px] text-secondary">{{ p.health.detail }}</div>
|
||||
<div v-if="p.capabilities && p.capabilities.length" class="mt-2 flex flex-col gap-1.5">
|
||||
<div class="pv-eyebrow">Capabilities</div>
|
||||
<div v-for="c in p.capabilities" :key="c.id" class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span class="rounded-sm bg-sunken px-1.5 py-0.5 font-mono text-[10px] text-secondary">{{ c.id }}</span>
|
||||
<span v-if="c.method || c.endpoint" class="font-mono text-[10px] text-muted">{{ c.method }} {{ c.endpoint }}</span>
|
||||
<span v-if="c.description" class="w-full text-[11px] text-secondary sm:w-auto">{{ c.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="p.baseURL" class="mt-1 truncate font-mono text-[11px] text-muted">{{ p.baseURL }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
class="pv-btn-sec pv-btn-sm"
|
||||
:disabled="busyPlugin === p.name"
|
||||
:class="p.enabled ? '!border-transparent !bg-success-tint !text-success' : ''"
|
||||
@click="togglePlugin(p)"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="p.enabled ? 'bg-success' : 'bg-muted'"></span>
|
||||
{{ p.enabled ? "Enabled" : "Disabled" }}
|
||||
</button>
|
||||
<button v-if="p.configFields && p.configFields.length" class="pv-btn-sec pv-btn-sm" @click="startPluginEdit(p)">Configure</button>
|
||||
<button class="pv-btn-sec pv-btn-sm" :disabled="busyPlugin === p.name" @click="checkPlugin(p)">Check</button>
|
||||
<button v-if="p.kind === 'external'" class="pv-btn-sec pv-btn-sm !text-danger" :disabled="busyPlugin === p.name" @click="removePlugin(p)">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- config form -->
|
||||
<div v-if="editingPlugin === p.name" class="mt-3 flex flex-col gap-2 rounded-md border border-subtle bg-sunken px-4 py-4">
|
||||
<label v-for="f in p.configFields" :key="f.key" class="flex flex-col gap-1">
|
||||
<span class="pv-eyebrow">{{ f.label }}<span v-if="f.required" class="text-danger"> *</span></span>
|
||||
<select v-if="f.type === 'select'" v-model="editConfig[f.key]" class="pv-input">
|
||||
<option v-for="o in f.options" :key="o.value" :value="o.value">{{ o.label }}</option>
|
||||
</select>
|
||||
<input
|
||||
v-else
|
||||
v-model="editConfig[f.key]"
|
||||
:type="f.secret || f.type === 'password' ? 'password' : f.type === 'number' ? 'number' : 'text'"
|
||||
class="pv-input"
|
||||
autocomplete="off"
|
||||
:placeholder="f.help || ''"
|
||||
/>
|
||||
<span v-if="f.help" class="text-[11px] text-muted">{{ f.help }}</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="pv-btn pv-btn-sm" :disabled="busyPlugin === p.name" @click="savePluginConfig(p)">Save configuration</button>
|
||||
<button class="pv-btn-sec pv-btn-sm" @click="editingPlugin = ''">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- register external plugin -->
|
||||
<div class="border-t border-subtle px-5 py-4">
|
||||
<div class="pv-eyebrow mb-2">Register external plugin</div>
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<input v-model="newExt.name" class="pv-input sm:w-40" placeholder="name" autocomplete="off" spellcheck="false" />
|
||||
<input v-model="newExt.baseURL" class="pv-input flex-1" placeholder="https://plugin.example.com" autocomplete="off" spellcheck="false" />
|
||||
<button class="pv-btn" :disabled="newExtBusy" @click="registerExternal">{{ newExtBusy ? "Adding…" : "Add" }}</button>
|
||||
</div>
|
||||
<p v-if="newExtErr" class="mt-2 text-xs font-medium text-danger">{{ newExtErr }}</p>
|
||||
<p class="mt-2 text-[11px] text-muted">
|
||||
A remote service that answers <span class="font-mono">GET /health</span> and
|
||||
<span class="font-mono">GET /manifest</span> — added at runtime, no rebuild.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p class="text-center font-mono text-[11px] text-muted">
|
||||
PilotVault — live drone telemetry, command & control.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: String,
|
||||
auth: String,
|
||||
endpoints: Array, // [{ method, path, desc }]
|
||||
});
|
||||
|
||||
const methodClass = {
|
||||
GET: "text-success",
|
||||
POST: "text-brand-text",
|
||||
PATCH: "text-warning",
|
||||
DELETE: "text-danger",
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-hidden rounded-lg border border-subtle bg-card shadow-xs">
|
||||
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
|
||||
<div class="text-base font-semibold text-primary">{{ title }}</div>
|
||||
<span class="pv-eyebrow">{{ auth }}</span>
|
||||
</div>
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="pv-eyebrow">
|
||||
<th class="px-5 py-2.5 font-medium">Endpoint</th>
|
||||
<th class="px-5 py-2.5 font-medium">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="e in endpoints" :key="e.method + e.path" class="transition-colors hover:bg-sunken">
|
||||
<td class="border-t border-subtle px-5 py-2.5 font-mono text-xs whitespace-nowrap">
|
||||
<span class="font-semibold" :class="methodClass[e.method]">{{ e.method }}</span>
|
||||
<span class="text-primary"> {{ e.path }}</span>
|
||||
</td>
|
||||
<td class="border-t border-subtle px-5 py-2.5 text-secondary">{{ e.desc }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./style.css";
|
||||
import "./theme";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
@@ -0,0 +1,261 @@
|
||||
/* PilotVault API panel — design tokens (Vault Navy + Signal Blue) mapped into
|
||||
Tailwind v4. Light is default; data-theme="dark" flips the semantic layer. */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ============================================================
|
||||
RAW RAMPS + SEMANTIC ALIASES (light)
|
||||
============================================================ */
|
||||
:root,
|
||||
[data-theme="light"] {
|
||||
/* Brand ramps (do not theme-flip) */
|
||||
--navy-950: #0B1730;
|
||||
--navy-900: #0F1E3D; /* Vault Navy — core brand */
|
||||
--navy-800: #1B2E52;
|
||||
--navy-700: #26406E;
|
||||
|
||||
--blue-50: #EAF1FE;
|
||||
--blue-100: #D6E3FD;
|
||||
--blue-300: #8FB4F6;
|
||||
--blue-400: #5B93F5;
|
||||
--blue-500: #3D7BF0; /* Signal Blue — accent */
|
||||
--blue-600: #2B62CC;
|
||||
--blue-700: #1F4CA0;
|
||||
|
||||
--slate-0: #FFFFFF;
|
||||
--slate-50: #F6F7F9;
|
||||
--slate-100: #EEF0F3;
|
||||
--slate-150: #E6E9EE;
|
||||
--slate-200: #DCE0E7;
|
||||
--slate-300: #C5CCD7;
|
||||
--slate-400: #97A1B0;
|
||||
--slate-500: #6B7688;
|
||||
--slate-700: #333B4A;
|
||||
|
||||
--steel: #5A6B85;
|
||||
|
||||
--green-500: #1F8A5B; --green-100: #DCF1E7; --green-600: #177049;
|
||||
--amber-500: #D9852B; --amber-100: #FBEBD5; --amber-600: #B86C1B;
|
||||
--red-500: #D64545; --red-100: #FBE0E0; --red-600: #B83232;
|
||||
|
||||
/* Semantic aliases — LIGHT */
|
||||
--bg-page: var(--slate-100);
|
||||
--bg-sunken: var(--slate-50);
|
||||
--surface-card: var(--slate-0);
|
||||
|
||||
--border-subtle: var(--slate-200);
|
||||
--border-strong: var(--slate-300);
|
||||
--border-focus: var(--blue-500);
|
||||
|
||||
--text-primary: var(--navy-900);
|
||||
--text-secondary: var(--steel);
|
||||
--text-muted: var(--slate-400);
|
||||
|
||||
--brand: var(--blue-500);
|
||||
--brand-hover: var(--blue-600);
|
||||
--brand-active: var(--blue-700);
|
||||
--brand-contrast: #FFFFFF;
|
||||
--text-brand: var(--blue-600);
|
||||
|
||||
--success: var(--green-600);
|
||||
--success-tint: var(--green-100);
|
||||
--warning: var(--amber-600);
|
||||
--warning-tint: var(--amber-100);
|
||||
--danger: var(--red-600);
|
||||
--danger-tint: var(--red-100);
|
||||
|
||||
--ring-focus: 0 0 0 3px color-mix(in srgb, var(--blue-500) 45%, transparent);
|
||||
|
||||
--sh-xs: 0 1px 2px rgba(15, 30, 61, 0.06);
|
||||
--sh-sm: 0 1px 2px rgba(15, 30, 61, 0.06), 0 1px 3px rgba(15, 30, 61, 0.04);
|
||||
|
||||
--dur-fast: 120ms;
|
||||
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
DARK THEME — only the semantic layer remaps.
|
||||
============================================================ */
|
||||
[data-theme="dark"] {
|
||||
--bg-page: var(--navy-950);
|
||||
--bg-sunken: #0B111C;
|
||||
--surface-card: #10203F;
|
||||
|
||||
--border-subtle: color-mix(in srgb, #ffffff 8%, transparent);
|
||||
--border-strong: color-mix(in srgb, #ffffff 18%, transparent);
|
||||
--border-focus: var(--blue-400);
|
||||
|
||||
--text-primary: #F4F7FC;
|
||||
--text-secondary: #8FA0BE;
|
||||
--text-muted: #5E6E8C;
|
||||
|
||||
--brand: var(--blue-400);
|
||||
--brand-hover: var(--blue-300);
|
||||
--brand-active: var(--blue-100);
|
||||
--brand-contrast: #0F1E3D;
|
||||
--text-brand: var(--blue-300);
|
||||
|
||||
--success: #5FD3A0;
|
||||
--success-tint: color-mix(in srgb, var(--green-500) 22%, transparent);
|
||||
--warning: #F0B26A;
|
||||
--warning-tint: color-mix(in srgb, var(--amber-500) 22%, transparent);
|
||||
--danger: #F08A8A;
|
||||
--danger-tint: color-mix(in srgb, var(--red-500) 22%, transparent);
|
||||
|
||||
--ring-focus: 0 0 0 3px color-mix(in srgb, var(--blue-400) 55%, transparent);
|
||||
|
||||
--sh-xs: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||
--sh-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
TAILWIND THEME — utilities resolve to the semantic vars, so
|
||||
everything flips automatically under data-theme="dark".
|
||||
============================================================ */
|
||||
@theme inline {
|
||||
--color-*: initial;
|
||||
|
||||
--color-page: var(--bg-page);
|
||||
--color-sunken: var(--bg-sunken);
|
||||
--color-card: var(--surface-card);
|
||||
|
||||
--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-brand: var(--brand);
|
||||
--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);
|
||||
|
||||
--font-display: 'Space Grotesk', ui-sans-serif, system-ui, 'Segoe UI', sans-serif;
|
||||
--font-sans: 'Space Grotesk', ui-sans-serif, system-ui, 'Segoe UI', sans-serif;
|
||||
--font-mono: 'Space Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace;
|
||||
|
||||
--radius-*: initial;
|
||||
--radius-xs: 4px;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
--shadow-*: initial;
|
||||
--shadow-xs: var(--sh-xs);
|
||||
--shadow-sm: var(--sh-sm);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
BASE
|
||||
============================================================ */
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-page);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* Secondary button */
|
||||
@utility pv-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);
|
||||
}
|
||||
|
||||
.pv-btn-sec:hover:not(:disabled) { background: var(--bg-sunken); }
|
||||
.pv-btn-sec:active:not(:disabled) { transform: translateY(1px); }
|
||||
.pv-btn-sec:focus-visible { outline: none; box-shadow: var(--ring-focus); }
|
||||
|
||||
/* Small button size modifier */
|
||||
@utility pv-btn-sm {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Primary button */
|
||||
@utility pv-btn {
|
||||
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);
|
||||
}
|
||||
|
||||
.pv-btn:hover:not(:disabled) { background: var(--brand-hover); }
|
||||
.pv-btn:active:not(:disabled) { transform: translateY(1px); }
|
||||
.pv-btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.pv-btn:focus-visible { outline: none; box-shadow: var(--ring-focus); }
|
||||
|
||||
/* Text input */
|
||||
@utility pv-input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
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-size: 0.875rem;
|
||||
}
|
||||
|
||||
.pv-input::placeholder { color: var(--text-muted); }
|
||||
.pv-input:focus { outline: none; border-color: var(--border-focus); box-shadow: var(--ring-focus); }
|
||||
|
||||
/* Mono eyebrow — uppercase, tracked out */
|
||||
@utility pv-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 PilotVault design-system kits.
|
||||
const KEY = "pilotvault-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-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,20 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// Builds into internal/api/dist, which the Go server embeds via go:embed
|
||||
// and serves at the server root.
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
build: {
|
||||
outDir: "../internal/api/dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
// Dev-mode proxy to a locally running API Server.
|
||||
"/api": "http://localhost:8080",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user