Initial commit
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="#2563eb" />
|
||||
<title>DriverVault · 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": "drivervault-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,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
|
||||
<rect width="256" height="256" rx="56" fill="#1E40AF"></rect>
|
||||
<svg x="53" y="53" width="150" height="150" viewBox="0 0 48 48">
|
||||
<g transform="translate(7 0) skewX(-13)">
|
||||
<rect x="9" y="16" width="6" height="16" rx="3" fill="#ffffff" opacity="0.55"></rect>
|
||||
<rect x="19" y="12" width="6" height="24" rx="3" fill="#ffffff" opacity="0.8"></rect>
|
||||
<rect x="29" y="8" width="6" height="32" rx="3" fill="#ffffff"></rect>
|
||||
</g>
|
||||
</svg>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 550 B |
@@ -0,0 +1,196 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed } from "vue";
|
||||
import { theme, toggleTheme } from "./theme";
|
||||
import EndpointTable from "./components/EndpointTable.vue";
|
||||
|
||||
// Live health poll against this server.
|
||||
const status = ref("checking"); // checking | ok | error | unreachable
|
||||
const httpStatus = ref(null);
|
||||
const latency = ref(null);
|
||||
const checkedAt = ref(null);
|
||||
let timer = null;
|
||||
|
||||
async function check() {
|
||||
const started = performance.now();
|
||||
try {
|
||||
const r = await fetch("/api/health");
|
||||
latency.value = Math.round(performance.now() - started);
|
||||
httpStatus.value = r.status;
|
||||
status.value = r.ok ? "ok" : "error";
|
||||
} catch {
|
||||
latency.value = null;
|
||||
httpStatus.value = null;
|
||||
status.value = "unreachable";
|
||||
}
|
||||
checkedAt.value = new Date();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
check();
|
||||
timer = setInterval(check, 10000);
|
||||
});
|
||||
onUnmounted(() => clearInterval(timer));
|
||||
|
||||
const badge = {
|
||||
checking: { label: "checking", cls: "bg-sunken text-muted" },
|
||||
ok: { label: "operational", cls: "bg-success-soft text-success" },
|
||||
error: { label: "error", cls: "bg-danger-soft text-danger" },
|
||||
unreachable: { label: "unreachable", cls: "bg-danger-soft text-danger" },
|
||||
};
|
||||
|
||||
// Logo bar fills — flip for legibility on the dark shell.
|
||||
const barFills = computed(() =>
|
||||
theme.value === "dark"
|
||||
? ["#60a5fa", "#93c5fd", "#ffffff"]
|
||||
: ["var(--brand-700)", "var(--brand-500)", "var(--brand-400)"],
|
||||
);
|
||||
|
||||
// The DriverVault REST surface, grouped by resource. Mirrors the routes registered
|
||||
// in internal/api/server.go.
|
||||
const publicApi = [
|
||||
{ method: "GET", path: "/api/health", desc: "Liveness probe (no auth)" },
|
||||
{ method: "POST", path: "/api/auth/login", desc: "Exchange email + password for a JWT" },
|
||||
{ method: "GET", path: "/api/auth/me", desc: "Identity of the bearer token" },
|
||||
];
|
||||
|
||||
const carsApi = [
|
||||
{ method: "GET", path: "/api/cars", desc: "List owned + shared cars" },
|
||||
{ method: "POST", path: "/api/cars", desc: "Create a car" },
|
||||
{ method: "GET", path: "/api/cars/{id}", desc: "Fetch one car" },
|
||||
{ method: "PATCH", path: "/api/cars/{id}", desc: "Update a car" },
|
||||
{ method: "DELETE", path: "/api/cars/{id}", desc: "Delete a car (owner only)" },
|
||||
{ method: "GET", path: "/api/cars/{id}/service-records", desc: "A car's service history" },
|
||||
{ method: "GET", path: "/api/cars/{id}/parts", desc: "A car's parts catalog" },
|
||||
{ method: "GET", path: "/api/cars/{id}/shares", desc: "Who a car is shared with (owner)" },
|
||||
{ method: "POST", path: "/api/cars/{id}/shares", desc: "Share a car by email (owner)" },
|
||||
{ method: "DELETE", path: "/api/cars/{id}/shares/{userId}", desc: "Revoke a share (owner)" },
|
||||
];
|
||||
|
||||
const serviceApi = [
|
||||
{ method: "GET", path: "/api/service-records", desc: "List service records" },
|
||||
{ method: "POST", path: "/api/service-records", desc: "Log a service record" },
|
||||
{ method: "GET", path: "/api/service-records/{id}", desc: "Fetch one record" },
|
||||
{ method: "PATCH", path: "/api/service-records/{id}", desc: "Update a record" },
|
||||
{ method: "DELETE", path: "/api/service-records/{id}", desc: "Delete a record" },
|
||||
];
|
||||
|
||||
const partsApi = [
|
||||
{ method: "GET", path: "/api/parts", desc: "List parts" },
|
||||
{ method: "POST", path: "/api/parts", desc: "Add a part" },
|
||||
{ method: "GET", path: "/api/parts/{id}", desc: "Fetch one part" },
|
||||
{ method: "PATCH", path: "/api/parts/{id}", desc: "Update a part" },
|
||||
{ method: "DELETE", path: "/api/parts/{id}", desc: "Delete a part" },
|
||||
];
|
||||
|
||||
const accountApi = [
|
||||
{ method: "GET", path: "/api/me", desc: "Current user profile" },
|
||||
{ method: "PATCH", path: "/api/me", desc: "Update profile" },
|
||||
{ method: "POST", path: "/api/me/password", desc: "Change password" },
|
||||
{ method: "POST", path: "/api/me/avatar", desc: "Upload avatar" },
|
||||
{ method: "GET", path: "/api/me/avatar", desc: "Fetch avatar" },
|
||||
{ method: "DELETE", path: "/api/me/avatar", desc: "Remove avatar" },
|
||||
{ method: "POST", path: "/api/me/verify/request", desc: "Request email verification" },
|
||||
{ method: "GET", path: "/api/me/export", desc: "Export your data" },
|
||||
{ method: "POST", path: "/api/me/import", desc: "Import data" },
|
||||
{ method: "POST", path: "/api/me/delete", desc: "Request account deletion" },
|
||||
{ method: "POST", path: "/api/me/delete/cancel", desc: "Cancel deletion request" },
|
||||
{ method: "DELETE", path: "/api/me", desc: "Finalize account deletion" },
|
||||
];
|
||||
|
||||
const sessionsApi = [
|
||||
{ method: "GET", path: "/api/sessions", desc: "List active sessions (devices)" },
|
||||
{ method: "DELETE", path: "/api/sessions/{id}", desc: "Revoke one session" },
|
||||
{ method: "DELETE", path: "/api/sessions", desc: "Revoke all other sessions" },
|
||||
];
|
||||
|
||||
const adminApi = [
|
||||
{ method: "GET", path: "/api/admin/users", desc: "List users" },
|
||||
{ method: "POST", path: "/api/admin/users", desc: "Create a user" },
|
||||
{ method: "PATCH", path: "/api/admin/users/{id}", desc: "Update name / role" },
|
||||
{ method: "POST", path: "/api/admin/users/{id}/password", desc: "Reset a user's password" },
|
||||
{ method: "DELETE", path: "/api/admin/users/{id}", desc: "Delete a user" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto flex max-w-3xl flex-col gap-6 px-6 pt-12 pb-16">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex items-center gap-2.5 select-none">
|
||||
<svg class="h-8 w-8 shrink-0" viewBox="0 0 48 48" fill="none" aria-hidden="true">
|
||||
<g transform="translate(7 0) skewX(-13)">
|
||||
<rect x="9" y="16" width="6" height="16" rx="3" :fill="barFills[0]" />
|
||||
<rect x="19" y="12" width="6" height="24" rx="3" :fill="barFills[1]" />
|
||||
<rect x="29" y="8" width="6" height="32" rx="3" :fill="barFills[2]" />
|
||||
</g>
|
||||
</svg>
|
||||
<span class="text-2xl leading-none font-extrabold tracking-[-0.03em] italic">
|
||||
<span class="text-strong">Driver</span><span class="text-brandtext">Vault</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="eyebrow mt-1.5">API server</span>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
class="dh-btn-ghost"
|
||||
:title="theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<svg
|
||||
v-if="theme === 'dark'"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path stroke-linecap="round" d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" />
|
||||
</svg>
|
||||
Theme
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="dh-card">
|
||||
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
|
||||
<div class="text-base font-bold tracking-[-0.02em] text-strong">Status</div>
|
||||
<span
|
||||
class="data inline-flex items-center gap-1.5 rounded-pill px-2.5 py-1 text-xs font-medium"
|
||||
:class="badge[status].cls"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
|
||||
{{ badge[status].label }}<template v-if="status === 'error'"> {{ httpStatus }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="data flex flex-wrap items-center gap-x-6 gap-y-2 px-5 py-4 text-xs text-muted">
|
||||
<span>GET /api/health</span>
|
||||
<span>{{ latency !== null ? latency + "ms" : "—" }}</span>
|
||||
<span>{{ checkedAt ? "checked " + checkedAt.toLocaleTimeString() : "—" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EndpointTable title="Public" auth="No auth" :endpoints="publicApi" />
|
||||
<EndpointTable title="Cars" auth="Bearer JWT" :endpoints="carsApi" />
|
||||
<EndpointTable title="Service records" auth="Bearer JWT" :endpoints="serviceApi" />
|
||||
<EndpointTable title="Parts" auth="Bearer JWT" :endpoints="partsApi" />
|
||||
<EndpointTable title="Account" auth="Bearer JWT" :endpoints="accountApi" />
|
||||
<EndpointTable title="Sessions" auth="Bearer JWT" :endpoints="sessionsApi" />
|
||||
<EndpointTable title="Admin" auth="Admin JWT" :endpoints="adminApi" />
|
||||
|
||||
<p class="eyebrow text-center">
|
||||
DriverVault — car maintenance & service tracker.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: String,
|
||||
auth: String,
|
||||
endpoints: Array, // [{ method, path, desc }]
|
||||
});
|
||||
|
||||
const methodClass = {
|
||||
GET: "text-success",
|
||||
POST: "text-brandtext",
|
||||
PATCH: "text-warning",
|
||||
DELETE: "text-danger",
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dh-card overflow-hidden">
|
||||
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
|
||||
<div class="text-base font-bold tracking-[-0.02em] text-strong">{{ title }}</div>
|
||||
<span class="eyebrow">{{ auth }}</span>
|
||||
</div>
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium">
|
||||
<th>Endpoint</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="e in endpoints"
|
||||
:key="e.method + e.path"
|
||||
class="transition-colors hover:bg-sunken"
|
||||
>
|
||||
<td class="data border-t border-subtle px-5 py-2.5 text-xs whitespace-nowrap">
|
||||
<span class="font-semibold" :class="methodClass[e.method]">{{ e.method }}</span>
|
||||
<span class="text-strong"> {{ e.path }}</span>
|
||||
</td>
|
||||
<td class="border-t border-subtle px-5 py-2.5 text-body">{{ 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,219 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* DriverVault webfonts — Archivo (UI + display, italic 800 is the brand voice) + DM Mono (data/labels). */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Archivo:ital,wght@0,400;0,500;0,600;0,700;0,800;1,700;1,800&family=DM+Mono:ital,wght@0,400;0,500&display=swap");
|
||||
|
||||
/* Tailwind v4 defaults dark: to prefers-color-scheme; switch to a class strategy
|
||||
so theme.js can toggle `.dark` on <html>. */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* ============================================================================
|
||||
DriverVault design tokens (subset used by the API panel). Raw ramps + semantic
|
||||
aliases; html.dark re-points the aliases so utilities flip for free.
|
||||
========================================================================== */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
--brand-900: #0b1730;
|
||||
--brand-800: #0f1e3d;
|
||||
--brand-700: #1e40af;
|
||||
--brand-600: #2563eb;
|
||||
--brand-500: #3b82f6;
|
||||
--brand-400: #60a5fa;
|
||||
--brand-300: #93c5fd;
|
||||
--brand-200: #c7dbfb;
|
||||
--brand-100: #e8f0fd;
|
||||
|
||||
--ink-900: #0f1e3d;
|
||||
--ink-700: #28374f;
|
||||
--ink-600: #3e4e68;
|
||||
--ink-500: #5c6b85;
|
||||
--ink-400: #7a8aa6;
|
||||
--ink-300: #b4bece;
|
||||
--ink-200: #d6deea;
|
||||
--ink-100: #e4e9f2;
|
||||
--ink-50: #eef2f8;
|
||||
--ink-25: #f7f9fc;
|
||||
--white: #ffffff;
|
||||
|
||||
--success-600: #1f8a5b;
|
||||
--success-100: #e1f3ea;
|
||||
--warning-600: #d9822b;
|
||||
--warning-100: #fbeddd;
|
||||
--danger-600: #dc2a45;
|
||||
--danger-100: #fbe3e7;
|
||||
--info-600: #2563eb;
|
||||
--info-100: #e8f0fd;
|
||||
|
||||
--surface-page: var(--ink-25);
|
||||
--surface-card: var(--white);
|
||||
--surface-sunken: var(--ink-50);
|
||||
|
||||
--border-subtle: var(--ink-100);
|
||||
--border-default: var(--ink-200);
|
||||
--border-strong: var(--ink-300);
|
||||
|
||||
--text-strong: var(--ink-900);
|
||||
--text-body: var(--ink-600);
|
||||
--text-muted: var(--ink-400);
|
||||
--text-brand: var(--brand-700);
|
||||
|
||||
--accent: var(--brand-600);
|
||||
--accent-hover: var(--brand-700);
|
||||
--focus-ring: var(--brand-400);
|
||||
|
||||
--shadow-xs: 0 1px 2px rgba(15, 30, 61, 0.06);
|
||||
--shadow-sm: 0 1px 2px rgba(15, 30, 61, 0.04), 0 2px 6px rgba(15, 30, 61, 0.06);
|
||||
--shadow-md: 0 1px 2px rgba(15, 30, 61, 0.04), 0 12px 30px -12px rgba(15, 30, 61, 0.14);
|
||||
|
||||
--font-display: "Archivo", system-ui, sans-serif;
|
||||
--font-sans: "Archivo", system-ui, sans-serif;
|
||||
--font-mono: "DM Mono", ui-monospace, "SF Mono", monospace;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
|
||||
--success-100: #12352a;
|
||||
--warning-100: #3a2a16;
|
||||
--danger-100: #3a1620;
|
||||
--info-100: #122a4d;
|
||||
|
||||
--surface-page: #0b1730;
|
||||
--surface-card: #13233f;
|
||||
--surface-sunken: #0f1e38;
|
||||
|
||||
--border-subtle: #21324f;
|
||||
--border-default: #2c3f5e;
|
||||
--border-strong: #3c5173;
|
||||
|
||||
--text-strong: #f2f6fc;
|
||||
--text-body: #b7c4d9;
|
||||
--text-muted: #7c8ca8;
|
||||
--text-brand: #93c5fd;
|
||||
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #60a5fa;
|
||||
--focus-ring: #60a5fa;
|
||||
|
||||
--success-600: #35b27a;
|
||||
--warning-600: #e0a03a;
|
||||
--danger-600: #e85c74;
|
||||
|
||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 6px rgba(0, 0, 0, 0.4);
|
||||
--shadow-md: 0 1px 2px rgba(0, 0, 0, 0.35), 0 12px 30px -12px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Map tokens into Tailwind's theme so semantic utilities exist and flip in dark
|
||||
mode without a dark: variant. Utility names mirror the DriverVault web app.
|
||||
========================================================================== */
|
||||
@theme inline {
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
|
||||
--color-brand-300: var(--brand-300);
|
||||
--color-brand-400: var(--brand-400);
|
||||
--color-brand-500: var(--brand-500);
|
||||
--color-brand-600: var(--brand-600);
|
||||
--color-brand-700: var(--brand-700);
|
||||
--color-brand-900: var(--brand-900);
|
||||
|
||||
--color-page: var(--surface-page);
|
||||
--color-card: var(--surface-card);
|
||||
--color-sunken: var(--surface-sunken);
|
||||
|
||||
--color-strong: var(--text-strong);
|
||||
--color-body: var(--text-body);
|
||||
--color-muted: var(--text-muted);
|
||||
--color-brandtext: var(--text-brand);
|
||||
|
||||
--color-subtle: var(--border-subtle);
|
||||
--color-default: var(--border-default);
|
||||
--color-strongline: var(--border-strong);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-hover: var(--accent-hover);
|
||||
|
||||
--color-success: var(--success-600);
|
||||
--color-success-soft: var(--success-100);
|
||||
--color-warning: var(--warning-600);
|
||||
--color-warning-soft: var(--warning-100);
|
||||
--color-danger: var(--danger-600);
|
||||
--color-danger-soft: var(--danger-100);
|
||||
--color-info: var(--info-600);
|
||||
--color-info-soft: var(--info-100);
|
||||
|
||||
--radius-control: 12px;
|
||||
--radius-card: 18px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
--shadow-xs: var(--shadow-xs);
|
||||
--shadow-sm: var(--shadow-sm);
|
||||
--shadow-card: var(--shadow-md);
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
background: var(--surface-page);
|
||||
color: var(--text-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Small mono eyebrow labels (uppercase, tracked out). */
|
||||
.eyebrow {
|
||||
font-family: var(--font-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Monospaced data (methods, paths, latencies) so columns align. */
|
||||
.data {
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Secondary button (theme toggle). */
|
||||
.dh-btn-ghost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--radius-control);
|
||||
border: 1px solid var(--border-default);
|
||||
background: var(--surface-card);
|
||||
color: var(--text-strong);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
.dh-btn-ghost:hover {
|
||||
background: var(--surface-sunken);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.dh-btn-ghost:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.dh-card {
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--surface-card);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
// Persisted light/dark theme for the panel. Matches the DriverVault web app's
|
||||
// class strategy (`.dark` on <html>), but under its own storage key so it
|
||||
// doesn't collide with the main app's `theme` preference.
|
||||
const KEY = "dh-panel-theme";
|
||||
|
||||
function initial() {
|
||||
try {
|
||||
const saved = localStorage.getItem(KEY);
|
||||
if (saved === "dark" || saved === "light") return saved;
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
// Fall back to the OS preference.
|
||||
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
export const theme = ref(initial());
|
||||
|
||||
export function applyTheme(t) {
|
||||
theme.value = t;
|
||||
document.documentElement.classList.toggle("dark", t === "dark");
|
||||
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 (the DriverVault API panel).
|
||||
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