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