feat: German as the main language, English under /en, with a language switch

The site now exists in German (at the root, with /impressum and /datenschutz)
and English (/en). A request without a language follows the visitor's choice
from the language switch, then the browser language, then German; the old
English legal addresses move to /en for good.

All visible texts are data in src/content/de.ts and en.ts, so components hold
none of their own. The header carries a DE/EN switch whose bit curtain covers
the page while it is swapped and reopens at the same reading position. Title,
description, canonical, hreflang, Open Graph, the share image, the sitemap and
the structured data are per language. The privacy notice now describes the
cookie that remembers the chosen language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 13:07:22 +02:00
co-authored by Claude Opus 5.5
parent e89a248ca5
commit 75993100fa
72 changed files with 4418 additions and 1405 deletions
-55
View File
@@ -1,55 +0,0 @@
import type { Metadata } from "next";
import { EmailLink } from "@/src/components/EmailLink";
import { LegalPage, LegalSection } from "@/src/components/LegalPage";
import { OWNER } from "@/src/content/site";
export const metadata: Metadata = {
title: "Imprint",
description: "Legal notice (Impressum) for mintel.me.",
alternates: { canonical: "/imprint" },
robots: { index: false, follow: true },
};
const LINK = "font-bold text-slate-900 underline-offset-4 hover:underline";
export default function ImprintPage() {
return (
<LegalPage title="Imprint">
<LegalSection heading="Information according to section 5 DDG">
<p>
{OWNER.name}
<br />
{OWNER.street}
<br />
{OWNER.city}
<br />
{OWNER.country}
</p>
</LegalSection>
<LegalSection heading="Contact">
<p>
Email: <EmailLink className={LINK} />
</p>
</LegalSection>
<LegalSection heading="VAT identification number">
<p>
VAT identification number according to section 27a of the German VAT
Act (Umsatzsteuergesetz): {OWNER.vatId}
</p>
</LegalSection>
<LegalSection heading="Responsible for the content">
<p>{OWNER.name}, address as above (section 18 paragraph 2 MStV).</p>
</LegalSection>
<LegalSection heading="Consumer dispute resolution">
<p>
I am neither willing nor obliged to take part in dispute resolution
proceedings before a consumer arbitration board.
</p>
</LegalSection>
</LegalPage>
);
}
-96
View File
@@ -1,96 +0,0 @@
import type { Metadata, Viewport } from "next";
import { Inter, JetBrains_Mono, Newsreader } from "next/font/google";
import { Analytics } from "@/src/components/Analytics";
import { BitBurst } from "@/src/components/bits/BitBurst";
import { BitGlow } from "@/src/components/bits/BitGlow";
import { Footer } from "@/src/components/Footer";
import { Header } from "@/src/components/Header";
import { InteractiveElements } from "@/src/components/InteractiveElements";
import {
INDEXING,
SITE_DESCRIPTION,
SITE_LANGUAGE,
SITE_NAME,
SITE_TITLE,
SITE_URL,
} from "@/src/content/site";
import { robotsMeta } from "@/src/domain/indexing";
import "./globals.css";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
// A real monospaced fallback has almost the same letter width. The default
// fallback (a resized Arial) wraps the mono labels differently, so the hero
// jumped when the font arrived.
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains-mono",
display: "swap",
adjustFontFallback: false,
fallback: ["ui-monospace", "Menlo", "Consolas", "monospace"],
});
const newsreader = Newsreader({
subsets: ["latin"],
variable: "--font-newsreader",
style: "italic",
display: "swap",
});
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
default: SITE_TITLE,
template: `%s | ${SITE_NAME}`,
},
description: SITE_DESCRIPTION,
applicationName: SITE_NAME,
authors: [{ name: SITE_NAME, url: SITE_URL }],
creator: SITE_NAME,
robots: robotsMeta(INDEXING),
icons: {
icon: "/favicon.svg",
apple: "/apple-touch-icon.png",
},
// The images come from opengraph-image.png and twitter-image.png next to this file.
openGraph: {
type: "website",
url: "/",
siteName: SITE_NAME,
locale: "en_US",
title: SITE_TITLE,
description: SITE_DESCRIPTION,
},
twitter: {
card: "summary_large_image",
title: SITE_TITLE,
description: SITE_DESCRIPTION,
},
};
export const viewport: Viewport = {
themeColor: "#ffffff",
colorScheme: "light",
viewportFit: "cover",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html
lang={SITE_LANGUAGE}
className={`${inter.variable} ${newsreader.variable} ${jetbrainsMono.variable}`}
>
<body className="min-h-screen bg-white">
<Header />
<main>{children}</main>
<Footer />
<InteractiveElements />
<BitBurst />
<BitGlow />
<Analytics />
</body>
</html>
);
}
@@ -1 +0,0 @@
Web apps and websites. Marc Mintel, freelance web developer with 15 years of experience.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

