pr_01m47d15m3e54sn21z27rpy5n9/apps/web/app/root.tsx

320 lines10,508 bytesCodeBlame
1import {
2 BookOpen,
3 ChevronDown,
4 LayoutDashboard,
5 LogOut,
6 Plus,
7 Search,
8 Settings,
9 UserRound,
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 <DropdownMenuItem asChild>
125 <Link to={`/${user.username}`}>
126 <UserRound />
127 Your profile
128 </Link>
129 </DropdownMenuItem>
130 <DropdownMenuItem asChild>
131 <Link to="/">
132 <LayoutDashboard />
133 Mission control
134 </Link>
135 </DropdownMenuItem>
136 <DropdownMenuItem asChild>
137 <Link to="/new">
138 <Plus />
139 New repository
140 </Link>
141 </DropdownMenuItem>
142 <DropdownMenuSeparator />
143 <DropdownMenuItem asChild>
144 <Link to="/settings">
145 <Settings />
146 Settings
147 </Link>
148 </DropdownMenuItem>
149 <DropdownMenuItem asChild>
150 <Link to="https://docs.g1t.sh/quickstart/">
151 <BookOpen />
152 Documentation
153 </Link>
154 </DropdownMenuItem>
155 <DropdownMenuSeparator />
156 <DropdownMenuItem asChild>
157 <button type="submit" form="sign-out" className="w-full">
158 <LogOut />
159 Sign out
160 </button>
161 </DropdownMenuItem>
162 </DropdownMenuContent>
163 </DropdownMenu>
164 <Form method="post" action="/logout" id="sign-out" hidden />
165 </>
166 ) : (
167 <>
168 <HeaderLink to="/login">Sign in</HeaderLink>
169 <ButtonLink to="/register">Sign up</ButtonLink>
170 </>
171 )}
172 </div>
173 </div>
174 </header>
175 );
176}
177
178const FOOTER_LINKS: { title: string; links: [string, string][] }[] = [
179 {
180 title: "Product",
181 links: [
182 ["Explore repositories", "/explore"],
183 ["g1t agents", "https://docs.g1t.sh/guides/g1t-agents/"],
184 ["Bring your own agent", "https://docs.g1t.sh/guides/bring-your-own-agent/"],
185 ["Sign up", "/register"],
186 ],
187 },
188 {
189 title: "Developers",
190 links: [
191 ["Quickstart", "https://docs.g1t.sh/quickstart/"],
192 ["Concepts", "https://docs.g1t.sh/concepts/overview/"],
193 ["API reference", "https://docs.g1t.sh/api/reference/"],
194 ["OpenAPI", "https://api.g1t.sh/openapi.json"],
195 ["llms.txt", "/llms.txt"],
196 ],
197 },
198 {
199 title: "Project",
200 links: [
201 ["Source on g1t", "/syntaqx/g1t"],
202 ["Source on GitHub", "https://github.com/syntaqx/g1t"],
203 ["MIT license", "/syntaqx/g1t/blob/main/LICENSE"],
204 ],
205 },
206];
207
208function Footer() {
209 return (
210 <footer className="mt-24 border-t border-line">
211 <div className="mx-auto flex max-w-6xl flex-wrap gap-x-20 gap-y-8 px-4 py-12">
212 <div className="grow">
213 <Logo />
214 <p className="mt-3 max-w-xs text-sm text-muted">
215 A git forge for agents. Open source under the MIT license, built on
216 Cloudflare Workers and Artifacts.
217 </p>
218 </div>
219 {FOOTER_LINKS.map((group) => (
220 <div key={group.title}>
221 <p className="text-sm font-medium">{group.title}</p>
222 <ul className="mt-3 space-y-2 text-sm text-muted">
223 {group.links.map(([label, to]) => (
224 <li key={to}>
225 {/* Plain links: some targets are files or other hosts. */}
226 <a href={to} className="hover:text-fg">
227 {label}
228 </a>
229 </li>
230 ))}
231 </ul>
232 </div>
233 ))}
234 </div>
235 <div className="border-t border-line">
236 <p className="mx-auto max-w-6xl px-4 py-5 text-xs text-faint">
237 g1t is open source software, built on Cloudflare Workers and
238 Artifacts.
239 </p>
240 </div>
241 </footer>
242 );
243}
244
245export function Layout({ children }: { children: React.ReactNode }) {
246 // Undefined when the root loader itself failed.
247 const user = useRouteLoaderData<typeof loader>("root")?.user;
248 return (
249 <html lang="en">
250 <head>
251 <meta charSet="utf-8" />
252 <meta name="viewport" content="width=device-width, initial-scale=1" />
253 <meta name="theme-color" content="#0e0d0a" />
254 <Meta />
255 <Links />
256 </head>
257 <body className="flex min-h-screen flex-col">
258 <Header user={user} />
259 {user && !user.verified && (
260 <Form
261 method="post"
262 action="/verify"
263 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"
264 >
265 <span>
266 Confirm your email address to create repositories and push. We
267 sent you a link.
268 </span>
269 <button type="submit" className="font-medium underline underline-offset-4">
270 Send it again
271 </button>
272 </Form>
273 )}
274 <div className="grow">{children}</div>
275 <Footer />
276 <ScrollRestoration />
277 <Scripts />
278 </body>
279 </html>
280 );
281}
282
283export default function App() {
284 return <Outlet />;
285}
286
287export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
288 let title = "Something went wrong";
289 let details = "An unexpected error occurred. Try again in a moment.";
290 let stack: string | undefined;
291
292 if (isRouteErrorResponse(error)) {
293 if (error.status === 404) {
294 title = "Page not found";
295 details = "There is nothing at this address, or you do not have access to it.";
296 } else if (typeof error.data === "string" && error.data) {
297 details = error.data;
298 }
299 } else if (import.meta.env.DEV && error instanceof Error) {
300 details = error.message;
301 stack = error.stack;
302 }
303
304 return (
305 <main className="mx-auto max-w-xl px-4 py-32 text-center">
306 <h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
307 <p className="mt-3 text-muted">{details}</p>
308 <div className="mt-8">
309 <ButtonLink to="/" variant="quiet">
310 Back to home
311 </ButtonLink>
312 </div>
313 {stack && (
314 <pre className="mt-8 overflow-x-auto rounded-lg border border-line p-4 text-left text-xs">
315 <code>{stack}</code>
316 </pre>
317 )}
318 </main>
319 );
320}