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.

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