feat: scaffold Vue 3 + TypeScript + Vite frontend admin panel

Set up web/ directory with complete frontend scaffolding:
- Vue 3 + TypeScript + Vite with Tailwind CSS v4
- Vue Router with auth guard (redirects to /login when unauthenticated)
- Pinia stores: auth, cookies, devices, settings
- Axios HTTP client with token interceptor
- Views: Login, Dashboard, Cookies, Devices, Settings
- Vite dev server proxy to relay API on port 8100
- Headless UI and Heroicons dependencies

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
徐枫
2026-03-17 20:22:35 +08:00
parent b6fbf7a921
commit e3a9d9f63c
23 changed files with 3564 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
web/node_modules/
web/dist/

11
web/env.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<
Record<string, unknown>,
Record<string, unknown>,
unknown
>;
export default component;
}

12
web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CookieBridge Admin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2694
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
web/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "cookiebridge-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@headlessui/vue": "^1.7.0",
"@heroicons/vue": "^2.2.0",
"axios": "^1.8.0",
"pinia": "^3.0.0",
"vue": "^3.5.0",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-vue": "^5.2.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.9.0",
"vite": "^6.2.0",
"vue-tsc": "^2.2.0"
}
}

3
web/src/App.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

29
web/src/api/client.ts Normal file
View File

@@ -0,0 +1,29 @@
import axios from "axios";
const api = axios.create({
baseURL: "/api",
headers: { "Content-Type": "application/json" },
});
// Attach auth token to requests
api.interceptors.request.use((config) => {
const token = localStorage.getItem("cb_admin_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Handle 401 responses
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem("cb_admin_token");
window.location.href = "/login";
}
return Promise.reject(error);
},
);
export default api;

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth";
const router = useRouter();
const auth = useAuthStore();
const navItems = [
{ name: "Dashboard", path: "/", icon: "📊" },
{ name: "Cookies", path: "/cookies", icon: "🍪" },
{ name: "Devices", path: "/devices", icon: "📱" },
{ name: "Settings", path: "/settings", icon: "⚙️" },
];
function logout() {
auth.logout();
router.push("/login");
}
</script>
<template>
<div class="flex h-screen bg-gray-50">
<!-- Sidebar -->
<aside class="w-64 border-r border-gray-200 bg-white">
<div class="flex h-16 items-center border-b border-gray-200 px-6">
<h1 class="text-lg font-semibold text-gray-900">CookieBridge</h1>
</div>
<nav class="mt-4 space-y-1 px-3">
<router-link
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100"
active-class="!bg-blue-50 !text-blue-700"
>
<span>{{ item.icon }}</span>
<span>{{ item.name }}</span>
</router-link>
</nav>
<div class="absolute bottom-0 w-64 border-t border-gray-200 p-4">
<button
class="w-full rounded-lg px-3 py-2 text-left text-sm font-medium text-gray-600 hover:bg-gray-100"
@click="logout"
>
Sign Out
</button>
</div>
</aside>
<!-- Main content -->
<main class="flex-1 overflow-auto">
<router-view />
</main>
</div>
</template>

10
web/src/main.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import router from "./router";
import App from "./App.vue";
import "./style.css";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");

55
web/src/router/index.ts Normal file
View File

