Add React UI with Vite

Dashboard, agents, goals, issues, and projects pages with sidebar
navigation. API client layer, custom hooks, and shared layout
components. Built with Vite and TypeScript.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-16 13:32:04 -06:00
parent c9d7cbfe44
commit c3d82ed857
25 changed files with 482 additions and 0 deletions

12
ui/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>Paperclip</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

28
ui/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "@paperclip/ui",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b"
},
"dependencies": {
"@paperclip/shared": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.5"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.7",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.7",
"typescript": "^5.7.3",
"vite": "^6.1.0",
"vitest": "^3.0.5"
}
}

21
ui/src/App.tsx Normal file
View File

@@ -0,0 +1,21 @@
import { Routes, Route } from "react-router-dom";
import { Layout } from "./components/Layout";
import { Dashboard } from "./pages/Dashboard";
import { Agents } from "./pages/Agents";
import { Projects } from "./pages/Projects";
import { Issues } from "./pages/Issues";
import { Goals } from "./pages/Goals";
export function App() {
return (
<Routes>
<Route element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="agents" element={<Agents />} />
<Route path="projects" element={<Projects />} />
<Route path="issues" element={<Issues />} />
<Route path="goals" element={<Goals />} />
</Route>
</Routes>
);
}

10
ui/src/api/agents.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { Agent } from "@paperclip/shared";
import { api } from "./client";
export const agentsApi = {
list: () => api.get<Agent[]>("/agents"),
get: (id: string) => api.get<Agent>(`/agents/${id}`),
create: (data: Partial<Agent>) => api.post<Agent>("/agents", data),
update: (id: string, data: Partial<Agent>) => api.patch<Agent>(`/agents/${id}`, data),
remove: (id: string) => api.delete<Agent>(`/agents/${id}`),
};

22
ui/src/api/client.ts Normal file
View File

@@ -0,0 +1,22 @@
const BASE = "/api";
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
headers: { "Content-Type": "application/json" },
...init,
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.error ?? `Request failed: ${res.status}`);
}
return res.json();
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) =>
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
};

10
ui/src/api/goals.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { Goal } from "@paperclip/shared";
import { api } from "./client";
export const goalsApi = {
list: () => api.get<Goal[]>("/goals"),
get: (id: string) => api.get<Goal>(`/goals/${id}`),
create: (data: Partial<Goal>) => api.post<Goal>("/goals", data),
update: (id: string, data: Partial<Goal>) => api.patch<Goal>(`/goals/${id}`, data),
remove: (id: string) => api.delete<Goal>(`/goals/${id}`),
};

5
ui/src/api/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export { api } from "./client";
export { agentsApi } from "./agents";
export { projectsApi } from "./projects";
export { issuesApi } from "./issues";
export { goalsApi } from "./goals";

10
ui/src/api/issues.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { Issue } from "@paperclip/shared";
import { api } from "./client";
export const issuesApi = {
list: () => api.get<Issue[]>("/issues"),
get: (id: string) => api.get<Issue>(`/issues/${id}`),
create: (data: Partial<Issue>) => api.post<Issue>("/issues", data),
update: (id: string, data: Partial<Issue>) => api.patch<Issue>(`/issues/${id}`, data),
remove: (id: string) => api.delete<Issue>(`/issues/${id}`),
};

10
ui/src/api/projects.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { Project } from "@paperclip/shared";
import { api } from "./client";
export const projectsApi = {
list: () => api.get<Project[]>("/projects"),
get: (id: string) => api.get<Project>(`/projects/${id}`),
create: (data: Partial<Project>) => api.post<Project>("/projects", data),
update: (id: string, data: Partial<Project>) => api.patch<Project>(`/projects/${id}`, data),
remove: (id: string) => api.delete<Project>(`/projects/${id}`),
};

View File

