diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 00000000..6676856f --- /dev/null +++ b/ui/index.html @@ -0,0 +1,12 @@ + + + + + + Paperclip + + +
+ + + diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 00000000..ce079633 --- /dev/null +++ b/ui/package.json @@ -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" + } +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx new file mode 100644 index 00000000..6ecfb5cc --- /dev/null +++ b/ui/src/App.tsx @@ -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 ( + + }> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts new file mode 100644 index 00000000..ffe65eff --- /dev/null +++ b/ui/src/api/agents.ts @@ -0,0 +1,10 @@ +import type { Agent } from "@paperclip/shared"; +import { api } from "./client"; + +export const agentsApi = { + list: () => api.get("/agents"), + get: (id: string) => api.get(`/agents/${id}`), + create: (data: Partial) => api.post("/agents", data), + update: (id: string, data: Partial) => api.patch(`/agents/${id}`, data), + remove: (id: string) => api.delete(`/agents/${id}`), +}; diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts new file mode 100644 index 00000000..7fc4942e --- /dev/null +++ b/ui/src/api/client.ts @@ -0,0 +1,22 @@ +const BASE = "/api"; + +async function request(path: string, init?: RequestInit): Promise { + 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: (path: string) => request(path), + post: (path: string, body: unknown) => + request(path, { method: "POST", body: JSON.stringify(body) }), + patch: (path: string, body: unknown) => + request(path, { method: "PATCH", body: JSON.stringify(body) }), + delete: (path: string) => request(path, { method: "DELETE" }), +}; diff --git a/ui/src/api/goals.ts b/ui/src/api/goals.ts new file mode 100644 index 00000000..e04df5d8 --- /dev/null +++ b/ui/src/api/goals.ts @@ -0,0 +1,10 @@ +import type { Goal } from "@paperclip/shared"; +import { api } from "./client"; + +export const goalsApi = { + list: () => api.get("/goals"), + get: (id: string) => api.get(`/goals/${id}`), + create: (data: Partial) => api.post("/goals", data), + update: (id: string, data: Partial) => api.patch(`/goals/${id}`, data), + remove: (id: string) => api.delete(`/goals/${id}`), +}; diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts new file mode 100644 index 00000000..68e7ab2b --- /dev/null +++ b/ui/src/api/index.ts @@ -0,0 +1,5 @@ +export { api } from "./client"; +export { agentsApi } from "./agents"; +export { projectsApi } from "./projects"; +export { issuesApi } from "./issues"; +export { goalsApi } from "./goals"; diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts new file mode 100644 index 00000000..9cd8f522 --- /dev/null +++ b/ui/src/api/issues.ts @@ -0,0 +1,10 @@ +import type { Issue } from "@paperclip/shared"; +import { api } from "./client"; + +export const issuesApi = { + list: () => api.get("/issues"), + get: (id: string) => api.get(`/issues/${id}`), + create: (data: Partial) => api.post("/issues", data), + update: (id: string, data: Partial) => api.patch(`/issues/${id}`, data), + remove: (id: string) => api.delete(`/issues/${id}`), +}; diff --git a/ui/src/api/projects.ts b/ui/src/api/projects.ts new file mode 100644 index 00000000..8e293691 --- /dev/null +++ b/ui/src/api/projects.ts @@ -0,0 +1,10 @@ +import type { Project } from "@paperclip/shared"; +import { api } from "./client"; + +export const projectsApi = { + list: () => api.get("/projects"), + get: (id: string) => api.get(`/projects/${id}`), + create: (data: Partial) => api.post("/projects", data), + update: (id: string, data: Partial) => api.patch(`/projects/${id}`, data), + remove: (id: string) => api.delete(`/projects/${id}`), +}; diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx new file mode 100644 index 00000000..db4002c1 --- /dev/null +++ b/ui/src/components/Layout.tsx @@ -0,0 +1,13 @@ +import { Outlet } from "react-router-dom"; +import { Sidebar } from "./Sidebar"; + +export function Layout() { + return ( +
+ +
+ +
+
+ ); +} diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx new file mode 100644 index 00000000..23a1b485 --- /dev/null +++ b/ui/src/components/Sidebar.tsx @@ -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 ( + + ); +} diff --git a/ui/src/components/StatusBadge.tsx b/ui/src/components/StatusBadge.tsx new file mode 100644 index 00000000..c1cd488e --- /dev/null +++ b/ui/src/components/StatusBadge.tsx @@ -0,0 +1,27 @@ +import { cn } from "../lib/utils"; + +const statusColors: Record = { + 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 ( + + {status.replace("_", " ")} + + ); +} diff --git a/ui/src/hooks/useAgents.ts b/ui/src/hooks/useAgents.ts new file mode 100644 index 00000000..11fe6c80 --- /dev/null +++ b/ui/src/hooks/useAgents.ts @@ -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); +} diff --git a/ui/src/hooks/useApi.ts b/ui/src/hooks/useApi.ts new file mode 100644 index 00000000..bcc5f89c --- /dev/null +++ b/ui/src/hooks/useApi.ts @@ -0,0 +1,21 @@ +import { useState, useEffect, useCallback } from "react"; + +export function useApi(fetcher: () => Promise) { + const [data, setData] = useState(null); + const [error, setError] = useState(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 }; +} diff --git a/ui/src/index.css b/ui/src/index.css new file mode 100644 index 00000000..f1d8c73c --- /dev/null +++ b/ui/src/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts new file mode 100644 index 00000000..238852e8 --- /dev/null +++ b/ui/src/lib/utils.ts @@ -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", + }); +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx new file mode 100644 index 00000000..da522424 --- /dev/null +++ b/ui/src/main.tsx @@ -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( + + + + + +); diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx new file mode 100644 index 00000000..1ffc76b3 --- /dev/null +++ b/ui/src/pages/Agents.tsx @@ -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 ( +
+

