g1t/apps/web/app/routes/repo/layout.tsx

121 lines3,765 bytesCodeBlame
1import { BookMarked, CircleDot, Code2, GitPullRequest, History, Lock } from "lucide-react";
2import type { ReactNode } from "react";
3import { Link, NavLink, Outlet, useLocation } from "react-router";
4
5import type { Route } from "./+types/layout";
6import { Pill } from "../../components/ui";
7import { repos, work } from "../../lib/services.server";
8import { getViewer, unwrap } from "../../lib/session.server";
9
10export function meta({ params }: Route.MetaArgs) {
11 return [{ title: `${params.owner}/${params.repo} · g1t` }];
12}
13
14export async function loader({ params, context }: Route.LoaderArgs) {
15 const viewer = getViewer(context);
16 const path = { namespace: params.owner, name: params.repo };
17 const [repo, counts] = await Promise.all([
18 repos.get(path, viewer),
19 work.counts(path, viewer),
20 ]);
21 return {
22 repo: unwrap(repo),
23 open: counts.ok ? counts.value : { issues: 0, pulls: 0 },
24 };
25}
26
27function Tab({
28 to,
29 also,
30 end,
31 icon,
32 count,
33 children,
34}: {
35 to: string;
36 /** Another path prefix under which this tab is the current one. */
37 also?: string;
38 end?: boolean;
39 icon: ReactNode;
40 count?: number;
41 children: ReactNode;
42}) {
43 const { pathname } = useLocation();
44 return (
45 <NavLink
46 to={to}
47 end={end}
48 className={({ isActive }) =>
49 `-mb-px flex items-center gap-2 border-b-2 px-1 pb-3 text-sm transition-colors ${
50 isActive || (also && pathname.startsWith(also + "/"))
51 ? "border-accent font-medium text-fg"
52 : "border-transparent text-muted hover:text-fg"
53 }`
54 }
55 >
56 {icon}
57 {children}
58 {count != null && count > 0 && (
59 <span className="rounded-full bg-raised px-1.5 py-px text-xs text-muted">
60 {count}
61 </span>
62 )}
63 </NavLink>
64 );
65}
66
67export default function RepoLayout({ loaderData }: Route.ComponentProps) {
68 const { repo, open } = loaderData;
69 const base = `/${repo.namespace}/${repo.name}`;
70 return (
71 <>
72 {/* The repository's own header band, under the site header. */}
73 <div className="border-b border-line bg-surface/60">
74 <div className="mx-auto max-w-6xl px-4 pt-6">
75 <div className="flex flex-wrap items-center gap-2.5">
76 {repo.isPrivate ? (
77 <Lock size={17} className="text-faint" />
78 ) : (
79 <BookMarked size={17} className="text-faint" />
80 )}
81 <h1 className="font-mono text-lg">
82 <Link to={`/${repo.namespace}`} className="text-muted hover:text-fg">
83 {repo.namespace}
84 </Link>
85 <span className="mx-1 text-faint">/</span>
86 <Link to={base} className="font-semibold hover:underline">
87 {repo.name}
88 </Link>
89 </h1>
90 <Pill>{repo.isPrivate ? "private" : "public"}</Pill>
91 </div>
92 {repo.description && (
93 <p className="mt-2 max-w-2xl text-sm text-muted">{repo.description}</p>
94 )}
95 <nav className="mt-5 flex gap-6">
96 <Tab to={base} end icon={<Code2 size={15} />}>
97 Code
98 </Tab>
99 <Tab to={`${base}/issues`} icon={<CircleDot size={15} />} count={open.issues}>
100 Issues
101 </Tab>
102 <Tab
103 to={`${base}/pulls`}
104 also={`${base}/pull`}
105 icon={<GitPullRequest size={15} />}
106 count={open.pulls}
107 >
108 Pull requests
109 </Tab>
110 <Tab to={`${base}/commits`} icon={<History size={15} />}>
111 Commits
112 </Tab>
113 </nav>
114 </div>
115 </div>
116 <div className="mx-auto max-w-6xl px-4 py-6">
117 <Outlet />
118 </div>
119 </>
120 );
121}