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:
johnfrum1234 2026-03-15 13:50:15 +02:00
parent b95db7c7d6
commit 86e5b8febf
21 changed files with 3386 additions and 1 deletions

7
.gitignore vendored
View file

@ -162,3 +162,10 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
# Kin
kin.db
kin.db-wal
kin.db-shm
PROGRESS.md
node_modules/
web/frontend/dist/

View file

@ -7,7 +7,7 @@ name = "kin"
version = "0.1.0" version = "0.1.0"
description = "Multi-agent project orchestrator" description = "Multi-agent project orchestrator"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = ["click>=8.0"] dependencies = ["click>=8.0", "fastapi>=0.110", "uvicorn>=0.29"]
[project.scripts] [project.scripts]
kin = "cli.main:cli" kin = "cli.main:cli"

248
web/api.py Normal file
View 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
View 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
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>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

File diff suppressed because it is too large Load diff

26
web/frontend/package.json Normal file
View 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"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

16
web/frontend/src/App.vue Normal file
View 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
View 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),
}

View 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>

View 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">&times;</button>
</div>
<div class="px-5 py-4">
<slot />
</div>
</div>
</div>
</template>

16
web/frontend/src/main.ts Normal file
View 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')

View 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;
}

View 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>

View 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">&larr; 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>

View file

@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: ["./index.html", "./src/**/*.{vue,ts}"],
theme: {
extend: {},
},
plugins: [],
}

View 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"]
}

View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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"]
}

View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})