pr_01m47d24b0e6n91zwymwxg0vpx/apps/web/app/routes/workspace.tsx

128 lines4,639 bytesCodeBlame
1import { Plus, Users } from "lucide-react";
2import { Form, data } from "react-router";
3
4import type { Route } from "./+types/workspace";
5import { RepoList } from "../components/repo-list";
6import { Avatar, Button, ButtonLink, ErrorText, Input, Pill } from "../components/ui";
7import { identity, repos as reposApi } from "../lib/services.server";
8import { assertSameOrigin, getViewer, requireUser } from "../lib/session.server";
9
10export function meta({ loaderData, params }: Route.MetaArgs) {
11 return [{ title: `${loaderData?.workspace.name ?? params.owner} · g1t` }];
12}
13
14export async function loader({ params, context }: Route.LoaderArgs) {
15 const viewer = getViewer(context);
16 const workspace = await identity.getWorkspace(params.owner);
17 if (!workspace) throw data(null, { status: 404 });
18 const role =
19 viewer?.workspaces?.find((membership) => membership.slug === workspace.slug)
20 ?.role ?? null;
21 const [repos, members] = await Promise.all([
22 reposApi.list(viewer, { namespace: workspace.slug }),
23 role ? identity.listMembers(workspace.slug, viewer) : null,
24 ]);
25 return {
26 workspace,
27 repos,
28 role,
29 members: members?.ok ? members.value : null,
30 };
31}
32
33export async function action({ request, params, context }: Route.ActionArgs) {
34 assertSameOrigin(request);
35 const user = requireUser(context, request);
36 const form = await request.formData();
37 const username = String(form.get("username") ?? "");
38 const result =
39 form.get("action") === "remove"
40 ? await identity.removeMember(user, params.owner, username)
41 : await identity.addMember(user, params.owner, username);
42 return result.ok ? null : { error: result.error.message };
43}
44
45export default function WorkspacePage({
46 loaderData,
47 actionData,
48}: Route.ComponentProps) {
49 const { workspace, repos, role, members } = loaderData;
50 return (
51 <main className="mx-auto grid max-w-6xl gap-10 px-4 py-10 lg:grid-cols-[1fr_18rem]">
52 <div className="min-w-0">
53 <div className="flex items-center gap-4">
54 <Avatar name={workspace.slug} size={56} />
55 <div className="min-w-0 grow">
56 <h1 className="truncate text-2xl font-semibold tracking-tight">
57 {workspace.name}
58 </h1>
59 <p className="font-mono text-sm text-muted">
60 g1t.sh/{workspace.slug}
61 </p>
62 </div>
63 {role && (
64 <ButtonLink to={`/new?workspace=${workspace.slug}`}>
65 <Plus size={15} />
66 New repository
67 </ButtonLink>
68 )}
69 </div>
70 <h2 className="mt-10 text-sm font-medium text-muted">Repositories</h2>
71 <RepoList repos={repos} />
72 </div>
73
74 <aside>
75 <h2 className="flex items-center gap-2 text-sm font-medium">
76 <Users size={15} className="text-faint" />
77 {workspace.memberCount}{" "}
78 {workspace.memberCount === 1 ? "member" : "members"}
79 </h2>
80 {members && (
81 <ul className="mt-3 space-y-1">
82 {members.map((member) => (
83 <li
84 key={member.username}
85 className="flex items-center gap-2 rounded-md px-1 py-1 text-sm"
86 >
87 <Avatar name={member.username} />
88 <span className="grow font-mono">{member.username}</span>
89 {member.role === "owner" ? (
90 <Pill>owner</Pill>
91 ) : (
92 role === "owner" && (
93 <Form method="post">
94 <input type="hidden" name="action" value="remove" />
95 <input type="hidden" name="username" value={member.username} />
96 <button
97 type="submit"
98 className="text-xs text-faint hover:text-danger"
99 >
100 Remove
101 </button>
102 </Form>
103 )
104 )}
105 </li>
106 ))}
107 </ul>
108 )}
109 {role === "owner" && (
110 <Form method="post" className="mt-4 flex gap-2">
111 <Input name="username" placeholder="Username to add" required />
112 <Button variant="quiet" type="submit">
113 Add
114 </Button>
115 </Form>
116 )}
117 <div className="mt-2">
118 <ErrorText>{actionData?.error}</ErrorText>
119 </div>
120 {!role && (
121 <p className="mt-2 text-sm text-muted">
122 Members can create repositories here and merge pull requests into them.
123 </p>
124 )}
125 </aside>
126 </main>
127 );
128}