CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
error-page.tsx
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| c63b860 | 1 | /** |
| 2 | * BLOCK O2 — Shared error page surface (404/500/403). | |
| 3 | * | |
| 4 | * One JSX component renders all three error surfaces consistently. | |
| 5 | * Used from app.notFound, app.onError, and the requireAdmin middleware. | |
| 6 | * | |
| 7 | * - NO database access (must render when DB is down). | |
| 8 | * - Reuses existing CSS tokens via Layout. | |
| 9 | * - Dark + light theme both via the same gradient-text utility. | |
| 10 | * - Accessible: role="main", aria-labelledby, polite live region. | |
| 11 | */ | |
| 12 | ||
| 13 | import type { FC } from "hono/jsx"; | |
| 14 | import type { User } from "../db/schema"; | |
| 15 | import { Layout } from "./layout"; | |
| 16 | ||
| 17 | export interface ErrorPageSuggestion { href: string; label: string } | |
| 18 | export interface ErrorPageProps { | |
| 19 | code: string; | |
| 20 | eyebrow: string; | |
| 21 | title: string; | |
| 22 | body: string; | |
| 23 | requestId?: string; | |
| 24 | user?: User | null; | |
| 25 | suggestions?: ErrorPageSuggestion[]; | |
| 26 | primaryCta?: { href: string; label: string }; | |
| 27 | secondaryCta?: { href: string; label: string }; | |
| 28 | meta?: string; | |
| 29 | trace?: string; | |
| 30 | layoutTitle?: string; | |
| 31 | } | |
| 32 | ||
| 33 | export const ErrorPage: FC<ErrorPageProps> = ({ | |
| 34 | code, eyebrow, title, body, requestId, user, suggestions, | |
| 35 | primaryCta, secondaryCta, meta, trace, layoutTitle, | |
| 36 | }) => { | |
| 37 | const primary = primaryCta ?? { href: "/", label: "Go home" }; | |
| 38 | const secondary = secondaryCta ?? { href: "/status", label: "Status page" }; | |
| 39 | return ( | |
| 40 | <Layout title={layoutTitle ?? `${code} ${eyebrow}`} user={user ?? null}> | |
| 41 | <main class="error-page" role="main" aria-labelledby="error-page-title" data-error-code={code}> | |
| 42 | <div class="error-page-code gradient-text" aria-hidden="true">{code}</div> | |
| 43 | <div class="error-page-eyebrow">{eyebrow}</div> | |
| 44 | <h1 id="error-page-title" class="error-page-title">{title}</h1> | |
| 45 | <p class="error-page-body">{body}</p> | |
| 46 | <div class="error-page-actions"> | |
| 47 | <a href={primary.href} class="btn btn-primary btn-lg" data-error-cta="primary">{primary.label}</a> | |
| 48 | <a href={secondary.href} class="btn btn-ghost btn-lg" data-error-cta="secondary">{secondary.label}</a> | |
| 49 | </div> | |
| 50 | {suggestions && suggestions.length > 0 && ( | |
| 51 | <ul class="error-page-suggestions" aria-label="Try one of these"> | |
| 52 | {suggestions.map((s) => (<li><a href={s.href}>{s.label} →</a></li>))} | |
| 53 | </ul> | |
| 54 | )} | |
| 55 | {requestId && ( | |
| 56 | <div class="error-page-meta" aria-live="polite"> | |
| 57 | <span class="error-page-meta-label">Request ID:</span>{" "} | |
| 58 | <span class="meta-mono">{requestId}</span> | |
| 59 | </div> | |
| 60 | )} | |
| 61 | {meta && (<div class="error-page-meta"><span class="meta-mono">{meta}</span></div>)} | |
| 62 | {trace && (<pre class="error-page-trace" aria-label="Stack trace">{trace}</pre>)} | |
| 63 | </main> | |
| 64 | <style dangerouslySetInnerHTML={{ __html: errorPageCss }} /> | |
| 65 | </Layout> | |
| 66 | ); | |
| 67 | }; | |
| 68 | ||
| 69 | export const NotFoundPage: FC<{ user?: User | null; method?: string; path?: string }> = ({ user, method, path }) => ( | |
| 70 | <ErrorPage | |
| 71 | code="404" | |
| 72 | eyebrow="Not found" | |
| 73 | title="We can't find that page." | |
| 74 | body="The URL might be wrong, the resource might have moved, or you might not have permission to see it." | |
| 75 | user={user} | |
| 76 | primaryCta={{ href: "/", label: "Go home" }} | |
| 77 | secondaryCta={{ href: "/status", label: "Status page" }} | |
| 78 | suggestions={[ | |
| 79 | { href: "/explore", label: "Explore public repositories" }, | |
| 80 | { href: "/help", label: "Read the quickstart guide" }, | |
| 81 | ]} | |
| 82 | meta={method && path ? `${method} ${path}` : undefined} | |
| 83 | layoutTitle="Not Found" | |
| 84 | /> | |
| 85 | ); | |
| 86 | ||
| 87 | export const ServerErrorPage: FC<{ user?: User | null; requestId?: string; trace?: string }> = ({ user, requestId, trace }) => ( | |
| 88 | <ErrorPage | |
| 89 | code="500" | |
| 90 | eyebrow="Server error" | |
| 91 | title="Something went wrong on our end." | |
| 92 | body="The error has been reported. Try again in a moment — if it persists, include the Request ID below when you file an issue." | |
| 93 | user={user} | |
| 94 | primaryCta={{ href: "/", label: "Go home" }} | |
| 95 | secondaryCta={{ href: "/status", label: "Status page" }} | |
| 96 | requestId={requestId} | |
| 97 | trace={trace} | |
| 98 | layoutTitle="Error" | |
| 99 | /> | |
| 100 | ); | |
| 101 | ||
| 102 | export const ForbiddenPage: FC<{ user?: User | null; message?: string }> = ({ user, message }) => { | |
| 103 | const suggestions: ErrorPageSuggestion[] = user | |
| 104 | ? [{ href: "/logout", label: "Sign in as a different user" }, { href: "/help", label: "Read the access docs" }] | |
| 105 | : [{ href: "/login", label: "Sign in" }, { href: "/help", label: "Read the access docs" }]; | |
| 106 | return ( | |
| 107 | <ErrorPage | |
| 108 | code="403" | |
| 109 | eyebrow="Forbidden" | |
| 110 | title={message ?? "Admin access required."} | |
| 111 | body="You're signed in, but this resource is restricted. If you think this is a mistake, contact a site admin." | |
| 112 | user={user} | |
| 113 | primaryCta={{ href: "/", label: "Go home" }} | |
| 114 | secondaryCta={{ href: "/status", label: "Status page" }} | |
| 115 | suggestions={suggestions} | |
| 116 | layoutTitle="Forbidden" | |
| 117 | /> | |
| 118 | ); | |
| 119 | }; | |
| 120 | ||
| 121 | export const errorPageCss = ` | |
| 122 | .error-page { max-width: 720px; margin: 64px auto 96px; padding: 0 24px; text-align: center; } | |
| 123 | .error-page-code { font-size: clamp(96px, 22vw, 192px); line-height: 1; font-weight: 800; letter-spacing: -0.04em; margin-bottom: 8px; } | |
| 124 | .error-page-eyebrow { text-transform: uppercase; font-size: 12px; letter-spacing: 0.18em; color: var(--text-muted); font-weight: 600; margin-bottom: 12px; } | |
| 125 | .error-page-title { font-size: clamp(24px, 4vw, 36px); font-weight: 700; letter-spacing: -0.02em; margin: 0 0 12px; color: var(--text); } | |
| 126 | .error-page-body { font-size: 16px; line-height: 1.6; color: var(--text-muted); max-width: 520px; margin: 0 auto 28px; } | |
| 127 | .error-page-actions { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; margin-bottom: 24px; } | |
| 128 | .error-page-suggestions { list-style: none; padding: 0; margin: 0 auto 24px; max-width: 480px; display: flex; flex-direction: column; gap: 4px; } | |
| 129 | .error-page-suggestions li { font-size: 14px; } | |
| 130 | .error-page-suggestions a { color: var(--accent); text-decoration: none; } | |
| 131 | .error-page-suggestions a:hover { text-decoration: underline; } | |
| 132 | .error-page-meta { font-size: 13px; color: var(--text-muted); margin-top: 12px; } | |
| 133 | .error-page-meta-label { text-transform: uppercase; letter-spacing: 0.12em; font-size: 11px; font-weight: 600; } | |
| 134 | .error-page .meta-mono { font-family: var(--font-mono); background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px; font-size: 12px; color: var(--text); } | |
| 135 | .error-page-trace { text-align: left; margin: 24px auto 0; padding: 16px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 8px; font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); overflow-x: auto; max-width: 100%; white-space: pre-wrap; } | |
| 136 | `; | |
| 137 | ||
| 138 | export function renderStandaloneErrorPage(opts: { | |
| 139 | code: string; eyebrow: string; title: string; body: string; | |
| 140 | requestId?: string; signedIn?: boolean; | |
| 141 | }): string { | |
| 142 | const { code, eyebrow, title, body, requestId, signedIn } = opts; | |
| 143 | const suggestionsHtml = signedIn | |
| 144 | ? `<li><a href="/logout">Sign in as a different user →</a></li><li><a href="/help">Read the access docs →</a></li>` | |
| 145 | : `<li><a href="/login">Sign in →</a></li><li><a href="/help">Read the access docs →</a></li>`; | |
| 146 | const requestIdHtml = requestId | |
| 147 | ? `<div class="error-page-meta" aria-live="polite"><span class="error-page-meta-label">Request ID:</span> <span class="meta-mono">${escapeHtml(requestId)}</span></div>` | |
| 148 | : ""; | |
| 149 | return `<!doctype html> | |
| 150 | <html lang="en" data-theme="dark"> | |
| 151 | <head> | |
| 152 | <meta charset="UTF-8"> | |
| 153 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 154 | <title>${escapeHtml(code)} ${escapeHtml(eyebrow)}</title> | |
| 155 | <style> | |
| 156 | :root { --bg:#0d1117; --bg-elevated:#161b22; --text:#e6edf3; --text-muted:#8b949e; --border:#30363d; --accent:#8c6dff; --accent-gradient:linear-gradient(135deg,#8c6dff 0%,#36c5d6 100%); --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace; } | |
| 157 | :root[data-theme='light'] { --bg:#fff; --bg-elevated:#f6f8fa; --text:#1f2328; --text-muted:#57606a; --border:#d1d9e0; --accent:#6d4dff; } | |
| 158 | html, body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; margin:0; padding:0; } | |
| 159 | .error-page { max-width: 720px; margin: 96px auto; padding: 0 24px; text-align: center; } | |
| 160 | .error-page-code { font-size: clamp(96px,22vw,192px); line-height:1; font-weight:800; letter-spacing:-0.04em; margin-bottom:8px; background: var(--accent-gradient); -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent; color: transparent; } | |
| 161 | .error-page-eyebrow { text-transform:uppercase; font-size:12px; letter-spacing:0.18em; color: var(--text-muted); font-weight:600; margin-bottom:12px; } | |
| 162 | .error-page-title { font-size: clamp(24px,4vw,36px); font-weight:700; letter-spacing:-0.02em; margin:0 0 12px; color: var(--text); } | |
| 163 | .error-page-body { font-size:16px; line-height:1.6; color: var(--text-muted); max-width: 520px; margin: 0 auto 28px; } | |
| 164 | .error-page-actions { display:flex; gap:12px; justify-content:center; flex-wrap:wrap; margin-bottom:24px; } | |
| 165 | .btn { display:inline-flex; align-items:center; padding:10px 18px; border-radius:8px; border:1px solid var(--border); text-decoration:none; color: var(--text); background: var(--bg-elevated); font-size:14px; font-weight:500; } | |
| 166 | .btn-primary { background: var(--accent); color: white; border-color: var(--accent); } | |
| 167 | .btn-lg { padding:12px 22px; font-size:15px; } | |
| 168 | .error-page-suggestions { list-style:none; padding:0; margin: 0 auto 24px; max-width: 480px; display:flex; flex-direction:column; gap:4px; font-size:14px; } | |
| 169 | .error-page-suggestions a { color: var(--accent); text-decoration:none; } | |
| 170 | .error-page-meta { font-size:13px; color: var(--text-muted); margin-top:12px; } | |
| 171 | .error-page-meta-label { text-transform:uppercase; letter-spacing:0.12em; font-size:11px; font-weight:600; } | |
| 172 | .meta-mono { font-family: var(--font-mono); background: var(--bg-elevated); border:1px solid var(--border); border-radius:6px; padding:2px 8px; font-size:12px; color: var(--text); } | |
| 173 | </style> | |
| 174 | </head> | |
| 175 | <body> | |
| 176 | <main class="error-page" role="main" aria-labelledby="error-page-title" data-error-code="${escapeHtml(code)}"> | |
| 177 | <div class="error-page-code" aria-hidden="true">${escapeHtml(code)}</div> | |
| 178 | <div class="error-page-eyebrow">${escapeHtml(eyebrow)}</div> | |
| 179 | <h1 id="error-page-title" class="error-page-title">${escapeHtml(title)}</h1> | |
| 180 | <p class="error-page-body">${escapeHtml(body)}</p> | |
| 181 | <div class="error-page-actions"> | |
| 182 | <a href="/" class="btn btn-primary btn-lg" data-error-cta="primary">Go home</a> | |
| 183 | <a href="/status" class="btn btn-lg" data-error-cta="secondary">Status page</a> | |
| 184 | </div> | |
| 185 | <ul class="error-page-suggestions" aria-label="Try one of these">${suggestionsHtml}</ul> | |
| 186 | ${requestIdHtml} | |
| 187 | </main> | |
| 188 | </body> | |
| 189 | </html>`; | |
| 190 | } | |
| 191 | ||
| 192 | function escapeHtml(s: string): string { | |
| 193 | return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'"); | |
| 194 | } |