Skip to content
157 linesCodeBlameRaw
1import { Box, ChevronRight, Code2, Crown, Database, Hash, Network, Settings, Users, Wallet } from "lucide-react";
2import type { ReactNode } from "react";
3import { Link, Outlet, data } from "react-router";
4
5import { teamHandle, teamKindLabel } from "@g1t/contracts";
6
7import type { Route } from "./+types/layout";
8import { page } from "../../../lib/meta";
9import { TeamBadges, type TeamContext } from "../../../components/teams";
10import { Coming, TeamKindMark } from "../../../components/people";
11import { CopyLine, TabLink } from "../../../components/ui";
12import { Hint } from "../../../components/ui/hint";
13import { TabStrip } from "../../../components/ui/tab-strip";
14import { agentsOn, agentPath, peopleAgent, personPath } from "../../../lib/people";
15import { teamPath } from "../../../lib/teams";
16import { money } from "../../../lib/usage";
17import { identity, workspaceAgents } from "../../../lib/services.server";
18import { getViewer, roleIn } from "../../../lib/session.server";
19
20export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
21 return page(args, { title: `${loaderData?.team.name ?? params.team} · ${params.owner} · g1t` });
22}
23
24export async function loader({ params, context }: Route.LoaderArgs) {
25 const viewer = getViewer(context);
26 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
27 const [found, added, listed] = await Promise.all([
28 identity.getTeam(viewer, params.owner, params.team),
29 identity.teamAgents(viewer, params.owner, params.team).catch(() => null),
30 workspaceAgents.list(params.owner, viewer!).catch(() => null),
31 ]);
32 // A secret team someone is not in looks like no team at all.
33 if (!found.ok) throw data(null, { status: 404 });
34 const team = found.value;
35 const ids = added?.ok ? added.value.map((agent) => agent.agent_id) : [];
36 const all = listed?.ok ? listed.value.map(peopleAgent) : [];
37 return { team, agents: agentsOn({ slug: team.slug, agent_ids: ids }, all), added: ids };
38}
39
40export default function TeamLayout({ loaderData }: Route.ComponentProps) {
41 const { team, agents, added } = loaderData;
42 const base = teamPath(team.workspace, team.slug);
43 const lead =
44 team.lead?.kind === "user"
45 ? { name: team.lead.name ?? team.lead.username, to: personPath(team.workspace, team.lead.username) }
46 : team.lead?.kind === "agent"
47 ? (() => {
48 const agentId = team.lead.agent_id;
49 const agent = agents.find((a) => a.id === agentId);
50 return agent ? { name: agent.display_name, to: agentPath(team.workspace, agent.handle) } : null;
51 })()
52 : null;
53 const spent = agents.reduce((sum, agent) => sum + agent.spent_month_micros, 0);
54 return (
55 <div>
56 <nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-1 text-sm text-muted">
57 <Link to={`/${team.workspace}/-/teams`} className="hover:text-fg">
58 Teams
59 </Link>
60 {team.parent && (
61 <>
62 <ChevronRight size={13} className="text-faint" />
63 <Link to={teamPath(team.workspace, team.parent.slug)} className="hover:text-fg">
64 {team.parent.name}
65 </Link>
66 </>
67 )}
68 <ChevronRight size={13} className="text-faint" />
69 <span className="text-fg">{team.name}</span>
70 </nav>
71 <header className="mt-4 border-b border-line">
72 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
73 <h1 className="text-2xl font-semibold tracking-tight">{team.name}</h1>
74 <TeamBadges team={team} />
75 </div>
76 {team.description && <p className="mt-1.5 text-sm text-muted">{team.description}</p>}
77 <ul aria-label="What the team has" className="mt-3 flex flex-wrap gap-1.5">
78 <Fact>
79 <TeamKindMark people={team.members_count} agents={agents.length} />
80 {teamKindLabel(team.members_count, agents.length)}
81 </Fact>
82 <Fact icon={<Crown size={12} />}>
83 {lead ? (
84 <>
85 Led by{" "}
86 <Link to={lead.to} className="font-medium text-fg hover:text-accent">
87 {lead.name}
88 </Link>
89 </>
90 ) : (
91 "No lead"
92 )}
93 </Fact>
94 <Fact icon={<Hash size={12} />}>
95 {team.channel ? (
96 <Link to={`/${team.workspace}/-/chat/${team.channel.name}`} className="font-medium text-fg hover:text-accent">
97 {team.channel.name}
98 </Link>
99 ) : (
100 "No channel"
101 )}
102 </Fact>
103 <Fact icon={<Code2 size={12} />}>
104 <Hint label="Its roles on repositories, given to everyone on it and its child teams. Agents on it work with the access of whoever asks them.">
105 <Link to={`${base}/repositories`} className="hover:text-fg">
106 Code: {team.repos_count ? `${team.repos_count} ${team.repos_count === 1 ? "repository" : "repositories"}` : "no roles yet"}
107 </Link>
108 </Hint>
109 </Fact>
110 <Fact icon={<Database size={12} />}>
111 Storage <Coming />
112 </Fact>
113 <Fact icon={<Wallet size={12} />}>
114 {team.budget_micros ? (
115 <span className="tabular-nums">
116 {money(spent)} of {money(team.budget_micros)} this month
117 </span>
118 ) : (
119 "No team budget"
120 )}
121 </Fact>
122 </ul>
123 <div className="mt-3 max-w-xs">
124 <CopyLine text={teamHandle(team)} />
125 </div>
126 <TabStrip label="Team" className="mt-5 gap-1">
127 <TabLink to={base} end icon={<Users size={15} />} count={team.members_count + agents.length}>
128 People and agents
129 </TabLink>
130 <TabLink to={`${base}/teams`} icon={<Network size={15} />} count={team.child_teams_count}>
131 Child teams
132 </TabLink>
133 <TabLink to={`${base}/repositories`} icon={<Box size={15} />} count={team.repos_count}>
134 Repositories
135 </TabLink>
136 {team.can_manage && (
137 <TabLink to={`${base}/settings`} icon={<Settings size={15} />}>
138 Settings
139 </TabLink>
140 )}
141 </TabStrip>
142 </header>
143 <div className="pt-6">
144 <Outlet context={{ team, agents, added } satisfies TeamContext} />
145 </div>
146 </div>
147 );
148}
149
150function Fact({ icon, children }: { icon?: ReactNode; children: ReactNode }) {
151 return (
152 <li className="inline-flex items-center gap-1.5 rounded-full border border-line bg-surface px-2.5 py-0.5 text-xs text-muted">
153 {icon && <span className="text-faint">{icon}</span>}
154 {children}
155 </li>
156 );
157}