Skip to content
File

Blob: src/client/pages/projects-page.tsx

typescript139 lines
1import type { ProjectSummary, RunStatus } from "@/contracts";
2import { useQuery } from "@tanstack/react-query";
3import { FolderGit2, FolderPlus, GitBranch, RefreshCw } from "lucide-react";
4import { Link } from "react-router-dom";
5import { useAuth } from "@/client/auth";
6import { StatusPill } from "@/client/components";
7import { Badge, Button, ButtonLink, EmptyState, ErrorBanner, PageHeader, Skeleton } from "@/client/components/ui";
8import {
9 formatApiError,
10 formatProjectUpdatedLabel,
11 getApiClient,
12 inferRepositoryProvider,
13 queryKeys,
14} from "@/client/lib";
15 
16const countByStatus = (projects: ProjectSummary[], target: RunStatus): number =>
17 projects.filter((project) => project.lastRunStatus === target).length;
18 
19const ProjectCard = ({ project }: { project: ProjectSummary }) => (
20 <Link
21 to={`/app/projects/${project.id}`}
22 className="group flex flex-col gap-3 rounded-2xl border border-zinc-800/60 bg-zinc-900/50 p-4 hover:-translate-y-0.5 hover:border-zinc-700/60 hover:bg-zinc-900/80 transition-transform"
23 >
24 <div className="flex items-start justify-between gap-3">
25 <div className="min-w-0">
26 <p className="truncate text-base font-semibold text-zinc-100 group-hover:text-accent-100">{project.name}</p>
27 <p className="mt-0.5 truncate text-sm text-zinc-500">
28 {project.ownerSlug}/{project.projectSlug}
29 </p>
30 </div>
31 <StatusPill status={project.lastRunStatus} />
32 </div>
33 <div className="flex items-center gap-2 text-xs text-zinc-500">
34 <Badge>{inferRepositoryProvider(project.repoUrl)}</Badge>
35 <span className="inline-flex items-center gap-1">
36 <GitBranch className="h-3 w-3" />
37 {project.defaultBranch}
38 </span>
39 </div>
40 <p className="text-xs text-zinc-600">updated {formatProjectUpdatedLabel(project)}</p>
41 </Link>
42);
43 
44const ProjectCardSkeleton = () => (
45 <div className="flex flex-col gap-3 rounded-2xl border border-zinc-800/60 bg-zinc-900/50 p-4">
46 <div className="flex items-start justify-between gap-3">
47 <div className="min-w-0 flex-1">
48 <Skeleton className="h-5 w-40" />
49 <Skeleton className="mt-1.5 h-4 w-28" />
50 </div>
51 <Skeleton className="h-6 w-16 rounded-full" />
52 </div>
53 <div className="flex items-center gap-2">
54 <Skeleton className="h-5 w-14" />
55 <Skeleton className="h-4 w-16" />
56 </div>
57 <Skeleton className="h-3.5 w-24" />
58 </div>
59);
60 
61export const ProjectsPage = () => {
62 const { user } = useAuth();
63 const userId = user?.id ?? "";
64 const projectsQuery = useQuery({
65 queryKey: queryKeys.projects(userId),
66 queryFn: () => getApiClient().getProjects(),
67 enabled: userId.length > 0,
68 });
69 const projects = projectsQuery.data?.projects ?? [];
70 const loading = projectsQuery.isPending;
71 const error = projectsQuery.isError ? formatApiError(projectsQuery.error) : null;
72 
73 const activeCount =
74 countByStatus(projects, "queued") +
75 countByStatus(projects, "starting") +
76 countByStatus(projects, "running") +
77 countByStatus(projects, "cancel_requested") +
78 countByStatus(projects, "canceling");
79 const healthyCount = countByStatus(projects, "passed");
80 
81 return (
82 <div className="animate-slide-up space-y-5">
83 <PageHeader
84 label="Workspace"
85 title="Projects"
86 description={
87 !loading && projects.length > 0
88 ? `${projects.length} project${projects.length !== 1 ? "s" : ""} \u00b7 ${activeCount} active, ${healthyCount} passing`
89 : undefined
90 }
91 actions={
92 <>
93 <ButtonLink to="/app/projects/new" variant="primary">
94 <FolderPlus className="h-4 w-4" />
95 New Project
96 </ButtonLink>
97 <Button
98 variant="ghost"
99 size="sm"
100 icon={<RefreshCw className="h-3.5 w-3.5" />}
101 aria-label="Refresh"
102 onClick={() => {
103 void projectsQuery.refetch();
104 }}
105 />
106 </>
107 }
108 />
109 
110 {error ? <ErrorBanner message={error} /> : null}
111 
112 {loading ? (
113 <div className="grid gap-4 xl:grid-cols-2">
114 {Array.from({ length: 4 }, (_, i) => (
115 <ProjectCardSkeleton key={i} />
116 ))}
117 </div>
118 ) : projects.length === 0 ? (
119 <EmptyState
120 icon={<FolderGit2 className="h-6 w-6" />}
121 title="No projects yet"
122 description="Create the first repository connection for this workspace."
123 action={
124 <ButtonLink to="/app/projects/new" variant="primary">
125 Create Project
126 </ButtonLink>
127 }
128 />
129 ) : (
130 <div className="grid gap-4 xl:grid-cols-2">
131 {projects.map((project) => (
132 <ProjectCard key={project.id} project={project} />
133 ))}
134 </div>
135 )}
136 </div>
137 );
138};