pr_01m47d15m3e54sn21z27rpy5n9/apps/web/app/root.tsx

245 lines7,733 bytesCodeBlame
1import { Plus, Search } from "lucide-react";
2import {
3 Form,
4 isRouteErrorResponse,
5 Link,
6 Links,
7 Meta,
8 NavLink,
9 Outlet,
10 Scripts,
11 ScrollRestoration,
12 useRouteLoaderData,
13} from "react-router";
14
15import type { User } from "@g1t/contracts";
16
17import type { Route } from "./+types/root";
18import "./app.css";
19import { Logo } from "./components/logo";
20import { Avatar, ButtonLink } from "./components/ui";
21import { getViewer, viewerMiddleware } from "./lib/session.server";
22
23export const links: Route.LinksFunction = () => [
24 { rel: "icon", type: "image/svg+xml", href: "/favicon.svg" },
25 { rel: "preconnect", href: "https://fonts.googleapis.com" },
26 {
27 rel: "preconnect",
28 href: "https://fonts.gstatic.com",
29 crossOrigin: "anonymous",
30 },
31 {
32 rel: "stylesheet",
33 href: "https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400..700&family=JetBrains+Mono:wght@400;500;600&display=swap",
34 },
35];
36
37export const middleware: Route.MiddlewareFunction[] = [viewerMiddleware];
38
39export function loader({ context }: Route.LoaderArgs) {
40 return { user: getViewer(context) };
41}
42
43function HeaderLink({ to, children }: { to: string; children: React.ReactNode }) {
44 return (
45 <NavLink
46 to={to}
47 className={({ isActive }) =>
48 `rounded-md px-2.5 py-1.5 text-sm transition-colors hover:bg-raised hover:text-fg ${
49 isActive ? "text-fg" : "text-muted"
50 }`
51 }
52 >
53 {children}
54 </NavLink>
55 );
56}
57
58function Header({ user }: { user: User | null | undefined }) {
59 return (
60 <header className="sticky top-0 z-40 border-b border-line bg-surface/85 backdrop-blur">
61 <div className="mx-auto flex h-14 max-w-6xl items-center gap-2 px-4">
62 <Link to="/" aria-label="g1t home" className="mr-2">
63 <Logo />
64 </Link>
65 <Form action="/search" role="search" className="relative hidden grow sm:block sm:max-w-xs">
66 <Search
67 size={14}
68 className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint"
69 />
70 <input
71 name="q"
72 placeholder="Search repositories"
73 aria-label="Search repositories"
74 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"
75 />
76 </Form>
77 <nav className="flex items-center gap-0.5">
78 <HeaderLink to="/explore">Explore</HeaderLink>
79 <HeaderLink to="/docs">Docs</HeaderLink>
80 </nav>
81 <div className="ml-auto flex items-center gap-2">
82 {user ? (
83 <>
84 <Link
85 to="/new"
86 aria-label="New repository"
87 className="rounded-md border border-line p-1.5 text-muted transition-colors hover:border-line-strong hover:text-fg"
88 >
89 <Plus size={16} />
90 </Link>
91 <Link
92 to={`/${user.username}`}
93 className="flex items-center gap-2 rounded-md px-2 py-1 text-sm hover:bg-raised"
94 >
95 <Avatar name={user.username} size={22} />
96 <span className="hidden font-mono sm:inline">{user.username}</span>
97 </Link>
98 <HeaderLink to="/settings">Settings</HeaderLink>
99 <Form method="post" action="/logout">
100 <button
101 type="submit"
102 className="rounded-md px-2.5 py-1.5 text-sm text-muted hover:bg-raised hover:text-fg"
103 >
104 Sign out
105 </button>
106 </Form>
107 </>
108 ) : (
109 <>
110 <HeaderLink to="/login">Sign in</HeaderLink>
111 <ButtonLink to="/register">Sign up</ButtonLink>
112 </>
113 )}
114 </div>
115 </div>
116 </header>
117 );
118}
119
120const FOOTER_LINKS: { title: string; links: [string, string][] }[] = [
121 {
122 title: "Product",
123 links: [
124 ["Explore", "/explore"],
125 ["Sign up", "/register"],
126 ["Source", "/syntaqx/g1t"],
127 ],
128 },
129 {
130 title: "Docs",
131 links: [
132 ["Getting started", "/docs"],
133 ["Concepts", "/docs/concepts"],
134 ["Connect an agent", "/docs/agents"],
135 ["API", "/docs/api"],
136 ],
137 },
138];
139
140function Footer() {
141 return (
142 <footer className="mt-24 border-t border-line">
143 <div className="mx-auto flex max-w-6xl flex-wrap gap-x-20 gap-y-8 px-4 py-12">
144 <div className="grow">
145 <Logo />
146 <p className="mt-3 max-w-xs text-sm text-muted">
147 A git forge for agents. Open source under the MIT license, built on
148 Cloudflare Workers and Artifacts.
149 </p>
150 </div>
151 {FOOTER_LINKS.map((group) => (
152 <div key={group.title}>
153 <p className="text-sm font-medium">{group.title}</p>
154 <ul className="mt-3 space-y-2 text-sm text-muted">
155 {group.links.map(([label, to]) => (
156 <li key={to}>
157 <Link to={to} className="hover:text-fg">
158 {label}
159 </Link>
160 </li>
161 ))}
162 </ul>
163 </div>
164 ))}
165 </div>
166 </footer>
167 );
168}
169
170export function Layout({ children }: { children: React.ReactNode }) {
171 // Undefined when the root loader itself failed.
172 const user = useRouteLoaderData<typeof loader>("root")?.user;
173 return (
174 <html lang="en">
175 <head>
176 <meta charSet="utf-8" />
177 <meta name="viewport" content="width=device-width, initial-scale=1" />
178 <meta name="theme-color" content="#0e0d0a" />
179 <Meta />
180 <Links />
181 </head>
182 <body className="flex min-h-screen flex-col">
183 <Header user={user} />
184 {user && !user.verified && (
185 <Form
186 method="post"
187 action="/verify"
188 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"
189 >
190 <span>
191 Confirm your email address to create repositories and push. We
192 sent you a link.
193 </span>
194 <button type="submit" className="font-medium underline underline-offset-4">
195 Send it again
196 </button>
197 </Form>
198 )}
199 <div className="grow">{children}</div>
200 <Footer />
201 <ScrollRestoration />
202 <Scripts />
203 </body>
204 </html>
205 );
206}
207
208export default function App() {
209 return <Outlet />;
210}
211
212export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
213 let title = "Something went wrong";
214 let details = "An unexpected error occurred. Try again in a moment.";
215 let stack: string | undefined;
216
217 if (isRouteErrorResponse(error)) {
218 if (error.status === 404) {
219 title = "Page not found";
220 details = "There is nothing at this address, or you do not have access to it.";
221 } else if (typeof error.data === "string" && error.data) {
222 details = error.data;
223 }
224 } else if (import.meta.env.DEV && error instanceof Error) {
225 details = error.message;
226 stack = error.stack;
227 }
228
229 return (
230 <main className="mx-auto max-w-xl px-4 py-32 text-center">
231 <h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
232 <p className="mt-3 text-muted">{details}</p>
233 <div className="mt-8">
234 <ButtonLink to="/" variant="quiet">
235 Back to home
236 </ButtonLink>
237 </div>
238 {stack && (
239 <pre className="mt-8 overflow-x-auto rounded-lg border border-line p-4 text-left text-xs">
240 <code>{stack}</code>
241 </pre>
242 )}
243 </main>
244 );
245}