flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/web/app/routes/workspace/overview.tsx

325 lines13,807 bytesCodeBlame
1import { ArrowRight, ArrowUpRight, Box, CircleDot, GitBranch, GitPullRequest, KeyRound, Lock, Plus, Rocket } from "lucide-react";
2import { Link } from "react-router";
3
4import type { Project, ProjectDeploys } from "@g1t/contracts";
5
6import type { Route } from "./+types/overview";
7import { host, StatusDot } from "../../components/deploy";
8import { Avatar, ButtonLink, CopyLine, Pill, TimeAgo } from "../../components/ui";
9import { PullIcon } from "../../components/work";
10import { deployments, identity, projects as projectsApi, work } from "../../lib/services.server";
11import { getViewer, roleIn } from "../../lib/session.server";
12
13/** Projects whose open issues and pull requests are counted. */
14const MAX_COUNTED = 30;
15/** Projects whose pull requests are listed under "In progress". */
16const MAX_LISTED = 8;
17const MAX_PULLS = 8;
18const MAX_FACES = 8;
19
20export async function loader({ params, context }: Route.LoaderArgs) {
21 const viewer = getViewer(context);
22 const slug = params.owner.toLowerCase();
23 const role = roleIn(viewer, slug);
24 const [listed, members, deploys] = await Promise.all([
25 projectsApi.list(slug, viewer),
26 role ? identity.listMembers(slug, viewer) : null,
27 role ? deployments.overview(slug, viewer) : null,
28 ]);
29 const projects = listed.ok ? listed.value : [];
30
31 const pathOf = (project: Project) =>
32 project.source.kind === "hosted" ? project.source.repo : { namespace: project.workspace, name: project.slug };
33 const counted = projects.slice(0, MAX_COUNTED);
34 const counts = await Promise.all(counted.map((project) => work.counts(pathOf(project), viewer)));
35 const open: Record<string, { issues: number; pulls: number }> = {};
36 counted.forEach((project, i) => {
37 const result = counts[i];
38 if (result.ok) open[project.id] = result.value;
39 });
40
41 const busy = counted.filter((project) => (open[project.id]?.pulls ?? 0) > 0).slice(0, MAX_LISTED);
42 const lists = await Promise.all(busy.map((project) => work.listPulls(pathOf(project), viewer, "open")));
43 const pulls = lists
44 .flatMap((result, i) => (result.ok ? result.value.map((pull) => ({ pull, project: busy[i] })) : []))
45 .sort((a, b) => b.pull.updatedAt.localeCompare(a.pull.updatedAt))
46 .slice(0, MAX_PULLS);
47
48 const bySlug: Record<string, ProjectDeploys> = {};
49 for (const entry of deploys?.ok ? deploys.value : []) bySlug[entry.slug] = entry;
50
51 return {
52 slug,
53 role,
54 projects,
55 open,
56 deploys: bySlug,
57 pulls: pulls.map(({ pull, project }) => ({ pull, slug: project.slug, name: project.name })),
58 members: members?.ok ? members.value : null,
59 };
60}
61
62function Stat({ value, label }: { value: number; label: string }) {
63 return (
64 <div className="rounded-xl border border-line bg-surface px-4 py-3">
65 <p className="text-xl font-semibold tabular-nums tracking-tight">{value}</p>
66 <p className="text-xs text-muted">{label}</p>
67 </div>
68 );
69}
70
71/** What a member sees in a workspace with no projects yet. */
72function GetStarted({ slug }: { slug: string }) {
73 return (
74 <div className="rounded-2xl border border-line bg-surface p-8">
75 <span className="flex size-10 items-center justify-center rounded-xl bg-accent/10 text-accent ring-1 ring-accent/30">
76 <Box size={18} />
77 </span>
78 <h2 className="mt-4 text-lg font-semibold tracking-tight">Create your first project</h2>
79 <p className="mt-1.5 max-w-xl text-sm text-muted">
80 A project is what you build and run: its code, the issues that say what should change, the agents and
81 people who change it, and where it is deployed.
82 </p>
83 <div className="mt-6 flex flex-wrap gap-3">
84 <ButtonLink to={`/new?workspace=${slug}`} variant="accent">
85 <Plus size={14} />
86 New project
87 </ButtonLink>
88 </div>
89 <p className="mt-6 text-sm text-muted">Or push code you already have, and it becomes a project:</p>
90 <div className="mt-2 max-w-xl">
91 <CopyLine prompt text={`git push https://g1t.sh/${slug}/my-project.git main`} />
92 </div>
93 </div>
94 );
95}
96
97function ProjectCard({
98 project,
99 open,
100 deploys,
101 member,
102}: {
103 project: Project;
104 open: { issues: number; pulls: number } | undefined;
105 deploys: ProjectDeploys | undefined;
106 member: boolean;
107}) {
108 const base = `/${project.workspace}/${project.slug}`;
109 const production = deploys?.production ?? null;
110 const latest = deploys?.latest ?? null;
111 const source = project.source.kind === "hosted" ? project.source : null;
112 return (
113 <li className="group relative flex flex-col rounded-2xl border border-line bg-surface p-5 transition-colors hover:border-line-strong">
114 <div className="flex items-start gap-3">
115 <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-raised text-muted ring-1 ring-line">
116 {project.private ? <Lock size={15} /> : <Box size={15} />}
117 </span>
118 <div className="min-w-0 grow">
119 <Link to={base} prefetch="intent" className="font-medium after:absolute after:inset-0 hover:underline">
120 {project.name}
121 </Link>
122 {production ? (
123 <a
124 href={production.url}
125 className="relative z-10 mt-0.5 flex items-center gap-1 truncate font-mono text-xs text-muted hover:text-accent"
126 >
127 {host(production.url)}
128 <ArrowUpRight size={11} className="shrink-0" />
129 </a>
130 ) : (
131 <p className="mt-0.5 truncate text-xs text-faint">{project.description ?? "Not deployed"}</p>
132 )}
133 </div>
134 {project.private && <Pill>private</Pill>}
135 </div>
136
137 {project.description && production && <p className="mt-3 line-clamp-2 text-sm text-muted">{project.description}</p>}
138
139 <div className="mt-auto pt-5">
140 {member && latest && (
141 <p className="mb-3 flex items-center gap-2 text-xs text-muted">
142 <StatusDot status={latest.status} />
143 <span className="truncate">
144 {latest.kind === "production" ? "Production" : `Preview of ${latest.branch}`}
145 </span>
146 <span className="ml-auto shrink-0 text-faint">
147 <TimeAgo at={latest.createdAt} />
148 </span>
149 </p>
150 )}
151 <div className="flex items-center gap-4 border-t border-line pt-3 text-xs text-muted">
152 {source && (
153 <span className="flex min-w-0 items-center gap-1.5 font-mono">
154 <GitBranch size={12} className="shrink-0 text-faint" />
155 <span className="truncate">{source.defaultBranch}</span>
156 </span>
157 )}
158 <span className="flex items-center gap-1">
159 <CircleDot size={12} className="text-faint" />
160 {open?.issues ?? 0}
161 </span>
162 <span className="flex items-center gap-1">
163 <GitPullRequest size={12} className="text-faint" />
164 {open?.pulls ?? 0}
165 </span>
166 {member && deploys?.enabled && (deploys.previews ?? 0) > 0 && (
167 <span className="ml-auto flex items-center gap-1 text-accent">
168 <Rocket size={12} />
169 {deploys.previews} {deploys.previews === 1 ? "preview" : "previews"}
170 </span>
171 )}
172 </div>
173 </div>
174 </li>
175 );
176}
177
178export default function WorkspaceOverview({ loaderData }: Route.ComponentProps) {
179 const { slug, role, projects, open, deploys, pulls, members } = loaderData;
180 const totals = Object.values(open).reduce(
181 (sum, counts) => ({ issues: sum.issues + counts.issues, pulls: sum.pulls + counts.pulls }),
182 { issues: 0, pulls: 0 },
183 );
184 const liveApps = Object.values(deploys).filter((entry) => entry.production).length;
185 return (
186 <div className="grid gap-10 lg:grid-cols-[1fr_18rem]">
187 <div className="min-w-0 space-y-10">
188 {projects.length === 0 && role ? (
189 <GetStarted slug={slug} />
190 ) : (
191 <>
192 <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
193 <Stat value={projects.length} label={projects.length === 1 ? "Project" : "Projects"} />
194 <Stat value={liveApps} label="In production" />
195 <Stat value={totals.issues} label="Open issues" />
196 <Stat value={totals.pulls} label="Pull requests in progress" />
197 </div>
198
199 <section>
200 <div className="flex items-center justify-between">
201 <h2 className="text-sm font-medium text-muted">Projects</h2>
202 {role && (
203 <Link to={`/new?workspace=${slug}`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
204 <Plus size={13} />
205 New project
206 </Link>
207 )}
208 </div>
209 <ul className="mt-3 grid gap-4 sm:grid-cols-2">
210 {projects.map((project) => (
211 <ProjectCard
212 key={project.id}
213 project={project}
214 open={open[project.id]}
215 deploys={deploys[project.slug]}
216 member={role != null}
217 />
218 ))}
219 </ul>
220 </section>
221
222 {pulls.length > 0 && (
223 <section>
224 <h2 className="text-sm font-medium text-muted">In progress</h2>
225 <ul className="mt-3 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
226 {pulls.map(({ pull, slug: project, name }) => (
227 <li key={pull.id}>
228 <Link
229 prefetch="intent"
230 to={`/${slug}/${project}/pull/${pull.number}`}
231 className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-raised"
232 >
233 <PullIcon status={pull.status} />
234 <span className="min-w-0 grow">
235 <span className="block truncate font-medium">{pull.title}</span>
236 <span className="text-xs text-muted">
237 {name} <span className="font-mono">#{pull.number}</span>
238 {pull.issue != null && ` · for #${pull.issue}`} · {pull.author.username}
239 </span>
240 </span>
241 <span className="hidden shrink-0 text-xs text-muted sm:block">
242 {pull.status === "draft" ? "Being worked on" : "Ready for review"}
243 </span>
244 <span className="w-14 shrink-0 text-right text-xs text-faint">
245 <TimeAgo at={pull.updatedAt} />
246 </span>
247 </Link>
248 </li>
249 ))}
250 </ul>
251 </section>
252 )}
253 </>
254 )}
255 </div>
256
257 <aside className="space-y-6">
258 {members ? (
259 <section>
260 <div className="flex items-center justify-between">
261 <h2 className="text-sm font-medium">Members</h2>
262 <Link to={`/${slug}/-/people`} className="text-xs text-muted hover:text-fg">
263 {role === "owner" ? "Manage" : "See all"}
264 </Link>
265 </div>
266 <ul className="mt-3 space-y-1.5">
267 {members.slice(0, MAX_FACES).map((member) => (
268 <li key={member.username} className="flex items-center gap-2 text-sm">
269 <Avatar name={member.username} />
270 <span className="grow truncate font-mono">{member.username}</span>
271 {member.role === "owner" && <Pill>owner</Pill>}
272 </li>
273 ))}
274 </ul>
275 {members.length > MAX_FACES && <p className="mt-2 text-xs text-faint">and {members.length - MAX_FACES} more</p>}
276 </section>
277 ) : (
278 <p className="text-sm text-muted">
279 Members of this workspace build its projects. Anyone can open an issue or a pull request on a public one.
280 </p>
281 )}
282
283 {role && (
284 <section className="rounded-xl border border-line bg-surface p-5">
285 <h2 className="flex items-center gap-2 font-medium">
286 <Rocket size={15} className="text-faint" />
287 Deploy on g1t.page
288 </h2>
289 <p className="mt-1.5 text-sm text-muted">
290 Production from each project's default branch, and a live preview for every pull request. Apps cost
291 nothing while no one visits.
292 </p>
293 <Link
294 prefetch="intent"
295 to={`/${slug}/-/billing`}
296 className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline"
297 >
298 Billing and plans <ArrowRight size={13} />
299 </Link>
300 </section>
301 )}
302
303 {role && (
304 <section className="rounded-xl border border-line bg-surface p-5">
305 <h2 className="flex items-center gap-2 font-medium">
306 <KeyRound size={15} className="text-faint" />
307 Automate without a service account
308 </h2>
309 <p className="mt-1.5 text-sm text-muted">
310 A workspace has access tokens of its own for CI, integrations and agents. They act as the workspace,
311 not as a person.
312 </p>
313 <Link
314 prefetch="intent"
315 to={`/${slug}/-/tokens`}
316 className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline"
317 >
318 Access tokens <ArrowRight size={13} />
319 </Link>
320 </section>
321 )}
322 </aside>
323 </div>
324 );
325}