-97
View File
@@ -1,97 +0,0 @@
import type { Metadata } from "next";
import { EmailLink } from "@/src/components/EmailLink";
import { LegalPage, LegalSection } from "@/src/components/LegalPage";
import { OWNER } from "@/src/content/site";
export const metadata: Metadata = {
title: "Privacy",
description: "How mintel.me handles personal data.",
alternates: { canonical: "/privacy" },
robots: { index: false, follow: true },
};
const LINK = "font-bold text-slate-900 underline-offset-4 hover:underline";
export default function PrivacyPage() {
return (
<LegalPage title="Privacy">
<LegalSection heading="Who is responsible">
<p>
{OWNER.name}, {OWNER.street}, {OWNER.city}, {OWNER.country}.
<br />
Email: <EmailLink className={LINK} />
</p>
</LegalSection>
<LegalSection heading="In short">
<p>
This website sets no cookies, shows no advertising and includes no
content from third parties. Fonts and images are served from this
website itself.
</p>
</LegalSection>
<LegalSection heading="Server logs">
<p>
When you open a page, the server processes technical data that your
browser sends: your IP address, the time, the page requested and the
browser type. This is necessary to deliver the website and to keep it
secure. The legal basis is my legitimate interest in running the
website (Article 6(1)(f) GDPR).
</p>
</LegalSection>
<LegalSection heading="Visitor statistics">
<p>
I count page views with Umami, which I run on my own server. It works
without cookies and does not create profiles of individual visitors.
The data is not passed on to third parties. The legal basis is my
legitimate interest in understanding how the website is used (Article
6(1)(f) GDPR).
</p>
</LegalSection>
<LegalSection heading="Error reports">
<p>
If something on this website fails technically, an error report is
sent to an error tracking service that I run on my own server. It
contains technical details such as the page, the browser and the error
message. Sessions are not recorded. The legal basis is my legitimate
interest in a working website (Article 6(1)(f) GDPR).
</p>
</LegalSection>
<LegalSection heading="Storage in your browser">
<p>
If loading a page fails, the website stores a timestamp in your
browser&apos;s session storage so that it reloads only once. It is
deleted when you close the tab and is not sent anywhere.
</p>
</LegalSection>
<LegalSection heading="When you write to me">
<p>
If you send me an email, I process what you write and your address in
order to answer you and, if it comes to that, to prepare a contract
(Article 6(1)(b) GDPR). I keep the correspondence for as long as it is
needed for that purpose or as the law requires.
</p>
</LegalSection>
<LegalSection heading="Your rights">
<p>You have the right to:</p>
<ul>
<li>ask what data I hold about you</li>
<li>have incorrect data corrected</li>
<li>have your data deleted or its processing restricted</li>
<li>object to processing that is based on legitimate interest</li>
<li>receive your data in a portable format</li>
<li>complain to a data protection supervisory authority</li>
</ul>
<p>
To use any of these rights, write to <EmailLink className={LINK} />.
</p>
</LegalSection>
</LegalPage>
);
}
-11
View File
@@ -1,11 +0,0 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/src/content/site";
/**
* The site is a single page; the legal pages are not meant for search results.
* No modification date: it would have to be the date the content last changed,
* and a date that moves with every build teaches search engines to ignore it.
*/
export default function sitemap(): MetadataRoute.Sitemap {
return [{ url: `${SITE_URL}/` }];
}
@@ -1 +0,0 @@
Web apps and websites. Marc Mintel, freelance web developer with 15 years of experience.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