@@ -0,0 +1,13 @@
import { Outlet } from "react-router-dom";
import { Sidebar } from "./Sidebar";
export function Layout() {
return (
<div className="flex h-screen bg-gray-50 text-gray-900">
<Sidebar />
<main className="flex-1 overflow-auto p-8">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { NavLink } from "react-router-dom";
import { cn } from "../lib/utils";
const links = [
{ to: "/", label: "Dashboard" },
{ to: "/agents", label: "Agents" },
{ to: "/projects", label: "Projects" },
{ to: "/issues", label: "Issues" },
{ to: "/goals", label: "Goals" },
];
export function Sidebar() {
return (
<aside className="w-56 border-r border-gray-200 bg-white p-4 flex flex-col gap-1">
<h1 className="text-lg font-bold mb-6 px-3">Paperclip</h1>
<nav className="flex flex-col gap-1">
{links.map((link) => (
<NavLink
key={link.to}
to={link.to}
end={link.to === "/"}
className={({ isActive }) =>
cn(
"px-3 py-2 rounded-md text-sm font-medium transition-colors",
isActive
? "bg-gray-100 text-gray-900"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
)
}
>
{link.label}
</NavLink>
))}
</nav>
</aside>
);
}

View File

@@ -0,0 +1,27 @@
import { cn } from "../lib/utils";
const statusColors: Record<string, string> = {
active: "bg-green-100 text-green-800",
idle: "bg-yellow-100 text-yellow-800",
offline: "bg-gray-100 text-gray-600",
error: "bg-red-100 text-red-800",
backlog: "bg-gray-100 text-gray-600",
todo: "bg-blue-100 text-blue-800",
in_progress: "bg-indigo-100 text-indigo-800",
in_review: "bg-purple-100 text-purple-800",
done: "bg-green-100 text-green-800",
cancelled: "bg-gray-100 text-gray-500",
};
export function StatusBadge({ status }: { status: string }) {
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
statusColors[status] ?? "bg-gray-100 text-gray-600"
)}
>
{status.replace("_", " ")}
</span>
);
}

View File

@@ -0,0 +1,8 @@
import { useCallback } from "react";
import { agentsApi } from "../api/agents";
import { useApi } from "./useApi";
export function useAgents() {
const fetcher = useCallback(() => agentsApi.list(), []);
return useApi(fetcher);
}

21
ui/src/hooks/useApi.ts Normal file
View File

@@ -0,0 +1,21 @@
import { useState, useEffect, useCallback } from "react";
export function useApi<T>(fetcher: () => Promise<T>) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(() => {
setLoading(true);
fetcher()
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [fetcher]);
useEffect(() => {
load();
}, [load]);
return { data, error, loading, reload: load };
}

1
ui/src/index.css Normal file
View File

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

15
ui/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,15 @@
export function cn(...classes: (string | false | null | undefined)[]) {
return classes.filter(Boolean).join(" ");
}
export function formatCents(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}
export function formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}

13
ui/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);

36
ui/src/pages/Agents.tsx Normal file
View File

