g1t/apps/web/app/components/footer.tsx
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1 | /** |
| 2 | * The site's footer: a few links worth having, who makes g1t, and a row of | |
| 3 | * the trust pages with the live status dot, the account, and the legal | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 4 | * line. Marketing and signed-out pages show it; the app's account menu |
| 5 | * has its own rows (components/shell.tsx). | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 6 | */ |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 7 | import { useEffect, useState } from "react"; |
| 8 | import { Link } from "react-router"; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 9 | |
| 10 | import type { User } from "@g1t/contracts"; | |
| 11 | ||
| 12 | import { Mark } from "./logo"; | |
| 13 | import { COMPANY, MAKER_PRODUCTS, copyright, listed } from "../lib/legal"; | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 14 | import { type OverallState, STATUS_JSON_URL, STATUS_URL, STATUS_WORDS, type StatusReport, dotClass } from "../lib/status"; |
| 15 | ||
| 16 | /** How long one fetched report is reused across the pages of one visit. */ | |
| 17 | const STATUS_REUSE_MS = 60_000; | |
| 18 | let lastStatus: { report: StatusReport; at: number } | null = null; | |
| 19 | let pendingStatus: Promise<StatusReport | null> | null = null; | |
| 20 | ||
| 21 | function fetchStatus(): Promise<StatusReport | null> { | |
| 22 | if (lastStatus && Date.now() - lastStatus.at < STATUS_REUSE_MS) return Promise.resolve(lastStatus.report); | |
| 23 | pendingStatus ??= fetch(STATUS_JSON_URL, { headers: { accept: "application/json" } }) | |
| 24 | .then((response) => (response.ok ? (response.json() as Promise<StatusReport>) : null)) | |
| 25 | .then((report) => { | |
| 26 | if (report?.overall) lastStatus = { report, at: Date.now() }; | |
| 27 | return report?.overall ? report : null; | |
| 28 | }) | |
| 29 | // The status page being unreachable only means no dot. | |
| 30 | .catch(() => null) | |
| 31 | .finally(() => { | |
| 32 | pendingStatus = null; | |
| 33 | }); | |
| 34 | return pendingStatus; | |
| 35 | } | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 36 | |
| 37 | /** | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 38 | * The live status, from status.g1t.sh once the page is up (or `when` turns |
| 39 | * true), so no page waits on it. Kept a minute across pages. Null until it | |
| 40 | * arrives, or if it cannot. | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 41 | */ |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 42 | export function useSiteStatus(when = true): StatusReport | null { |
| 43 | const [report, setReport] = useState<StatusReport | null>(() => lastStatus?.report ?? null); | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 44 | useEffect(() => { |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 45 | if (!when) return; |
| 46 | let live = true; | |
| 47 | fetchStatus().then((fresh) => { | |
| 48 | if (live && fresh) setReport(fresh); | |
| 49 | }); | |
| 50 | return () => { | |
| 51 | live = false; | |
| 52 | }; | |
| 53 | }, [when]); | |
| 54 | return report; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 55 | } |
| 56 | ||
| 57 | /** The coloured dot, with words for screen readers. */ | |
| 58 | export function StatusDot({ state, className = "" }: { state: OverallState | null; className?: string }) { | |
| 59 | return ( | |
| 60 | <span className={`relative inline-flex size-2 shrink-0 ${className}`}> | |
| 61 | {state && state !== "up" && state !== "unknown" && ( | |
| 62 | <span aria-hidden className={`absolute inset-0 animate-ping rounded-full opacity-60 ${dotClass(state)}`} /> | |
| 63 | )} | |
| 64 | <span aria-hidden className={`relative inline-flex size-2 rounded-full ${dotClass(state)}`} /> | |
| 65 | <span className="sr-only">{state ? STATUS_WORDS[state] : "Checking status"}</span> | |
| 66 | </span> | |
| 67 | ); | |
| 68 | } | |
| 69 | ||
| 70 | /** Where the footer's grid links go. Plain links: some are files or other hosts. */ | |
| 71 | const FOOTER_LINKS: { title: string; links: [string, string][] }[] = [ | |
| 72 | { | |
| 73 | title: "Product", | |
| 74 | links: [ | |
| 75 | ["Explore", "/explore"], | |
| 76 | ["Pricing", "/pricing"], | |
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 77 | ["Working with g1t", "https://docs.g1t.sh/guides/working-with-g1t/"], |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 78 | ["Bring your own agent", "https://docs.g1t.sh/guides/bring-your-own-agent/"], |
| 79 | ["Integrations", "https://docs.g1t.sh/guides/integrations/"], | |
| 80 | ], | |
| 81 | }, | |
| 82 | { | |
| 83 | title: "Developers", | |
| 84 | links: [ | |
| 85 | ["Documentation", "https://docs.g1t.sh/"], | |
| 86 | ["API reference", "https://docs.g1t.sh/reference/api/"], | |
| 87 | ["MCP tools", "https://docs.g1t.sh/reference/mcp/"], | |
| 88 | ["llms.txt", "/llms.txt"], | |
| 89 | ], | |
| 90 | }, | |
| 91 | { | |
| 92 | title: "Company", | |
| 93 | links: [ | |
| 94 | ["Flagon, Inc.", COMPANY.url], | |
| 95 | ["Support", "/support"], | |
| 96 | ["Security", "/security"], | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 97 | ["Status", STATUS_URL], |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 98 | ], |
| 99 | }, | |
| 100 | { | |
| 101 | title: "Open source", | |
| 102 | links: [ | |
| 103 | ["Source on g1t", "/flagon-io/g1t"], | |
| 104 | ["MIT license", "/flagon-io/g1t/blob/main/LICENSE"], | |
| 105 | ["Run g1t yourself", "https://docs.g1t.sh/guides/self-hosting/"], | |
| 106 | ], | |
| 107 | }, | |
| 108 | ]; | |
| 109 | ||
| 110 | /** The row's own links, after Status. */ | |
| 111 | const ROW_LINKS: [string, string][] = [ | |
| 112 | ["Support", "/support"], | |
| 113 | ["Policies", "/policies"], | |
| 114 | ["Privacy", "/policies/privacy"], | |
| 115 | ["Security", "/security"], | |
| 116 | ]; | |
| 117 | ||
| 118 | /** | |
| 119 | * Flagon's mark, single-ink, as in Flagon's brand files | |
| 120 | * (flagon-mark-mono.svg): a flagon, its brew a tone of the text colour. | |
| 121 | */ | |
| 122 | function FlagonMark({ className = "" }: { className?: string }) { | |
| 123 | return ( | |
| 124 | <svg viewBox="11.3 12.3 41 41" fill="none" aria-hidden="true" className={className}> | |
| 125 | <defs> | |
| 126 | <clipPath id="g1t-footer-flagon-body"> | |
| 127 | <path d="M19.5 23 L34.5 23 L36.5 46 Q36.7 48 34.3 48 L19.7 48 Q17.3 48 17.5 46 Z" /> | |
| 128 | </clipPath> | |
| 129 | </defs> | |
| 130 | <g clipPath="url(#g1t-footer-flagon-body)"> | |
| 131 | <path | |
| 132 | d="M13 27 q3.75 -0.7 7.5 0 t7.5 0 t7.5 0 t7.5 0 t7.5 0 L51 52 L13 52 Z" | |
| 133 | fill="currentColor" | |
| 134 | fillOpacity="0.22" | |
| 135 | /> | |
| 136 | </g> | |
| 137 | <path | |
| 138 | d="M37 27 L45.5 27 L48.5 30 L48.5 36.5 L45.5 39.5 L38 39.5" | |
| 139 | stroke="currentColor" | |
| 140 | strokeWidth="3.1" | |
| 141 | strokeLinecap="round" | |
| 142 | strokeLinejoin="round" | |
| 143 | /> | |
| 144 | <path | |
| 145 | d="M17.5 21 L36.5 21 L38.6 47 Q39 50 36 50 L18 50 Q15 50 15.4 47 Z" | |
| 146 | stroke="currentColor" | |
| 147 | strokeWidth="3.1" | |
| 148 | strokeLinejoin="round" | |
| 149 | /> | |
| 150 | <path d="M17 21 Q16.3 16.3 20 15.6 L33 15.6 Q37.2 16.2 37 21 Z" stroke="currentColor" strokeWidth="3" strokeLinejoin="round" /> | |
| 151 | <path d="M36.7 17.1 L39.4 16.1" stroke="currentColor" strokeWidth="2.4" strokeLinejoin="round" /> | |
| 152 | <circle cx="40.3" cy="15.8" r="1.4" fill="currentColor" /> | |
| 153 | </svg> | |
| 154 | ); | |
| 155 | } | |
| 156 | ||
| 157 | /** "g1t is designed, built, and backed by Flagon, Inc., …" */ | |
| 158 | export function MakerLine() { | |
| 159 | const products = MAKER_PRODUCTS.map((product) => product.name); | |
| 160 | return ( | |
| 161 | <p className="text-sm leading-6 text-muted text-balance"> | |
| 162 | <Mark tight className="mr-1.5 inline-block h-[0.85em] w-auto align-baseline" /> | |
| 163 | <span className="font-semibold text-fg">g1t</span> is designed, built, and backed by{" "} | |
| 164 | <a href={COMPANY.url} className="whitespace-nowrap font-semibold text-fg hover:underline hover:underline-offset-4"> | |
| 165 | <FlagonMark className="mr-1 inline-block size-[0.95em] -translate-y-px align-middle" /> | |
| 166 | {COMPANY.name} | |
| 167 | </a> | |
| 168 | {products.length > 0 ? `, the people behind ${listed(products)}.` : `, ${COMPANY.about}.`} | |
| 169 | </p> | |
| 170 | ); | |
| 171 | } | |
| 172 | ||
| 173 | const ROW_LINK = "rounded-sm transition-colors hover:text-fg"; | |
| 174 | ||
| 175 | /** | |
| 176 | * Status, the trust pages, the account and the legal line, separated by | |
| 177 | * dots that wrap away cleanly on a phone. | |
| 178 | */ | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 179 | export function LegalRow({ user }: { user: User | null | undefined }) { |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 180 | const status = useSiteStatus(); |
| 181 | const links = [ | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 182 | <a key="status" href={STATUS_URL} className={`inline-flex items-center gap-1.5 ${ROW_LINK}`}> |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 183 | <StatusDot state={status?.overall.state ?? null} /> |
| 184 | Status | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 185 | </a>, |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 186 | ...ROW_LINKS.map(([label, to]) => ( |
| 187 | <Link key={to} to={to} className={ROW_LINK}> | |
| 188 | {label} | |
| 189 | </Link> | |
| 190 | )), | |
| 191 | ]; | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 192 | const account = [ |
| 193 | user ? ( | |
| 194 | <Link key="me" to={`/u/${user.username}`} className={`font-medium text-fg/90 ${ROW_LINK}`}> | |
| 195 | {user.username} | |
| 196 | </Link> | |
| 197 | ) : ( | |
| 198 | <Link key="login" to="/login" className={ROW_LINK}> | |
| 199 | Sign in | |
| 200 | </Link> | |
| 201 | ), | |
| 202 | ]; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 203 | const legal = [ |
| 204 | ...account, | |
| 205 | <span key="copyright" className="text-faint"> | |
| 206 | {copyright()} | |
| 207 | </span>, | |
| 208 | ]; | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 209 | const gap = "gap-x-3"; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 210 | const dot = ( |
| 211 | <span aria-hidden className="text-line-strong"> | |
| 212 | · | |
| 213 | </span> | |
| 214 | ); | |
| 215 | // Two runs, the links and the legal line, so that on a narrow screen the | |
| 216 | // second starts its own line rather than a line starting with a dot. | |
| 217 | const run = (items: React.ReactNode[]) => | |
| 218 | items.map((item, index) => ( | |
| 219 | <li key={index} className={`flex items-center ${gap}`}> | |
| 220 | {index > 0 && dot} | |
| 221 | {item} | |
| 222 | </li> | |
| 223 | )); | |
| 224 | return ( | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 225 | <div className={`flex flex-wrap items-center justify-center gap-y-1.5 ${gap} text-[0.8125rem] text-muted`}> |
| 226 | <ul className={`flex flex-wrap items-center justify-center gap-y-1.5 ${gap}`}>{run(links)}</ul> | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 227 | {/* Between the two runs only while they share a line. */} |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 228 | <span className="hidden md:inline">{dot}</span> |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 229 | <ul className={`flex flex-wrap items-center justify-center gap-y-1.5 ${gap}`}>{run(legal)}</ul> |
| 230 | </div> | |
| 231 | ); | |
| 232 | } | |
| 233 | ||
| 234 | export function SiteFooter({ user }: { user: User | null | undefined }) { | |
| 235 | return ( | |
| 236 | <footer className="mt-24 border-t border-line"> | |
| 237 | <nav aria-label="Footer" className="mx-auto grid max-w-6xl grid-cols-2 gap-x-8 gap-y-8 px-4 pt-12 pb-10 sm:grid-cols-4"> | |
| 238 | {FOOTER_LINKS.map((group) => ( | |
| 239 | <div key={group.title}> | |
| 240 | <p className="text-sm font-medium">{group.title}</p> | |
| 241 | <ul className="mt-3 space-y-2 text-sm text-muted"> | |
| 242 | {group.links.map(([label, to]) => ( | |
| 243 | <li key={to}> | |
| 244 | <a href={to} className="transition-colors hover:text-fg"> | |
| 245 | {label} | |
| 246 | </a> | |
| 247 | </li> | |
| 248 | ))} | |
| 249 | </ul> | |
| 250 | </div> | |
| 251 | ))} | |
| 252 | </nav> | |
| 253 | <div className="border-t border-line"> | |
| 254 | <div className="mx-auto max-w-3xl space-y-4 px-4 py-10 text-center"> | |
| 255 | <MakerLine /> | |
| 256 | <LegalRow user={user} /> | |
| 257 | </div> | |
| 258 | </div> | |
| 259 | </footer> | |
| 260 | ); | |
| 261 | } |