tailwind
1 file+257−690/1 viewed
| 14 | 14 | useRouteLoaderData, | |
| 15 | 15 | } from "react-router"; | |
| 16 | 16 | ||
| 17 | − | import { type User, awaitsConfirmation, hasAccessIn, sharedWorkspaces } from "@g1t/contracts"; | |
| 17 | + | import { | |
| 18 | + | type User, | |
| 19 | + | awaitsConfirmation, | |
| 20 | + | hasAccessIn, | |
| 21 | + | sharedWorkspaces, | |
| 22 | + | } from "@g1t/contracts"; | |
| 18 | 23 | ||
| 19 | 24 | import type { Route } from "./+types/root"; | |
| 20 | 25 | import appCss from "./app.css?url"; | |
| 21 | 26 | import displayFont from "@g1t/theme/fonts/bricolage-grotesque-latin.woff2?url"; | |
| 22 | 27 | import sansFont from "@g1t/theme/fonts/hanken-grotesk-latin.woff2?url"; | |
| 23 | 28 | import { ButtonLink } from "./components/ui"; | |
| 24 | − | import { AppShell, Progress, type ShellData, useLeaving } from "./components/shell"; | |
| 29 | + | import { | |
| 30 | + | AppShell, | |
| 31 | + | Progress, | |
| 32 | + | type ShellData, | |
| 33 | + | useLeaving, | |
| 34 | + | } from "./components/shell"; | |
| 25 | 35 | import { SiteFooter } from "./components/footer"; | |
| 26 | 36 | import { PublicHeader } from "./components/public-header"; | |
| 27 | 37 | import { SpikeBanner } from "./components/spike-banner"; | |
| ⋯ | |||
| 34 | 44 | import { PageMain } from "./components/landmark"; | |
| 35 | 45 | import { NotFound } from "./components/not-found"; | |
| 36 | 46 | import { frameOf } from "./lib/chrome"; | |
| 37 | − | import { DOCK_COOKIE, SIDEBAR_COOKIE, pinsToShow, sidebarClosed } from "./lib/apps"; | |
| 47 | + | import { | |
| 48 | + | DOCK_COOKIE, | |
| 49 | + | SIDEBAR_COOKIE, | |
| 50 | + | pinsToShow, | |
| 51 | + | sidebarClosed, | |
| 52 | + | } from "./lib/apps"; | |
| 38 | 53 | import { savedPins } from "./lib/dock.server"; | |
| 39 | 54 | import { StandaloneFrame } from "./components/standalone"; | |
| 40 | − | import { billing, chat, identity, inbox, projects } from "./lib/services.server"; | |
| 55 | + | import { | |
| 56 | + | billing, | |
| 57 | + | chat, | |
| 58 | + | identity, | |
| 59 | + | inbox, | |
| 60 | + | projects, | |
| 61 | + | } from "./lib/services.server"; | |
| 41 | 62 | import { unreadTotals } from "./lib/chat"; | |
| 42 | 63 | import { countsFor, readableRepos } from "./lib/access.server"; | |
| 43 | 64 | import { shortCache } from "./lib/cache.server"; | |
| 44 | 65 | import { getViewer, viewerMiddleware } from "./lib/session.server"; | |
| 45 | 66 | import { shortcutOf, workspaceProjects } from "./lib/workspace-projects.server"; | |
| 46 | 67 | import { addresses } from "./lib/addresses.server"; | |
| 47 | − | import { RELOADED_KEY, RELOAD_GIVE_UP_MS, clientNavigated, reloadFixes, reloadedBefore } from "./lib/stale-build"; | |
| 68 | + | import { | |
| 69 | + | RELOADED_KEY, | |
| 70 | + | RELOAD_GIVE_UP_MS, | |
| 71 | + | clientNavigated, | |
| 72 | + | reloadFixes, | |
| 73 | + | reloadedBefore, | |
| 74 | + | } from "./lib/stale-build"; | |
| 48 | 75 | import { useNonce } from "./lib/nonce"; | |
| 49 | 76 | import { useStuckNavigationRescue } from "./lib/stuck"; | |
| 50 | 77 | import { isNeedsSignIn } from "./lib/website-token"; | |
| ⋯ | |||
| 53 | 80 | import { DesktopApp } from "./components/desktop-app"; | |
| 54 | 81 | import { DesktopRunnerLink } from "./components/desktop-runner-link"; | |
| 55 | 82 | import { LiveNotifications } from "./components/notifications/live-notifications"; | |
| 56 | − | import { THEME_COLOR, THEME_COOKIE, readTheme, themeAttribute, useThemeChoice } from "./lib/theme"; | |
| 57 | − | import { DEVELOPER_COOKIE, readDeveloperMode, useDeveloperMode } from "./lib/developer-mode"; | |
| 83 | + | import { | |
| 84 | + | THEME_COLOR, | |
| 85 | + | THEME_COOKIE, | |
| 86 | + | readTheme, | |
| 87 | + | themeAttribute, | |
| 88 | + | useThemeChoice, | |
| 89 | + | } from "./lib/theme"; | |
| 90 | + | import { | |
| 91 | + | DEVELOPER_COOKIE, | |
| 92 | + | readDeveloperMode, | |
| 93 | + | useDeveloperMode, | |
| 94 | + | } from "./lib/developer-mode"; | |
| 58 | 95 | import { DebugDetails } from "./components/debug-details"; | |
| 59 | − | ||
| 60 | 96 | ||
| 61 | 97 | export const links: Route.LinksFunction = () => [ | |
| 62 | 98 | { rel: "icon", href: "/favicon.ico", sizes: "32x32" }, | |
| ⋯ | |||
| 71 | 107 | { rel: "manifest", href: "/site.webmanifest" }, | |
| 72 | 108 | // The text and headline faces are wanted on every page, so they start | |
| 73 | 109 | // loading with the stylesheet; mono waits until something uses it. | |
| 74 | − | { rel: "preload", href: sansFont, as: "font", type: "font/woff2", crossOrigin: "anonymous" }, | |
| 75 | − | { rel: "preload", href: displayFont, as: "font", type: "font/woff2", crossOrigin: "anonymous" }, | |
| 110 | + | { | |
| 111 | + | rel: "preload", | |
| 112 | + | href: sansFont, | |
| 113 | + | as: "font", | |
| 114 | + | type: "font/woff2", | |
| 115 | + | crossOrigin: "anonymous", | |
| 116 | + | }, | |
| 117 | + | { | |
| 118 | + | rel: "preload", | |
| 119 | + | href: displayFont, | |
| 120 | + | as: "font", | |
| 121 | + | type: "font/woff2", | |
| 122 | + | crossOrigin: "anonymous", | |
| 123 | + | }, | |
| 76 | 124 | ]; | |
| 77 | 125 | ||
| 78 | 126 | export const middleware: Route.MiddlewareFunction[] = [viewerMiddleware]; | |
| ⋯ | |||
| 95 | 143 | : visitorShell(params, context), | |
| 96 | 144 | // Their chat sounds, with the page, so the first live event already | |
| 97 | 145 | // respects them (lib/sounds.ts); the defaults when identity is slow. | |
| 98 | − | user && !awaitsConfirmation(user) ? identity.soundSettings(user).catch(() => null) : Promise.resolve(null), | |
| 146 | + | user && !awaitsConfirmation(user) | |
| 147 | + | ? identity.soundSettings(user).catch(() => null) | |
| 148 | + | : Promise.resolve(null), | |
| 99 | 149 | ]); | |
| 100 | 150 | // The apps this person pinned to their rail here: as their account keeps | |
| 101 | 151 | // them (read with the rest of the shell), else as this device's cookie | |
| 102 | 152 | // remembers them (lib/apps.ts). | |
| 103 | − | if (shell.workspace) shell.pins = pinsToShow(shell.pins, dock, shell.workspace.slug); | |
| 153 | + | if (shell.workspace) | |
| 154 | + | shell.pins = pinsToShow(shell.pins, dock, shell.workspace.slug); | |
| 104 | 155 | // Where this g1t lives, for clone lines, agent setup and link previews. | |
| 105 | 156 | return { | |
| 106 | 157 | user, | |
| 107 | 158 | shell, | |
| 108 | 159 | // For a page opened with an access token: the feed socket's ticket, | |
| 109 | 160 | // minted here so the feed opens without asking for one (lib/live-socket.ts). | |
| 110 | − | liveTicket: user?.token?.website ? await socketTicketFor(context, request, "/-/live") : null, | |
| 161 | + | liveTicket: user?.token?.website | |
| 162 | + | ? await socketTicketFor(context, request, "/-/live") | |
| 163 | + | : null, | |
| 111 | 164 | // Whether they folded the sidebar away, so the page is drawn that way from the start. | |
| 112 | 165 | sidebarClosed: sidebarClosed(readCookie(cookies, SIDEBAR_COOKIE)), | |
| 113 | 166 | // Their Appearance, so the page is drawn in it from the first byte (lib/theme.ts). | |
| ⋯ | |||
| 127 | 180 | * open counts of the project being looked at. One call, and only in a | |
| 128 | 181 | * project; it answers the same for a private project as a missing one. | |
| 129 | 182 | */ | |
| 130 | − | async function visitorShell(params: { owner?: string; repo?: string }, context: Route.LoaderArgs["context"]): Promise<ShellData> { | |
| 131 | − | const path = params.owner && params.repo ? { namespace: params.owner, name: params.repo } : null; | |
| 183 | + | async function visitorShell( | |
| 184 | + | params: { owner?: string; repo?: string }, | |
| 185 | + | context: Route.LoaderArgs["context"], | |
| 186 | + | ): Promise<ShellData> { | |
| 187 | + | const path = | |
| 188 | + | params.owner && params.repo | |
| 189 | + | ? { namespace: params.owner, name: params.repo } | |
| 190 | + | : null; | |
| 132 | 191 | const counts = path ? await countsFor(context, params) : null; | |
| 133 | 192 | return { | |
| 134 | 193 | workspace: null, | |
| 135 | 194 | repos: [], | |
| 136 | − | repo: path && counts?.ok ? { ...path, member: false, issues: counts.value.issues, pulls: counts.value.pulls } : null, | |
| 195 | + | repo: | |
| 196 | + | path && counts?.ok | |
| 197 | + | ? { | |
| 198 | + | ...path, | |
| 199 | + | member: false, | |
| 200 | + | issues: counts.value.issues, | |
| 201 | + | pulls: counts.value.pulls, | |
| 202 | + | } | |
| 203 | + | : null, | |
| 137 | 204 | limit: null, | |
| 138 | 205 | monthUsageMicros: null, | |
| 139 | 206 | }; | |
| 140 | 207 | } | |
| 141 | 208 | ||
| 142 | 209 | /** The sidebar from what the session already says, with no service asked. */ | |
| 143 | − | function bareShell(user: User, params: { owner?: string; repo?: string }, chosen: string | null): ShellData { | |
| 210 | + | function bareShell( | |
| 211 | + | user: User, | |
| 212 | + | params: { owner?: string; repo?: string }, | |
| 213 | + | chosen: string | null, | |
| 214 | + | ): ShellData { | |
| 144 | 215 | return { | |
| 145 | 216 | workspace: workspaceFor(user.workspaces ?? [], chosen, params), | |
| 146 | 217 | repos: [], | |
| ⋯ | |||
| 161 | 232 | defaultShouldRevalidate, | |
| 162 | 233 | }: ShouldRevalidateFunctionArgs) { | |
| 163 | 234 | if (formMethod && formMethod !== "GET") return defaultShouldRevalidate; | |
| 164 | − | return currentParams.owner !== nextParams.owner || currentParams.repo !== nextParams.repo; | |
| 235 | + | return ( | |
| 236 | + | currentParams.owner !== nextParams.owner || | |
| 237 | + | currentParams.repo !== nextParams.repo | |
| 238 | + | ); | |
| 165 | 239 | } | |
| 166 | 240 | ||
| 167 | 241 | /** | |
| ⋯ | |||
| 177 | 251 | ): Promise<ShellData> { | |
| 178 | 252 | const memberships = user.workspaces ?? []; | |
| 179 | 253 | const workspace = workspaceFor(memberships, chosen, params); | |
| 180 | − | const path = params.owner && params.repo ? { namespace: params.owner, name: params.repo } : null; | |
| 254 | + | const path = | |
| 255 | + | params.owner && params.repo | |
| 256 | + | ? { namespace: params.owner, name: params.repo } | |
| 257 | + | : null; | |
| 181 | 258 | const now = new Date(); | |
| 182 | − | const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString(); | |
| 259 | + | const monthStart = new Date( | |
| 260 | + | Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1), | |
| 261 | + | ).toISOString(); | |
| 183 | 262 | // What the sidebar shows of the workspace changes rarely: kept for a few | |
| 184 | 263 | // seconds per person and workspace, except just after they changed | |
| 185 | 264 | // something (lib/cache.server.ts). | |
| 186 | 265 | const kept = <T,>(what: string, load: () => Promise<T>) => | |
| 187 | − | workspace ? shortCache(`shell:${what}:${user.id}:${workspace.slug}`, SHELL_TTL_MS, load) : Promise.resolve(null); | |
| 188 | − | const [listed, counts, status, usage, limit, entitlements, shared, unread, shortcuts, chatUnread, dockPins] = await Promise.all([ | |
| 266 | + | workspace | |
| 267 | + | ? shortCache( | |
| 268 | + | `shell:${what}:${user.id}:${workspace.slug}`, | |
| 269 | + | SHELL_TTL_MS, | |
| 270 | + | load, | |
| 271 | + | ) | |
| 272 | + | : Promise.resolve(null); | |
| 273 | + | const [ | |
| 274 | + | listed, | |
| 275 | + | counts, | |
| 276 | + | status, | |
| 277 | + | usage, | |
| 278 | + | limit, | |
| 279 | + | entitlements, | |
| 280 | + | shared, | |
| 281 | + | unread, | |
| 282 | + | shortcuts, | |
| 283 | + | chatUnread, | |
| 284 | + | dockPins, | |
| 285 | + | ] = await Promise.all([ | |
| 189 | 286 | // Every project, for the palette and the count; the sidebar lists only | |
| 190 | 287 | // the person's pinned and recent ones (lib/pins.ts). | |
| 191 | 288 | workspace ? workspaceProjects(workspace.slug, user) : Promise.resolve(null), | |
| ⋯ | |||
| 193 | 290 | // Whether billing is on, without reading the account: that asks the | |
| 194 | 291 | // card processor about the workspace's cards, too slow for every page. | |
| 195 | 292 | kept("status", () => billing.status()).catch(() => null), | |
| 196 | − | kept(`usage:${monthStart}`, () => billing.usage(workspace!.slug, user, monthStart)).catch(() => null), | |
| 293 | + | kept(`usage:${monthStart}`, () => | |
| 294 | + | billing.usage(workspace!.slug, user, monthStart), | |
| 295 | + | ).catch(() => null), | |
| 197 | 296 | kept("limit", () => billing.limit(workspace!.slug, user)).catch(() => null), | |
| 198 | − | kept("entitlements", () => billing.entitlements(workspace!.slug)).catch(() => null), | |
| 297 | + | kept("entitlements", () => billing.entitlements(workspace!.slug)).catch( | |
| 298 | + | () => null, | |
| 299 | + | ), | |
| 199 | 300 | sharedRepos(user), | |
| 200 | 301 | // The bell's count: one query, never kept, so marking an item shows at once. | |
| 201 | 302 | inbox.counts(user.username).catch(() => null), | |
| 202 | 303 | // Never kept: opening a project moves it up Recent. | |
| 203 | − | workspace ? projects.shortcuts(workspace.slug, user).catch(() => null) : Promise.resolve(null), | |
| 304 | + | workspace | |
| 305 | + | ? projects.shortcuts(workspace.slug, user).catch(() => null) | |
| 306 | + | : Promise.resolve(null), | |
| 204 | 307 | // The rail's Chat badge: never kept, and never waited on for long. | |
| 205 | 308 | workspace ? chatUnreadFor(workspace.slug, user) : Promise.resolve(null), | |
| 206 | 309 | // The rail's pins, kept with the account so every device shows the same | |
| ⋯ | |||
| 237 | 340 | free: Boolean(status?.free), | |
| 238 | 341 | // A spend spike or a hold pauses new compute: the shell says so on every page. | |
| 239 | 342 | compute: | |
| 240 | − | workspace && entitlements && (entitlements.paused || entitlements.spike?.status === "open") | |
| 241 | − | ? { paused: entitlements.paused, spike: entitlements.spike ?? null, owner: workspace.role === "owner" } | |
| 343 | + | workspace && | |
| 344 | + | entitlements && | |
| 345 | + | (entitlements.paused || entitlements.spike?.status === "open") | |
| 346 | + | ? { | |
| 347 | + | paused: entitlements.paused, | |
| 348 | + | spike: entitlements.spike ?? null, | |
| 349 | + | owner: workspace.role === "owner", | |
| 350 | + | } | |
| 242 | 351 | : null, | |
| 243 | 352 | shared, | |
| 244 | 353 | inbox: unread, | |
| ⋯ | |||
| 246 | 355 | pins: dockPins ?? undefined, | |
| 247 | 356 | // While g1t is free every charge is zero, so usage is shown at cost. | |
| 248 | 357 | // Usage at price, the one figure every page shows. | |
| 249 | − | monthUsageMicros: usage?.ok ? (usage.value.free ? usage.value.usedMicros : (usage.value.priceMicros ?? usage.value.spentMicros)) : null, | |
| 358 | + | monthUsageMicros: usage?.ok | |
| 359 | + | ? usage.value.free | |
| 360 | + | ? usage.value.usedMicros | |
| 361 | + | : (usage.value.priceMicros ?? usage.value.spentMicros) | |
| 362 | + | : null, | |
| 250 | 363 | }; | |
| 251 | 364 | } | |
| 252 | 365 | ||
| ⋯ | |||
| 258 | 371 | * conversations, for Home's sidebar; null when chat is slow to answer or | |
| 259 | 372 | * does not. | |
| 260 | 373 | */ | |
| 261 | − | async function chatUnreadFor(slug: string, user: User): Promise<ShellData["chat"]> { | |
| 262 | − | const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), CHAT_BADGE_WAIT_MS)); | |
| 374 | + | async function chatUnreadFor( | |
| 375 | + | slug: string, | |
| 376 | + | user: User, | |
| 377 | + | ): Promise<ShellData["chat"]> { | |
| 378 | + | const timeout = new Promise<null>((resolve) => | |
| 379 | + | setTimeout(() => resolve(null), CHAT_BADGE_WAIT_MS), | |
| 380 | + | ); | |
| 263 | 381 | const read = chat | |
| 264 | 382 | .sidebar(slug, user) | |
| 265 | 383 | .then((result) => { | |
| 266 | 384 | if (!result.ok) return null; | |
| 267 | 385 | const entries = result.value.entries; | |
| 268 | − | const latest = [...entries].sort((a, b) => (b.channel.last_message_at ?? "").localeCompare(a.channel.last_message_at ?? "")); | |
| 386 | + | const latest = [...entries].sort((a, b) => | |
| 387 | + | (b.channel.last_message_at ?? "").localeCompare( | |
| 388 | + | a.channel.last_message_at ?? "", | |
| 389 | + | ), | |
| 390 | + | ); | |
| 269 | 391 | return { | |
| 270 | 392 | ...unreadTotals(entries), | |
| 271 | 393 | starred: entries.filter((e) => e.starred).slice(0, SHELL_CONVERSATIONS), | |
| ⋯ | |||
| 295 | 417 | async function sharedRepos(user: User): Promise<ShellData["shared"]> { | |
| 296 | 418 | const outside = new Set(sharedWorkspaces(user)); | |
| 297 | 419 | if (outside.size === 0) return []; | |
| 298 | − | const ids = (user.grants ?? []).filter((grant) => outside.has(grant.workspace)).map((grant) => grant.repo_id).slice(0, MAX_SHARED); | |
| 420 | + | const ids = (user.grants ?? []) | |
| 421 | + | .filter((grant) => outside.has(grant.workspace)) | |
| 422 | + | .map((grant) => grant.repo_id) | |
| 423 | + | .slice(0, MAX_SHARED); | |
| 299 | 424 | const found = await readableRepos(ids, user); | |
| 300 | 425 | return found | |
| 301 | − | .map((repo) => ({ namespace: repo.namespace, name: repo.name, isPrivate: repo.isPrivate })) | |
| 302 | − | .sort((a, b) => `${a.namespace}/${a.name}`.localeCompare(`${b.namespace}/${b.name}`)); | |
| 426 | + | .map((repo) => ({ | |
| 427 | + | namespace: repo.namespace, | |
| 428 | + | name: repo.name, | |
| 429 | + | isPrivate: repo.isPrivate, | |
| 430 | + | })) | |
| 431 | + | .sort((a, b) => | |
| 432 | + | `${a.namespace}/${a.name}`.localeCompare(`${b.namespace}/${b.name}`), | |
| 433 | + | ); | |
| 303 | 434 | } | |
| 304 | 435 | ||
| 305 | 436 | /** | |
| ⋯ | |||
| 329 | 460 | const error = useRouteError(); | |
| 330 | 461 | const missing = isRouteErrorResponse(error) && error.status === 404; | |
| 331 | 462 | // The billing page shows the full banner itself. | |
| 332 | − | const paused = root?.shell?.compute && root.shell.workspace && !pathname.endsWith("/-/billing") && ( | |
| 333 | − | <SpikeBanner | |
| 334 | − | compact | |
| 335 | − | slug={root.shell.workspace.slug} | |
| 336 | − | entitlements={{ paused: root.shell.compute.paused, spike: root.shell.compute.spike }} | |
| 337 | − | owner={root.shell.compute.owner} | |
| 338 | − | /> | |
| 339 | − | ); | |
| 463 | + | const paused = root?.shell?.compute && | |
| 464 | + | root.shell.workspace && | |
| 465 | + | !pathname.endsWith("/-/billing") && ( | |
| 466 | + | <SpikeBanner | |
| 467 | + | compact | |
| 468 | + | slug={root.shell.workspace.slug} | |
| 469 | + | entitlements={{ | |
| 470 | + | paused: root.shell.compute.paused, | |
| 471 | + | spike: root.shell.compute.spike, | |
| 472 | + | }} | |
| 473 | + | owner={root.shell.compute.owner} | |
| 474 | + | /> | |
| 475 | + | ); | |
| 340 | 476 | // On the few pages an account still confirming its address can open | |
| 341 | 477 | // besides /confirm-email (policies, support, signing in): the way back. | |
| 342 | − | const verify = user && awaitsConfirmation(user) && pathname !== "/confirm-email" && ( | |
| 343 | − | <p | |
| 344 | − | role="status" | |
| 345 | − | 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" | |
| 346 | − | > | |
| 347 | − | <span>Confirm your email address to start using g1t.</span> | |
| 348 | − | <Link to="/confirm-email" className="font-medium underline underline-offset-4"> | |
| 349 | − | Enter your code | |
| 350 | − | </Link> | |
| 351 | − | </p> | |
| 352 | − | ); | |
| 478 | + | const verify = user && | |
| 479 | + | awaitsConfirmation(user) && | |
| 480 | + | pathname !== "/confirm-email" && ( | |
| 481 | + | <p | |
| 482 | + | role="status" | |
| 483 | + | 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" | |
| 484 | + | > | |
| 485 | + | <span>Confirm your email address to start using g1t.</span> | |
| 486 | + | <Link | |
| 487 | + | to="/confirm-email" | |
| 488 | + | className="font-medium underline underline-offset-4" | |
| 489 | + | > | |
| 490 | + | Enter your code | |
| 491 | + | </Link> | |
| 492 | + | </p> | |
| 493 | + | ); | |
| 353 | 494 | // A workspace that requires two-factor authentication of someone who | |
| 354 | 495 | // has not turned it on: they keep their place, and cannot use it yet. | |
| 355 | − | const held = user?.held && user.held.length > 0 && pathname !== "/settings/two-factor" && <PolicyNotice held={user.held} />; | |
| 496 | + | const held = user?.held && | |
| 497 | + | user.held.length > 0 && | |
| 498 | + | pathname !== "/settings/two-factor" && <PolicyNotice held={user.held} />; | |
| 356 | 499 | const leaving = useLeaving(); | |
| 357 | 500 | // Auto, Light or Dark: as the cookie said when the page was drawn, or as just switched. | |
| 358 | 501 | const theme = useThemeChoice(root?.theme); | |
| ⋯ | |||
| 373 | 516 | <html lang="en" data-theme={themeAttribute(theme)}> | |
| 374 | 517 | <head> | |
| 375 | 518 | <meta charSet="utf-8" /> | |
| 376 | − | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> | |
| 519 | + | <meta | |
| 520 | + | name="viewport" | |
| 521 | + | content="width=device-width, initial-scale=1, viewport-fit=cover" | |
| 522 | + | /> | |
| 377 | 523 | {/* The browser bar matches the page: one colour for a chosen theme, the system's for Auto. */} | |
| 378 | 524 | {theme === "auto" ? ( | |
| 379 | 525 | <> | |
| 380 | − | <meta name="theme-color" media="(prefers-color-scheme: light)" content={THEME_COLOR.light} /> | |
| 381 | − | <meta name="theme-color" media="(prefers-color-scheme: dark)" content={THEME_COLOR.dark} /> | |
| 526 | + | <meta | |
| 527 | + | name="theme-color" | |
| 528 | + | media="(prefers-color-scheme: light)" | |
| 529 | + | content={THEME_COLOR.light} | |
| 530 | + | /> | |
| 531 | + | <meta | |
| 532 | + | name="theme-color" | |
| 533 | + | media="(prefers-color-scheme: dark)" | |
| 534 | + | content={THEME_COLOR.dark} | |
| 535 | + | /> | |
| 382 | 536 | </> | |
| 383 | 537 | ) : ( | |
| 384 | 538 | <meta name="theme-color" content={THEME_COLOR[theme]} /> | |
| 385 | 539 | )} | |
| 386 | 540 | <meta name="mobile-web-app-capable" content="yes" /> | |
| 387 | 541 | <meta name="apple-mobile-web-app-capable" content="yes" /> | |
| 388 | − | <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> | |
| 542 | + | <meta | |
| 543 | + | name="apple-mobile-web-app-status-bar-style" | |
| 544 | + | content="black-translucent" | |
| 545 | + | /> | |
| 389 | 546 | {/* The stylesheet before everything React Router preloads, so a slow | |
| 390 | 547 | connection paints sooner. */} | |
| 391 | 548 | <link rel="stylesheet" href={appCss} precedence="default" /> | |
| 392 | − | {root?.analyticsConsent && <meta name="g1t-analytics" content="consent" />} | |
| 549 | + | {root?.analyticsConsent && ( | |
| 550 | + | <meta name="g1t-analytics" content="consent" /> | |
| 551 | + | )} | |
| 393 | 552 | <Meta /> | |
| 394 | 553 | <Links nonce={nonce} /> | |
| 395 | 554 | </head> | |
| 396 | − | <body className="flex min-h-screen flex-col" data-frame={frame === "app" && user && root?.shell ? "app" : frame}> | |
| 555 | + | <body | |
| 556 | + | className="flex min-h-screen flex-col" | |
| 557 | + | data-frame={frame === "app" && user && root?.shell ? "app" : frame} | |
| 558 | + | > | |
| 397 | 559 | {/* The first stop for the keyboard: past the menus, to the page. */} | |
| 398 | 560 | <a | |
| 399 | 561 | href="#content" | |
| 400 | − | className="sr-only rounded-md bg-fg px-3 py-2 text-sm font-medium text-bg focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[70]" | |
| 562 | + | className="sr-only rounded-md bg-fg px-3 py-2 text-sm font-medium text-bg focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-70" | |
| 401 | 563 | > | |
| 402 | 564 | Skip to content | |
| 403 | 565 | </a> | |
| 404 | 566 | {frame === "standalone" ? ( | |
| 405 | 567 | <StandaloneFrame>{children}</StandaloneFrame> | |
| 406 | 568 | ) : frame === "app" && user && root?.shell ? ( | |
| 407 | − | <AppShell user={user} shell={root.shell} missing={missing} banner={banner} sidebarClosed={root.sidebarClosed}> | |
| 569 | + | <AppShell | |
| 570 | + | user={user} | |
| 571 | + | shell={root.shell} | |
| 572 | + | missing={missing} | |
| 573 | + | banner={banner} | |
| 574 | + | sidebarClosed={root.sidebarClosed} | |
| 575 | + | > | |
| 408 | 576 | {children} | |
| 409 | 577 | </AppShell> | |
| 410 | 578 | ) : ( | |
| ⋯ | |||
| 412 | 580 | <Progress /> | |
| 413 | 581 | <PublicHeader user={user} /> | |
| 414 | 582 | {banner} | |
| 415 | − | <div id="content" tabIndex={-1} {...leaving} className={`grow outline-none ${leaving.className}`}> | |
| 583 | + | <div | |
| 584 | + | id="content" | |
| 585 | + | tabIndex={-1} | |
| 586 | + | {...leaving} | |
| 587 | + | className={`grow outline-none ${leaving.className}`} | |
| 588 | + | > | |
| 416 | 589 | {children} | |
| 417 | 590 | </div> | |
| 418 | 591 | <SiteFooter user={user} /> | |
| ⋯ | |||
| 432 | 605 | <DesktopRunnerLink | |
| 433 | 606 | offer={{ | |
| 434 | 607 | signedIn: user != null, | |
| 435 | − | workspaces: (user?.workspaces ?? []).filter((m) => m.role === "owner").map((m) => ({ slug: m.slug, name: m.name ?? null })), | |
| 608 | + | workspaces: (user?.workspaces ?? []) | |
| 609 | + | .filter((m) => m.role === "owner") | |
| 610 | + | .map((m) => ({ slug: m.slug, name: m.name ?? null })), | |
| 436 | 611 | current: root?.shell?.workspace?.slug ?? null, | |
| 437 | 612 | }} | |
| 438 | 613 | /> | |
| ⋯ | |||
| 474 | 649 | caughtByCatchAll: matches.at(-1)?.id === "routes/not-found", | |
| 475 | 650 | }); | |
| 476 | 651 | // Already loaded again once, or the reload never started: show the page. | |
| 477 | − | const tried = useMemo(() => reloadable && reloadedBefore(href), [reloadable, href]); | |
| 652 | + | const tried = useMemo( | |
| 653 | + | () => reloadable && reloadedBefore(href), | |
| 654 | + | [reloadable, href], | |
| 655 | + | ); | |
| 478 | 656 | // The error in full, for reporting it (lib/developer-mode.ts). | |
| 479 | − | const developer = useDeveloperMode(useRouteLoaderData<typeof loader>("root")?.developer) || import.meta.env.DEV; | |
| 657 | + | const developer = | |
| 658 | + | useDeveloperMode(useRouteLoaderData<typeof loader>("root")?.developer) || | |
| 659 | + | import.meta.env.DEV; | |
| 480 | 660 | const [gaveUp, setGaveUp] = useState<string | null>(null); | |
| 481 | 661 | const reload = reloadable && !tried && gaveUp !== href; | |
| 482 | 662 | useEffect(() => { | |
| ⋯ | |||
| 492 | 672 | }, [reload, href]); | |
| 493 | 673 | if (reload) { | |
| 494 | 674 | return ( | |
| 495 | − | <PageMain className="mx-auto max-w-xl px-4 py-32 text-center text-sm text-muted" aria-busy="true"> | |
| 675 | + | <PageMain | |
| 676 | + | className="mx-auto max-w-xl px-4 py-32 text-center text-sm text-muted" | |
| 677 | + | aria-busy="true" | |
| 678 | + | > | |
| 496 | 679 | <title>Loading · g1t</title> | |
| 497 | 680 | Loading the latest version of g1t… | |
| 498 | 681 | </PageMain> | |
| ⋯ | |||
| 523 | 706 | } | |
| 524 | 707 | ||
| 525 | 708 | return ( | |
| 526 | − | <PageMain className={`mx-auto px-4 text-center ${developer ? "max-w-3xl py-16" : "max-w-xl py-32"}`}> | |
| 709 | + | <PageMain | |
| 710 | + | className={`mx-auto px-4 text-center ${developer ? "max-w-3xl py-16" : "max-w-xl py-32"}`} | |
| 711 | + | > | |
| 527 | 712 | <h1 className="text-2xl font-semibold tracking-tight">{title}</h1> | |
| 528 | 713 | <p className="mt-3 text-muted">{details}</p> | |
| 529 | 714 | <div className="mt-8"> | |
| ⋯ | |||
| 536 | 721 | ) : ( | |
| 537 | 722 | <p className="mt-6 text-xs text-faint"> | |
| 538 | 723 | Turn on developer mode in{" "} | |
| 539 | − | <Link to="/settings/account#developer" className="underline underline-offset-4 hover:text-fg"> | |
| 724 | + | <Link | |
| 725 | + | to="/settings/account#developer" | |
| 726 | + | className="underline underline-offset-4 hover:text-fg" | |
| 727 | + | > | |
| 540 | 728 | Settings | |
| 541 | 729 | </Link>{" "} | |
| 542 | 730 | to see what went wrong. | |