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:
12
ui/index.html
Normal file
12
ui/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>Paperclip</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
28
ui/package.json
Normal file
28
ui/package.json
Normal 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
21
ui/src/App.tsx
Normal 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
10
ui/src/api/agents.ts
Normal 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
22
ui/src/api/client.ts
Normal 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
10
ui/src/api/goals.ts
Normal 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
5
ui/src/api/index.ts
Normal 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
10
ui/src/api/issues.ts
Normal 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
10
ui/src/api/projects.ts
Normal 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}`),
|
||||||
|
};
|
||||||
13
ui/src/components/Layout.tsx
Normal file
13
ui/src/components/Layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
ui/src/components/Sidebar.tsx
Normal file
37
ui/src/components/Sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
ui/src/components/StatusBadge.tsx
Normal file
27
ui/src/components/StatusBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
ui/src/hooks/useAgents.ts
Normal file
8
ui/src/hooks/useAgents.ts
Normal 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
21
ui/src/hooks/useApi.ts
Normal 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
1
ui/src/index.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
15
ui/src/lib/utils.ts
Normal file
15
ui/src/lib/utils.ts
Normal 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
13
ui/src/main.tsx
Normal 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
36
ui/src/pages/Agents.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
ui/src/pages/Dashboard.tsx
Normal file
8
ui/src/pages/Dashboard.tsx
Normal 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
49
ui/src/pages/Goals.tsx
Normal 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
55
ui/src/pages/Issues.tsx
Normal 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
35
ui/src/pages/Projects.tsx
Normal 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
16
ui/tsconfig.json
Normal 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
13
ui/vite.config.ts
Normal 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
7
ui/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user