Global error page: ErrorView with per-status copy, did-you-mean suggestions, error ids from handleError, static error.html; 404 instead of 500 for bad progress ids; tolerate full header line in DAISY_TASK_WEBHOOK_HEADER and stop logging raw fetch errors
All checks were successful
Deploy production / deploy (push) Successful in 1m9s

This commit is contained in:
2026-10-25 13:10:50 +00:00
parent f86c8ef294
commit a89a187346
13 changed files with 665 additions and 30 deletions

View File

@@ -0,0 +1,315 @@
<script lang="ts">
// The site-wide error screen (rendered by src/routes/+error.svelte inside the normal
// header/footer). Status-specific copy, the requested path, a short error id for 5xx
// (matches the server log line from handleError), "did you mean" tiles for 404s and
// quick navigation tiles. The owner can file the error as a task in one click.
import { resolve } from '$app/paths';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
import Bug from 'lucide-svelte/icons/bug';
import Check from 'lucide-svelte/icons/check';
import Copy from 'lucide-svelte/icons/copy';
import FileText from 'lucide-svelte/icons/file-text';
import FolderKanban from 'lucide-svelte/icons/folder-kanban';
import House from 'lucide-svelte/icons/house';
import LogIn from 'lucide-svelte/icons/log-in';
import ChartColumn from 'lucide-svelte/icons/chart-column';
import ProjectIcon from '#lib/components/ProjectIcon.svelte';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import { errorCopy, suggestPages } from '#lib/errors.js';
let {
status,
message = '',
errorId,
path,
signedIn = false,
isOwner = false
}: {
status: number;
message?: string;
errorId?: string;
path: string;
signedIn?: boolean;
isOwner?: boolean;
} = $props();
const copy = $derived(errorCopy(status));
// Show a thrown error's own message ("Spotify is not configured…"), not the defaults.
const detail = $derived(
message && !/^(not found|internal error|error)$/i.test(message.trim()) ? message : ''
);
const suggestions = $derived(status === 404 ? suggestPages(path, isOwner) : []);
const showSignIn = $derived(!signedIn && (status === 401 || status === 403 || status === 404));
const loginHref = $derived(`${resolve('/login')}?returnTo=${encodeURIComponent(path)}`);
let report = $state<'idle' | 'sending' | 'failed' | number>('idle');
async function reportIt() {
if (report === 'sending' || typeof report === 'number') return;
report = 'sending';
const notes = [
`Status: ${status}`,
`Path: ${path}`,
errorId ? `Error id: ${errorId} (grep the app logs for "[error ${errorId}]")` : '',
detail ? `Message: ${detail}` : '',
`When: ${new Date().toISOString()}`,
document.referrer ? `From: ${document.referrer}` : ''
]
.filter(Boolean)
.join('\n');
try {
const res = await fetch(resolve('/trackers/tasks/quick'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: `Fix ${status} on ${path}`.slice(0, 500), notes })
});
report = res.ok ? ((await res.json()) as { id: number }).id : 'failed';
} catch {
report = 'failed';
}
}
let copied = $state(false);
async function copyId() {
if (!errorId) return;
try {
await navigator.clipboard.writeText(errorId);
copied = true;
setTimeout(() => (copied = false), 1500);
} catch {
// clipboard blocked
}
}
function goBack() {
const sameOrigin = document.referrer.startsWith(location.origin);
if (history.length > 1 && sameOrigin) history.back();
else location.href = resolve('/');
}
</script>
<section class="mx-auto max-w-6xl px-4 py-14 sm:px-6 sm:py-20" aria-labelledby="error-title">
<p class="code tone-{copy.tone} text-8xl font-semibold tracking-tighter tabular-nums sm:text-9xl">
{status}
</p>
<h1 id="error-title" class="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">
{copy.title}
</h1>
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
{copy.message}
{#if detail}<span class="mt-1 block text-foreground">{detail}</span>{/if}
</p>
<div class="mt-6 flex flex-wrap items-center gap-2 text-sm">
<code
class="max-w-full truncate rounded-md border border-[var(--site-border)] bg-[var(--site-surface)] px-2.5 py-1 font-mono text-xs"
title={path}>{path}</code
>
{#if errorId}
<button
type="button"
class="chip flex items-center gap-1.5 px-2.5 py-1 font-mono text-xs"
onclick={copyId}
title="Copy error id"
>error id {errorId}
{#if copied}<Check size={12} aria-hidden="true" />{:else}<Copy
size={12}
aria-hidden="true"
/>{/if}</button
>
{/if}
</div>
{#if suggestions.length}
<h2 class="mt-12 mb-3 text-sm font-medium text-[var(--site-muted)]">did you mean</h2>
<ul class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each suggestions as s (s.href)}
<li>
<a href={resolve(s.href as '/')} class="tile flex h-full items-center gap-3 p-4">
{#if s.kind === 'tracker'}
<TrackerIcon name={s.name} class="tile-icon size-10" />
{:else if s.kind === 'project'}
<span
class="tile-icon flex size-10 shrink-0 items-center justify-center rounded-lg border border-border bg-[var(--site-surface)] text-[var(--site-muted)]"
><ProjectIcon name={s.name} class="size-5" /></span
>
{:else}
<span
class="tile-icon flex size-10 shrink-0 items-center justify-center rounded-lg border border-border bg-[var(--site-surface)] text-[var(--site-muted)]"
><FileText size={18} aria-hidden="true" /></span
>
{/if}
<span class="min-w-0 flex-1">
<span class="block font-medium">{s.name}</span>
<span class="block truncate text-sm text-muted-foreground">{s.subtitle}</span>
</span>
<ArrowRight
size={16}
aria-hidden="true"
class="tile-arrow shrink-0 text-[var(--site-muted)]"
/>
</a>
</li>
{/each}
</ul>
{/if}
<h2 class="mt-12 mb-3 text-sm font-medium text-[var(--site-muted)]">go somewhere else</h2>
<div class="flex flex-wrap gap-2">
{#if showSignIn}
<a href={loginHref} class="tile flex items-center gap-2 px-3 py-2 text-sm font-medium"
><span class="tile-icon accent accent-signin"><LogIn size={14} aria-hidden="true" /></span
>sign in</a
>
{/if}
<a href={resolve('/')} class="tile flex items-center gap-2 px-3 py-2 text-sm"
><span class="tile-icon accent accent-home"><House size={14} aria-hidden="true" /></span
>home</a
>
<a href={resolve('/trackers')} class="tile flex items-center gap-2 px-3 py-2 text-sm"
><span class="tile-icon accent accent-trackers"
><ChartColumn size={14} aria-hidden="true" /></span
>trackers</a
>
<a href={resolve('/projects')} class="tile flex items-center gap-2 px-3 py-2 text-sm"
><span class="tile-icon accent accent-projects"
><FolderKanban size={14} aria-hidden="true" /></span
>projects</a
>
<button type="button" class="tile flex items-center gap-2 px-3 py-2 text-sm" onclick={goBack}
><span class="tile-icon accent accent-back"><ArrowLeft size={14} aria-hidden="true" /></span
>go back</button
>
{#if isOwner}
<button
type="button"
class="tile flex items-center gap-2 px-3 py-2 text-sm"
onclick={reportIt}
disabled={report === 'sending' || typeof report === 'number'}
aria-live="polite"
><span class="tile-icon accent accent-report"><Bug size={14} aria-hidden="true" /></span
>{#if report === 'sending'}reporting…{:else if typeof report === 'number'}added as task #{report}{:else if report === 'failed'}could
not report, retry{:else}report it{/if}</button
>
{/if}
</div>
</section>
<style>
.code {
line-height: 1;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
background-image: linear-gradient(135deg, var(--from), var(--to));
}
.tone-missing {
--from: #fbbf24;
--to: #e11d48;
}
.tone-locked {
--from: #38bdf8;
--to: #6366f1;
}
.tone-broken {
--from: #f472b6;
--to: #7c3aed;
}
.tone-down {
--from: #fdba74;
--to: #dc2626;
}
.tile,
.chip {
border: 1px solid color-mix(in oklab, var(--foreground) 10%, transparent);
border-radius: 0.7rem;
background: color-mix(in oklab, var(--foreground) 2%, transparent);
color: var(--foreground);
cursor: pointer;
outline: none;
transition:
transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1),
background-color 160ms ease,
border-color 160ms ease,
box-shadow 160ms ease;
}
.chip {
border-radius: 999px;
}
.tile:hover,
.chip:hover {
background: color-mix(in oklab, var(--foreground) 6%, transparent);
border-color: color-mix(in oklab, var(--foreground) 24%, transparent);
transform: translateY(-1px);
box-shadow: 0 8px 20px -12px rgb(0 0 0 / 0.5);
}
.tile:active,
.chip:active {
transform: translateY(0);
box-shadow: none;
}
.tile:focus-visible,
.chip:focus-visible {
outline: 2px solid var(--foreground);
outline-offset: 2px;
}
.tile:disabled {
cursor: default;
transform: none;
box-shadow: none;
}
.tile :global(.tile-icon),
.tile :global(.tile-arrow) {
transition: transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tile:hover :global(.tile-icon) {
transform: scale(1.08);
}
.tile:hover :global(.tile-arrow) {
transform: translateX(2px);
}
.accent {
display: inline-flex;
width: 1.5rem;
height: 1.5rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 0.4rem;
color: #fff;
}
.accent-home {
background-image: linear-gradient(135deg, #34d399, #059669);
}
.accent-trackers {
background-image: linear-gradient(135deg, #60a5fa, #2563eb);
}
.accent-projects {
background-image: linear-gradient(135deg, #a78bfa, #7c3aed);
}
.accent-back {
background-image: linear-gradient(135deg, #a1a1aa, #52525b);
}
.accent-signin {
background-image: linear-gradient(135deg, #38bdf8, #6366f1);
}
.accent-report {
background-image: linear-gradient(135deg, #fb7185, #e11d48);
}
@media (prefers-reduced-motion: reduce) {
.tile,
.chip,
.tile :global(.tile-icon),
.tile :global(.tile-arrow) {
transition:
background-color 160ms ease,
border-color 160ms ease;
}
.tile:hover,
.chip:hover,
.tile:hover :global(.tile-icon),
.tile:hover :global(.tile-arrow) {
transform: none;
}
}
</style>