Agents

+ {loading &&

Loading...

} + {error &&

{error.message}

} + {agents && agents.length === 0 &&

No agents yet.

} + {agents && agents.length > 0 && ( +
+ {agents.map((agent) => ( +
+
+
+

{agent.name}

+

{agent.role}

+
+
+ + {formatCents(agent.spentCents)} / {formatCents(agent.budgetCents)} + + +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx new file mode 100644 index 00000000..08123246 --- /dev/null +++ b/ui/src/pages/Dashboard.tsx @@ -0,0 +1,8 @@ +export function Dashboard() { + return ( +
+

Dashboard

+

Welcome to Paperclip. Select a section from the sidebar.

+
+ ); +} diff --git a/ui/src/pages/Goals.tsx b/ui/src/pages/Goals.tsx new file mode 100644 index 00000000..4d346daa --- /dev/null +++ b/ui/src/pages/Goals.tsx @@ -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 = { + 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 ( +
+

Goals

+ {loading &&

Loading...

} + {error &&

{error.message}

} + {goals && goals.length === 0 &&

No goals yet.

} + {goals && goals.length > 0 && ( +
+ {goals.map((goal) => ( +
+
+
+

{goal.title}

+ {goal.description && ( +

{goal.description}

+ )} +
+ + {goal.level} + +
+
+ ))} +
+ )} +
+ ); +} diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx new file mode 100644 index 00000000..004c3625 --- /dev/null +++ b/ui/src/pages/Issues.tsx @@ -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 = { + 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 ( +
+

Issues

+ {loading &&

Loading...

} + {error &&

{error.message}

} + {issues && issues.length === 0 &&

No issues yet.

} + {issues && issues.length > 0 && ( +
+ {issues.map((issue) => ( +
+
+
+

{issue.title}

+ {issue.description && ( +

+ {issue.description} +

+ )} +
+
+ + {issue.priority} + + +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/ui/src/pages/Projects.tsx b/ui/src/pages/Projects.tsx new file mode 100644 index 00000000..a94350a6 --- /dev/null +++ b/ui/src/pages/Projects.tsx @@ -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 ( +
+

Projects

+ {loading &&

Loading...

} + {error &&

{error.message}

} + {projects && projects.length === 0 &&

No projects yet.

} + {projects && projects.length > 0 && ( +
+ {projects.map((project) => ( +
+
+
+

{project.name}

+ {project.description && ( +

{project.description}

+ )} +
+ {formatDate(project.createdAt)} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 00000000..9c2b135f --- /dev/null +++ b/ui/tsconfig.json @@ -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"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 00000000..fa143a56 --- /dev/null +++ b/ui/vite.config.ts @@ -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", + }, + }, +}); diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts new file mode 100644 index 00000000..9f6250a3 --- /dev/null +++ b/ui/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + }, +});