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.

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