Skip to content

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

158 linesCodeBlameRaw
1import { ArrowRight, Box, ShieldCheck, Users } from "lucide-react";
2import type { ReactNode } from "react";
3import { Link, data } from "react-router";
4
5import type { Route } from "./+types/workspace-overview";
6
7import { UsageCard } from "../../components/usage-card";
8import { identity } from "../../lib/services.server";
9import { loadOnboarding } from "../../lib/onboarding.server";
10import { OnboardingOnPage } from "../../components/onboarding";
11import { getViewer, roleIn } from "../../lib/session.server";
12import { usageFor } from "../../lib/workspace-usage.server";
13import { workspaceProjects } from "../../lib/workspace-projects.server";
14import { page } from "../../lib/meta";
15import { Avatar } from "../../components/ui/avatar";
16import { Badge } from "../../components/ui/badge";
17import { Card } from "../../components/ui/card";
18import { Unavailable } from "../../components/boundary";
19import { isFailed, part } from "../../lib/partial";
20
21export function meta({ params, ...args }: Route.MetaArgs) {
22 return page(args, { title: `Workspace · ${params.owner} · g1t` });
23}
24
25/** Most members listed before "and N more". */
26const MAX_FACES = 10;
27
28/**
29 * The workspace at a glance, for every member: who is in it, its
30 * projects, and the month's usage against its plan. Each part is left out
31 * when its service does not answer.
32 */
33export async function loader({ params, context, request }: Route.LoaderArgs) {
34 const viewer = getViewer(context);
35 const slug = params.owner.toLowerCase();
36 const role = roleIn(viewer, slug);
37 if (!role) throw data(null, { status: 404 });
38 // Getting started, for an owner, as on Home: streamed beside the rest.
39 const setupAsked = new URL(request.url).searchParams.has("setup");
40 const onboarding = loadOnboarding(viewer, slug, !setupAsked).catch(() => null);
41 const [members, usage, projects] = await Promise.all([
42 part(identity.listMembers(slug, viewer), "The member list"),
43 usageFor(slug, viewer),
44 workspaceProjects(slug, viewer).catch(() => null),
45 ]);
46 return {
47 slug,
48 role,
49 onboarding,
50 setupAsked,
51 members: isFailed(members) ? members : members.ok ? members.value : null,
52 usage,
53 projects: projects?.ok ? projects.value.length : null,
54 };
55}
56
57function Tile({ icon, label, value, to, action }: { icon: ReactNode; label: string; value: string; to: string; action: string }) {
58 return (
59 <Link to={to} className="group rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
60 <p className="flex items-center gap-1.5 text-xs text-muted">
61 {icon}
62 {label}
63 </p>
64 <p className="mt-1.5 text-2xl font-semibold tracking-tight tabular-nums">{value}</p>
65 <p className="mt-0.5 flex items-center gap-1 text-xs text-faint group-hover:text-muted">
66 {action} <ArrowRight size={11} />
67 </p>
68 </Link>
69 );
70}
71
72export default function WorkspaceOverview({ loaderData }: Route.ComponentProps) {
73 const { slug, role, members, usage, projects } = loaderData;
74 return (
75 <div className="space-y-8">
76 <OnboardingOnPage slug={slug} view={loaderData.onboarding} asked={loaderData.setupAsked} />
77 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
78 <Tile icon={<Users size={12} />} label="Members" value={members && !isFailed(members) ? String(members.length) : "—"} to={role === "owner" ? `/${slug}/-/members` : `/${slug}/-/people`} action={role === "owner" ? "Manage people" : "See everyone"} />
79 <Tile icon={<Box size={12} />} label="Projects" value={projects == null ? "—" : String(projects)} to={`/${slug}/-/projects`} action="Open Code" />
80 </div>
81 <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_22rem]">
82 <div className="space-y-6">
83 {usage ? (
84 <UsageCard slug={slug} glance={usage} owner={role === "owner"} />
85 ) : (
86 <Card asChild className="p-5">
87 <section>
88 <h2 className="font-medium">Usage</h2>
89 <p className="mt-1.5 text-sm text-muted">This month&apos;s usage isn&apos;t available right now.</p>
90 <Link to={`/${slug}/-/usage`} className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline">
91 Open Usage <ArrowRight size={13} />
92 </Link>
93 </section>
94 </Card>
95 )}
96 <Card asChild className="p-5">
97 <section>
98 <h2 className="flex items-center gap-2 font-medium">
99 <ShieldCheck size={15} className="text-faint" />
100 Policies
101 </h2>
102 <p className="mt-1.5 text-sm text-muted">
103 How long runs may take, the rules every repository follows, and how the workspace&apos;s security is set up.
104 </p>
105 <div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm">
106 <Link to={`/${slug}/-/guardrails`} className="text-accent hover:underline">
107 Guardrails
108 </Link>
109 <Link to={`/${slug}/-/rules`} className="text-accent hover:underline">
110 Rules
111 </Link>
112 <Link to={`/${slug}/-/security/settings`} className="text-accent hover:underline">
113 Security settings
114 </Link>
115 <Link to={`/${slug}/-/audit`} className="text-accent hover:underline">
116 Audit log
117 </Link>
118 </div>
119 </section>
120 </Card>
121 </div>
122 <Card asChild className="self-start p-5">
123 <section>
124 <div className="flex items-center justify-between">
125 <h2 className="font-medium">Members</h2>
126 <Link to={role === "owner" ? `/${slug}/-/members` : `/${slug}/-/people`} className="text-xs text-muted hover:text-fg">
127 {role === "owner" ? "Manage" : "See all"}
128 </Link>
129 </div>
130 {isFailed(members) ? (
131 <div className="mt-3">
132 <Unavailable what={members.what} compact />
133 </div>
134 ) : members ? (
135 <>
136 <ul className="mt-3 space-y-2">
137 {members.slice(0, MAX_FACES).map((member) => (
138 <li key={member.username} className="flex items-center gap-2.5 text-sm">
139 <Avatar name={member.username} image={member.avatar} size={24} />
140 <Link to={`/u/${member.username}`} className="min-w-0 grow truncate hover:text-accent">
141 {member.name?.trim() || member.username}
142 {member.name?.trim() && <span className="ml-1.5 font-mono text-xs text-faint">{member.username}</span>}
143 </Link>
144 {member.role === "owner" && <Badge size="md">owner</Badge>}
145 </li>
146 ))}
147 </ul>
148 {members.length > MAX_FACES && <p className="mt-2 text-xs text-faint">and {members.length - MAX_FACES} more</p>}
149 </>
150 ) : (
151 <p className="mt-3 text-sm text-muted">The member list isn&apos;t available right now.</p>
152 )}
153 </section>
154 </Card>
155 </div>
156 </div>
157 );
158}