@@ -0,0 +1,55 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router";
import { useAuthStore } from "@/stores/auth";
const routes: RouteRecordRaw[] = [
{
path: "/login",
name: "login",
component: () => import("@/views/LoginView.vue"),
meta: { requiresAuth: false },
},
{
path: "/",
component: () => import("@/components/layout/AppLayout.vue"),
meta: { requiresAuth: true },
children: [
{
path: "",
name: "dashboard",
component: () => import("@/views/DashboardView.vue"),
},
{
path: "cookies",
name: "cookies",
component: () => import("@/views/CookiesView.vue"),
},
{
path: "devices",
name: "devices",
component: () => import("@/views/DevicesView.vue"),
},
{
path: "settings",
name: "settings",
component: () => import("@/views/SettingsView.vue"),
},
],
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach((to) => {
const auth = useAuthStore();
if (to.meta.requiresAuth !== false && !auth.isAuthenticated) {
return { name: "login" };
}
if (to.name === "login" && auth.isAuthenticated) {
return { name: "dashboard" };
}
});
export default router;

22
web/src/stores/auth.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import api from "@/api/client";
export const useAuthStore = defineStore("auth", () => {
const token = ref<string | null>(localStorage.getItem("cb_admin_token"));
const isAuthenticated = computed(() => !!token.value);
async function login(username: string, password: string): Promise<void> {
const { data } = await api.post("/auth/login", { username, password });
token.value = data.token;
localStorage.setItem("cb_admin_token", data.token);
}
function logout(): void {
token.value = null;
localStorage.removeItem("cb_admin_token");
}
return { token, isAuthenticated, login, logout };
});

53
web/src/stores/cookies.ts Normal file
View File

@@ -0,0 +1,53 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import api from "@/api/client";
import type { EncryptedCookieBlob } from "@/types/api";
export const useCookiesStore = defineStore("cookies", () => {
const cookies = ref<EncryptedCookieBlob[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const domains = computed(() => {
const set = new Set(cookies.value.map((c) => c.domain));
return Array.from(set).sort();
});
const byDomain = computed(() => {
const map = new Map<string, EncryptedCookieBlob[]>();
for (const cookie of cookies.value) {
const list = map.get(cookie.domain) ?? [];
list.push(cookie);
map.set(cookie.domain, list);
}
return map;
});
async function fetchCookies(domain?: string): Promise<void> {
loading.value = true;
error.value = null;
try {
const params = domain ? { domain } : {};
const { data } = await api.get("/cookies", { params });
cookies.value = data.cookies;
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : "Failed to fetch cookies";
} finally {
loading.value = false;
}
}
async function deleteCookie(
domain: string,
cookieName: string,
path: string,
): Promise<void> {
await api.delete("/cookies", { data: { domain, cookieName, path } });
cookies.value = cookies.value.filter(
(c) =>
!(c.domain === domain && c.cookieName === cookieName && c.path === path),
);
}
return { cookies, loading, error, domains, byDomain, fetchCookies, deleteCookie };
});

30
web/src/stores/devices.ts Normal file
View File

@@ -0,0 +1,30 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import api from "@/api/client";
import type { DeviceInfo } from "@/types/api";
export const useDevicesStore = defineStore("devices", () => {
const devices = ref<DeviceInfo[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
async function fetchDevices(): Promise<void> {
loading.value = true;
error.value = null;
try {
const { data } = await api.get("/devices");
devices.value = data.devices;
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : "Failed to fetch devices";
} finally {
loading.value = false;
}
}
async function revokeDevice(deviceId: string): Promise<void> {
await api.delete(`/devices/${deviceId}`);
devices.value = devices.value.filter((d) => d.deviceId !== deviceId);
}
return { devices, loading, error, fetchDevices, revokeDevice };
});

View File

@@ -0,0 +1,41 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import api from "@/api/client";
export interface AppSettings {
syncIntervalMs: number;
maxDevices: number;
autoSync: boolean;
theme: "light" | "dark" | "system";
}
const DEFAULT_SETTINGS: AppSettings = {
syncIntervalMs: 30_000,
maxDevices: 10,
autoSync: true,
theme: "system",
};
export const useSettingsStore = defineStore("settings", () => {
const settings = ref<AppSettings>({ ...DEFAULT_SETTINGS });
const loading = ref(false);
async function fetchSettings(): Promise<void> {
loading.value = true;
try {
const { data } = await api.get("/settings");
settings.value = { ...DEFAULT_SETTINGS, ...data };
} catch {
// Use defaults if settings endpoint doesn't exist yet
} finally {
loading.value = false;
}
}
async function updateSettings(patch: Partial<AppSettings>): Promise<void> {
const { data } = await api.patch("/settings", patch);
settings.value = { ...settings.value, ...data };
}
return { settings, loading, fetchSettings, updateSettings };
});

1
web/src/style.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

62
web/src/types/api.ts Normal file
View File

@@ -0,0 +1,62 @@
/** Device registration response */
export interface DeviceInfo {
deviceId: string;
name: string;
platform: string;
encPub: string;
token: string;
createdAt: string;
}
/** Encrypted cookie blob stored on the relay server */
export interface EncryptedCookieBlob {
id: string;
deviceId: string;
domain: string;
cookieName: string;
path: string;
ciphertext: string;
nonce: string;
lamportTs: number;
updatedAt: string;
}
/** Agent token */
export interface AgentToken {
id: string;
name: string;
token: string;
encPub: string;
allowedDomains: string[];
createdAt: string;
}
/** Pairing session */
export interface PairingSession {
pairingCode: string;
expiresAt: string;
}
/** Pairing accept response */
export interface PairingResult {
initiator: { deviceId: string; x25519PubKey: string };
acceptor: { deviceId: string; x25519PubKey: string };
}
/** Health check response */
export interface HealthStatus {
status: string;
connections: number;
}
/** Login credentials for admin auth */
export interface LoginCredentials {
username: string;
password: string;
}
/** Auth token response */
export interface AuthResponse {
token: string;
expiresAt: string;
}

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useCookiesStore } from "@/stores/cookies";
const store = useCookiesStore();
const search = ref("");
const selectedDomain = ref<string | null>(null);
onMounted(() => store.fetchCookies());
function selectDomain(domain: string | null) {
selectedDomain.value = domain;
store.fetchCookies(domain ?? undefined);
}
async function handleDelete(domain: string, cookieName: string, path: string) {
await store.deleteCookie(domain, cookieName, path);
}
</script>
<template>
<div class="p-8">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-semibold text-gray-900">Cookies</h2>
<p class="mt-1 text-sm text-gray-500">Manage synced encrypted cookies</p>
</div>
<input
v-model="search"
type="text"
placeholder="Search cookies..."
class="rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<!-- Domain filter chips -->
<div class="mt-4 flex flex-wrap gap-2">
<button
class="rounded-full px-3 py-1 text-xs font-medium"
:class="selectedDomain === null ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="selectDomain(null)"
>
All
</button>
<button
v-for="domain in store.domains"
:key="domain"
class="rounded-full px-3 py-1 text-xs font-medium"
:class="selectedDomain === domain ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
@click="selectDomain(domain)"
>
{{ domain }}
</button>
</div>
<!-- Cookie table -->
<div class="mt-6 overflow-hidden rounded-xl bg-white ring-1 ring-gray-200">
<div v-if="store.loading" class="p-8 text-center text-sm text-gray-500">Loading...</div>
<div v-else-if="store.cookies.length === 0" class="p-8 text-center text-sm text-gray-500">
No cookies found
</div>
<table v-else class="w-full text-left text-sm">
<thead class="border-b border-gray-200 bg-gray-50">
<tr>
<th class="px-4 py-3 font-medium text-gray-600">Domain</th>
<th class="px-4 py-3 font-medium text-gray-600">Name</th>
<th class="px-4 py-3 font-medium text-gray-600">Path</th>
<th class="px-4 py-3 font-medium text-gray-600">Device</th>
<th class="px-4 py-3 font-medium text-gray-600">Updated</th>
<th class="px-4 py-3 font-medium text-gray-600"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr
v-for="cookie in store.cookies.filter(
(c) => !search || c.domain.includes(search) || c.cookieName.includes(search),
)"
:key="cookie.id"
class="hover:bg-gray-50"
>
<td class="px-4 py-3 font-mono text-xs">{{ cookie.domain }}</td>
<td class="px-4 py-3">{{ cookie.cookieName }}</td>
<td class="px-4 py-3 font-mono text-xs">{{ cookie.path }}</td>
<td class="px-4 py-3 font-mono text-xs truncate max-w-[120px]">
{{ cookie.deviceId.slice(0, 12) }}...
</td>
<td class="px-4 py-3 text-gray-500">
{{ new Date(cookie.updatedAt).toLocaleString() }}
</td>
<td class="px-4 py-3">
<button
class="text-red-600 hover:text-red-800 text-xs font-medium"
@click="handleDelete(cookie.domain, cookie.cookieName, cookie.path)"
>
Delete
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import api from "@/api/client";
import type { HealthStatus } from "@/types/api";
const health = ref<HealthStatus | null>(null);
const loading = ref(true);
onMounted(async () => {
try {
const { data } = await api.get("/health", { baseURL: "" });
health.value = data;
} catch {
// Server might be down
} finally {
loading.value = false;
}
});
</script>
<template>
<div class="p-8">
<h2 class="text-2xl font-semibold text-gray-900">Dashboard</h2>
<p class="mt-1 text-sm text-gray-500">CookieBridge relay server overview</p>
<div class="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-3">
<!-- Server Status -->
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
<p class="text-sm font-medium text-gray-500">Server Status</p>
<div class="mt-2 flex items-center gap-2">
<span
class="inline-block h-2.5 w-2.5 rounded-full"
:class="health?.status === 'ok' ? 'bg-green-500' : 'bg-red-500'"
/>
<span class="text-lg font-semibold text-gray-900">
{{ loading ? "Checking..." : health?.status === "ok" ? "Online" : "Offline" }}
</span>
</div>
</div>
<!-- Active Connections -->
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
<p class="text-sm font-medium text-gray-500">Active Connections</p>
<p class="mt-2 text-3xl font-semibold text-gray-900">
{{ health?.connections ?? "—" }}
</p>
</div>
<!-- Quick Actions -->
<div class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
<p class="text-sm font-medium text-gray-500">Quick Actions</p>
<div class="mt-3 flex flex-col gap-2">
<router-link
to="/devices"
class="text-sm font-medium text-blue-600 hover:text-blue-800"
>
Manage devices
</router-link>
<router-link
to="/cookies"
class="text-sm font-medium text-blue-600 hover:text-blue-800"
>
View cookies
</router-link>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { useDevicesStore } from "@/stores/devices";
const store = useDevicesStore();
onMounted(() => store.fetchDevices());
async function handleRevoke(deviceId: string) {
if (confirm("Revoke this device? It will need to re-register.")) {
await store.revokeDevice(deviceId);
}
}
</script>
<template>
<div class="p-8">
<h2 class="text-2xl font-semibold text-gray-900">Devices</h2>
<p class="mt-1 text-sm text-gray-500">Registered devices and their status</p>
<div v-if="store.loading" class="mt-6 text-sm text-gray-500">Loading...</div>
<div v-else-if="store.devices.length === 0" class="mt-6 text-sm text-gray-500">
No devices registered
</div>
<div v-else class="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div
v-for="device in store.devices"
:key="device.deviceId"
class="rounded-xl bg-white p-5 ring-1 ring-gray-200"
>
<div class="flex items-center justify-between">
<h3 class="font-medium text-gray-900">{{ device.name }}</h3>
<span
class="inline-flex items-center rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700"
>
Online
</span>
</div>
<dl class="mt-3 space-y-1 text-sm">
<div class="flex justify-between">
<dt class="text-gray-500">Platform</dt>
<dd class="text-gray-900">{{ device.platform }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Device ID</dt>
<dd class="font-mono text-xs text-gray-600">{{ device.deviceId.slice(0, 16) }}...</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500">Registered</dt>
<dd class="text-gray-900">{{ new Date(device.createdAt).toLocaleDateString() }}</dd>
</div>
</dl>
<button
class="mt-4 w-full rounded-lg border border-red-200 px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50"
@click="handleRevoke(device.deviceId)"
>
Revoke Device
</button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth";
const router = useRouter();
const auth = useAuthStore();
const username = ref("");
const password = ref("");
const error = ref("");
const loading = ref(false);
async function handleLogin() {
error.value = "";
loading.value = true;
try {
await auth.login(username.value, password.value);
router.push("/");
} catch {
error.value = "Invalid credentials";
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center bg-gray-50">
<div class="w-full max-w-sm rounded-xl bg-white p-8 shadow-sm ring-1 ring-gray-200">
<h1 class="mb-1 text-xl font-semibold text-gray-900">CookieBridge</h1>
<p class="mb-6 text-sm text-gray-500">Sign in to the admin panel</p>
<form @submit.prevent="handleLogin" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700" for="username">
Username
</label>
<input
id="username"
v-model="username"
type="text"
required
autocomplete="username"
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700" for="password">
Password
</label>
<input
id="password"
v-model="password"
type="password"
required
autocomplete="current-password"
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
<button
type="submit"
:disabled="loading"
class="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50"
>
{{ loading ? "Signing in..." : "Sign In" }}
</button>
</form>
</div>
</div>
</template>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { useSettingsStore } from "@/stores/settings";
const store = useSettingsStore();
onMounted(() => store.fetchSettings());
async function save() {
await store.updateSettings(store.settings);
}
</script>
<template>
<div class="p-8">
<h2 class="text-2xl font-semibold text-gray-900">Settings</h2>
<p class="mt-1 text-sm text-gray-500">Configure sync and security settings</p>
<div class="mt-6 max-w-lg space-y-6">
<!-- Sync Settings -->
<section class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
<h3 class="font-medium text-gray-900">Sync</h3>
<div class="mt-4 space-y-4">
<div class="flex items-center justify-between">
<label class="text-sm text-gray-700">Auto-sync</label>
<button
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="store.settings.autoSync ? 'bg-blue-600' : 'bg-gray-200'"
@click="store.settings.autoSync = !store.settings.autoSync"
>
<span
class="inline-block h-4 w-4 rounded-full bg-white transition-transform"
:class="store.settings.autoSync ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
<div>
<label class="block text-sm text-gray-700">Sync interval (seconds)</label>
<input
:value="store.settings.syncIntervalMs / 1000"
type="number"
min="5"
max="300"
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
@input="store.settings.syncIntervalMs = Number(($event.target as HTMLInputElement).value) * 1000"
/>
</div>
</div>
</section>
<!-- Security Settings -->
<section class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
<h3 class="font-medium text-gray-900">Security</h3>
<div class="mt-4 space-y-4">
<div>
<label class="block text-sm text-gray-700">Max devices</label>
<input
v-model.number="store.settings.maxDevices"
type="number"
min="1"
max="50"
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</div>
</div>
</section>
<!-- Appearance -->
<section class="rounded-xl bg-white p-6 ring-1 ring-gray-200">
<h3 class="font-medium text-gray-900">Appearance</h3>
<div class="mt-4">
<label class="block text-sm text-gray-700">Theme</label>
<select
v-model="store.settings.theme"
class="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
>
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
</section>
<button
class="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
@click="save"
>
Save Settings
</button>
</div>
</div>
</template>

24
web/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"importHelpers": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"isolatedModules": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"paths": {
"@/*": ["./src/*"]
},
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "env.d.ts"],
"exclude": ["node_modules", "dist"]
}

30
web/vite.config.ts Normal file
View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
import { resolve } from "node:path";
export default defineConfig({
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:8100",
changeOrigin: true,
},
"/ws": {
target: "ws://localhost:8100",
ws: true,
},
"/health": {
target: "http://localhost:8100",
changeOrigin: true,
},
},
},
});