feat: custom 404 and 500 error pages with gear theme

- 404: stripped gear with wobble animation, missing tooth, clack text
- 500: seized gear with sparks and crack animations, fire extinguisher joke
- Both use site design system (dark theme, orange accent, Inter/JetBrains Mono)
- Nginx configured: error_page 404/500/502/503/504 → custom pages
- SVGs rendered via set:html to avoid JSX brace conflicts in Astro
This commit is contained in:
Jose Juan Moñino
2026-07-10 19:18:12 +02:00
parent 903e51cf5b
commit f2d5e74f19
2 changed files with 270 additions and 0 deletions
+124
View File
@@ -0,0 +1,124 @@
---
import Base from '../layouts/Base.astro';
const gearSvg = `<svg width="160" height="160" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<g class="gear-spin-slow">
<path d="M50 10 L54 18 L62 16 L64 24 L72 24 L70 32 L78 36 L72 42 L78 48 L70 52 L72 60 L64 60 L62 68 L54 66 L50 74 L46 66 L38 68 L36 60 L28 60 L30 52 L22 48 L28 42 L22 36 L30 32 L28 24 L36 24 L38 16 L46 18 Z"
fill="none" stroke="var(--accent)" stroke-width="2.5" stroke-linejoin="round" />
<circle cx="50" cy="50" r="14" fill="none" stroke="var(--accent)" stroke-width="2.5" />
<circle cx="50" cy="50" r="5" fill="var(--accent)" />
</g>
<rect x="44" y="4" width="12" height="10" fill="var(--bg)" />
<text x="50" y="14" text-anchor="middle" font-size="8" font-family="var(--font-mono)" fill="var(--text-muted)">clack</text>
</svg>`;
---
<Base title="404 — This page stripped a gear | Greasy Sprocket">
<section class="error-page">
<div class="container error-content">
<div class="error-gear" aria-hidden="true" set:html={gearSvg} />
<span class="error-code">404</span>
<h1>This page stripped a gear</h1>
<p class="error-msg">
The sprocket you're looking for isn't here. Maybe it fell off the bike,
maybe it never existed, maybe it's in the workshop getting degreased.
Either way — nothing to see here.
</p>
<div class="error-actions">
<a href="/" class="btn btn-primary">⟵ Back to the garage</a>
<a href="/support" class="btn btn-outline">Support the work</a>
</div>
<div class="error-hint">
<code>try &#123; find('/page') &#125; catch (GearStrippedError) &#123; redirect('/') &#125;</code>
</div>
</div>
</section>
</Base>
<style>
.error-page {
min-height: 70vh;
display: flex;
align-items: center;
justify-content: center;
padding: 60px 0;
}
.error-content {
text-align: center;
max-width: 520px;
}
.error-gear {
margin-bottom: 28px;
display: flex;
justify-content: center;
}
.gear-spin-slow {
transform-origin: 50px 50px;
animation: gear-wobble 4s ease-in-out infinite;
}
@keyframes gear-wobble {
0%, 100% { transform: rotate(0deg); }
20% { transform: rotate(-12deg); }
40% { transform: rotate(8deg); }
60% { transform: rotate(-5deg); }
80% { transform: rotate(3deg); }
}
.error-code {
display: block;
font-family: var(--font-mono);
font-size: 64px;
font-weight: 500;
color: var(--accent);
line-height: 1;
margin-bottom: 12px;
letter-spacing: -0.04em;
}
.error-page h1 {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.025em;
margin-bottom: 16px;
color: var(--text);
}
.error-msg {
font-size: 16px;
color: var(--text-secondary);
line-height: 1.55;
margin-bottom: 28px;
}
.error-actions {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 32px;
}
.error-hint {
padding: 12px 16px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-muted);
text-align: left;
overflow-x: auto;
}
@media (min-width: 768px) {
.error-code { font-size: 80px; }
.error-page h1 { font-size: 32px; }
}
</style>
+146
View File
@@ -0,0 +1,146 @@
---
import Base from '../layouts/Base.astro';
const gearSvg = `<svg width="160" height="160" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<g class="gear-spin-fast">
<path d="M50 10 L54 18 L62 16 L64 24 L72 24 L70 32 L78 36 L72 42 L78 48 L70 52 L72 60 L64 60 L62 68 L54 66 L50 74 L46 66 L38 68 L36 60 L28 60 L30 52 L22 48 L28 42 L22 36 L30 32 L28 24 L36 24 L38 16 L46 18 Z"
fill="none" stroke="var(--accent)" stroke-width="2.5" stroke-linejoin="round" />
<circle cx="50" cy="50" r="14" fill="none" stroke="var(--accent)" stroke-width="2.5" />
<circle cx="50" cy="50" r="5" fill="var(--accent)" />
</g>
<path d="M50 50 L70 30 L75 15" stroke="var(--accent)" stroke-width="1.5" stroke-dasharray="3 3" opacity="0.6" />
<path d="M50 50 L25 35 L15 20" stroke="var(--accent)" stroke-width="1.5" stroke-dasharray="3 3" opacity="0.6" />
<path d="M50 50 L60 80 L55 95" stroke="var(--accent)" stroke-width="1.5" stroke-dasharray="3 3" opacity="0.4" />
<circle cx="80" cy="20" r="1.5" fill="var(--accent)" class="spark spark-1" />
<circle cx="12" cy="25" r="1.5" fill="var(--accent)" class="spark spark-2" />
<circle cx="85" cy="75" r="1.5" fill="var(--accent)" class="spark spark-3" />
<circle cx="18" cy="85" r="1" fill="var(--accent)" class="spark spark-4" />
</svg>`;
---
<Base title="500 — The workshop is on fire | Greasy Sprocket">
<section class="error-page">
<div class="container error-content">
<div class="error-gear" aria-hidden="true" set:html={gearSvg} />
<span class="error-code">500</span>
<h1>The workshop caught fire</h1>
<p class="error-msg">
Something went very wrong on our end. The greased sprocket overheated,
seized up, and threw a spark. We're in the back with a fire extinguisher
— give us a moment and try again.
</p>
<div class="error-actions">
<a href="/" class="btn btn-primary">⟵ Back to safety</a>
<a href="/feedback" class="btn btn-outline">Report what happened</a>
</div>
<div class="error-hint">
<code>while (engine.running) &#123; lubricate(gear); &#125; // TODO: buy fire extinguisher</code>
</div>
</div>
</section>
</Base>
<style>
.error-page {
min-height: 70vh;
display: flex;
align-items: center;
justify-content: center;
padding: 60px 0;
}
.error-content {
text-align: center;
max-width: 520px;
}
.error-gear {
margin-bottom: 28px;
display: flex;
justify-content: center;
}
.gear-spin-fast {
transform-origin: 50px 50px;
animation: gear-seize 1.5s ease-in-out infinite;
}
@keyframes gear-seize {
0%, 100% { transform: rotate(0deg); }
15% { transform: rotate(25deg); }
20% { transform: rotate(22deg); }
25% { transform: rotate(35deg); }
30% { transform: rotate(33deg); }
50% { transform: rotate(33deg); }
55% { transform: rotate(10deg); }
60% { transform: rotate(15deg); }
100% { transform: rotate(0deg); }
}
.spark {
animation: spark-blink 0.8s ease-in-out infinite;
}
.spark-1 { animation-delay: 0s; }
.spark-2 { animation-delay: 0.15s; }
.spark-3 { animation-delay: 0.3s; }
.spark-4 { animation-delay: 0.45s; }
@keyframes spark-blink {
0%, 100% { opacity: 0; }
50% { opacity: 1; }
}
.error-code {
display: block;
font-family: var(--font-mono);
font-size: 64px;
font-weight: 500;
color: var(--accent);
line-height: 1;
margin-bottom: 12px;
letter-spacing: -0.04em;
}
.error-page h1 {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.025em;
margin-bottom: 16px;
color: var(--text);
}
.error-msg {
font-size: 16px;
color: var(--text-secondary);
line-height: 1.55;
margin-bottom: 28px;
}
.error-actions {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 32px;
}
.error-hint {
padding: 12px 16px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-muted);
text-align: left;
overflow-x: auto;
}
@media (min-width: 768px) {
.error-code { font-size: 80px; }
.error-page h1 { font-size: 32px; }
}
</style>