@@ -0,0 +1,36 @@
import { useAgents } from "../hooks/useAgents";
import { StatusBadge } from "../components/StatusBadge";
import { formatCents } from "../lib/utils";
export function Agents() {
const { data: agents, loading, error } = useAgents();
return (
<div>
<h2 className="text-2xl font-bold mb-4">Agents</h2>
{loading && <p className="text-gray-500">Loading...</p>}
{error && <p className="text-red-600">{error.message}</p>}
{agents && agents.length === 0 && <p className="text-gray-500">No agents yet.</p>}
{agents && agents.length > 0 && (
<div className="grid gap-4">
{agents.map((agent) => (
<div key={agent.id} className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">{agent.name}</h3>
<p className="text-sm text-gray-500">{agent.role}</p>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500">
{formatCents(agent.spentCents)} / {formatCents(agent.budgetCents)}
</span>
<StatusBadge status={agent.status} />
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,8 @@
export function Dashboard() {
return (
<div>
<h2 className="text-2xl font-bold mb-4">Dashboard</h2>
<p className="text-gray-600">Welcome to Paperclip. Select a section from the sidebar.</p>
</div>
);
}

49
ui/src/pages/Goals.tsx Normal file
View File

@@ -0,0 +1,49 @@
import { useCallback } from "react";
import { goalsApi } from "../api/goals";
import { useApi } from "../hooks/useApi";
import { cn } from "../lib/utils";
const levelColors: Record<string, string> = {
company: "bg-purple-100 text-purple-800",
team: "bg-blue-100 text-blue-800",
agent: "bg-indigo-100 text-indigo-800",
task: "bg-gray-100 text-gray-600",
};
export function Goals() {
const fetcher = useCallback(() => goalsApi.list(), []);
const { data: goals, loading, error } = useApi(fetcher);
return (
<div>
<h2 className="text-2xl font-bold mb-4">Goals</h2>
{loading && <p className="text-gray-500">Loading...</p>}
{error && <p className="text-red-600">{error.message}</p>}
{goals && goals.length === 0 && <p className="text-gray-500">No goals yet.</p>}
{goals && goals.length > 0 && (
<div className="grid gap-4">
{goals.map((goal) => (
<div key={goal.id} className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">{goal.title}</h3>
{goal.description && (
<p className="text-sm text-gray-500 mt-1">{goal.description}</p>
)}
</div>
<span
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
levelColors[goal.level] ?? "bg-gray-100 text-gray-600"
)}
>
{goal.level}
</span>
</div>
</div>
))}
</div>
)}
</div>
);
}

55
ui/src/pages/Issues.tsx Normal file
View File

@@ -0,0 +1,55 @@
import { useCallback } from "react";
import { issuesApi } from "../api/issues";
import { useApi } from "../hooks/useApi";
import { StatusBadge } from "../components/StatusBadge";
import { cn } from "../lib/utils";
const priorityColors: Record<string, string> = {
critical: "text-red-700 bg-red-50",
high: "text-orange-700 bg-orange-50",
medium: "text-yellow-700 bg-yellow-50",
low: "text-gray-600 bg-gray-50",
};
export function Issues() {
const fetcher = useCallback(() => issuesApi.list(), []);
const { data: issues, loading, error } = useApi(fetcher);
return (
<div>
<h2 className="text-2xl font-bold mb-4">Issues</h2>
{loading && <p className="text-gray-500">Loading...</p>}
{error && <p className="text-red-600">{error.message}</p>}
{issues && issues.length === 0 && <p className="text-gray-500">No issues yet.</p>}
{issues && issues.length > 0 && (
<div className="grid gap-4">
{issues.map((issue) => (
<div key={issue.id} className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">{issue.title}</h3>
{issue.description && (
<p className="text-sm text-gray-500 mt-1 line-clamp-1">
{issue.description}
</p>
)}
</div>
<div className="flex items-center gap-3">
<span
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
priorityColors[issue.priority] ?? "text-gray-600 bg-gray-50"
)}
>
{issue.priority}
</span>
<StatusBadge status={issue.status} />
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

35
ui/src/pages/Projects.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { useCallback } from "react";
import { projectsApi } from "../api/projects";
import { useApi } from "../hooks/useApi";
import { formatDate } from "../lib/utils";
export function Projects() {
const fetcher = useCallback(() => projectsApi.list(), []);
const { data: projects, loading, error } = useApi(fetcher);
return (
<div>
<h2 className="text-2xl font-bold mb-4">Projects</h2>
{loading && <p className="text-gray-500">Loading...</p>}
{error && <p className="text-red-600">{error.message}</p>}
{projects && projects.length === 0 && <p className="text-gray-500">No projects yet.</p>}
{projects && projects.length > 0 && (
<div className="grid gap-4">
{projects.map((project) => (
<div key={project.id} className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">{project.name}</h3>
{project.description && (
<p className="text-sm text-gray-500 mt-1">{project.description}</p>
)}
</div>
<span className="text-sm text-gray-400">{formatDate(project.createdAt)}</span>
</div>
</div>
))}
</div>
)}
</div>
);
}

16
ui/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src"]
}

13
ui/vite.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
"/api": "http://localhost:3100",
},
},
});

7
ui/vitest.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
},
});