refactor(errors): redesign error pages in editorial style

Replace the emoji/gradient card with an editorial layout: small lucide
glyph, oversized error code, hairline-divided serif bible quote.

Extract shared ErrorView + SectionError components and a bilingual
string helper. Add +error.svelte at each section root (faith, recipes,
fitness, tasks, cospend) so errors render inside the correct layout and
inherit the section-specific header/nav. Catch-all [...rest]/+page.ts
stubs route unmatched URLs through the section layout so the right
error page catches them.
This commit is contained in:
2026-04-20 19:53:50 +02:00
parent 97e8734709
commit fbd09fbdae
16 changed files with 505 additions and 788 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.36.3",
"version": "1.37.0",
"private": true,
"type": "module",
"scripts": {
+222
View File
@@ -0,0 +1,222 @@
<script lang="ts">
import type { Snippet, Component } from 'svelte';
import { Lock, Ban, SearchX, TriangleAlert, CircleAlert } from '@lucide/svelte';
interface BibleQuote {
text: string;
reference: string;
}
interface Props {
status: number;
title: string;
description: string;
details?: string;
bibleQuote?: BibleQuote | null;
isEnglish?: boolean;
icon?: Component;
actions?: Snippet;
}
let {
status,
title,
description,
details,
bibleQuote,
isEnglish = true,
icon,
actions
}: Props = $props();
function defaultIcon(status: number): Component {
switch (status) {
case 401: return Lock;
case 403: return Ban;
case 404: return SearchX;
case 500: return TriangleAlert;
default: return CircleAlert;
}
}
let Icon = $derived(icon ?? defaultIcon(status));
let openQuote = $derived(isEnglish ? '\u201C' : '\u201E');
let closeQuote = $derived(isEnglish ? '\u201D' : '\u201C');
</script>
<main class="error-page">
<article class="error-article">
<header class="eyebrow">
<Icon size={14} strokeWidth={1.5} aria-hidden="true" />
<span class="eyebrow-label">
{isEnglish ? 'Error' : 'Fehler'}
</span>
</header>
<div class="code" aria-hidden="true">{status}</div>
<h1 class="title">{title}</h1>
<p class="description">{description}</p>
{#if details}
<p class="details">{details}</p>
{/if}
{#if actions}
<nav class="actions">
{@render actions()}
</nav>
{/if}
{#if bibleQuote}
<hr class="rule" />
<figure class="quote">
<blockquote class="quote-text">
{openQuote}{bibleQuote.text}{closeQuote}
</blockquote>
<figcaption class="quote-reference">{bibleQuote.reference}</figcaption>
</figure>
{/if}
</article>
</main>
<style>
.error-page {
min-height: calc(100vh - 6rem);
display: flex;
align-items: flex-start;
justify-content: center;
padding: clamp(3rem, 10vh, 8rem) 1.5rem 4rem;
background: var(--color-bg-primary);
}
.error-article {
width: 100%;
max-width: 640px;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--color-text-tertiary);
margin-bottom: 1rem;
}
.eyebrow-label {
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.code {
font-size: clamp(7rem, 22vw, 14rem);
font-weight: 200;
line-height: 0.9;
letter-spacing: -0.05em;
color: var(--color-text-primary);
margin: 0 0 1.5rem;
font-variant-numeric: lining-nums tabular-nums;
}
.title {
font-size: clamp(1.5rem, 3vw, 2rem);
font-weight: 500;
letter-spacing: -0.01em;
color: var(--color-text-primary);
margin: 0 0 0.5rem;
}
.description {
font-size: 1.0625rem;
line-height: 1.55;
color: var(--color-text-secondary);
margin: 0;
max-width: 44ch;
}
.details {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
font-size: 0.8125rem;
color: var(--color-text-tertiary);
margin: 1.25rem 0 0;
padding-left: 0.875rem;
border-left: 1px solid var(--color-border);
max-width: 44ch;
}
.actions {
display: flex;
gap: 1.75rem;
margin: 2.25rem 0 0;
flex-wrap: wrap;
}
:global(.error-article .link) {
background: none;
border: none;
padding: 0;
font: inherit;
font-size: 0.95rem;
cursor: pointer;
color: var(--color-text-secondary);
position: relative;
transition: color var(--transition-normal, 200ms ease);
}
:global(.error-article .link::after) {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -2px;
height: 1px;
background: currentColor;
opacity: 0.35;
transition: opacity var(--transition-normal, 200ms ease);
}
:global(.error-article .link:hover) { color: var(--color-text-primary); }
:global(.error-article .link:hover::after) { opacity: 1; }
:global(.error-article .link-primary) { color: var(--color-primary); }
:global(.error-article .link-primary:hover) {
color: var(--color-primary-hover, var(--color-primary));
}
.rule {
border: none;
border-top: 1px solid var(--color-border);
margin: 4rem 0 2.5rem;
width: 3rem;
}
.quote {
margin: 0;
}
.quote-text {
font-family: Georgia, "Times New Roman", Cambria, serif;
font-style: italic;
font-size: clamp(1.25rem, 2.2vw, 1.625rem);
line-height: 1.5;
color: var(--color-text-primary);
margin: 0 0 1rem;
text-wrap: balance;
hyphens: auto;
}
.quote-reference {
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-text-tertiary);
}
@media (max-width: 560px) {
.actions { gap: 1.25rem; }
.rule { margin: 3rem 0 2rem; }
}
</style>
+59
View File
@@ -0,0 +1,59 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import ErrorView from './ErrorView.svelte';
import { getErrorTitle, getErrorDescription, errorLabels, pick } from '$lib/js/errorStrings';
import type { Snippet } from 'svelte';
interface Props {
/** Destination of the section's primary "home" link, e.g. "/rezepte" */
sectionHref: string;
/** Label for that link in both languages */
sectionLabel: { en: string; de: string };
/** Override language detection (defaults to error.lang from handleError) */
isEnglish?: boolean;
/** Extra action buttons rendered before the defaults */
extraActions?: Snippet;
}
let { sectionHref, sectionLabel, isEnglish: isEnglishProp, extraActions }: Props = $props();
let status = $derived($page.status);
let error = $derived($page.error as any);
let bibleQuote = $derived(error?.bibleQuote);
let detectedEnglish = $derived(error?.lang === 'en');
let isEnglish = $derived(isEnglishProp ?? detectedEnglish);
let details = $derived(error?.details);
let label = $derived(pick(sectionLabel, isEnglish));
function goBack() {
if (window.history.length > 1) window.history.back();
else goto(sectionHref);
}
function login() { goto('/login'); }
</script>
<ErrorView
{status}
title={getErrorTitle(status, isEnglish)}
description={getErrorDescription(status, isEnglish)}
{details}
{bibleQuote}
{isEnglish}
>
{#snippet actions()}
{#if extraActions}{@render extraActions()}{/if}
{#if status === 401}
<button class="link link-primary" onclick={login}>{pick(errorLabels.login, isEnglish)}</button>
<a class="link" href={sectionHref}>{label}</a>
{:else if status === 500}
<button class="link link-primary" onclick={goBack}>{pick(errorLabels.tryAgain, isEnglish)}</button>
<a class="link" href={sectionHref}>{label}</a>
{:else}
<a class="link link-primary" href={sectionHref}>{label}</a>
<button class="link" onclick={goBack}>{pick(errorLabels.goBack, isEnglish)}</button>
{/if}
{/snippet}
</ErrorView>
+48
View File
@@ -0,0 +1,48 @@
export function getErrorTitle(status: number, isEnglish: boolean): string {
if (isEnglish) {
switch (status) {
case 401: return 'Login Required';
case 403: return 'Access Denied';
case 404: return 'Page Not Found';
case 500: return 'Server Error';
default: return 'Error';
}
}
switch (status) {
case 401: return 'Anmeldung erforderlich';
case 403: return 'Zugriff verweigert';
case 404: return 'Seite nicht gefunden';
case 500: return 'Serverfehler';
default: return 'Fehler';
}
}
export function getErrorDescription(status: number, isEnglish: boolean): string {
if (isEnglish) {
switch (status) {
case 401: return 'You must be logged in to access this page.';
case 403: return 'You do not have permission for this area.';
case 404: return 'The requested page could not be found.';
case 500: return 'An unexpected error occurred. Please try again later.';
default: return 'An unexpected error occurred.';
}
}
switch (status) {
case 401: return 'Du musst angemeldet sein, um auf diese Seite zugreifen zu können.';
case 403: return 'Du hast keine Berechtigung für diesen Bereich.';
case 404: return 'Die angeforderte Seite konnte nicht gefunden werden.';
case 500: return 'Es ist ein unerwarteter Fehler aufgetreten. Bitte versuche es später erneut.';
default: return 'Es ist ein unerwarteter Fehler aufgetreten.';
}
}
export const errorLabels = {
login: { en: 'Log in', de: 'Anmelden' },
tryAgain: { en: 'Try again', de: 'Erneut versuchen' },
goBack: { en: 'Go back', de: 'Zurück' },
homepage: { en: 'Homepage', de: 'Startseite' }
} as const;
export function pick(pair: { en: string; de: string }, isEnglish: boolean): string {
return isEnglish ? pair.en : pair.de;
}
+33 -411
View File
@@ -1,431 +1,53 @@
<script>
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import Header from '$lib/components/Header.svelte';
import ErrorView from '$lib/components/ErrorView.svelte';
import { getErrorTitle, getErrorDescription, errorLabels, pick } from '$lib/js/errorStrings';
let status = $derived($page.status);
let error = $derived($page.error);
let error = $derived($page.error as any);
// Get session data if available (may not be available in error context)
let session = $derived($page.data?.session);
let user = $derived(session?.user);
// Get Bible quote and language from SSR via handleError hook
let bibleQuote = $derived(/** @type {any} */ ($page.error)?.bibleQuote);
let isEnglish = $derived(/** @type {any} */ ($page.error)?.lang === 'en');
/** @param {number} status */
function getErrorTitle(status) {
if (isEnglish) {
switch (status) {
case 401: return 'Login Required';
case 403: return 'Access Denied';
case 404: return 'Page Not Found';
case 500: return 'Server Error';
default: return 'Error';
}
}
switch (status) {
case 401: return 'Anmeldung erforderlich';
case 403: return 'Zugriff verweigert';
case 404: return 'Seite nicht gefunden';
case 500: return 'Serverfehler';
default: return 'Fehler';
}
}
/** @param {number} status */
function getErrorDescription(status) {
if (isEnglish) {
switch (status) {
case 401: return 'You must be logged in to access this page.';
case 403: return 'You do not have permission for this area.';
case 404: return 'The requested page could not be found.';
case 500: return 'An unexpected error occurred. Please try again later.';
default: return 'An unexpected error occurred.';
}
}
switch (status) {
case 401: return 'Du musst angemeldet sein, um auf diese Seite zugreifen zu können.';
case 403: return 'Du hast keine Berechtigung für diesen Bereich.';
case 404: return 'Die angeforderte Seite konnte nicht gefunden werden.';
case 500: return 'Es ist ein unerwarteter Fehler aufgetreten. Bitte versuche es später erneut.';
default: return 'Es ist ein unerwarteter Fehler aufgetreten.';
}
}
/** @param {number} status */
function getErrorIcon(status) {
switch (status) {
case 401:
return '🔐';
case 403:
return '🚫';
case 404:
return '🔍';
case 500:
return '⚠️';
default:
return '❌';
}
}
function goHome() {
goto('/');
}
let bibleQuote = $derived(error?.bibleQuote);
let isEnglish = $derived(error?.lang === 'en');
let details = $derived(error?.details);
function goHome() { goto('/'); }
function goBack() {
if (window.history.length > 1) {
window.history.back();
} else {
goto('/');
}
}
function login() {
goto('/login');
if (window.history.length > 1) window.history.back();
else goto('/');
}
function login() { goto('/login'); }
</script>
<svelte:head>
<title>{getErrorTitle(status)} - Alexander's Website</title>
<title>{getErrorTitle(status, isEnglish)} Alexander's Website</title>
</svelte:head>
<Header>
{#snippet links()}
<ul class="site_header">
</ul>
<ul class="site_header"></ul>
{/snippet}
<main class="error-page">
<div class="error-container">
<div class="error-icon">
{getErrorIcon(status)}
</div>
<h1 class="error-title">
{getErrorTitle(status)}
</h1>
<div class="error-code">
{isEnglish ? 'Error' : 'Fehler'} {status}
</div>
<p class="error-description">
{getErrorDescription(status)}
</p>
{#if /** @type {any} */ (error)?.details}
<div class="error-details">
{/** @type {any} */ (error).details}
</div>
<ErrorView
{status}
title={getErrorTitle(status, isEnglish)}
description={getErrorDescription(status, isEnglish)}
{details}
{bibleQuote}
{isEnglish}
>
{#snippet actions()}
{#if status === 401}
<button class="link link-primary" onclick={login}>{pick(errorLabels.login, isEnglish)}</button>
<button class="link" onclick={goHome}>{pick(errorLabels.homepage, isEnglish)}</button>
{:else if status === 500}
<button class="link link-primary" onclick={goBack}>{pick(errorLabels.tryAgain, isEnglish)}</button>
<button class="link" onclick={goHome}>{pick(errorLabels.homepage, isEnglish)}</button>
{:else}
<button class="link link-primary" onclick={goHome}>{pick(errorLabels.homepage, isEnglish)}</button>
<button class="link" onclick={goBack}>{pick(errorLabels.goBack, isEnglish)}</button>
{/if}
<div class="error-actions">
{#if status === 401}
<button class="btn btn-primary" onclick={login}>
{isEnglish ? 'Log In' : 'Anmelden'}
</button>
<button class="btn btn-secondary" onclick={goHome}>
{isEnglish ? 'Go to Homepage' : 'Zur Startseite'}
</button>
{:else if status === 500}
<button class="btn btn-primary" onclick={goHome}>
{isEnglish ? 'Go to Homepage' : 'Zur Startseite'}
</button>
<button class="btn btn-secondary" onclick={goBack}>
{isEnglish ? 'Try Again' : 'Erneut versuchen'}
</button>
{:else}
<button class="btn btn-primary" onclick={goHome}>
{isEnglish ? 'Go to Homepage' : 'Zur Startseite'}
</button>
<button class="btn btn-secondary" onclick={goBack}>
{isEnglish ? 'Go Back' : 'Zurück'}
</button>
{/if}
</div>
<!-- Bible Quote Section -->
{#if bibleQuote}
<div class="bible-quote">
<div class="quote-text">
{isEnglish ? '"' : '„'}{bibleQuote.text}{isEnglish ? '"' : '"'}
</div>
<div class="quote-reference">
{bibleQuote.reference}
</div>
</div>
{/if}
</div>
</main>
{/snippet}
</ErrorView>
</Header>
<style>
.error-page {
min-height: calc(100vh - 6rem);
display: flex;
align-items: center;
justify-content: center;
background: #fbf9f3;
padding: 2rem;
}
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .error-page {
background: var(--background-dark);
}
}
:global(:root[data-theme="dark"]) .error-page {
background: var(--background-dark);
}
.error-container {
background: var(--nord5);
border-radius: 1rem;
padding: 3rem;
max-width: 600px;
width: 100%;
text-align: center;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border: 1px solid var(--nord4);
}
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .error-container {
background: var(--nord1);
border-color: var(--nord2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
}
:global(:root[data-theme="dark"]) .error-container {
background: var(--nord1);
border-color: var(--nord2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.error-icon {
font-size: 4rem;
margin-bottom: 1.5rem;
opacity: 0.8;
}
.error-title {
font-size: 2.5rem;
color: var(--nord0);
margin-bottom: 0.5rem;
font-weight: 700;
}
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .error-title {
color: var(--nord6);
}
}
:global(:root[data-theme="dark"]) .error-title {
color: var(--nord6);
}
.error-code {
font-size: 1.2rem;
color: var(--nord3);
font-weight: 600;
margin-bottom: 1.5rem;
opacity: 0.8;
}
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .error-code {
color: var(--nord4);
}
}
:global(:root[data-theme="dark"]) .error-code {
color: var(--nord4);
}
.error-description {
font-size: 1.1rem;
color: var(--nord2);
margin-bottom: 1rem;
line-height: 1.6;
}
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .error-description {
color: var(--nord5);
}
}
:global(:root[data-theme="dark"]) .error-description {
color: var(--nord5);
}
.error-details {
background: var(--nord4);
border-radius: 0.5rem;
padding: 1rem;
margin: 1.5rem 0;
font-size: 0.9rem;
color: var(--nord0);
border-left: 4px solid var(--blue);
}
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .error-details {
background: var(--nord2);
color: var(--nord6);
}
}
:global(:root[data-theme="dark"]) .error-details {
background: var(--nord2);
color: var(--nord6);
}
.error-actions {
display: flex;
gap: 1rem;
justify-content: center;
margin: 2rem 0;
flex-wrap: wrap;
}
.btn {
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
text-decoration: none;
border: none;
cursor: pointer;
transition: all 0.2s ease;
font-size: 1rem;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-primary {
background: linear-gradient(135deg, var(--blue), var(--lightblue));
color: white;
}
.btn-primary:hover {
background: linear-gradient(135deg, var(--lightblue), var(--blue));
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(94, 129, 172, 0.3);
}
.btn-secondary {
background: var(--nord4);
color: var(--nord0);
border: 1px solid var(--nord3);
}
.btn-secondary:hover {
background: var(--nord3);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .btn-secondary {
background: var(--nord2);
color: var(--nord6);
border-color: var(--nord3);
}
:global(:root:not([data-theme="light"])) .btn-secondary:hover {
background: var(--nord3);
}
}
:global(:root[data-theme="dark"]) .btn-secondary {
background: var(--nord2);
color: var(--nord6);
border-color: var(--nord3);
}
:global(:root[data-theme="dark"]) .btn-secondary:hover {
background: var(--nord3);
}
.bible-quote {
margin: 2.5rem 0;
padding: 2rem;
background: linear-gradient(135deg, var(--nord5), var(--nord4));
border-radius: 0.75rem;
border-left: 4px solid var(--blue);
font-style: italic;
}
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .bible-quote {
background: linear-gradient(135deg, var(--nord2), var(--nord3));
}
}
:global(:root[data-theme="dark"]) .bible-quote {
background: linear-gradient(135deg, var(--nord2), var(--nord3));
}
.quote-text {
font-size: 1.1rem;
line-height: 1.6;
color: var(--nord0);
margin-bottom: 1rem;
text-align: left;
}
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .quote-text {
color: var(--nord6);
}
}
:global(:root[data-theme="dark"]) .quote-text {
color: var(--nord6);
}
.quote-reference {
font-size: 0.9rem;
color: var(--nord2);
font-weight: 600;
text-align: right;
font-style: normal;
}
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .quote-reference {
color: var(--nord4);
}
}
:global(:root[data-theme="dark"]) .quote-reference {
color: var(--nord4);
}
@media (max-width: 600px) {
.error-container {
padding: 2rem;
margin: 1rem;
}
.error-title {
font-size: 2rem;
}
.error-actions {
flex-direction: column;
align-items: center;
}
.btn {
width: 100%;
max-width: 250px;
}
.bible-quote {
padding: 1.5rem;
}
.quote-text {
font-size: 1rem;
}
}
</style>
@@ -0,0 +1,14 @@
<script lang="ts">
import { page } from '$app/stores';
import SectionError from '$lib/components/SectionError.svelte';
import { detectCospendLang, cospendRoot } from '$lib/js/cospendI18n';
let lang = $derived(detectCospendLang($page.url.pathname));
let isEnglish = $derived(lang === 'en');
</script>
<SectionError
sectionHref={cospendRoot(lang)}
sectionLabel={{ en: 'Expenses', de: 'Kosten' }}
{isEnglish}
/>
@@ -0,0 +1,5 @@
import { error } from '@sveltejs/kit';
export const load = () => {
error(404, 'Not found');
};
@@ -0,0 +1,13 @@
<script lang="ts">
import { page } from '$app/stores';
import SectionError from '$lib/components/SectionError.svelte';
let faithLang = $derived($page.params.faithLang);
let isEnglish = $derived(faithLang === 'faith');
</script>
<SectionError
sectionHref="/{faithLang}"
sectionLabel={{ en: 'Faith', de: 'Glaube' }}
{isEnglish}
/>
@@ -0,0 +1,5 @@
import { error } from '@sveltejs/kit';
export const load = () => {
error(404, 'Not found');
};
@@ -0,0 +1,13 @@
<script lang="ts">
import { page } from '$app/stores';
import SectionError from '$lib/components/SectionError.svelte';
let recipeLang = $derived($page.params.recipeLang);
let isEnglish = $derived(recipeLang === 'recipes');
</script>
<SectionError
sectionHref="/{recipeLang}"
sectionLabel={{ en: 'Recipes', de: 'Rezepte' }}
{isEnglish}
/>
@@ -0,0 +1,5 @@
import { error } from '@sveltejs/kit';
export const load = () => {
error(404, 'Not found');
};
@@ -2,28 +2,29 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import ErrorView from '$lib/components/ErrorView.svelte';
import { getErrorTitle, getErrorDescription, errorLabels, pick } from '$lib/js/errorStrings';
let status = $derived($page.status);
let error = $derived($page.error);
let error = $derived($page.error as any);
let recipeLang = $derived($page.params.recipeLang);
let recipeName = $derived($page.params.name);
let session = $derived($page.data?.session);
let user = $derived(session?.user);
let user = $derived($page.data?.session?.user);
let isEnglishRoute = $derived(recipeLang === 'recipes');
let isEnglish = $derived(error?.lang === 'en' || isEnglishRoute);
let bibleQuote = $derived(error?.bibleQuote);
// State to track if German recipe exists
let germanRecipeExists = $state(false);
let checkingGermanRecipe = $state(false);
// Check if German recipe exists when on English route with 404
onMount(async () => {
const isEnglishRoute = recipeLang === 'recipes';
if (isEnglishRoute && status === 404) {
checkingGermanRecipe = true;
try {
// Check if German recipe exists
const response = await fetch(`/api/rezepte/items/${recipeName}`);
germanRecipeExists = response.ok;
} catch (e) {
} catch {
germanRecipeExists = false;
} finally {
checkingGermanRecipe = false;
@@ -31,384 +32,61 @@
}
});
function getErrorTitle(status: number) {
switch (status) {
case 401:
return 'Authentication Required';
case 403:
return 'Access Denied';
case 404:
return 'Recipe Not Found';
case 500:
return 'Server Error';
default:
return 'Error';
}
}
let showGermanFallback = $derived(
status === 404 && isEnglishRoute && germanRecipeExists && !checkingGermanRecipe
);
function getErrorDescription(status: number) {
const isEnglishRoute = recipeLang === 'recipes';
let title = $derived(
status === 404 ? (isEnglish ? 'Recipe Not Found' : 'Rezept nicht gefunden')
: getErrorTitle(status, isEnglish)
);
switch (status) {
case 401:
return 'You must be logged in to access this page.';
case 403:
return 'You do not have permission to access this area.';
case 404:
if (isEnglishRoute && germanRecipeExists) {
return 'This recipe has not been translated to English yet, but the German version is available.';
}
return 'The requested recipe could not be found.';
case 500:
return 'An unexpected error occurred. Please try again later.';
default:
return 'An unexpected error occurred.';
}
}
let description = $derived(
showGermanFallback
? 'This recipe has not been translated to English yet, but the German version is available.'
: status === 404
? (isEnglish ? 'The requested recipe could not be found.' : 'Das angeforderte Rezept konnte nicht gefunden werden.')
: getErrorDescription(status, isEnglish)
);
function getErrorIcon(status: number) {
switch (status) {
case 401:
return '🔐';
case 403:
return '🚫';
case 404:
return '🔍';
case 500:
return '⚠️';
default:
return '❌';
}
}
let details = $derived(
checkingGermanRecipe
? (isEnglish ? 'Checking for German version…' : 'Suche nach deutscher Version…')
: error?.details
);
function viewGermanRecipe() {
goto(`/rezepte/${recipeName}`);
}
function editToTranslate() {
goto(`/rezepte/edit/${recipeName}`);
}
function goToRecipes() {
const lang = recipeLang === 'recipes' ? 'recipes' : 'rezepte';
goto(`/${lang}`);
}
let recipesHref = $derived(isEnglishRoute ? '/recipes' : '/rezepte');
function viewGermanRecipe() { goto(`/rezepte/${recipeName}`); }
function editToTranslate() { goto(`/rezepte/edit/${recipeName}`); }
function goBack() {
if (window.history.length > 1) {
window.history.back();
} else {
goToRecipes();
}
}
function login() {
goto('/login');
if (window.history.length > 1) window.history.back();
else goto(recipesHref);
}
function login() { goto('/login'); }
</script>
<svelte:head>
<title>{getErrorTitle(status)} - Alexander's Website</title>
<title>{title} Alexander's Website</title>
</svelte:head>
<main class="error-page">
<div class="error-container">
<div class="error-icon">
{getErrorIcon(status)}
</div>
<h1 class="error-title">
{getErrorTitle(status)}
</h1>
<div class="error-code">
Error {status}
</div>
<p class="error-description">
{getErrorDescription(status)}
</p>
{#if error?.message && !checkingGermanRecipe}
<div class="error-details">
{error.message}
</div>
{/if}
{#if checkingGermanRecipe}
<div class="checking-message">
Checking for German version...
</div>
{/if}
<div class="error-actions">
{#if status === 401}
<button class="btn btn-primary" onclick={login}>
Log In
</button>
<button class="btn btn-secondary" onclick={goToRecipes}>
Go to Recipes
</button>
{:else if status === 404 && recipeLang === 'recipes' && germanRecipeExists && !checkingGermanRecipe}
<!-- Special case: English recipe not found but German exists -->
<button class="btn btn-primary" onclick={viewGermanRecipe}>
View German Recipe
</button>
{#if user}
<button class="btn btn-secondary" onclick={editToTranslate}>
Edit to Translate
</button>
{/if}
<button class="btn btn-secondary" onclick={goToRecipes}>
Go to Recipes
</button>
{:else if status === 404}
<button class="btn btn-primary" onclick={goToRecipes}>
Go to Recipes
</button>
<button class="btn btn-secondary" onclick={goBack}>
Go Back
</button>
{:else if status === 403}
<button class="btn btn-primary" onclick={goToRecipes}>
Go to Recipes
</button>
<button class="btn btn-secondary" onclick={goBack}>
Go Back
</button>
{:else if status === 500}
<button class="btn btn-primary" onclick={goToRecipes}>
Go to Recipes
</button>
<button class="btn btn-secondary" onclick={goBack}>
Try Again
</button>
{:else}
<button class="btn btn-primary" onclick={goToRecipes}>
Go to Recipes
</button>
<button class="btn btn-secondary" onclick={goBack}>
Go Back
</button>
<ErrorView {status} {title} {description} {details} {bibleQuote} {isEnglish}>
{#snippet actions()}
{#if status === 401}
<button class="link link-primary" onclick={login}>{pick(errorLabels.login, isEnglish)}</button>
<a class="link" href={recipesHref}>{isEnglish ? 'Recipes' : 'Rezepte'}</a>
{:else if showGermanFallback}
<button class="link link-primary" onclick={viewGermanRecipe}>View German recipe</button>
{#if user}
<button class="link" onclick={editToTranslate}>Edit to translate</button>
{/if}
</div>
</div>
</main>
<style>
.error-page {
min-height: calc(100vh - 6rem);
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-primary);
padding: 2rem;
}
.error-container {
background: var(--nord5);
border-radius: 1rem;
padding: 3rem;
max-width: 600px;
width: 100%;
text-align: center;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border: 1px solid var(--nord4);
}
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .error-container {
background: var(--nord1);
border-color: var(--nord2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
}
:global(:root[data-theme="dark"]) .error-container {
background: var(--nord1);
border-color: var(--nord2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.error-icon {
font-size: 4rem;
margin-bottom: 1.5rem;
opacity: 0.8;
}
.error-title {
font-size: 2.5rem;
color: var(--nord0);
margin-bottom: 0.5rem;
font-weight: 700;
}
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .error-title {
color: var(--nord6);
}
}
:global(:root[data-theme="dark"]) .error-title {
color: var(--nord6);
}
.error-code {
font-size: 1.2rem;
color: var(--nord3);
font-weight: 600;
margin-bottom: 1.5rem;
opacity: 0.8;
}
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .error-code {
color: var(--nord4);
}
}
:global(:root[data-theme="dark"]) .error-code {
color: var(--nord4);
}
.error-description {
font-size: 1.1rem;
color: var(--nord2);
margin-bottom: 1rem;
line-height: 1.6;
}
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .error-description {
color: var(--nord5);
}
}
:global(:root[data-theme="dark"]) .error-description {
color: var(--nord5);
}
.error-details {
background: var(--nord4);
border-radius: 0.5rem;
padding: 1rem;
margin: 1.5rem 0;
font-size: 0.9rem;
color: var(--nord0);
border-left: 4px solid var(--blue);
}
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .error-details {
background: var(--nord2);
color: var(--nord6);
}
}
:global(:root[data-theme="dark"]) .error-details {
background: var(--nord2);
color: var(--nord6);
}
.checking-message {
background: var(--nord4);
border-radius: 0.5rem;
padding: 1rem;
margin: 1.5rem 0;
font-size: 0.9rem;
color: var(--nord2);
font-style: italic;
}
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .checking-message {
background: var(--nord2);
color: var(--nord4);
}
}
:global(:root[data-theme="dark"]) .checking-message {
background: var(--nord2);
color: var(--nord4);
}
.error-actions {
display: flex;
gap: 1rem;
justify-content: center;
margin: 2rem 0;
flex-wrap: wrap;
}
.btn {
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
text-decoration: none;
border: none;
cursor: pointer;
transition: all 0.2s ease;
font-size: 1rem;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-primary {
background: linear-gradient(135deg, var(--blue), var(--lightblue));
color: white;
}
.btn-primary:hover {
background: linear-gradient(135deg, var(--lightblue), var(--blue));
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(94, 129, 172, 0.3);
}
.btn-secondary {
background: var(--nord4);
color: var(--nord0);
border: 1px solid var(--nord3);
}
.btn-secondary:hover {
background: var(--nord3);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .btn-secondary {
background: var(--nord2);
color: var(--nord6);
border-color: var(--nord3);
}
:global(:root:not([data-theme="light"])) .btn-secondary:hover {
background: var(--nord3);
}
}
:global(:root[data-theme="dark"]) .btn-secondary {
background: var(--nord2);
color: var(--nord6);
border-color: var(--nord3);
}
:global(:root[data-theme="dark"]) .btn-secondary:hover {
background: var(--nord3);
}
@media (max-width: 600px) {
.error-container {
padding: 2rem;
margin: 1rem;
}
.error-title {
font-size: 2rem;
}
.error-actions {
flex-direction: column;
align-items: center;
}
.btn {
width: 100%;
max-width: 250px;
}
}
</style>
<a class="link" href={recipesHref}>Recipes</a>
{:else if status === 500}
<button class="link link-primary" onclick={goBack}>{pick(errorLabels.tryAgain, isEnglish)}</button>
<a class="link" href={recipesHref}>{isEnglish ? 'Recipes' : 'Rezepte'}</a>
{:else}
<a class="link link-primary" href={recipesHref}>{isEnglish ? 'Recipes' : 'Rezepte'}</a>
<button class="link" onclick={goBack}>{pick(errorLabels.goBack, isEnglish)}</button>
{/if}
{/snippet}
</ErrorView>
+14
View File
@@ -0,0 +1,14 @@
<script lang="ts">
import { page } from '$app/stores';
import SectionError from '$lib/components/SectionError.svelte';
import { detectFitnessLang } from '$lib/js/fitnessI18n';
let lang = $derived(detectFitnessLang($page.url.pathname));
let isEnglish = $derived(lang === 'en');
</script>
<SectionError
sectionHref={isEnglish ? '/fitness/workout' : '/fitness/training'}
sectionLabel={{ en: 'Fitness', de: 'Fitness' }}
{isEnglish}
/>
+5
View File
@@ -0,0 +1,5 @@
import { error } from '@sveltejs/kit';
export const load = () => {
error(404, 'Not found');
};
+9
View File
@@ -0,0 +1,9 @@
<script lang="ts">
import SectionError from '$lib/components/SectionError.svelte';
</script>
<SectionError
sectionHref="/tasks"
sectionLabel={{ en: 'Tasks', de: 'Aufgaben' }}
isEnglish={false}
/>
+5
View File
@@ -0,0 +1,5 @@
import { error } from '@sveltejs/kit';
export const load = () => {
error(404, 'Not found');
};