Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b95db7c7d6
commit
86e5b8febf
21 changed files with 3386 additions and 1 deletions
248
web/api.py
Normal file
248
web/api.py
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
"""
|
||||
Kin Web API — FastAPI backend reading ~/.kin/kin.db via core.models.
|
||||
Run: uvicorn web.api:app --reload --port 8420
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure project root on sys.path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.db import init_db
|
||||
from core import models
|
||||
from agents.bootstrap import (
|
||||
detect_tech_stack, detect_modules, extract_decisions_from_claude_md,
|
||||
find_vault_root, scan_obsidian, save_to_db,
|
||||
)
|
||||
|
||||
DB_PATH = Path.home() / ".kin" / "kin.db"
|
||||
|
||||
app = FastAPI(title="Kin API", version="0.1.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
def get_conn():
|
||||
return init_db(DB_PATH)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Projects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/projects")
|
||||
def list_projects(status: str | None = None):
|
||||
conn = get_conn()
|
||||
summary = models.get_project_summary(conn)
|
||||
if status:
|
||||
summary = [s for s in summary if s["status"] == status]
|
||||
conn.close()
|
||||
return summary
|
||||
|
||||
|
||||
@app.get("/api/projects/{project_id}")
|
||||
def get_project(project_id: str):
|
||||
conn = get_conn()
|
||||
p = models.get_project(conn, project_id)
|
||||
if not p:
|
||||
conn.close()
|
||||
raise HTTPException(404, f"Project '{project_id}' not found")
|
||||
tasks = models.list_tasks(conn, project_id=project_id)
|
||||
mods = models.get_modules(conn, project_id)
|
||||
decisions = models.get_decisions(conn, project_id)
|
||||
conn.close()
|
||||
return {**p, "tasks": tasks, "modules": mods, "decisions": decisions}
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
path: str
|
||||
tech_stack: list[str] | None = None
|
||||
status: str = "active"
|
||||
priority: int = 5
|
||||
|
||||
|
||||
@app.post("/api/projects")
|
||||
def create_project(body: ProjectCreate):
|
||||
conn = get_conn()
|
||||
if models.get_project(conn, body.id):
|
||||
conn.close()
|
||||
raise HTTPException(409, f"Project '{body.id}' already exists")
|
||||
p = models.create_project(
|
||||
conn, body.id, body.name, body.path,
|
||||
tech_stack=body.tech_stack, status=body.status, priority=body.priority,
|
||||
)
|
||||
conn.close()
|
||||
return p
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tasks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/tasks/{task_id}")
|
||||
def get_task(task_id: str):
|
||||
conn = get_conn()
|
||||
t = models.get_task(conn, task_id)
|
||||
conn.close()
|
||||
if not t:
|
||||
raise HTTPException(404, f"Task '{task_id}' not found")
|
||||
return t
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
project_id: str
|
||||
title: str
|
||||
priority: int = 5
|
||||
route_type: str | None = None
|
||||
|
||||
|
||||
@app.post("/api/tasks")
|
||||
def create_task(body: TaskCreate):
|
||||
conn = get_conn()
|
||||
p = models.get_project(conn, body.project_id)
|
||||
if not p:
|
||||
conn.close()
|
||||
raise HTTPException(404, f"Project '{body.project_id}' not found")
|
||||
# Auto-generate task ID
|
||||
existing = models.list_tasks(conn, project_id=body.project_id)
|
||||
prefix = body.project_id.upper()
|
||||
max_num = 0
|
||||
for t in existing:
|
||||
if t["id"].startswith(prefix + "-"):
|
||||
try:
|
||||
num = int(t["id"].split("-", 1)[1])
|
||||
max_num = max(max_num, num)
|
||||
except ValueError:
|
||||
pass
|
||||
task_id = f"{prefix}-{max_num + 1:03d}"
|
||||
brief = {"route_type": body.route_type} if body.route_type else None
|
||||
t = models.create_task(conn, task_id, body.project_id, body.title,
|
||||
priority=body.priority, brief=brief)
|
||||
conn.close()
|
||||
return t
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decisions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/decisions")
|
||||
def list_decisions(
|
||||
project: str = Query(...),
|
||||
category: str | None = None,
|
||||
tag: list[str] | None = Query(None),
|
||||
type: list[str] | None = Query(None),
|
||||
):
|
||||
conn = get_conn()
|
||||
decisions = models.get_decisions(
|
||||
conn, project, category=category, tags=tag, types=type,
|
||||
)
|
||||
conn.close()
|
||||
return decisions
|
||||
|
||||
|
||||
class DecisionCreate(BaseModel):
|
||||
project_id: str
|
||||
type: str
|
||||
title: str
|
||||
description: str
|
||||
category: str | None = None
|
||||
tags: list[str] | None = None
|
||||
task_id: str | None = None
|
||||
|
||||
|
||||
@app.post("/api/decisions")
|
||||
def create_decision(body: DecisionCreate):
|
||||
conn = get_conn()
|
||||
p = models.get_project(conn, body.project_id)
|
||||
if not p:
|
||||
conn.close()
|
||||
raise HTTPException(404, f"Project '{body.project_id}' not found")
|
||||
d = models.add_decision(
|
||||
conn, body.project_id, body.type, body.title, body.description,
|
||||
category=body.category, tags=body.tags, task_id=body.task_id,
|
||||
)
|
||||
conn.close()
|
||||
return d
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cost
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/cost")
|
||||
def cost_summary(days: int = 7):
|
||||
conn = get_conn()
|
||||
costs = models.get_cost_summary(conn, days=days)
|
||||
conn.close()
|
||||
return costs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Support
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/support/tickets")
|
||||
def list_tickets(project: str | None = None, status: str | None = None):
|
||||
conn = get_conn()
|
||||
tickets = models.list_tickets(conn, project_id=project, status=status)
|
||||
conn.close()
|
||||
return tickets
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bootstrap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class BootstrapRequest(BaseModel):
|
||||
path: str
|
||||
id: str
|
||||
name: str
|
||||
vault_path: str | None = None
|
||||
|
||||
|
||||
@app.post("/api/bootstrap")
|
||||
def bootstrap(body: BootstrapRequest):
|
||||
project_path = Path(body.path).expanduser().resolve()
|
||||
if not project_path.is_dir():
|
||||
raise HTTPException(400, f"Path '{body.path}' is not a directory")
|
||||
|
||||
conn = get_conn()
|
||||
if models.get_project(conn, body.id):
|
||||
conn.close()
|
||||
raise HTTPException(409, f"Project '{body.id}' already exists")
|
||||
|
||||
tech_stack = detect_tech_stack(project_path)
|
||||
modules = detect_modules(project_path)
|
||||
decisions = extract_decisions_from_claude_md(project_path, body.id, body.name)
|
||||
|
||||
obsidian = None
|
||||
vault_root = find_vault_root(Path(body.vault_path) if body.vault_path else None)
|
||||
if vault_root:
|
||||
dir_name = project_path.name
|
||||
obs = scan_obsidian(vault_root, body.id, body.name, dir_name)
|
||||
if obs["tasks"] or obs["decisions"]:
|
||||
obsidian = obs
|
||||
|
||||
save_to_db(conn, body.id, body.name, str(project_path),
|
||||
tech_stack, modules, decisions, obsidian)
|
||||
p = models.get_project(conn, body.id)
|
||||
conn.close()
|
||||
return {
|
||||
"project": p,
|
||||
"modules_count": len(modules),
|
||||
"decisions_count": len(decisions) + len((obsidian or {}).get("decisions", [])),
|
||||
"tasks_count": len((obsidian or {}).get("tasks", [])),
|
||||
}
|
||||
24
web/frontend/.gitignore
vendored
Normal file
24
web/frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
12
web/frontend/index.html
Normal file
12
web/frontend/index.html
Normal 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>Kin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2345
web/frontend/package-lock.json
generated
Normal file
2345
web/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
26
web/frontend/package.json
Normal file
26
web/frontend/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.30",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.0.0",
|
||||
"vue-tsc": "^3.2.5"
|
||||
}
|
||||
}
|
||||
6
web/frontend/postcss.config.js
Normal file
6
web/frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
16
web/frontend/src/App.vue
Normal file
16
web/frontend/src/App.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen">
|
||||
<header class="border-b border-gray-800 px-6 py-4 flex items-center justify-between">
|
||||
<router-link to="/" class="text-lg font-bold text-gray-100 hover:text-white no-underline">
|
||||
Kin
|
||||
</router-link>
|
||||
<span class="text-xs text-gray-600">multi-agent orchestrator</span>
|
||||
</header>
|
||||
<main class="max-w-6xl mx-auto px-6 py-6">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
95
web/frontend/src/api.ts
Normal file
95
web/frontend/src/api.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
const BASE = 'http://localhost:8420/api'
|
||||
|
||||
async function get<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`)
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function post<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string
|
||||
name: string
|
||||
path: string
|
||||
status: string
|
||||
priority: number
|
||||
tech_stack: string[] | null
|
||||
created_at: string
|
||||
total_tasks: number
|
||||
done_tasks: number
|
||||
active_tasks: number
|
||||
blocked_tasks: number
|
||||
}
|
||||
|
||||
export interface ProjectDetail extends Project {
|
||||
tasks: Task[]
|
||||
modules: Module[]
|
||||
decisions: Decision[]
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string
|
||||
project_id: string
|
||||
title: string
|
||||
status: string
|
||||
priority: number
|
||||
assigned_role: string | null
|
||||
parent_task_id: string | null
|
||||
brief: Record<string, unknown> | null
|
||||
spec: Record<string, unknown> | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Decision {
|
||||
id: number
|
||||
project_id: string
|
||||
task_id: string | null
|
||||
type: string
|
||||
category: string | null
|
||||
title: string
|
||||
description: string
|
||||
tags: string[] | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Module {
|
||||
id: number
|
||||
project_id: string
|
||||
name: string
|
||||
type: string
|
||||
path: string
|
||||
description: string | null
|
||||
owner_role: string | null
|
||||
dependencies: string[] | null
|
||||
}
|
||||
|
||||
export interface CostEntry {
|
||||
project_id: string
|
||||
project_name: string
|
||||
runs: number
|
||||
total_tokens: number
|
||||
total_cost_usd: number
|
||||
total_duration_seconds: number
|
||||
}
|
||||
|
||||
export const api = {
|
||||
projects: () => get<Project[]>('/projects'),
|
||||
project: (id: string) => get<ProjectDetail>(`/projects/${id}`),
|
||||
cost: (days = 7) => get<CostEntry[]>(`/cost?days=${days}`),
|
||||
createProject: (data: { id: string; name: string; path: string; tech_stack?: string[]; priority?: number }) =>
|
||||
post<Project>('/projects', data),
|
||||
createTask: (data: { project_id: string; title: string; priority?: number; route_type?: string }) =>
|
||||
post<Task>('/tasks', data),
|
||||
bootstrap: (data: { path: string; id: string; name: string }) =>
|
||||
post<{ project: Project }>('/bootstrap', data),
|
||||
}
|
||||
19
web/frontend/src/components/Badge.vue
Normal file
19
web/frontend/src/components/Badge.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{ color?: string; text: string }>()
|
||||
|
||||
const colors: Record<string, string> = {
|
||||
green: 'bg-green-900/50 text-green-400 border-green-800',
|
||||
blue: 'bg-blue-900/50 text-blue-400 border-blue-800',
|
||||
red: 'bg-red-900/50 text-red-400 border-red-800',
|
||||
yellow: 'bg-yellow-900/50 text-yellow-400 border-yellow-800',
|
||||
gray: 'bg-gray-800/50 text-gray-400 border-gray-700',
|
||||
purple: 'bg-purple-900/50 text-purple-400 border-purple-800',
|
||||
orange: 'bg-orange-900/50 text-orange-400 border-orange-800',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="text-xs px-2 py-0.5 rounded border" :class="colors[color || 'gray']">
|
||||
{{ text }}
|
||||
</span>
|
||||
</template>
|
||||
18
web/frontend/src/components/Modal.vue
Normal file
18
web/frontend/src/components/Modal.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{ title: string }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60" @click.self="emit('close')">
|
||||
<div class="bg-gray-900 border border-gray-700 rounded-lg w-full max-w-lg mx-4 shadow-2xl">
|
||||
<div class="flex items-center justify-between px-5 py-3 border-b border-gray-800">
|
||||
<h3 class="text-sm font-semibold text-gray-200">{{ title }}</h3>
|
||||
<button @click="emit('close')" class="text-gray-500 hover:text-gray-300 text-lg leading-none">×</button>
|
||||
</div>
|
||||
<div class="px-5 py-4">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
16
web/frontend/src/main.ts
Normal file
16
web/frontend/src/main.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import Dashboard from './views/Dashboard.vue'
|
||||
import ProjectView from './views/ProjectView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: Dashboard },
|
||||
{ path: '/project/:id', component: ProjectView, props: true },
|
||||
],
|
||||
})
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
8
web/frontend/src/style.css
Normal file
8
web/frontend/src/style.css
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply bg-gray-950 text-gray-100;
|
||||
font-family: ui-monospace, 'SF Mono', Consolas, monospace;
|
||||
}
|
||||
167
web/frontend/src/views/Dashboard.vue
Normal file
167
web/frontend/src/views/Dashboard.vue
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { api, type Project, type CostEntry } from '../api'
|
||||
import Badge from '../components/Badge.vue'
|
||||
import Modal from '../components/Modal.vue'
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const costs = ref<CostEntry[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
// Add project modal
|
||||
const showAdd = ref(false)
|
||||
const form = ref({ id: '', name: '', path: '', tech_stack: '', priority: 5 })
|
||||
const formError = ref('')
|
||||
|
||||
// Bootstrap modal
|
||||
const showBootstrap = ref(false)
|
||||
const bsForm = ref({ id: '', name: '', path: '' })
|
||||
const bsError = ref('')
|
||||
const bsResult = ref('')
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
loading.value = true
|
||||
;[projects.value, costs.value] = await Promise.all([api.projects(), api.cost(7)])
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
const costMap = computed(() => {
|
||||
const m: Record<string, number> = {}
|
||||
for (const c of costs.value) m[c.project_id] = c.total_cost_usd
|
||||
return m
|
||||
})
|
||||
|
||||
const totalCost = computed(() => costs.value.reduce((s, c) => s + c.total_cost_usd, 0))
|
||||
|
||||
function statusColor(s: string) {
|
||||
if (s === 'active') return 'green'
|
||||
if (s === 'paused') return 'yellow'
|
||||
if (s === 'maintenance') return 'orange'
|
||||
return 'gray'
|
||||
}
|
||||
|
||||
async function addProject() {
|
||||
formError.value = ''
|
||||
try {
|
||||
const ts = form.value.tech_stack ? form.value.tech_stack.split(',').map(s => s.trim()).filter(Boolean) : undefined
|
||||
await api.createProject({ ...form.value, tech_stack: ts, priority: form.value.priority })
|
||||
showAdd.value = false
|
||||
form.value = { id: '', name: '', path: '', tech_stack: '', priority: 5 }
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
formError.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function runBootstrap() {
|
||||
bsError.value = ''
|
||||
bsResult.value = ''
|
||||
try {
|
||||
const res = await api.bootstrap(bsForm.value)
|
||||
bsResult.value = `Created: ${res.project.id} (${res.project.name})`
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
bsError.value = e.message
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-100">Dashboard</h1>
|
||||
<p class="text-sm text-gray-500" v-if="totalCost > 0">Cost this week: ${{ totalCost.toFixed(2) }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="showBootstrap = true"
|
||||
class="px-3 py-1.5 text-xs bg-purple-900/50 text-purple-400 border border-purple-800 rounded hover:bg-purple-900">
|
||||
Bootstrap
|
||||
</button>
|
||||
<button @click="showAdd = true"
|
||||
class="px-3 py-1.5 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700">
|
||||
+ Project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="text-gray-500 text-sm">Loading...</p>
|
||||
<p v-else-if="error" class="text-red-400 text-sm">{{ error }}</p>
|
||||
|
||||
<div v-else class="grid gap-3">
|
||||
<router-link
|
||||
v-for="p in projects" :key="p.id"
|
||||
:to="`/project/${p.id}`"
|
||||
class="block border border-gray-800 rounded-lg p-4 hover:border-gray-600 transition-colors no-underline"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-semibold text-gray-200">{{ p.id }}</span>
|
||||
<Badge :text="p.status" :color="statusColor(p.status)" />
|
||||
<span class="text-sm text-gray-400">{{ p.name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-gray-500">
|
||||
<span v-if="costMap[p.id]">${{ costMap[p.id]?.toFixed(2) }}/wk</span>
|
||||
<span>pri {{ p.priority }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4 text-xs">
|
||||
<span class="text-gray-500">{{ p.total_tasks }} tasks</span>
|
||||
<span v-if="p.active_tasks" class="text-blue-400">{{ p.active_tasks }} active</span>
|
||||
<span v-if="p.blocked_tasks" class="text-red-400">{{ p.blocked_tasks }} blocked</span>
|
||||
<span v-if="p.done_tasks" class="text-green-500">{{ p.done_tasks }} done</span>
|
||||
<span v-if="p.total_tasks - p.done_tasks - p.active_tasks - p.blocked_tasks > 0" class="text-gray-500">
|
||||
{{ p.total_tasks - p.done_tasks - p.active_tasks - p.blocked_tasks }} pending
|
||||
</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Add Project Modal -->
|
||||
<Modal v-if="showAdd" title="Add Project" @close="showAdd = false">
|
||||
<form @submit.prevent="addProject" class="space-y-3">
|
||||
<input v-model="form.id" placeholder="ID (e.g. vdol)" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<input v-model="form.name" placeholder="Name" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<input v-model="form.path" placeholder="Path (e.g. ~/projects/myproj)" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<input v-model="form.tech_stack" placeholder="Tech stack (comma-separated)"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<input v-model.number="form.priority" type="number" min="1" max="10" placeholder="Priority (1-10)"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<p v-if="formError" class="text-red-400 text-xs">{{ formError }}</p>
|
||||
<button type="submit"
|
||||
class="w-full py-2 bg-blue-900/50 text-blue-400 border border-blue-800 rounded text-sm hover:bg-blue-900">
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- Bootstrap Modal -->
|
||||
<Modal v-if="showBootstrap" title="Bootstrap Project" @close="showBootstrap = false">
|
||||
<form @submit.prevent="runBootstrap" class="space-y-3">
|
||||
<input v-model="bsForm.path" placeholder="Project path (e.g. ~/projects/vdolipoperek)" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<input v-model="bsForm.id" placeholder="ID (e.g. vdol)" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<input v-model="bsForm.name" placeholder="Name" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<p v-if="bsError" class="text-red-400 text-xs">{{ bsError }}</p>
|
||||
<p v-if="bsResult" class="text-green-400 text-xs">{{ bsResult }}</p>
|
||||
<button type="submit"
|
||||
class="w-full py-2 bg-purple-900/50 text-purple-400 border border-purple-800 rounded text-sm hover:bg-purple-900">
|
||||
Bootstrap
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
312
web/frontend/src/views/ProjectView.vue
Normal file
312
web/frontend/src/views/ProjectView.vue
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { api, type ProjectDetail } from '../api'
|
||||
import Badge from '../components/Badge.vue'
|
||||
import Modal from '../components/Modal.vue'
|
||||
|
||||
const props = defineProps<{ id: string }>()
|
||||
|
||||
const project = ref<ProjectDetail | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const activeTab = ref<'tasks' | 'decisions' | 'modules'>('tasks')
|
||||
|
||||
// Filters
|
||||
const taskStatusFilter = ref('')
|
||||
const decisionTypeFilter = ref('')
|
||||
const decisionSearch = ref('')
|
||||
|
||||
// Add task modal
|
||||
const showAddTask = ref(false)
|
||||
const taskForm = ref({ title: '', priority: 5, route_type: '' })
|
||||
const taskFormError = ref('')
|
||||
|
||||
// Add decision modal
|
||||
const showAddDecision = ref(false)
|
||||
const decForm = ref({ type: 'decision', title: '', description: '', category: '', tags: '' })
|
||||
const decFormError = ref('')
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
loading.value = true
|
||||
project.value = await api.project(props.id)
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
if (!project.value) return []
|
||||
let tasks = project.value.tasks
|
||||
if (taskStatusFilter.value) tasks = tasks.filter(t => t.status === taskStatusFilter.value)
|
||||
return tasks
|
||||
})
|
||||
|
||||
const filteredDecisions = computed(() => {
|
||||
if (!project.value) return []
|
||||
let decs = project.value.decisions
|
||||
if (decisionTypeFilter.value) decs = decs.filter(d => d.type === decisionTypeFilter.value)
|
||||
if (decisionSearch.value) {
|
||||
const q = decisionSearch.value.toLowerCase()
|
||||
decs = decs.filter(d => d.title.toLowerCase().includes(q) || d.description.toLowerCase().includes(q))
|
||||
}
|
||||
return decs
|
||||
})
|
||||
|
||||
function taskStatusColor(s: string) {
|
||||
const m: Record<string, string> = {
|
||||
pending: 'gray', in_progress: 'blue', review: 'purple',
|
||||
done: 'green', blocked: 'red', decomposed: 'yellow',
|
||||
}
|
||||
return m[s] || 'gray'
|
||||
}
|
||||
|
||||
function decTypeColor(t: string) {
|
||||
const m: Record<string, string> = {
|
||||
decision: 'blue', gotcha: 'red', workaround: 'yellow',
|
||||
rejected_approach: 'gray', convention: 'purple',
|
||||
}
|
||||
return m[t] || 'gray'
|
||||
}
|
||||
|
||||
function modTypeColor(t: string) {
|
||||
const m: Record<string, string> = {
|
||||
frontend: 'blue', backend: 'green', shared: 'purple', infra: 'orange',
|
||||
}
|
||||
return m[t] || 'gray'
|
||||
}
|
||||
|
||||
const taskStatuses = computed(() => {
|
||||
if (!project.value) return []
|
||||
const s = new Set(project.value.tasks.map(t => t.status))
|
||||
return Array.from(s).sort()
|
||||
})
|
||||
|
||||
const decTypes = computed(() => {
|
||||
if (!project.value) return []
|
||||
const s = new Set(project.value.decisions.map(d => d.type))
|
||||
return Array.from(s).sort()
|
||||
})
|
||||
|
||||
async function addTask() {
|
||||
taskFormError.value = ''
|
||||
try {
|
||||
await api.createTask({
|
||||
project_id: props.id,
|
||||
title: taskForm.value.title,
|
||||
priority: taskForm.value.priority,
|
||||
route_type: taskForm.value.route_type || undefined,
|
||||
})
|
||||
showAddTask.value = false
|
||||
taskForm.value = { title: '', priority: 5, route_type: '' }
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
taskFormError.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function addDecision() {
|
||||
decFormError.value = ''
|
||||
try {
|
||||
const tags = decForm.value.tags ? decForm.value.tags.split(',').map(s => s.trim()).filter(Boolean) : undefined
|
||||
const body = {
|
||||
project_id: props.id,
|
||||
type: decForm.value.type,
|
||||
title: decForm.value.title,
|
||||
description: decForm.value.description,
|
||||
category: decForm.value.category || undefined,
|
||||
tags,
|
||||
}
|
||||
const res = await fetch('http://localhost:8420/api/decisions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed')
|
||||
showAddDecision.value = false
|
||||
decForm.value = { type: 'decision', title: '', description: '', category: '', tags: '' }
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
decFormError.value = e.message
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="text-gray-500 text-sm">Loading...</div>
|
||||
<div v-else-if="error" class="text-red-400 text-sm">{{ error }}</div>
|
||||
<div v-else-if="project">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<router-link to="/" class="text-gray-600 hover:text-gray-400 text-sm no-underline">← back</router-link>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h1 class="text-xl font-bold text-gray-100">{{ project.id }}</h1>
|
||||
<span class="text-gray-400">{{ project.name }}</span>
|
||||
<Badge :text="project.status" :color="project.status === 'active' ? 'green' : 'gray'" />
|
||||
</div>
|
||||
<div class="flex gap-2 flex-wrap mb-2" v-if="project.tech_stack?.length">
|
||||
<Badge v-for="t in project.tech_stack" :key="t" :text="t" color="purple" />
|
||||
</div>
|
||||
<p class="text-xs text-gray-600">{{ project.path }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-1 mb-4 border-b border-gray-800">
|
||||
<button v-for="tab in (['tasks', 'decisions', 'modules'] as const)" :key="tab"
|
||||
@click="activeTab = tab"
|
||||
class="px-4 py-2 text-sm border-b-2 transition-colors"
|
||||
:class="activeTab === tab
|
||||
? 'text-gray-200 border-blue-500'
|
||||
: 'text-gray-500 border-transparent hover:text-gray-300'">
|
||||
{{ tab.charAt(0).toUpperCase() + tab.slice(1) }}
|
||||
<span class="text-xs text-gray-600 ml-1">
|
||||
{{ tab === 'tasks' ? project.tasks.length
|
||||
: tab === 'decisions' ? project.decisions.length
|
||||
: project.modules.length }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tasks Tab -->
|
||||
<div v-if="activeTab === 'tasks'">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex gap-2">
|
||||
<select v-model="taskStatusFilter"
|
||||
class="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300">
|
||||
<option value="">All statuses</option>
|
||||
<option v-for="s in taskStatuses" :key="s" :value="s">{{ s }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button @click="showAddTask = true"
|
||||
class="px-3 py-1 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700">
|
||||
+ Task
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">No tasks.</div>
|
||||
<div v-else class="space-y-1">
|
||||
<div v-for="t in filteredTasks" :key="t.id"
|
||||
class="flex items-center justify-between px-3 py-2 border border-gray-800 rounded text-sm hover:border-gray-700">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-gray-500 shrink-0 w-24">{{ t.id }}</span>
|
||||
<Badge :text="t.status" :color="taskStatusColor(t.status)" />
|
||||
<span class="text-gray-300 truncate">{{ t.title }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-600 shrink-0">
|
||||
<span v-if="t.assigned_role">{{ t.assigned_role }}</span>
|
||||
<span>pri {{ t.priority }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Decisions Tab -->
|
||||
<div v-if="activeTab === 'decisions'">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex gap-2">
|
||||
<select v-model="decisionTypeFilter"
|
||||
class="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300">
|
||||
<option value="">All types</option>
|
||||
<option v-for="t in decTypes" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
<input v-model="decisionSearch" placeholder="Search..."
|
||||
class="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300 placeholder-gray-600 w-48" />
|
||||
</div>
|
||||
<button @click="showAddDecision = true"
|
||||
class="px-3 py-1 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700">
|
||||
+ Decision
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="filteredDecisions.length === 0" class="text-gray-600 text-sm">No decisions.</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="d in filteredDecisions" :key="d.id"
|
||||
class="px-3 py-2 border border-gray-800 rounded hover:border-gray-700">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-gray-600 text-xs">#{{ d.id }}</span>
|
||||
<Badge :text="d.type" :color="decTypeColor(d.type)" />
|
||||
<Badge v-if="d.category" :text="d.category" color="gray" />
|
||||
</div>
|
||||
<div class="text-sm text-gray-300">{{ d.title }}</div>
|
||||
<div v-if="d.description !== d.title" class="text-xs text-gray-500 mt-1">{{ d.description }}</div>
|
||||
<div v-if="d.tags?.length" class="flex gap-1 mt-1">
|
||||
<Badge v-for="tag in d.tags" :key="tag" :text="tag" color="purple" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modules Tab -->
|
||||
<div v-if="activeTab === 'modules'">
|
||||
<div v-if="project.modules.length === 0" class="text-gray-600 text-sm">No modules.</div>
|
||||
<div v-else class="space-y-1">
|
||||
<div v-for="m in project.modules" :key="m.id"
|
||||
class="flex items-center justify-between px-3 py-2 border border-gray-800 rounded text-sm hover:border-gray-700">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-300 font-medium">{{ m.name }}</span>
|
||||
<Badge :text="m.type" :color="modTypeColor(m.type)" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-gray-600">
|
||||
<span>{{ m.path }}</span>
|
||||
<span v-if="m.owner_role">{{ m.owner_role }}</span>
|
||||
<span v-if="m.description">{{ m.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Task Modal -->
|
||||
<Modal v-if="showAddTask" title="Add Task" @close="showAddTask = false">
|
||||
<form @submit.prevent="addTask" class="space-y-3">
|
||||
<input v-model="taskForm.title" placeholder="Task title" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<select v-model="taskForm.route_type"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-300">
|
||||
<option value="">No type</option>
|
||||
<option value="debug">debug</option>
|
||||
<option value="feature">feature</option>
|
||||
<option value="refactor">refactor</option>
|
||||
<option value="hotfix">hotfix</option>
|
||||
</select>
|
||||
<input v-model.number="taskForm.priority" type="number" min="1" max="10" placeholder="Priority"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<p v-if="taskFormError" class="text-red-400 text-xs">{{ taskFormError }}</p>
|
||||
<button type="submit"
|
||||
class="w-full py-2 bg-blue-900/50 text-blue-400 border border-blue-800 rounded text-sm hover:bg-blue-900">
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- Add Decision Modal -->
|
||||
<Modal v-if="showAddDecision" title="Add Decision" @close="showAddDecision = false">
|
||||
<form @submit.prevent="addDecision" class="space-y-3">
|
||||
<select v-model="decForm.type" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-300">
|
||||
<option value="decision">decision</option>
|
||||
<option value="gotcha">gotcha</option>
|
||||
<option value="workaround">workaround</option>
|
||||
<option value="convention">convention</option>
|
||||
<option value="rejected_approach">rejected_approach</option>
|
||||
</select>
|
||||
<input v-model="decForm.title" placeholder="Title" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<textarea v-model="decForm.description" placeholder="Description" rows="3" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-y"></textarea>
|
||||
<input v-model="decForm.category" placeholder="Category (e.g. ui, api, security)"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<input v-model="decForm.tags" placeholder="Tags (comma-separated)"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
<p v-if="decFormError" class="text-red-400 text-xs">{{ decFormError }}</p>
|
||||
<button type="submit"
|
||||
class="w-full py-2 bg-blue-900/50 text-blue-400 border border-blue-800 rounded text-sm hover:bg-blue-900">
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
10
web/frontend/tailwind.config.js
Normal file
10
web/frontend/tailwind.config.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: ["./index.html", "./src/**/*.{vue,ts}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
16
web/frontend/tsconfig.app.json
Normal file
16
web/frontend/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
7
web/frontend/tsconfig.json
Normal file
7
web/frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
web/frontend/tsconfig.node.json
Normal file
26
web/frontend/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
7
web/frontend/vite.config.ts
Normal file
7
web/frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue