feat: add localized theme-aware 404 page

This commit is contained in:
2026-09-10 09:09:54 +03:30
parent 8f03f8e9ca
commit 9a778653cf
4 changed files with 230 additions and 0 deletions

View File

@@ -0,0 +1,137 @@
.page {
display: grid;
min-height: 100svh;
padding: var(--space-6) var(--layout-page-gutter);
place-items: center;
overflow: hidden;
background: var(--bg-page);
color: var(--text-primary);
}
.content {
display: flex;
width: min(100%, 36rem);
align-items: center;
flex-direction: column;
text-align: center;
}
.persian {
display: none;
}
:global(html[lang='fa']) .english {
display: none;
}
:global(html[lang='fa']) .persian {
display: flex;
}
.code {
color: color-mix(in srgb, var(--accent-default) 38%, var(--bg-page));
font-size: clamp(6rem, 22vw, 12rem);
font-weight: 850;
letter-spacing: -0.08em;
line-height: 0.82;
user-select: none;
}
:global(html[dir='rtl']) .code {
letter-spacing: 0;
}
.divider {
width: 3rem;
height: 0.2rem;
margin-block: var(--space-7) var(--space-5);
border-radius: var(--radius-pill);
background: var(--accent-default);
}
.content h1 {
max-width: 18ch;
color: var(--text-primary);
font-size: clamp(2rem, 5vw, 3rem);
font-weight: 780;
letter-spacing: -0.04em;
line-height: 1.12;
text-wrap: balance;
}
:global(html[dir='rtl']) .content h1 {
letter-spacing: 0;
}
.description {
max-width: 34rem;
margin-top: var(--space-4);
color: var(--text-secondary);
font-size: var(--font-size-body-large);
line-height: var(--line-height-body-large);
text-wrap: pretty;
}
.homeLink {
display: inline-flex;
min-height: var(--size-button-height);
align-items: center;
justify-content: center;
gap: var(--space-2);
margin-top: var(--space-7);
padding-inline: var(--space-5);
border: 1px solid transparent;
border-radius: var(--radius-pill);
background: var(--action-primary-bg);
color: var(--action-primary-fg);
font-size: var(--font-size-button);
font-weight: 700;
line-height: var(--line-height-button);
transition:
background-color var(--motion-duration-fast) ease,
box-shadow var(--motion-duration-fast) ease,
transform var(--motion-duration-fast) var(--motion-ease-out);
}
.homeLink svg {
width: var(--size-icon-md);
height: var(--size-icon-md);
}
.homeLink:active {
transform: scale(0.97);
}
@media (hover: hover) and (pointer: fine) {
.homeLink:hover {
background: var(--action-primary-hover);
box-shadow: 0 0.25rem 0.75rem var(--shadow-color);
transform: translateY(-1px);
}
}
@media (max-width: 650px) {
.page {
padding-block: var(--space-5);
}
.divider {
margin-block: var(--space-6) var(--space-4);
}
.description {
font-size: var(--font-size-body-default);
line-height: var(--line-height-body-default);
}
}
@media (prefers-reduced-motion: reduce) {
.homeLink {
transition: background-color 120ms ease, box-shadow 120ms ease;
}
.homeLink:hover,
.homeLink:active {
transform: none;
}
}

View File

@@ -0,0 +1,72 @@
import type { Metadata } from 'next';
import localFont from 'next/font/local';
import Link from 'next/link';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { messagesByLocale, type Locale } from '@/i18n/content';
import '@/styles/globals.css';
import styles from './global-not-found.module.css';
const themeAndLocaleScript = `
try {
const root = document.documentElement;
const isPersian = /^\\/fa(?:\\/|$)/.test(window.location.pathname);
root.lang = isPersian ? 'fa' : 'en';
root.dir = isPersian ? 'rtl' : 'ltr';
const savedTheme = localStorage.getItem('portfolio-theme');
root.dataset.theme = savedTheme === 'dark' ? 'dark' : 'light';
} catch {}
`;
const iranYekan = localFont({
src: '../../public/fonts/IRANYekanXVF.woff2',
variable: '--font-body',
weight: '100 1000',
style: 'normal',
display: 'swap',
preload: false,
fallback: ['-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'sans-serif'],
});
export const metadata: Metadata = {
title: '404 | Amirhossein Khalili',
description: 'The requested page could not be found.',
};
function NotFoundContent({ locale }: { locale: Locale }) {
const copy = messagesByLocale[locale].notFound;
const isRtl = locale === 'fa';
return (
<section
className={`${styles.content} ${locale === 'fa' ? styles.persian : styles.english}`}
lang={locale}
dir={isRtl ? 'rtl' : 'ltr'}
aria-labelledby={`not-found-title-${locale}`}
>
<p className={styles.code} aria-hidden="true">{copy.code}</p>
<span className={styles.divider} aria-hidden="true" />
<h1 id={`not-found-title-${locale}`}>{copy.title}</h1>
<p className={styles.description}>{copy.description}</p>
<Link href={isRtl ? '/fa' : '/'} className={styles.homeLink}>
{isRtl ? <ArrowRight aria-hidden="true" /> : <ArrowLeft aria-hidden="true" />}
<span>{copy.backHome}</span>
</Link>
</section>
);
}
export default function GlobalNotFound() {
return (
<html lang="en" dir="ltr" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeAndLocaleScript }} />
</head>
<body className={iranYekan.variable}>
<main className={styles.page}>
<NotFoundContent locale="en" />
<NotFoundContent locale="fa" />
</main>
</body>
</html>
);
}

View File

@@ -9,6 +9,12 @@ export interface SiteMessages {
description: string;
keywords: string[];
};
notFound: {
code: string;
title: string;
description: string;
backHome: string;
};
loader: {
signature: string;
message: string;
@@ -146,6 +152,12 @@ export const messagesByLocale = {
'Portfolio',
],
},
notFound: {
code: '404',
title: 'This page isn’t here',
description: 'The address may have changed, or the page may no longer exist.',
backHome: 'Back to portfolio',
},
loader: {
signature: 'Amirhossein Khalili',
message: 'Assembling the experience',
@@ -306,6 +318,12 @@ export const messagesByLocale = {
'نمونه‌کار',
],
},
notFound: {
code: '۴۰۴',
title: 'این صفحه پیدا نشد',
description: 'ممکن است نشانی صفحه تغییر کرده باشد یا دیگر در دسترس نباشد.',
backHome: 'بازگشت به نمونه‌کارها',
},
loader: {
signature: 'امیرحسین خلیلی',
message: 'در حال آماده‌سازی صفحه',