pr_01m47d15m3e54sn21z27rpy5n9/apps/web/app/root.tsx

329 lines10,906 bytesCodeBlame
1import {
2 BookOpen,
3 Building2,
4 ChevronDown,
5 LayoutDashboard,
6 LogOut,
7 Plus,
8 Search,
9 Settings,
10} from "lucide-react";
11import {
12 Form,
13 isRouteErrorResponse,
14 Link,
15 Links,
16 Meta,
17 NavLink,
18 Outlet,
19 Scripts,
20 ScrollRestoration,
21 useRouteLoaderData,
22} from "react-router";
23
24import type { User } from "@g1t/contracts";
25
26import type { Route } from "./+types/root";
27import "./app.css";
28import { Logo } from "./components/logo";
29import { Avatar, ButtonLink } from "./components/ui";
30import {
31 DropdownMenu,
32 DropdownMenuContent,
33 DropdownMenuItem,
34 DropdownMenuLabel,
35 DropdownMenuSeparator,
36 DropdownMenuTrigger,
37} from "./components/ui/dropdown-menu";
38import { getViewer, viewerMiddleware } from "./lib/session.server";
39
40export const links: Route.LinksFunction = () => [
41 { rel: "icon", type: "image/svg+xml", href: "/favicon.svg" },
42 { rel: "preconnect", href: "https://fonts.googleapis.com" },
43 {
44 rel: "preconnect",
45 href: "https://fonts.gstatic.com",
46 crossOrigin: "anonymous",
47 },
48 {
49 rel: "stylesheet",
50 href: "https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400..700&family=JetBrains+Mono:wght@400;500;600&display=swap",
51 },
52];
53
54export const middleware: Route.MiddlewareFunction[] = [viewerMiddleware];
55
56export function loader({ context }: Route.LoaderArgs) {
57 return { user: getViewer(context) };
58}
59
60function HeaderLink({ to, children }: { to: string; children: React.ReactNode }) {
61 return (
62 <NavLink
63 to={to}
64 className={({ isActive }) =>
65 `rounded-md px-2.5 py-1.5 text-sm transition-colors hover:bg-raised hover:text-fg ${
66 isActive ? "text-fg" : "text-muted"
67 }`
68 }
69 >
70 {children}
71 </NavLink>
72 );
73}
74
75function Header({ user }: { user: User | null | undefined }) {
76 return (
77 <header className="sticky top-0 z-40 border-b border-line bg-surface/85 backdrop-blur">
78 <div className="mx-auto flex h-14 max-w-6xl items-center gap-2 px-4">
79 <Link to="/" aria-label="g1t home" className="mr-2">
80 <Logo />
81 </Link>
82 <Form action="/search" role="search" className="relative hidden grow sm:block sm:max-w-xs">
83 <Search
84 size={14}
85 className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint"
86 />
87 <input
88 name="q"
89 placeholder="Search repositories"
90 aria-label="Search repositories"
91 className="w-full rounded-md border border-line bg-bg py-1.5 pr-3 pl-8 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"
92 />
93 </Form>
94 <nav className="flex items-center gap-0.5">
95 <HeaderLink to="/explore">Explore</HeaderLink>
96 <HeaderLink to="https://docs.g1t.sh/">Docs</HeaderLink>
97 </nav>
98 <div className="ml-auto flex items-center gap-2">
99 {user ? (
100 <>
101 <Link
102 to="/new"
103 aria-label="New repository"
104 className="rounded-md border border-line p-1.5 text-muted transition-colors hover:border-line-strong hover:text-fg"
105 >
106 <Plus size={16} />
107 </Link>
108 <DropdownMenu>
109 <DropdownMenuTrigger
110 aria-label="Account menu"
111 className="flex items-center gap-1.5 rounded-md p-1 outline-none transition-colors hover:bg-raised data-[state=open]:bg-raised"
112 >
113 <Avatar name={user.username} size={24} />
114 <ChevronDown size={14} className="text-faint" />
115 </DropdownMenuTrigger>
116 <DropdownMenuContent align="end" className="w-56">
117 <DropdownMenuLabel>
118 Signed in as{" "}
119 <span className="font-mono font-medium text-fg">
120 {user.username}
121 </span>
122 </DropdownMenuLabel>
123 <DropdownMenuSeparator />
124 {(user.workspaces ?? []).map((membership) => (
125 <DropdownMenuItem asChild key={membership.slug}>
126 <Link to={`/${membership.slug}`}>
127 <Building2 />
128 {membership.slug}
129 </Link>
130 </DropdownMenuItem>
131 ))}
132 <DropdownMenuItem asChild>
133 <Link to="/workspaces/new">
134 <Plus />
135 New workspace
136 </Link>
137 </DropdownMenuItem>
138 <DropdownMenuSeparator />
139 <DropdownMenuItem asChild>
140 <Link to="/">
141 <LayoutDashboard />
142 Mission control
143 </Link>
144 </DropdownMenuItem>
145 <DropdownMenuItem asChild>
146 <Link to="/new">
147 <Plus />
148 New repository
149 </Link>
150 </DropdownMenuItem>
151 <DropdownMenuSeparator />
152 <DropdownMenuItem asChild>
153 <Link to="/settings">
154 <Settings />
155 Settings
156 </Link>
157 </DropdownMenuItem>
158 <DropdownMenuItem asChild>
159 <Link to="https://docs.g1t.sh/quickstart/">
160 <BookOpen />
161 Documentation
162 </Link>
163 </DropdownMenuItem>
164 <DropdownMenuSeparator />
165 <DropdownMenuItem asChild>
166 <button type="submit" form="sign-out" className="w-full">
167 <LogOut />
168 Sign out
169 </button>
170 </DropdownMenuItem>
171 </DropdownMenuContent>
172 </DropdownMenu>
173 <Form method="post" action="/logout" id="sign-out" hidden />
174 </>
175 ) : (
176 <>
177 <HeaderLink to="/login">Sign in</HeaderLink>
178 <ButtonLink to="/register">Sign up</ButtonLink>
179 </>
180 )}
181 </div>
182 </div>
183 </header>
184 );
185}
186
187const FOOTER_LINKS: { title: string; links: [string, string][] }[] = [
188 {
189 title: "Product",
190 links: [
191 ["Explore repositories", "/explore"],
192 ["g1t agents", "https://docs.g1t.sh/guides/g1t-agents/"],
193 ["Bring your own agent", "https://docs.g1t.sh/guides/bring-your-own-agent/"],
194 ["Sign up", "/register"],
195 ],
196 },
197 {
198 title: "Developers",
199 links: [
200 ["Quickstart", "https://docs.g1t.sh/quickstart/"],
201 ["Concepts", "https://docs.g1t.sh/concepts/overview/"],
202 ["API reference", "https://docs.g1t.sh/api/reference/"],
203 ["OpenAPI", "https://api.g1t.sh/openapi.json"],
204 ["llms.txt", "/llms.txt"],
205 ],
206 },
207 {
208 title: "Project",
209 links: [
210 ["Source on g1t", "/syntaqx/g1t"],
211 ["Source on GitHub", "https://github.com/syntaqx/g1t"],
212 ["MIT license", "/syntaqx/g1t/blob/main/LICENSE"],
213 ],
214 },
215];
216
217function Footer() {
218 return (
219 <footer className="mt-24 border-t border-line">
220 <div className="mx-auto flex max-w-6xl flex-wrap gap-x-20 gap-y-8 px-4 py-12">
221 <div className="grow">
222 <Logo />
223 <p className="mt-3 max-w-xs text-sm text-muted">
224 A git forge for agents. Open source under the MIT license, built on
225 Cloudflare Workers and Artifacts.
226 </p>
227 </div>
228 {FOOTER_LINKS.map((group) => (
229 <div key={group.title}>
230 <p className="text-sm font-medium">{group.title}</p>
231 <ul className="mt-3 space-y-2 text-sm text-muted">
232 {group.links.map(([label, to]) => (
233 <li key={to}>
234 {/* Plain links: some targets are files or other hosts. */}
235 <a href={to} className="hover:text-fg">
236 {label}
237 </a>
238 </li>
239 ))}
240 </ul>
241 </div>
242 ))}
243 </div>
244 <div className="border-t border-line">
245 <p className="mx-auto max-w-6xl px-4 py-5 text-xs text-faint">
246 g1t is open source software, built on Cloudflare Workers and
247 Artifacts.
248 </p>
249 </div>
250 </footer>
251 );
252}
253
254export function Layout({ children }: { children: React.ReactNode }) {
255 // Undefined when the root loader itself failed.
256 const user = useRouteLoaderData<typeof loader>("root")?.user;
257 return (
258 <html lang="en">
259 <head>
260 <meta charSet="utf-8" />
261 <meta name="viewport" content="width=device-width, initial-scale=1" />
262 <meta name="theme-color" content="#0e0d0a" />
263 <Meta />
264 <Links />
265 </head>
266 <body className="flex min-h-screen flex-col">
267 <Header user={user} />
268 {user && !user.verified && (
269 <Form
270 method="post"
271 action="/verify"
272 className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b border-warn/30 bg-warn/10 px-4 py-2 text-sm"
273 >
274 <span>
275 Confirm your email address to create repositories and push. We
276 sent you a link.
277 </span>
278 <button type="submit" className="font-medium underline underline-offset-4">
279 Send it again
280 </button>
281 </Form>
282 )}
283 <div className="grow">{children}</div>
284 <Footer />
285 <ScrollRestoration />
286 <Scripts />
287 </body>
288 </html>
289 );
290}
291
292export default function App() {
293 return <Outlet />;
294}
295
296export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
297 let title = "Something went wrong";
298 let details = "An unexpected error occurred. Try again in a moment.";
299 let stack: string | undefined;
300
301 if (isRouteErrorResponse(error)) {
302 if (error.status === 404) {
303 title = "Page not found";
304 details = "There is nothing at this address, or you do not have access to it.";
305 } else if (typeof error.data === "string" && error.data) {
306 details = error.data;
307 }
308 } else if (import.meta.env.DEV && error instanceof Error) {
309 details = error.message;
310 stack = error.stack;
311 }
312
313 return (
314 <main className="mx-auto max-w-xl px-4 py-32 text-center">
315 <h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
316 <p className="mt-3 text-muted">{details}</p>
317 <div className="mt-8">
318 <ButtonLink to="/" variant="quiet">
319 Back to home
320 </ButtonLink>
321 </div>
322 {stack && (
323 <pre className="mt-8 overflow-x-auto rounded-lg border border-line p-4 text-left text-xs">
324 <code>{stack}</code>
325 </pre>
326 )}
327 </main>
328 );
329}