Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

error-page.tsxBlame194 lines · 1 contributor
c63b860Claude1/**
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
13import type { FC } from "hono/jsx";
14import type { User } from "../db/schema";
15import { Layout } from "./layout";
16
17export interface ErrorPageSuggestion { href: string; label: string }
18export 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
33export 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} &rarr;</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
69export 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
87export 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
102export 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
121export 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
138export 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 &rarr;</a></li><li><a href="/help">Read the access docs &rarr;</a></li>`
145 : `<li><a href="/login">Sign in &rarr;</a></li><li><a href="/help">Read the access docs &rarr;</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; }
158html, 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
192function escapeHtml(s: string): string {
193 return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
194}