forked from farhoodlabs/paperclip
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:
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`),
|
||||
};
|
||||
@@ -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" }),
|
||||
};
|
||||
@@ -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}`),
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export { api } from "./client";
|
||||
export { agentsApi } from "./agents";
|
||||
export { projectsApi } from "./projects";
|
||||
export { issuesApi } from "./issues";
|
||||
export { goalsApi } from "./goals";
|
||||
@@ -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}`),
|
||||
};
|
||||
@@ -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}`),
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user