Skip to content
457 linesCodeBlameRaw
1import { ArrowRight, ArrowUpRight, Box, CircleDot, GitBranch, GitPullRequest, KeyRound, Lock, Pin, Plus, Rocket } from "lucide-react";
2import { Link, redirect } from "react-router";
3
4import type { PackageSummary, Project, ProjectDeploys, User } from "@g1t/contracts";
5
6import type { Route } from "./+types/overview";
7import { DeployLink, host, StatusDot } from "../../components/deploy";
8import { ButtonLink, CopyLine, TimeAgo } from "../../components/ui";
9import { Avatar } from "../../components/ui/avatar";
10import { Badge } from "../../components/ui/badge";
11import { Card } from "../../components/ui/card";
12import { UsageCard } from "../../components/usage-card";
13import { PullIcon } from "../../components/work-icons";
14import { openedBy } from "../../lib/opened-by";
15import { cloneUrl, useAddresses } from "../../lib/addresses";
16import { kindLabel, libraryPackages, packageLine, packagePath, primaryLink } from "../../lib/project-kind";
17import { PinButton } from "../../components/pin-button";
18import { sortProjects } from "../../lib/project-list";
19import { usageFor } from "../../lib/workspace-usage.server";
20import { deployments, identity, packages, projects as projectsApi, work } from "../../lib/services.server";
21import { getViewer, roleIn } from "../../lib/session.server";
22import { workspaceProjects } from "../../lib/workspace-projects.server";
23
24/** Projects whose open issues and pull requests are counted: the most active. */
25const MAX_COUNTED = 30;
26/** Projects shown under Recently active, after the pins. */
27const MAX_ACTIVE = 6;
28/** Projects whose pull requests are listed under "In progress". */
29const MAX_LISTED = 8;
30const MAX_PULLS = 8;
31const MAX_FACES = 8;
32export async function loader({ params, context }: Route.LoaderArgs) {
33 const viewer = getViewer(context);
34 const slug = params.owner.toLowerCase();
35 const role = roleIn(viewer, slug);
36 // A member's way in is Home; this page
37 // is the workspace as a visitor sees it.
38 if (role) throw redirect(`/${slug}/-/home`);
39 const [listed, members, deploys, usage, published, shortcuts] = await Promise.all([
40 // The listing the sidebar and the Projects tab share.
41 workspaceProjects(slug, viewer),
42 role ? identity.listMembers(slug, viewer) : null,
43 role ? deployments.overview(slug, viewer) : null,
44 role ? usageFor(slug, viewer) : null,
45 // One listing for every card: a library's card shows its package where an app's shows production.
46 packages.list(slug, viewer).catch(() => null),
47 // The viewer's pins lead the page, as they lead the sidebar.
48 role ? projectsApi.shortcuts(slug, viewer).catch(() => null) : null,
49 ]);
50 const projects = listed.ok ? listed.value : [];
51 const pinnedIds = new Set((shortcuts?.pinned ?? []).map((project) => project.id));
52 const byId = new Map(projects.map((project) => [project.id, project]));
53 const pinned = (shortcuts?.pinned ?? []).map((pin) => byId.get(pin.id)).filter((project): project is Project => project != null);
54 // However many there are: the most active first, archived ones last.
55 const byActivity = sortProjects(
56 // Deploying and mirroring do not order the list, so neither is read here.
57 projects.map((project) => ({ ...project, pushedAt: project.pushedAt ?? null, activity: project.activity ?? 0, deploying: false, mirror: null })),
58 "active",
59 )
60 .sort((a, b) => Number(a.archived) - Number(b.archived))
61 .map((listedProject) => byId.get(listedProject.id)!);
62 const active = byActivity.filter((project) => !pinnedIds.has(project.id)).slice(0, MAX_ACTIVE);
63
64 const pathOf = (project: Project) =>
65 project.source.kind === "hosted" ? project.source.repo : { namespace: project.workspace, name: project.slug };
66 const counted = [...new Map([...pinned, ...active, ...byActivity].map((project) => [project.id, project])).values()].slice(0, Math.max(MAX_COUNTED, pinned.length + active.length));
67 const counts = await Promise.all(counted.map((project) => work.counts(pathOf(project), viewer)));
68 const open: Record<string, { issues: number; pulls: number }> = {};
69 counted.forEach((project, i) => {
70 const result = counts[i];
71 if (result.ok) open[project.id] = result.value;
72 });
73
74 const busy = counted.filter((project) => (open[project.id]?.pulls ?? 0) > 0).slice(0, MAX_LISTED);
75 const lists = await Promise.all(busy.map((project) => work.listPulls(pathOf(project), viewer, "open")));
76 const pulls = lists
77 .flatMap((result, i) => (result.ok ? result.value.map((pull) => ({ pull, project: busy[i] })) : []))
78 .sort((a, b) => b.pull.updatedAt.localeCompare(a.pull.updatedAt))
79 .slice(0, MAX_PULLS);
80
81 const bySlug: Record<string, ProjectDeploys> = {};
82 for (const entry of deploys?.ok ? deploys.value : []) bySlug[entry.slug] = entry;
83
84 const byRepo = new Map<string, PackageSummary[]>();
85 for (const pkg of published?.ok ? published.value : []) {
86 if (pkg.repo) byRepo.set(pkg.repo.id, [...(byRepo.get(pkg.repo.id) ?? []), pkg]);
87 }
88 const packageOf: Record<string, PackageSummary> = {};
89 for (const project of [...pinned, ...active]) {
90 if ((project.kind !== "library" && project.kind !== "tool") || project.source.kind !== "hosted") continue;
91 const first = libraryPackages(byRepo.get(project.source.repoId) ?? [])[0];
92 if (first) packageOf[project.id] = first;
93 }
94
95 return {
96 slug,
97 role,
98 total: projects.length,
99 elsewhere: projects.filter((project) => project.runs === "elsewhere" && project.productionUrl).length,
100 pinned,
101 active,
102 open,
103 deploys: bySlug,
104 packageOf,
105 pulls: pulls.map(({ pull, project }) => ({ pull, slug: project.slug, name: project.name })),
106 members: members?.ok ? members.value : null,
107 usage,
108 };
109}
110
111function Stat({ value, label }: { value: number; label: string }) {
112 return (
113 <Card className="px-4 py-3">
114 <p className="text-xl font-semibold tabular-nums tracking-tight">{value}</p>
115 <p className="text-xs text-muted">{label}</p>
116 </Card>
117 );
118}
119
120/** What a member sees in a workspace with no projects yet. */
121function GetStarted({ slug }: { slug: string }) {
122 const addresses = useAddresses();
123 return (
124 <div className="rounded-2xl border border-line bg-surface p-8">
125 <span className="flex size-10 items-center justify-center rounded-xl bg-accent/10 text-accent ring-1 ring-accent/30">
126 <Box size={18} />
127 </span>
128 <h2 className="mt-4 text-lg font-semibold tracking-tight">Create your first project</h2>
129 <p className="mt-1.5 max-w-xl text-sm text-muted">
130 A project is what you build and run: its code, the issues that say what should change, the agents and
131 people who change it, and where it is deployed.
132 </p>
133 <div className="mt-6 flex flex-wrap gap-3">
134 <ButtonLink to={`/new?workspace=${slug}`} variant="accent">
135 <Plus size={14} />
136 New project
137 </ButtonLink>
138 </div>
139 <p className="mt-6 text-sm text-muted">Or push code you already have, and it becomes a project:</p>
140 <div className="mt-2 max-w-xl">
141 <CopyLine prompt text={`git push ${cloneUrl(addresses, `${slug}/my-project`)} main`} />
142 </div>
143 </div>
144 );
145}
146
147function ProjectCard({
148 project,
149 open,
150 deploys,
151 pkg,
152 member,
153 pinned,
154}: {
155 project: Project;
156 open: { issues: number; pulls: number } | undefined;
157 deploys: ProjectDeploys | undefined;
158 /** For a library, the package it publishes. */
159 pkg: PackageSummary | undefined;
160 member: boolean;
161 /** Whether the viewer pinned it; null when they cannot pin. */
162 pinned: boolean | null;
163}) {
164 // A library or a tool shows the package it publishes.
165 const library = project.kind === "library" || project.kind === "tool";
166 const base = `/${project.workspace}/${project.slug}`;
167 const production = deploys?.production ?? null;
168 // Production where g1t serves it or elsewhere, else its homepage or docs.
169 const link = primaryLink(project, production?.url);
170 const latest = deploys?.latest ?? null;
171 const source = project.source.kind === "hosted" ? project.source : null;
172 return (
173 <li className="group relative flex flex-col rounded-2xl border border-line bg-surface p-5 transition-colors hover:border-line-strong">
174 <div className="flex items-start gap-3">
175 <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-raised text-muted ring-1 ring-line">
176 {project.private ? <Lock size={15} /> : <Box size={15} />}
177 </span>
178 <div className="min-w-0 grow">
179 <Link to={base} prefetch="intent" className="font-medium after:absolute after:inset-0 hover:underline">
180 {project.name}
181 </Link>
182 {link && !(library && pkg && !production) ? (
183 <DeployLink
184 href={link}
185 className="relative z-10 mt-0.5 flex items-center gap-1 truncate font-mono text-xs text-muted hover:text-accent"
186 >
187 {host(link)}
188 <ArrowUpRight size={11} className="shrink-0" />
189 </DeployLink>
190 ) : library && pkg ? (
191 <Link
192 to={packagePath(pkg)}
193 className="relative z-10 mt-0.5 block truncate font-mono text-xs text-muted hover:text-accent"
194 >
195 {packageLine(pkg)}
196 </Link>
197 ) : (
198 <p className="mt-0.5 truncate text-xs text-faint">
199 {project.description ??
200 (library
201 ? "Not published yet"
202 : project.runs === "g1t"
203 ? deploys?.enabled
204 ? "Not deployed yet"
205 : "Deployments are off"
206 : kindLabel(project))}
207 </p>
208 )}
209 </div>
210 {(project.private || project.archived) && (
211 <span className="flex shrink-0 gap-1.5">
212 {project.private && <Badge size="md">private</Badge>}
213 {project.archived && <Badge size="md">archived</Badge>}
214 </span>
215 )}
216 {pinned != null && (
217 <PinButton workspace={project.workspace} slug={project.slug} name={project.name} pinned={pinned} compact className="-mt-1.5 -mr-2" />
218 )}
219 </div>
220
221 {project.description && (link || (library && pkg)) && <p className="mt-3 line-clamp-2 text-sm text-muted">{project.description}</p>}
222
223 <div className="mt-auto pt-5">
224 {member && latest && (
225 <p className="mb-3 flex items-center gap-2 text-xs text-muted">
226 <StatusDot status={latest.status} />
227 <span className="truncate">
228 {latest.kind === "production" ? "Production" : `Preview of ${latest.branch}`}
229 </span>
230 <span className="ml-auto shrink-0 text-faint">
231 <TimeAgo at={latest.createdAt} />
232 </span>
233 </p>
234 )}
235 <div className="flex items-center gap-4 border-t border-line pt-3 text-xs text-muted">
236 {source && (
237 <span className="flex min-w-0 items-center gap-1.5 font-mono">
238 <GitBranch size={12} className="shrink-0 text-faint" />
239 <span className="truncate">{source.defaultBranch}</span>
240 </span>
241 )}
242 <span className="flex items-center gap-1">
243 <CircleDot size={12} className="text-faint" />
244 {open?.issues ?? 0}
245 </span>
246 <span className="flex items-center gap-1">
247 <GitPullRequest size={12} className="text-faint" />
248 {open?.pulls ?? 0}
249 </span>
250 {member && deploys?.enabled && (deploys.previews ?? 0) > 0 && (
251 <span className="ml-auto flex items-center gap-1 text-success">
252 <Rocket size={12} />
253 {deploys.previews} {deploys.previews === 1 ? "preview" : "previews"}
254 </span>
255 )}
256 </div>
257 </div>
258 </li>
259 );
260}
261
262export default function WorkspaceOverview({ loaderData }: Route.ComponentProps) {
263 const { slug, role, total, pinned, active, open, deploys, pulls, members, usage } = loaderData;
264 const card = (project: Project, isPinned: boolean) => (
265 <ProjectCard
266 key={project.id}
267 project={project}
268 open={open[project.id]}
269 deploys={deploys[project.slug]}
270 pkg={loaderData.packageOf[project.id]}
271 member={role != null}
272 pinned={role ? isPinned : null}
273 />
274 );
275 const totals = Object.values(open).reduce(
276 (sum, counts) => ({ issues: sum.issues + counts.issues, pulls: sum.pulls + counts.pulls }),
277 { issues: 0, pulls: 0 },
278 );
279 // Live on g1t, or deployed elsewhere with an address.
280 const liveApps =
281 Object.values(deploys).filter((entry) => entry.production).length +
282 loaderData.elsewhere;
283 return (
284 <div className="grid gap-10 lg:grid-cols-[1fr_18rem]">
285 <div className="min-w-0 space-y-10">
286 {total === 0 && role ? (
287 <GetStarted slug={slug} />
288 ) : (
289 <>
290 <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
291 <Stat value={total} label={total === 1 ? "Project" : "Projects"} />
292 <Stat value={liveApps} label="In production" />
293 <Stat value={totals.issues} label="Open issues" />
294 <Stat value={totals.pulls} label="Pull requests in progress" />
295 </div>
296
297 {role && (
298 <section aria-labelledby="pinned">
299 <h2 id="pinned" className="text-sm font-medium text-muted">
300 Pinned
301 </h2>
302 {pinned.length > 0 ? (
303 <ul className="mt-3 grid gap-4 sm:grid-cols-2">{pinned.map((project) => card(project, true))}</ul>
304 ) : (
305 <Card asChild tone="plain" className="mt-3 flex items-center gap-2 border-dashed px-4 py-3 text-sm text-muted">
306 <p>
307 <Pin size={14} className="shrink-0 text-faint" />
308 Pin the projects you use most, from their page or from Projects, to keep them here and in your sidebar.
309 </p>
310 </Card>
311 )}
312 </section>
313 )}
314
315 {active.length > 0 && (
316 <section aria-labelledby="active">
317 <div className="flex items-center justify-between">
318 <h2 id="active" className="text-sm font-medium text-muted">
319 {role ? "Recently active" : "Projects"}
320 </h2>
321 {role && (
322 <Link to={`/new?workspace=${slug}`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
323 <Plus size={13} />
324 New project
325 </Link>
326 )}
327 </div>
328 <ul className="mt-3 grid gap-4 sm:grid-cols-2">{active.map((project) => card(project, false))}</ul>
329 </section>
330 )}
331
332 <Link
333 to={`/${slug}/-/projects`}
334 prefetch="intent"
335 className="flex items-center justify-between rounded-xl border border-line bg-surface px-4 py-3 text-sm transition-colors hover:border-line-strong"
336 >
337 <span>
338 All projects <span className="tabular-nums text-muted">({total})</span>
339 </span>
340 <span className="flex items-center gap-1 text-xs text-muted">
341 Search, filter and sort
342 <ArrowRight size={13} />
343 </span>
344 </Link>
345
346 {pulls.length > 0 && (
347 <section>
348 <h2 className="text-sm font-medium text-muted">In progress</h2>
349 <Card asChild divided className="mt-3 overflow-hidden">
350 <ul>
351 {pulls.map(({ pull, slug: project, name }) => (
352 <li key={pull.id}>
353 <Link
354 prefetch="intent"
355 to={`/${slug}/${project}/pull/${pull.number}`}
356 className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-raised"
357 >
358 <PullIcon status={pull.status} />
359 <span className="min-w-0 grow">
360 <span className="block truncate font-medium">{pull.title}</span>
361 <span className="text-xs text-muted">
362 {name} <span className="font-mono">#{pull.number}</span>
363 {pull.issue != null && ` · for #${pull.issue}`} · {openedBy(pull).name}
364 </span>
365 </span>
366 <span className="hidden shrink-0 text-xs text-muted sm:block">
367 {pull.status === "draft" ? "Being worked on" : "Ready for review"}
368 </span>
369 <span className="w-14 shrink-0 text-right text-xs text-faint">
370 <TimeAgo at={pull.updatedAt} />
371 </span>
372 </Link>
373 </li>
374 ))}
375 </ul>
376 </Card>
377 </section>
378 )}
379 </>
380 )}
381 </div>
382
383 <aside className="space-y-6">
384 {role && usage && <UsageCard slug={slug} glance={usage} owner={role === "owner"} />}
385
386 {members ? (
387 <section>
388 <div className="flex items-center justify-between">
389 <h2 className="text-sm font-medium">Members</h2>
390 <Link to={role === "owner" ? `/${slug}/-/members` : `/${slug}/-/people`} className="text-xs text-muted hover:text-fg">
391 {role === "owner" ? "Manage" : "See all"}
392 </Link>
393 </div>
394 <ul className="mt-3 space-y-1.5">
395 {members.slice(0, MAX_FACES).map((member) => (
396 <li key={member.username} className="flex items-center gap-2 text-sm">
397 <Avatar name={member.username} />
398 <span className="grow truncate font-mono">{member.username}</span>
399 {member.role === "owner" && <Badge size="md">owner</Badge>}
400 </li>
401 ))}
402 </ul>
403 {members.length > MAX_FACES && <p className="mt-2 text-xs text-faint">and {members.length - MAX_FACES} more</p>}
404 </section>
405 ) : (
406 <p className="text-sm text-muted">
407 Members of this workspace build its projects. Anyone can open an issue or a pull request on a public one.
408 </p>
409 )}
410
411 {role && (
412 <Card asChild className="p-5">
413 <section>
414 <h2 className="flex items-center gap-2 font-medium">
415 <Rocket size={15} className="text-faint" />
416 Deploy on g1t.page
417 </h2>
418 <p className="mt-1.5 text-sm text-muted">
419 Off until you turn them on for a project, under its Settings. Then production builds from its default
420 branch, with a live preview for every pull request. Apps cost nothing while no one visits.
421 </p>
422 <Link
423 prefetch="intent"
424 to={`/${slug}/-/billing`}
425 className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline"
426 >
427 Billing and plans <ArrowRight size={13} />
428 </Link>
429 </section>
430 </Card>
431 )}
432
433 {role && (
434 <Card asChild className="p-5">
435 <section>
436 <h2 className="flex items-center gap-2 font-medium">
437 <KeyRound size={15} className="text-faint" />
438 Automate without a service account
439 </h2>
440 <p className="mt-1.5 text-sm text-muted">
441 A workspace has access tokens of its own for CI, integrations and agents. They act as the workspace,
442 not as a person.
443 </p>
444 <Link
445 prefetch="intent"
446 to={`/${slug}/-/tokens`}
447 className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline"
448 >
449 Access tokens <ArrowRight size={13} />
450 </Link>
451 </section>
452 </Card>
453 )}
454 </aside>
455 </div>
456 );
457}