+6
View File
@@ -0,0 +1,6 @@
import { notFound } from "next/navigation";
/** Any address that is not a page ends in the error page of its language. */
export default function UnknownPage() {
notFound();
}
@@ -3,6 +3,8 @@
import { useEffect } from "react";
import * as Sentry from "@sentry/nextjs";
import { Button } from "@/src/components/Button";
import { useContent, useLocale } from "@/src/components/ContentProvider";
import { pathFor } from "@/src/domain/routes";
export default function Error({
error,
@@ -11,6 +13,9 @@ export default function Error({
error: Error & { digest?: string };
reset: () => void;
}) {
const { error: copy } = useContent();
const home = pathFor("home", useLocale());
useEffect(() => {
// Log the error to Sentry/GlitchTip
Sentry.captureException(error);
@@ -37,22 +42,21 @@ export default function Error({
<div className="flex flex-col items-center justify-center min-h-[70vh] px-5 py-20 text-center">
<div className="space-y-6 max-w-2xl mx-auto">
<span className="inline-block px-3 py-1 bg-red-50 border border-red-100 rounded text-[10px] font-mono text-red-500 uppercase tracking-widest">
Error 500
{copy.kicker}
</span>
<h1 className="text-5xl md:text-7xl font-black text-slate-900 tracking-tighter">
Kritischer Fehler.
{copy.title}
</h1>
<p className="text-xl md:text-2xl text-slate-500 font-serif italic max-w-xl mx-auto leading-relaxed">
Ein unerwartetes Problem ist aufgetreten. Unsere Systeme haben den
Vorfall protokolliert.
{copy.text}
</p>
<div className="pt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
<Button href="#" onClick={() => reset()} variant="primary">
System neu starten (Retry)
{copy.retry}
</Button>
<Button href="/" variant="outline">
Zurück zur Basis
<Button href={home} variant="outline">
{copy.home}
</Button>
</div>
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { LegalPage } from "@/src/components/LegalPage";
import { IMPRINT } from "@/src/content/legal/imprint";
import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale";
import { languageAlternates, pathFor } from "@/src/domain/routes";
type Props = { readonly params: Promise<{ readonly locale: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const { title, description } = IMPRINT[locale];
return {
title,
description,
alternates: {
canonical: pathFor("imprint", locale),
languages: languageAlternates("imprint"),
},
robots: { index: false, follow: true },
};
}
export default async function Page({ params }: Props) {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const { title, Body } = IMPRINT[locale];
return (
<LegalPage title={title}>
<Body />
</LegalPage>
);
}
+138
View File
@@ -0,0 +1,138 @@
import type { Metadata, Viewport } from "next";
import type { StaticImageData } from "next/image";
import { Inter, JetBrains_Mono, Newsreader } from "next/font/google";
import { Analytics } from "@/src/components/Analytics";
import { BitBurst } from "@/src/components/bits/BitBurst";
import { BitGlow } from "@/src/components/bits/BitGlow";
import { Footer } from "@/src/components/Footer";
import { Header } from "@/src/components/Header";
import { InteractiveElements } from "@/src/components/InteractiveElements";
import { ContentProvider } from "@/src/components/ContentProvider";
import { BitCurtain } from "@/src/components/bits/BitCurtain";
import shareImageDe from "@/src/assets/share/share-de.png";
import shareImageEn from "@/src/assets/share/share-en.png";
import { contentFor } from "@/src/content";
import { INDEXING, SITE_NAME, SITE_URL } from "@/src/content/site";
import { robotsMeta } from "@/src/domain/indexing";
import {
DEFAULT_LOCALE,
LOCALES,
parseLocale,
type Locale,
} from "@/src/domain/locale";
import { pathFor } from "@/src/domain/routes";
import "../globals.css";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
// A real monospaced fallback has almost the same letter width. The default
// fallback (a resized Arial) wraps the mono labels differently, so the hero
// jumped when the font arrived.
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains-mono",
display: "swap",
adjustFontFallback: false,
fallback: ["ui-monospace", "Menlo", "Consolas", "monospace"],
});
const newsreader = Newsreader({
subsets: ["latin"],
variable: "--font-newsreader",
style: "italic",
display: "swap",
});
type Props = {
readonly children: React.ReactNode;
readonly params: Promise<{ readonly locale: string }>;
};
/** The share image is a screenshot of the hero in that language (see scripts/render-share-image.mjs). */
const SHARE_IMAGE: Readonly<Record<Locale, StaticImageData>> = {
en: shareImageEn,
de: shareImageDe,
};
export function generateStaticParams() {
return LOCALES.map((locale) => ({ locale }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const { site } = contentFor(locale);
const image = SHARE_IMAGE[locale];
const images = [
{
url: image.src,
width: image.width,
height: image.height,
alt: site.shareImageAlt,
},
];
return {
metadataBase: new URL(SITE_URL),
title: {
default: site.title,
template: `%s | ${SITE_NAME}`,
},
description: site.description,
applicationName: SITE_NAME,
authors: [{ name: SITE_NAME, url: SITE_URL }],
creator: SITE_NAME,
robots: robotsMeta(INDEXING),
icons: {
icon: "/favicon.svg",
apple: "/apple-touch-icon.png",
},
openGraph: {
type: "website",
url: pathFor("home", locale),
siteName: SITE_NAME,
locale: site.ogLocale,
alternateLocale: LOCALES.filter((other) => other !== locale).map(
(other) => contentFor(other).site.ogLocale,
),
title: site.title,
description: site.description,
images,
},
twitter: {
card: "summary_large_image",
title: site.title,
description: site.description,
images,
},
};
}
export const viewport: Viewport = {
themeColor: "#ffffff",
colorScheme: "light",
viewportFit: "cover",
};
export default async function RootLayout({ children, params }: Props) {
// The proxy only lets the two languages through; anything else is a file-like
// address that ends in the error page of the main language.
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
return (
<html
lang={locale}
className={`${inter.variable} ${newsreader.variable} ${jetbrainsMono.variable}`}
>
<body className="min-h-screen bg-white">
<ContentProvider locale={locale} content={contentFor(locale)}>
<Header />
<main>{children}</main>
<Footer />
<InteractiveElements />
<BitCurtain />
</ContentProvider>
<BitBurst />
<BitGlow />
<Analytics />
</body>
</html>
);
}
@@ -1,27 +1,28 @@
export const metadata = {
title: "Page not found",
description: "This page does not exist.",
};
"use client";
import { Button } from "@/src/components/Button";
import { useContent, useLocale } from "@/src/components/ContentProvider";
import { pathFor } from "@/src/domain/routes";
export default function NotFound() {
const { notFound } = useContent();
const home = pathFor("home", useLocale());
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] px-5 py-20 text-center">
<div className="space-y-6 max-w-2xl mx-auto">
<span className="inline-block px-3 py-1 bg-slate-50 border border-slate-100 rounded text-[10px] font-mono text-slate-500 uppercase tracking-widest">
Error 404
{notFound.kicker}
</span>
<h1 className="text-5xl md:text-7xl font-black text-slate-900 tracking-tighter">
Page not found.
{notFound.title}
</h1>
<p className="text-xl md:text-2xl text-slate-500 font-serif italic max-w-xl mx-auto leading-relaxed">
This address does not exist. The page may have been moved or removed.
{notFound.text}
</p>
<div className="pt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
<Button href="/" variant="primary">
Back to the homepage
<Button href={home} variant="primary">
{notFound.home}
</Button>
</div>
@@ -13,120 +13,86 @@ import { AutomationFlow } from "@/src/components/illustrations/AutomationFlow";
import { CareerLine } from "@/src/components/illustrations/CareerLine";
import { ContactDiagram } from "@/src/components/illustrations/ContactDiagram";
import { PipelineRun } from "@/src/components/illustrations/PipelineRun";
import {
WorkShowcase,
type WorkItem,
} from "@/src/components/illustrations/WorkShowcase";
import { WorkShowcase } from "@/src/components/illustrations/WorkShowcase";
import { Reveal } from "@/src/components/Reveal";
import { CAREER, FIT, TECH } from "@/src/content/about";
import { SITE_DESCRIPTION, SITE_TITLE } from "@/src/content/site";
import { AUTOMATION_NOTES } from "@/src/content/automation";
import { contentFor } from "@/src/content";
import type { SplitTitle } from "@/src/content/types";
import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale";
import { languageAlternates, pathFor } from "@/src/domain/routes";
export const metadata: Metadata = {
title: { absolute: SITE_TITLE },
description: SITE_DESCRIPTION,
alternates: { canonical: "/" },
};
type Props = { readonly params: Promise<{ readonly locale: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const { site } = contentFor(locale);
return {
title: { absolute: site.title },
description: site.description,
alternates: {
canonical: pathFor("home", locale),
languages: languageAlternates("home"),
},
};
}
const MUTED = "text-slate-400";
const BODY = "m-0 font-sans text-lg leading-relaxed text-slate-500 md:text-xl";
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500";
const PRINCIPLES = [
{
title: "One contact",
text: "You talk to me, and I am the one who does the work.",
},
{
title: "Hours you can check",
text: "I track my time. The monthly invoice shows what the hours were spent on.",
},
{
title: "Careful work",
text: "I test what I build before I hand it over, and I say so when something is not finished or I am not sure.",
},
{
title: "What I build is yours",
text: "Code and design belong to you. You can hand them to another developer.",
},
] as const;
const Title: React.FC<{ title: SplitTitle; muted?: string }> = ({
title,
muted = MUTED,
}) => (
<>
{title.lead} <span className={muted}>{title.rest}</span>
</>
);
const WORK: readonly WorkItem[] = [
{
name: "klz-cables.com",
href: "https://klz-cables.com",
client: "Cable supplier",
text: "The company website of a supplier of energy cables, in German and English.",
did: [
"Product catalogue with categories and detail pages",
"Datasheets generated from the product data",
"Content the client can edit in a CMS",
],
tech: ["Next.js", "TypeScript", "Headless CMS", "Two languages"],
desktop: { src: "/work/klz-long.webp", width: 1200, height: 1680 },
mobile: { src: "/work/klz-mobile.webp", width: 390, height: 3600 },
},
{
name: "e-tib.com",
href: "https://e-tib.com",
client: "Cable network construction",
text: "The company website of a group in cable network construction, in German and English.",
did: [
"Pages for services, references, careers and events",
"The same content in two languages",
"Works on phone, tablet and desktop",
],
tech: ["Next.js", "TypeScript", "Headless CMS", "Two languages"],
desktop: { src: "/work/etib-long.webp", width: 1200, height: 4333 },
mobile: { src: "/work/etib-mobile.webp", width: 390, height: 3600 },
},
];
export default async function LandingPage({ params }: Props) {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const content = contentFor(locale);
const { hello, services, automation, technology, working, work, faq } =
content;
export default function LandingPage() {
return (
<div className="relative flex flex-col bg-white">
<HomeJsonLd />
<HeroStatement />
<HomeJsonLd locale={locale} />
<HeroStatement hero={content.hero} />
<StickyBook />
<PortraitSection
id="hello"
index="01"
label="Hello"
title={<>I am Marc.</>}
label={hello.label}
title={hello.title}
portraitAlt={hello.portraitAlt}
>
<p className="m-0 font-sans text-2xl leading-snug text-white md:text-4xl">
I have been building for the web for 15 years: first in agencies,
later as a senior developer, and now on my own.
</p>
<p className="m-0 mt-8 font-sans text-lg leading-relaxed text-slate-300 md:text-xl">
My strength is the part people see and use: design and interface. I am
also at home with what lies behind it, so I can build a complete
application and not just its surface.
</p>
<p className="m-0 mt-6 font-sans text-lg leading-relaxed text-slate-300 md:text-xl">
You work with me directly. Whether you run a product team or are
commissioning something like this for the first time.
{hello.lead}
</p>
{hello.paragraphs.map((paragraph, i) => (
<p
key={paragraph}
className={`m-0 font-sans text-lg leading-relaxed text-slate-300 md:text-xl ${i === 0 ? "mt-8" : "mt-6"}`}
>
{paragraph}
</p>
))}
</PortraitSection>
<section className="relative overflow-clip bg-slate-900">
<div className="mx-auto w-full max-w-[1400px] px-5 pb-20 pt-4 md:px-12 md:pb-36 md:pt-0">
<p className="m-0 mb-10 font-mono text-[11px] uppercase tracking-[0.2em] text-slate-400">
The path so far
{hello.careerLabel}
</p>
<CareerLine stations={CAREER} />
<CareerLine stations={hello.career} />
</div>
</section>
<Block
index="02"
label="Services"
title={
<>
What do you need? <span className={MUTED}>Four typical cases.</span>
</>
}
label={services.label}
title={<Title title={services.title} />}
>
<Reveal>
<PathPicker />
@@ -135,26 +101,17 @@ export default function LandingPage() {
<Block
index="03"
label="Process automation"
title={
<>
Let the systems <span className={MUTED}>do the copying.</span>
</>
}
label={automation.label}
title={<Title title={automation.title} />}
>
<Reveal>
<p className={`${BODY} mb-12 max-w-3xl`}>
In most companies, people move information from one tool to another:
read it here, type it in there, send an email about it. Automation
connects those tools so that the work passes from one to the next on
its own. Three typical examples:
</p>
<p className={`${BODY} mb-12 max-w-3xl`}>{automation.intro}</p>
</Reveal>
<Reveal>
<AutomationFlow />
</Reveal>
<div className="mt-16 grid grid-cols-1 gap-12 md:mt-24 md:grid-cols-3 md:gap-10">
{AUTOMATION_NOTES.map((group, i) => (
{automation.notes.map((group, i) => (
<Reveal key={group.title} delay={0.1 * i}>
<div className="border-t border-slate-900 pt-6">
<p className={`${MONO} m-0`}>{group.title}</p>
@@ -176,15 +133,11 @@ export default function LandingPage() {
<Block
index="04"
label="Technology"
title={
<>
Which projects <span className={MUTED}>I am right for.</span>
</>
}
label={technology.label}
title={<Title title={technology.title} />}
>
<div className="grid grid-cols-1 gap-12 md:grid-cols-3 md:gap-10">
{FIT.map((group, i) => (
{technology.fit.map((group, i) => (
<Reveal key={group.title} delay={0.1 * i}>
<div
className={`border-t pt-6 ${i === 2 ? "border-slate-300" : "border-slate-900"}`}
@@ -209,9 +162,9 @@ export default function LandingPage() {
<Reveal>
<div className="mt-16 rounded-3xl bg-slate-50 p-6 md:mt-24 md:p-10">
<p className={`${MONO} m-0`}>What I work with</p>
<p className={`${MONO} m-0`}>{technology.techLabel}</p>
<div className="mt-8 grid grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-4">
{TECH.map((group) => (
{technology.tech.map((group) => (
<div key={group.area}>
<p className="m-0 font-sans text-xl font-bold tracking-tight text-slate-900 md:text-2xl">
{group.area}
@@ -235,13 +188,10 @@ export default function LandingPage() {
<div className="mt-16 md:mt-24">
<Reveal>
<h3 className="m-0 font-sans text-3xl font-bold tracking-tighter text-slate-900 md:text-5xl">
What happens before a change goes live.
{technology.pipelineTitle}
</h3>
<p className={`${BODY} mb-12 mt-6 max-w-3xl`}>
Software is connected: a change in one place can break another. I
secure what I build with automated tests, and every change has to
pass them before it is released. This is what such a run looks
like.
{technology.pipelineIntro}
</p>
</Reveal>
<Reveal>
@@ -252,12 +202,8 @@ export default function LandingPage() {
<Block
index="05"
label="Working with me"
title={
<>
How I work. <span className={MUTED}>Nothing unusual.</span>
</>
}
label={working.label}
title={<Title title={working.title} />}
>
<Reveal>
<BitAura source="bottom" tone="light" className="mb-16 md:mb-24">
@@ -267,7 +213,7 @@ export default function LandingPage() {
</BitAura>
</Reveal>
<div className="grid grid-cols-1 gap-x-12 gap-y-12 md:grid-cols-2 md:gap-y-16">
{PRINCIPLES.map((item, i) => (
{working.principles.map((item, i) => (
<Reveal key={item.title} delay={0.08 * i}>
<div className="border-t border-slate-900 pt-6">
<h3 className="m-0 font-sans text-3xl font-bold tracking-tight text-slate-900 md:text-4xl">
@@ -282,39 +228,25 @@ export default function LandingPage() {
<Block
index="06"
label="My work"
label={work.label}
tone="dark"
title={
<>
Two sites I built.{" "}
<span className="text-slate-500">Both are live.</span>
</>
}
title={<Title title={work.title} muted="text-slate-500" />}
>
<WorkShowcase items={WORK} />
<WorkShowcase />
<Reveal>
<p className="m-0 mt-20 max-w-3xl border-t border-slate-800 pt-8 font-sans text-lg leading-relaxed text-slate-300 md:mt-28 md:text-xl">
Besides client work I build my own software, among other things a
system in Rust and Solidity that runs on seven blockchains.
{work.note}
</p>
</Reveal>
</Block>
<Block
index="07"
label="Questions"
title={
<>
Good to know <span className={MUTED}>before we talk.</span>
</>
}
>
<Block index="07" label={faq.label} title={<Title title={faq.title} />}>
<Reveal>
<Faq />
<Faq items={faq.items} />
</Reveal>
</Block>
<ClosingCta />
<ClosingCta contact={content.contact} />
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { LegalPage } from "@/src/components/LegalPage";
import { PRIVACY } from "@/src/content/legal/privacy";
import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale";
import { languageAlternates, pathFor } from "@/src/domain/routes";
type Props = { readonly params: Promise<{ readonly locale: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const { title, description } = PRIVACY[locale];
return {
title,
description,
alternates: {
canonical: pathFor("privacy", locale),
languages: languageAlternates("privacy"),
},
robots: { index: false, follow: true },
};
}
export default async function Page({ params }: Props) {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const { title, Body } = PRIVACY[locale];
return (
<LegalPage title={title}>
<Body />
</LegalPage>
);
}
@@ -1,8 +1,11 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { Inter, Newsreader } from "next/font/google";
import { contentFor } from "@/src/content";
import { DEFAULT_LOCALE, type Locale } from "@/src/domain/locale";
import { localeOfPath } from "@/src/domain/routes";
import "./globals.css";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
@@ -20,6 +23,11 @@ export default function GlobalError({
error: Error & { digest?: string };
reset: () => void;
}) {
// The root layout failed, so the language is read from the address.
const [locale, setLocale] = useState<Locale>(DEFAULT_LOCALE);
useEffect(() => setLocale(localeOfPath(window.location.pathname)), []);
const copy = contentFor(locale).error;
useEffect(() => {
Sentry.captureException(error);
@@ -41,19 +49,18 @@ export default function GlobalError({
}, [error]);
return (
<html lang="en" className={`${inter.variable} ${newsreader.variable}`}>
<html lang={locale} className={`${inter.variable} ${newsreader.variable}`}>
<body className="min-h-screen bg-white font-sans text-slate-900">
<div className="flex flex-col items-center justify-center min-h-screen px-5 py-20 text-center">
<div className="space-y-6 max-w-2xl mx-auto">
<span className="inline-block px-3 py-1 bg-red-50 border border-red-200 rounded text-[10px] font-mono text-red-600 uppercase tracking-widest border-dashed">
Root Level Error
{copy.kicker}
</span>
<h1 className="text-4xl md:text-6xl font-black tracking-tighter text-slate-900">
Systemausfall der Hauptebene.
{copy.title}
</h1>
<p className="text-lg md:text-xl text-slate-500 font-serif italic max-w-xl mx-auto">
Ein kritischer Fehler auf der Root-Layout Ebene hat das Rendering
blockiert. Der Vorfall wurde zur Untersuchung protokolliert.
{copy.text}
</p>
<div className="pt-8">
@@ -62,7 +69,7 @@ export default function GlobalError({
className="relative inline-flex items-center justify-center gap-3 overflow-hidden rounded-full font-bold uppercase tracking-[0.15em] transition-all duration-300 group cursor-pointer px-8 py-4 text-[10px] bg-slate-900 text-white hover:shadow-xl hover:-translate-y-0.5"
>
<span className="relative z-10 flex items-center gap-3">
Notfall-Neustart (Reset)
{copy.retry}
</span>
</button>
</div>
+16
View File
@@ -0,0 +1,16 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/src/content/site";
import { sitemapEntries } from "@/src/domain/routes";
/**
* The site is a single page in two languages; the legal pages are not meant for
* search results. No modification date: it would have to be the date the content
* last changed, and a date that moves with every build teaches search engines to
* ignore it.
*/
export default function sitemap(): MetadataRoute.Sitemap {
return sitemapEntries(SITE_URL, ["home"]).map((entry) => ({
url: entry.url,
alternates: { languages: { ...entry.alternates.languages } },
}));
}