pr_01m47d15m3e54sn21z27rpy5n9/apps/web/app/root.tsx

303 lines9,692 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="/docs">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="/docs">
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", "/explore"],
183 ["Sign up", "/register"],
184 ["Source", "/syntaqx/g1t"],
185 ],
186 },
187 {
188 title: "Docs",
189 links: [
190 ["Getting started", "/docs"],
191 ["Concepts", "/docs/concepts"],
192 ["Connect an agent", "/docs/agents"],
193 ["API", "/docs/api"],
194 ],
195 },
196];
197
198function Footer() {
199 return (
200 <footer className="mt-24 border-t border-line">
201 <div className="mx-auto flex max-w-6xl flex-wrap gap-x-20 gap-y-8 px-4 py-12">
202 <div className="grow">
203 <Logo />
204 <p className="mt-3 max-w-xs text-sm text-muted">
205 A git forge for agents. Open source under the MIT license, built on
206 Cloudflare Workers and Artifacts.
207 </p>
208 </div>
209 {FOOTER_LINKS.map((group) => (
210 <div key={group.title}>
211 <p className="text-sm font-medium">{group.title}</p>
212 <ul className="mt-3 space-y-2 text-sm text-muted">
213 {group.links.map(([label, to]) => (
214 <li key={to}>
215 <Link to={to} className="hover:text-fg">
216 {label}
217 </Link>
218 </li>
219 ))}
220 </ul>
221 </div>
222 ))}
223 </div>
224 </footer>
225 );
226}
227
228export function Layout({ children }: { children: React.ReactNode }) {
229 // Undefined when the root loader itself failed.
230 const user = useRouteLoaderData<typeof loader>("root")?.user;
231 return (
232 <html lang="en">
233 <head>
234 <meta charSet="utf-8" />
235 <meta name="viewport" content="width=device-width, initial-scale=1" />
236 <meta name="theme-color" content="#0e0d0a" />
237 <Meta />
238 <Links />
239 </head>
240 <body className="flex min-h-screen flex-col">
241 <Header user={user} />
242 {user && !user.verified && (
243 <Form
244 method="post"
245 action="/verify"
246 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"
247 >
248 <span>
249 Confirm your email address to create repositories and push. We
250 sent you a link.
251 </span>
252 <button type="submit" className="font-medium underline underline-offset-4">
253 Send it again
254 </button>
255 </Form>
256 )}
257 <div className="grow">{children}</div>
258 <Footer />
259 <ScrollRestoration />
260 <Scripts />
261 </body>
262 </html>
263 );
264}
265
266export default function App() {
267 return <Outlet />;
268}
269
270export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
271 let title = "Something went wrong";
272 let details = "An unexpected error occurred. Try again in a moment.";
273 let stack: string | undefined;
274
275 if (isRouteErrorResponse(error)) {
276 if (error.status === 404) {
277 title = "Page not found";
278 details = "There is nothing at this address, or you do not have access to it.";
279 } else if (typeof error.data === "string" && error.data) {
280 details = error.data;
281 }
282 } else if (import.meta.env.DEV && error instanceof Error) {
283 details = error.message;
284 stack = error.stack;
285 }
286
287 return (
288 <main className="mx-auto max-w-xl px-4 py-32 text-center">
289 <h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
290 <p className="mt-3 text-muted">{details}</p>
291 <div className="mt-8">
292 <ButtonLink to="/" variant="quiet">
293 Back to home
294 </ButtonLink>
295 </div>
296 {stack && (
297 <pre className="mt-8 overflow-x-auto rounded-lg border border-line p-4 text-left text-xs">
298 <code>{stack}</code>
299 </pre>
300 )}
301 </main>
302 );
303}