Compare commits
2
Commits
1f6894d781
...
b52f93989c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b52f93989c | ||
|
|
5657b1d202 |
@@ -20,6 +20,10 @@
|
|||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
|
|
||||||
|
# local data
|
||||||
|
/data/*.sqlite
|
||||||
|
/data/*.sqlite-*
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
|||||||
@@ -1,40 +1,27 @@
|
|||||||
# w-make.com
|
# w-make.com
|
||||||
|
|
||||||
Portfolio von Jan Wagner für industrielle Softwareentwicklung, Systems Thinking und Glass Technology.
|
Öffentliche Firmen- und Portfolio-Site von Jan Wagner: industrielle Software, Systems Thinking, Glass Technology.
|
||||||
|
|
||||||
Die Startseite stellt veröffentlichbare Projekte und die dahinterliegenden Engineering-Entscheidungen vor. Inhalte liegen bewusst noch direkt in `src/app/page.tsx`: Ein CMS oder eigene Case-Study-Routen kommen erst hinzu, wenn mehrere freigegebene Langform-Projekte vorhanden sind.
|
Deutsch ist die Standardsprache ohne Prefix (`/`, `/arbeit`). Englisch liegt unter `/en`. Inhalte stehen in `src/content/`, UI-Texte in `src/i18n/dictionaries.ts`. Die öffentliche Präsenz folgt `PRESENCE-STRATEGY.md`.
|
||||||
|
|
||||||
## Getting Started
|
## Lokal
|
||||||
|
|
||||||
First, run the development server:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm install
|
||||||
|
npm test
|
||||||
npm run dev
|
npm run dev
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
Kontaktformular speichert Anfragen in SQLite (`INQUIRIES_DB_PATH`) und sendet sie per SMTP, sobald `CONTACT_TO`, `SMTP_HOST`, `SMTP_USER` und `SMTP_PASS` gesetzt sind. Lokal liegen diese Werte in `.env.local` (nicht committen), bezogen auf `eldov@w-make.de`. Optional: `CONTACT_FROM`, `SMTP_PORT`, `NEXT_PUBLIC_SITE_URL`, `UPDATES_API_URL`.
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
## Routen
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
| DE | EN |
|
||||||
|
|---|---|
|
||||||
## Learn More
|
| `/` | `/en` |
|
||||||
|
| `/ueber-mich` | `/en/about` |
|
||||||
To learn more about Next.js, take a look at the following resources:
|
| `/arbeit` | `/en/work` |
|
||||||
|
| `/notizen` | `/en/notes` |
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
| `/kontakt` | `/en/contact` |
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
| `/impressum` | `/en/legal` |
|
||||||
|
| `/datenschutz` | `/en/privacy` |
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
|
||||||
|
|
||||||
## Deploy on Vercel
|
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
- UPDATES_API_URL=http://updates-api:8080
|
- UPDATES_API_URL=http://updates-api:8080
|
||||||
|
- NEXT_PUBLIC_SITE_URL=https://w-make.com
|
||||||
|
- CONTACT_TO=${CONTACT_TO:-eldov@w-make.de}
|
||||||
|
- "CONTACT_FROM=${CONTACT_FROM:-W-MAKE <eldov@w-make.de>}"
|
||||||
|
- SMTP_HOST=${SMTP_HOST:-smtp.strato.de}
|
||||||
|
- SMTP_PORT=${SMTP_PORT:-465}
|
||||||
|
- SMTP_USER=${SMTP_USER:-eldov@w-make.de}
|
||||||
|
- SMTP_PASS=${SMTP_PASS}
|
||||||
|
- INQUIRIES_DB_PATH=/data/inquiries.sqlite
|
||||||
|
volumes:
|
||||||
|
- inquiries_data:/data
|
||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
labels:
|
labels:
|
||||||
@@ -58,6 +68,7 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
updates_data:
|
updates_data:
|
||||||
|
inquiries_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
proxy:
|
proxy:
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
async redirects() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/projects/batchmaker-studio",
|
||||||
|
destination: "/arbeit/batchmaker-studio",
|
||||||
|
permanent: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,8 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"test": "node --experimental-strip-types --test src/i18n/routes.test.ts src/lib/contact/schema.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.3.1",
|
"next": "16.3.1",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 173 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 229 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
@@ -0,0 +1,45 @@
|
|||||||
|
import { getPerson } from "@/content/person";
|
||||||
|
import { getDictionary } from "@/i18n/dictionaries";
|
||||||
|
import { pageMetadata } from "@/lib/metadata";
|
||||||
|
import { requireLocale } from "@/lib/locale";
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps<"/[lang]/about">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
return pageMetadata(locale, "about", dict.about.title, dict.about.lede);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AboutPage({ params }: PageProps<"/[lang]/about">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
const person = getPerson(locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-6xl px-6 py-20 md:px-10 md:py-28">
|
||||||
|
<p className="kicker">{dict.about.kicker}</p>
|
||||||
|
<h1 className="display mt-5 max-w-4xl text-5xl md:text-7xl">{dict.about.title}</h1>
|
||||||
|
<p className="mt-8 max-w-2xl text-xl leading-9 text-mute-strong">{dict.about.lede}</p>
|
||||||
|
<p className="mt-6 font-mono text-xs uppercase tracking-[0.16em] text-mute">
|
||||||
|
{person.role} · {person.location}
|
||||||
|
</p>
|
||||||
|
<div className="mt-16 grid gap-8 border-t border-line pt-12 md:grid-cols-3">
|
||||||
|
{person.story.map((paragraph) => (
|
||||||
|
<p key={paragraph.slice(0, 24)} className="leading-8 text-mute-strong">
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ol className="mt-20 grid gap-10 border-t border-line pt-12">
|
||||||
|
{person.principles.map((principle, index) => (
|
||||||
|
<li key={principle.title} className="grid gap-3 md:grid-cols-[4rem_1fr]">
|
||||||
|
<span className="font-mono text-sm text-copper">0{index + 1}</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-serif text-3xl">{principle.title}</h2>
|
||||||
|
<p className="mt-3 max-w-2xl leading-7 text-mute-strong">{principle.text}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { ContactForm } from "@/components/contact-form";
|
||||||
|
import { getOffers } from "@/content/offers";
|
||||||
|
import { getDictionary } from "@/i18n/dictionaries";
|
||||||
|
import { pageMetadata } from "@/lib/metadata";
|
||||||
|
import { requireLocale } from "@/lib/locale";
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps<"/[lang]/contact">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
return pageMetadata(locale, "contact", dict.contact.title, dict.contact.lede);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ContactPage({ params }: PageProps<"/[lang]/contact">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
const offers = getOffers(locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto grid w-full max-w-6xl gap-16 px-6 py-20 md:grid-cols-[1.1fr_0.9fr] md:px-10 md:py-28">
|
||||||
|
<div>
|
||||||
|
<p className="kicker">{dict.contact.kicker}</p>
|
||||||
|
<h1 className="display mt-5 text-5xl md:text-6xl">{dict.contact.title}</h1>
|
||||||
|
<p className="mt-8 max-w-xl text-lg leading-8 text-mute-strong">{dict.contact.lede}</p>
|
||||||
|
<div className="mt-12">
|
||||||
|
<ContactForm locale={locale} dict={dict} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<aside className="border-t border-line pt-10 md:border-l md:border-t-0 md:pl-10 md:pt-0">
|
||||||
|
<p className="kicker">{dict.home.offerKicker}</p>
|
||||||
|
<div className="mt-8 grid gap-8">
|
||||||
|
{offers.map((offer) => (
|
||||||
|
<article key={offer.title}>
|
||||||
|
<h2 className="font-serif text-2xl">{offer.title}</h2>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-mute-strong">{offer.text}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { SiteFooter } from "@/components/site-footer";
|
||||||
|
import { SiteHeader } from "@/components/site-header";
|
||||||
|
import { getDictionary } from "@/i18n/dictionaries";
|
||||||
|
import { locales } from "@/i18n/routes";
|
||||||
|
import { requireLocale } from "@/lib/locale";
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
return locales.map((lang) => ({ lang }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LocaleLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: LayoutProps<"/[lang]">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="site-shell flex min-h-full flex-col">
|
||||||
|
<SiteHeader locale={locale} dict={dict} />
|
||||||
|
<div className="flex-1">{children}</div>
|
||||||
|
<SiteFooter locale={locale} dict={dict} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { getLegal } from "@/content/legal";
|
||||||
|
import { getDictionary } from "@/i18n/dictionaries";
|
||||||
|
import { pageMetadata } from "@/lib/metadata";
|
||||||
|
import { requireLocale } from "@/lib/locale";
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps<"/[lang]/legal">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
return pageMetadata(locale, "legal", dict.legal.imprintTitle, dict.legal.imprintTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LegalPage({ params }: PageProps<"/[lang]/legal">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
const legal = getLegal(locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-3xl px-6 py-20 md:px-10 md:py-28">
|
||||||
|
<p className="kicker">{dict.legal.imprintKicker}</p>
|
||||||
|
<h1 className="display mt-5 text-5xl">{dict.legal.imprintTitle}</h1>
|
||||||
|
<div className="mt-12 grid gap-6">
|
||||||
|
{legal.imprint.map((paragraph) => (
|
||||||
|
<p key={paragraph.slice(0, 32)} className="leading-8 text-mute-strong">
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getNote, noteSlugs } from "@/content/notes";
|
||||||
|
import { getDictionary } from "@/i18n/dictionaries";
|
||||||
|
import { locales, publicPath } from "@/i18n/routes";
|
||||||
|
import { pageMetadata } from "@/lib/metadata";
|
||||||
|
import { requireLocale } from "@/lib/locale";
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return locales.flatMap((lang) => noteSlugs.map((slug) => ({ lang, slug })));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps<"/[lang]/notes/[slug]">) {
|
||||||
|
const { lang, slug } = await params;
|
||||||
|
const locale = requireLocale(lang);
|
||||||
|
const note = getNote(slug, locale);
|
||||||
|
if (!note) notFound();
|
||||||
|
return pageMetadata(locale, "noteItem", note.title, note.summary, slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function NotePage({ params }: PageProps<"/[lang]/notes/[slug]">) {
|
||||||
|
const { lang, slug } = await params;
|
||||||
|
const locale = requireLocale(lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
const note = getNote(slug, locale);
|
||||||
|
if (!note) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-3xl px-6 py-20 md:px-10 md:py-28">
|
||||||
|
<Link href={publicPath(locale, "notes")} className="text-sm text-mute-strong hover:text-paper">
|
||||||
|
← {dict.actions.allNotes}
|
||||||
|
</Link>
|
||||||
|
<time className="kicker mt-10 block" dateTime={note.date}>
|
||||||
|
{new Date(note.date).toLocaleDateString(locale === "de" ? "de-DE" : "en-GB")}
|
||||||
|
</time>
|
||||||
|
<h1 className="display mt-5 text-5xl md:text-6xl">{note.title}</h1>
|
||||||
|
<p className="mt-8 text-xl leading-9 text-mute-strong">{note.summary}</p>
|
||||||
|
<div className="mt-12 grid gap-6">
|
||||||
|
{note.body.map((paragraph) => (
|
||||||
|
<p key={paragraph.slice(0, 32)} className="leading-8 text-mute-strong">
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { getNotes } from "@/content/notes";
|
||||||
|
import { getDictionary } from "@/i18n/dictionaries";
|
||||||
|
import { publicPath } from "@/i18n/routes";
|
||||||
|
import { pageMetadata } from "@/lib/metadata";
|
||||||
|
import { requireLocale } from "@/lib/locale";
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps<"/[lang]/notes">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
return pageMetadata(locale, "notes", dict.notes.title, dict.notes.lede);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function NotesPage({ params }: PageProps<"/[lang]/notes">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
const notes = getNotes(locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-6xl px-6 py-20 md:px-10 md:py-28">
|
||||||
|
<p className="kicker">{dict.notes.kicker}</p>
|
||||||
|
<h1 className="display mt-5 max-w-4xl text-5xl md:text-7xl">{dict.notes.title}</h1>
|
||||||
|
<p className="mt-8 max-w-2xl text-xl leading-9 text-mute-strong">{dict.notes.lede}</p>
|
||||||
|
<div className="mt-16 grid gap-12">
|
||||||
|
{notes.map((note) => (
|
||||||
|
<article key={note.slug} className="border-t border-line pt-8">
|
||||||
|
<time className="font-mono text-xs text-mute" dateTime={note.date}>
|
||||||
|
{new Date(note.date).toLocaleDateString(locale === "de" ? "de-DE" : "en-GB")}
|
||||||
|
</time>
|
||||||
|
<h2 className="mt-4 font-serif text-3xl">{note.title}</h2>
|
||||||
|
<p className="mt-4 max-w-2xl leading-7 text-mute-strong">{note.summary}</p>
|
||||||
|
<Link href={publicPath(locale, "noteItem", note.slug)} className="mt-6 inline-block text-sm text-copper">
|
||||||
|
{dict.actions.openNote} →
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
import { requireLocale } from "@/lib/locale";
|
||||||
|
|
||||||
|
export const size = { width: 1200, height: 630 };
|
||||||
|
export const contentType = "image/png";
|
||||||
|
|
||||||
|
export default async function OpenGraphImage({ params }: { params: Promise<{ lang: string }> }) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const title =
|
||||||
|
locale === "de"
|
||||||
|
? "Software für Prozesse, auf die man sich verlassen muss."
|
||||||
|
: "Software for processes that have to hold.";
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#12100e",
|
||||||
|
color: "#f4efe4",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "72px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", letterSpacing: "0.28em", fontSize: 22, color: "#c9843a" }}>
|
||||||
|
W-MAKE
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", maxWidth: 900 }}>
|
||||||
|
<div style={{ fontSize: 64, lineHeight: 1.05 }}>{title}</div>
|
||||||
|
<div style={{ marginTop: 28, fontSize: 28, color: "#9a9184" }}>Jan Wagner</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { HomeJsonLd } from "@/components/json-ld";
|
||||||
|
import { getOffers } from "@/content/offers";
|
||||||
|
import { getNotes } from "@/content/notes";
|
||||||
|
import { getPerson } from "@/content/person";
|
||||||
|
import { getProjects } from "@/content/projects";
|
||||||
|
import { getDictionary } from "@/i18n/dictionaries";
|
||||||
|
import { publicPath } from "@/i18n/routes";
|
||||||
|
import { pageMetadata } from "@/lib/metadata";
|
||||||
|
import { requireLocale } from "@/lib/locale";
|
||||||
|
import { getUpdates } from "@/lib/updates";
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps<"/[lang]">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
return pageMetadata(
|
||||||
|
locale,
|
||||||
|
"home",
|
||||||
|
locale === "de"
|
||||||
|
? "Jan Wagner — Software für anspruchsvolle Prozesse"
|
||||||
|
: "Jan Wagner — Software for demanding processes",
|
||||||
|
dict.home.lede,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function HomePage({ params }: PageProps<"/[lang]">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
const person = getPerson(locale);
|
||||||
|
const projects = getProjects(locale);
|
||||||
|
const offers = getOffers(locale);
|
||||||
|
const notes = getNotes(locale).slice(0, 2);
|
||||||
|
const updates = await getUpdates();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<HomeJsonLd />
|
||||||
|
<section className="mx-auto grid w-full max-w-6xl gap-16 px-6 py-20 md:grid-cols-[1.35fr_0.65fr] md:items-end md:px-10 md:py-28">
|
||||||
|
<div>
|
||||||
|
<p className="kicker">{dict.home.kicker}</p>
|
||||||
|
<h1 className="display mt-6 max-w-4xl text-5xl text-paper md:text-7xl">{dict.home.title}</h1>
|
||||||
|
<p className="mt-8 max-w-2xl text-lg leading-8 text-mute-strong md:text-xl">{dict.home.lede}</p>
|
||||||
|
<div className="mt-10 flex flex-wrap gap-8 text-sm">
|
||||||
|
<Link href={publicPath(locale, "work")} className="border-b border-copper pb-1 text-copper">
|
||||||
|
{dict.actions.viewWork}
|
||||||
|
</Link>
|
||||||
|
<Link href={publicPath(locale, "contact")} className="border-b border-line-strong pb-1 text-paper">
|
||||||
|
{dict.actions.startConversation}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<aside className="border-l border-copper/50 pl-6">
|
||||||
|
<p className="kicker">{dict.home.asideLabel}</p>
|
||||||
|
<p className="mt-4 font-serif text-2xl leading-snug text-paper">{dict.home.asideTitle}</p>
|
||||||
|
<p className="mt-5 text-sm leading-7 text-mute-strong">{dict.home.asideBody}</p>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-t border-line">
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-6 py-20 md:px-10">
|
||||||
|
<div className="mb-12 flex flex-wrap items-end justify-between gap-6">
|
||||||
|
<div>
|
||||||
|
<p className="kicker">{dict.home.workKicker}</p>
|
||||||
|
<h2 className="display mt-4 text-4xl md:text-5xl">{dict.home.workTitle}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="max-w-md text-sm leading-7 text-mute-strong">{dict.home.workLede}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-12 md:grid-cols-2">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<article key={project.slug} className="border-t border-line pt-8">
|
||||||
|
<div className="flex items-baseline justify-between gap-4">
|
||||||
|
<p className="kicker">{project.label}</p>
|
||||||
|
<span className="font-mono text-xs text-mute">{project.number}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-5 font-serif text-3xl">{project.title}</h3>
|
||||||
|
<p className="mt-4 leading-7 text-mute-strong">{project.summary}</p>
|
||||||
|
<p className="mt-6 font-mono text-xs uppercase tracking-[0.14em] text-mute">{project.status}</p>
|
||||||
|
<Link
|
||||||
|
href={publicPath(locale, "workItem", project.slug)}
|
||||||
|
className="mt-6 inline-block border-b border-copper pb-1 text-sm text-copper"
|
||||||
|
>
|
||||||
|
{dict.actions.openCase} →
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-t border-line">
|
||||||
|
<div className="mx-auto grid w-full max-w-6xl gap-12 px-6 py-20 md:grid-cols-[0.8fr_1.2fr] md:px-10">
|
||||||
|
<div>
|
||||||
|
<p className="kicker">{dict.home.methodKicker}</p>
|
||||||
|
<h2 className="display mt-4 text-4xl md:text-5xl">{dict.home.methodTitle}</h2>
|
||||||
|
<p className="mt-6 max-w-md leading-7 text-mute-strong">{dict.home.methodLede}</p>
|
||||||
|
</div>
|
||||||
|
<ol className="grid gap-8">
|
||||||
|
{person.principles.map((principle, index) => (
|
||||||
|
<li key={principle.title} className="grid gap-3 border-b border-line pb-8 md:grid-cols-[3rem_1fr]">
|
||||||
|
<span className="font-mono text-sm text-copper">0{index + 1}</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-serif text-2xl">{principle.title}</h3>
|
||||||
|
<p className="mt-2 max-w-xl leading-7 text-mute-strong">{principle.text}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-t border-line">
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-6 py-20 md:px-10">
|
||||||
|
<p className="kicker">{dict.home.offerKicker}</p>
|
||||||
|
<h2 className="display mt-4 max-w-2xl text-4xl md:text-5xl">{dict.home.offerTitle}</h2>
|
||||||
|
<div className="mt-12 grid gap-10 md:grid-cols-3">
|
||||||
|
{offers.map((offer, index) => (
|
||||||
|
<article key={offer.title} className="border-l border-line-strong pl-5">
|
||||||
|
<span className="font-mono text-xs text-copper">0{index + 1}</span>
|
||||||
|
<h3 className="mt-4 font-serif text-2xl">{offer.title}</h3>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-mute-strong">{offer.text}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-t border-line">
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-6 py-20 md:px-10">
|
||||||
|
<p className="kicker">{dict.home.notesKicker}</p>
|
||||||
|
<h2 className="display mt-4 text-4xl md:text-5xl">{dict.home.notesTitle}</h2>
|
||||||
|
<div className="mt-12 grid gap-10 md:grid-cols-2">
|
||||||
|
{notes.map((note) => (
|
||||||
|
<article key={note.slug}>
|
||||||
|
<time className="font-mono text-xs text-mute" dateTime={note.date}>
|
||||||
|
{new Date(note.date).toLocaleDateString(locale === "de" ? "de-DE" : "en-GB")}
|
||||||
|
</time>
|
||||||
|
<h3 className="mt-3 font-serif text-2xl">{note.title}</h3>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-mute-strong">{note.summary}</p>
|
||||||
|
<Link
|
||||||
|
href={publicPath(locale, "noteItem", note.slug)}
|
||||||
|
className="mt-5 inline-block text-sm text-copper"
|
||||||
|
>
|
||||||
|
{dict.actions.openNote} →
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{updates.length ? (
|
||||||
|
<div className="mt-16 border-t border-line pt-10">
|
||||||
|
<p className="kicker">Batchmaker</p>
|
||||||
|
<div className="mt-6 grid gap-6 md:grid-cols-3">
|
||||||
|
{updates.map((update) => (
|
||||||
|
<article key={update.slug}>
|
||||||
|
<h3 className="font-serif text-xl">{update.title}</h3>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-mute-strong">{update.summary}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-t border-line">
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-6 py-20 md:px-10">
|
||||||
|
<div className="border border-copper/30 bg-copper/5 px-8 py-12 md:px-12">
|
||||||
|
<p className="kicker">{dict.home.ctaKicker}</p>
|
||||||
|
<h2 className="display mt-4 max-w-3xl text-4xl md:text-6xl">{dict.home.ctaTitle}</h2>
|
||||||
|
<p className="mt-6 max-w-2xl leading-7 text-mute-strong">{dict.home.ctaBody}</p>
|
||||||
|
<Link
|
||||||
|
href={publicPath(locale, "contact")}
|
||||||
|
className="mt-8 inline-block border border-copper bg-copper px-6 py-3 text-sm font-medium text-ink"
|
||||||
|
>
|
||||||
|
{dict.actions.startConversation}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { getLegal } from "@/content/legal";
|
||||||
|
import { getDictionary } from "@/i18n/dictionaries";
|
||||||
|
import { pageMetadata } from "@/lib/metadata";
|
||||||
|
import { requireLocale } from "@/lib/locale";
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps<"/[lang]/privacy">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
return pageMetadata(locale, "privacy", dict.legal.privacyTitle, dict.legal.privacyTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PrivacyPage({ params }: PageProps<"/[lang]/privacy">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
const legal = getLegal(locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-3xl px-6 py-20 md:px-10 md:py-28">
|
||||||
|
<p className="kicker">{dict.legal.privacyKicker}</p>
|
||||||
|
<h1 className="display mt-5 text-5xl">{dict.legal.privacyTitle}</h1>
|
||||||
|
<div className="mt-12 grid gap-6">
|
||||||
|
{legal.privacy.map((paragraph) => (
|
||||||
|
<p key={paragraph.slice(0, 32)} className="leading-8 text-mute-strong">
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ProcessRail } from "@/components/process-rail";
|
||||||
|
import { getProject, projectSlugs } from "@/content/projects";
|
||||||
|
import { getDictionary } from "@/i18n/dictionaries";
|
||||||
|
import { locales, publicPath } from "@/i18n/routes";
|
||||||
|
import { pageMetadata } from "@/lib/metadata";
|
||||||
|
import { requireLocale } from "@/lib/locale";
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return locales.flatMap((lang) => projectSlugs.map((slug) => ({ lang, slug })));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps<"/[lang]/work/[slug]">) {
|
||||||
|
const { lang, slug } = await params;
|
||||||
|
const locale = requireLocale(lang);
|
||||||
|
const project = getProject(slug, locale);
|
||||||
|
if (!project) notFound();
|
||||||
|
return pageMetadata(locale, "workItem", project.title, project.summary, slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CaseStudyPage({ params }: PageProps<"/[lang]/work/[slug]">) {
|
||||||
|
const { lang, slug } = await params;
|
||||||
|
const locale = requireLocale(lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
const project = getProject(slug, locale);
|
||||||
|
if (!project) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-6xl px-6 py-20 md:px-10 md:py-28">
|
||||||
|
<Link href={publicPath(locale, "work")} className="text-sm text-mute-strong hover:text-paper">
|
||||||
|
← {dict.actions.backToWork}
|
||||||
|
</Link>
|
||||||
|
<p className="kicker mt-10">{project.label}</p>
|
||||||
|
<h1 className="display mt-5 max-w-4xl text-5xl md:text-7xl">{project.title}</h1>
|
||||||
|
<p className="mt-8 max-w-3xl text-xl leading-9 text-mute-strong">{project.summary}</p>
|
||||||
|
<dl className="mt-10 grid gap-6 border-y border-line py-8 text-sm md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<dt className="font-mono text-xs uppercase tracking-[0.14em] text-mute">{dict.case.role}</dt>
|
||||||
|
<dd className="mt-2 text-paper">{project.role}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="font-mono text-xs uppercase tracking-[0.14em] text-mute">Stack</dt>
|
||||||
|
<dd className="mt-2 text-paper">{project.stack}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="font-mono text-xs uppercase tracking-[0.14em] text-mute">{dict.case.proof}</dt>
|
||||||
|
<dd className="mt-2 text-copper">{project.status}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<section className="grid gap-10 border-b border-line py-16 md:grid-cols-2">
|
||||||
|
<h2 className="font-serif text-3xl">{dict.case.situation}</h2>
|
||||||
|
<p className="leading-8 text-mute-strong">{project.situation}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-b border-line py-16">
|
||||||
|
<h2 className="font-serif text-3xl">{dict.case.decisions}</h2>
|
||||||
|
<ol className="mt-8 grid gap-5">
|
||||||
|
{project.decisions.map((decision, index) => (
|
||||||
|
<li key={decision} className="grid gap-3 md:grid-cols-[3rem_1fr]">
|
||||||
|
<span className="font-mono text-sm text-copper">0{index + 1}</span>
|
||||||
|
<p className="leading-7 text-mute-strong">{decision}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{project.images.length ? (
|
||||||
|
<section className="border-b border-line py-16">
|
||||||
|
<p className="kicker">{dict.case.proof}</p>
|
||||||
|
<h2 className="mt-4 font-serif text-3xl md:text-4xl">{dict.home.workTitle}</h2>
|
||||||
|
<div className="mt-10 grid gap-8">
|
||||||
|
{project.images.map((image) => (
|
||||||
|
<figure key={image.src} className="overflow-hidden border border-line bg-ink-raised">
|
||||||
|
<Image
|
||||||
|
src={image.src}
|
||||||
|
alt={image[locale]}
|
||||||
|
width={1440}
|
||||||
|
height={900}
|
||||||
|
className="block h-auto w-full"
|
||||||
|
/>
|
||||||
|
<figcaption className="px-6 py-4 text-sm leading-6 text-mute-strong">{image[locale]}</figcaption>
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="py-16">
|
||||||
|
<p className="kicker">{dict.case.decisions}</p>
|
||||||
|
<h2 className="mt-4 font-serif text-3xl md:text-4xl">
|
||||||
|
{locale === "de" ? "Der fachliche Kern als Modell." : "The domain core as a model."}
|
||||||
|
</h2>
|
||||||
|
<div className="mt-10">
|
||||||
|
<ProcessRail stages={project.stages} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-10 border-t border-line py-16 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-serif text-3xl">{dict.case.outcome}</h2>
|
||||||
|
<p className="mt-4 leading-8 text-mute-strong">{project.outcome}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-serif text-3xl">{dict.case.lesson}</h2>
|
||||||
|
<p className="mt-4 leading-8 text-mute-strong">{project.lesson}</p>
|
||||||
|
{project.href ? (
|
||||||
|
<a href={project.href} className="mt-6 inline-block text-sm text-copper" rel="noreferrer">
|
||||||
|
{project.href.replace(/^https?:\/\//, "")} →
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-t border-line pt-16">
|
||||||
|
<div className="border border-copper/30 bg-copper/5 px-8 py-10">
|
||||||
|
<p className="kicker">{dict.home.ctaKicker}</p>
|
||||||
|
<h2 className="display mt-4 max-w-3xl text-3xl md:text-5xl">{dict.home.ctaTitle}</h2>
|
||||||
|
<Link
|
||||||
|
href={publicPath(locale, "contact")}
|
||||||
|
className="mt-8 inline-block border border-copper bg-copper px-6 py-3 text-sm font-medium text-ink"
|
||||||
|
>
|
||||||
|
{dict.actions.startConversation}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { getProjects } from "@/content/projects";
|
||||||
|
import { getDictionary } from "@/i18n/dictionaries";
|
||||||
|
import { publicPath } from "@/i18n/routes";
|
||||||
|
import { pageMetadata } from "@/lib/metadata";
|
||||||
|
import { requireLocale } from "@/lib/locale";
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: PageProps<"/[lang]/work">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
return pageMetadata(locale, "work", dict.work.title, dict.work.lede);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function WorkPage({ params }: PageProps<"/[lang]/work">) {
|
||||||
|
const locale = requireLocale((await params).lang);
|
||||||
|
const dict = getDictionary(locale);
|
||||||
|
const projects = getProjects(locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-6xl px-6 py-20 md:px-10 md:py-28">
|
||||||
|
<p className="kicker">{dict.work.kicker}</p>
|
||||||
|
<h1 className="display mt-5 max-w-4xl text-5xl md:text-7xl">{dict.work.title}</h1>
|
||||||
|
<p className="mt-8 max-w-2xl text-xl leading-9 text-mute-strong">{dict.work.lede}</p>
|
||||||
|
<div className="mt-16 grid gap-16">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<article key={project.slug} className="grid gap-8 border-t border-line pt-10 md:grid-cols-[0.4fr_1fr]">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-xs text-copper">{project.number}</p>
|
||||||
|
<h2 className="mt-4 font-serif text-4xl">{project.title}</h2>
|
||||||
|
<p className="mt-4 text-sm text-mute">{project.status}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="kicker">{project.label}</p>
|
||||||
|
<p className="mt-5 max-w-2xl leading-8 text-mute-strong">{project.summary}</p>
|
||||||
|
<p className="mt-6 text-sm text-mute">{project.stack}</p>
|
||||||
|
<Link
|
||||||
|
href={publicPath(locale, "workItem", project.slug)}
|
||||||
|
className="mt-8 inline-block border-b border-copper pb-1 text-sm text-copper"
|
||||||
|
>
|
||||||
|
{dict.actions.openCase} →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+96
-32
@@ -1,45 +1,109 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Tech Style Dark Theme by Default */
|
--ink: #12100e;
|
||||||
--background: #020617; /* Slate 950 */
|
--ink-raised: #1b1814;
|
||||||
--foreground: #f8fafc; /* Slate 50 */
|
--paper: #f4efe4;
|
||||||
|
--paper-dim: #e4dccb;
|
||||||
/* Primary Action Colors (Electric Blue / Cyan) */
|
--copper: #c9843a;
|
||||||
--primary: #0ea5e9;
|
--copper-deep: #a86b2a;
|
||||||
--primary-foreground: #ffffff;
|
--mute: #9a9184;
|
||||||
|
--mute-strong: #c9c1b3;
|
||||||
/* Metallic / Professional Accents */
|
--line: rgba(244, 239, 228, 0.12);
|
||||||
--accent: #334155; /* Slate 700 */
|
--line-strong: rgba(244, 239, 228, 0.22);
|
||||||
--accent-foreground: #f8fafc;
|
|
||||||
|
|
||||||
--border: #1e293b; /* Slate 800 */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-ink: var(--ink);
|
||||||
--color-foreground: var(--foreground);
|
--color-ink-raised: var(--ink-raised);
|
||||||
--color-primary: var(--primary);
|
--color-paper: var(--paper);
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
--color-paper-dim: var(--paper-dim);
|
||||||
--color-accent: var(--accent);
|
--color-copper: var(--copper);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-copper-deep: var(--copper-deep);
|
||||||
--color-border: var(--border);
|
--color-mute: var(--mute);
|
||||||
|
--color-mute-strong: var(--mute-strong);
|
||||||
|
--color-line: var(--line);
|
||||||
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-serif: var(--font-newsreader), "Iowan Old Style", "Palatino Linotype", serif;
|
||||||
--font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace;
|
--font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
html {
|
||||||
background-color: var(--background);
|
scroll-behavior: smooth;
|
||||||
color: var(--foreground);
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Subtle Grid Background for that "Tech" feel */
|
body {
|
||||||
.bg-grid-pattern {
|
background: var(--ink);
|
||||||
background-image: linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
|
color: var(--paper);
|
||||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
|
font-family: var(--font-sans);
|
||||||
background-size: 40px 40px;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: rgba(201, 132, 58, 0.35);
|
||||||
|
color: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-shell {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-shell::before {
|
||||||
|
content: "";
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 500px at 10% -10%, rgba(201, 132, 58, 0.08), transparent 55%),
|
||||||
|
linear-gradient(180deg, rgba(244, 239, 228, 0.03), transparent 28%);
|
||||||
|
z-index: -2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-shell::after {
|
||||||
|
content: "";
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.35'/%3E%3C/svg%3E");
|
||||||
|
opacity: 0.035;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--copper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.display {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-weight: 450;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
line-height: 0.96;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-quiet {
|
||||||
|
color: var(--mute-strong);
|
||||||
|
transition: color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-quiet:hover {
|
||||||
|
color: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.honeypot {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-8
@@ -1,5 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { headers } from "next/headers";
|
||||||
|
import { Geist, Geist_Mono, Newsreader } from "next/font/google";
|
||||||
|
import { site } from "@/content/site";
|
||||||
|
import { isLocale } from "@/i18n/routes";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@@ -12,17 +15,34 @@ const geistMono = Geist_Mono({
|
|||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const newsreader = Newsreader({
|
||||||
|
variable: "--font-newsreader",
|
||||||
|
subsets: ["latin"],
|
||||||
|
style: ["normal", "italic"],
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Jan Wagner — Software für anspruchsvolle Prozesse",
|
metadataBase: new URL(site.url),
|
||||||
description: "Portfolio von Jan Wagner: industrielle Software, Full-Stack Engineering, AI-Tooling und Glass Technology.",
|
title: {
|
||||||
|
default: "Jan Wagner — Software für anspruchsvolle Prozesse",
|
||||||
|
template: "%s · W-MAKE",
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
"Jan Wagner entwickelt robuste digitale Systeme für industrielle Prozesse — mit Domänenverständnis, klarer Architektur und Verantwortung für den Betrieb.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
export default async function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
const headerLocale = (await headers()).get("x-locale");
|
||||||
|
const lang = headerLocale && isLocale(headerLocale) ? headerLocale : "de";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="de" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
|
<html
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
lang={lang}
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} ${newsreader.variable} h-full antialiased`}
|
||||||
|
>
|
||||||
|
<body className="min-h-full bg-ink text-paper">{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ponytail: Open Graph, structured data and canonical URL follow after the public domain is final.
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex min-h-[70vh] max-w-3xl flex-col justify-center px-6">
|
||||||
|
<p className="kicker">404</p>
|
||||||
|
<h1 className="display mt-4 text-5xl">Seite nicht gefunden.</h1>
|
||||||
|
<p className="mt-6 text-mute-strong">Die Adresse existiert nicht oder wurde verschoben.</p>
|
||||||
|
<Link href="/" className="mt-8 text-copper">
|
||||||
|
Zur Startseite →
|
||||||
|
</Link>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
type Project = {
|
|
||||||
number: string;
|
|
||||||
label: string;
|
|
||||||
title: string;
|
|
||||||
summary: string;
|
|
||||||
details: string;
|
|
||||||
role: string;
|
|
||||||
stack: string;
|
|
||||||
status: string;
|
|
||||||
href: string;
|
|
||||||
cta: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Update = {
|
|
||||||
slug: string;
|
|
||||||
product: "batchmaker" | "standalone";
|
|
||||||
title: string;
|
|
||||||
summary: string;
|
|
||||||
published_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function getUpdates(): Promise<Update[]> {
|
|
||||||
const baseUrl = process.env.UPDATES_API_URL || "http://updates-api:8080";
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${baseUrl}/v1/updates?limit=3`, {
|
|
||||||
next: { revalidate: 60 },
|
|
||||||
signal: AbortSignal.timeout(1500),
|
|
||||||
});
|
|
||||||
if (!response.ok) return [];
|
|
||||||
const payload = await response.json() as { data?: Update[] };
|
|
||||||
return payload.data || [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const projects: Project[] = [
|
|
||||||
{
|
|
||||||
number: "01",
|
|
||||||
label: "Industrie-Software · Produktentwicklung",
|
|
||||||
title: "W-Make Batch",
|
|
||||||
summary: "Batchmanagement für die Behälterglas-Produktion — vom Satzzettel bis zum Tagesprotokoll.",
|
|
||||||
details: "Das System bildet eine reale Produktionskette ab: Rezepte, Materialverbrauch, Silos, Schichten und Berechnungen bleiben fachlich nachvollziehbar verbunden. Der Schwerpunkt liegt nicht auf möglichst vielen Screens, sondern auf sicheren Zuständen und belastbaren Buchungen.",
|
|
||||||
role: "Domänenmodell, Full-Stack-Entwicklung und technische Qualität",
|
|
||||||
stack: "Node.js · Express · SQLite · Web Components · native Tests · Playwright",
|
|
||||||
status: "öffentliche Produkt-Demo",
|
|
||||||
href: "https://batch.w-make.com",
|
|
||||||
cta: "Produkt-Showroom öffnen",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
number: "02",
|
|
||||||
label: "Standalone-Produkt · Recipe Engineering",
|
|
||||||
title: "Batchmaker Studio",
|
|
||||||
summary: "Schlankes Recipe-Studio für Rezepte, Rohstoffe, Satzzettel und Glaschemie.",
|
|
||||||
details: "Die eigenständig betreibbare Variante konzentriert sich auf Rezeptentwicklung und Berechnung — ohne den vollständigen Produktions- und Silo-Overhead von W-Make Batch.",
|
|
||||||
role: "Produktarchitektur, Full-Stack-Entwicklung und Calculation Engine",
|
|
||||||
stack: "Node.js · Express · node:sqlite · Vanilla JS · Vite · native Tests",
|
|
||||||
status: "eigenständig betreibbares Produkt",
|
|
||||||
href: "/projects/batchmaker-studio",
|
|
||||||
cta: "Case Study öffnen",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
number: "03",
|
|
||||||
label: "Domänenwissen · Systems Thinking",
|
|
||||||
title: "Glas-Technologie als Engineering-Vorteil",
|
|
||||||
summary: "Technische Software entsteht besser, wenn der Prozess nicht erst nachträglich erklärt werden muss.",
|
|
||||||
details: "Der Hintergrund in der Behälterglas-Produktion prägt die Art, wie Systeme entworfen werden: Materialflüsse, Messwerte, Zeitbezug und irreversible fachliche Entscheidungen werden als Teil der Architektur behandelt — nicht als Randnotiz im UI.",
|
|
||||||
role: "Domänenanalyse, Modellbildung und Übersetzung in Software",
|
|
||||||
stack: "Prozessverständnis · Datenmodelle · Berechnungen · Dokumentation",
|
|
||||||
status: "dauerhafter fachlicher Schwerpunkt",
|
|
||||||
href: "#kontakt",
|
|
||||||
cta: "Ähnlichen Prozess besprechen",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const principles = [
|
|
||||||
["Fachlichkeit zuerst", "Ich beginne beim realen Prozess, nicht beim Framework. Fachliche Regeln werden sichtbar, prüfbar und nachvollziehbar umgesetzt."],
|
|
||||||
["Verlässliche Zustände", "Buchungen, Migrationen und Fehlerpfade werden so entworfen, dass ein System auch unter realen Bedingungen verständlich bleibt."],
|
|
||||||
["Kleine, tragfähige Lösungen", "Keine Architektur um ihrer selbst willen. Erst die kleinste Lösung, die den Prozess dauerhaft besser macht."],
|
|
||||||
];
|
|
||||||
|
|
||||||
const offers = [
|
|
||||||
["Prozessanalyse", "Bestehende Abläufe verstehen, Bruchstellen sichtbar machen und eine priorisierte technische Roadmap ableiten."],
|
|
||||||
["Produkt- und Backend-Entwicklung", "Anwendungen entwickeln oder stabilisieren, bei denen Datenmodell und Geschäftslogik wichtiger sind als schnelle CRUD-Oberflächen."],
|
|
||||||
["Technische Zusammenarbeit", "Architekturentscheidungen, Integrationen und gewachsenen Code gemeinsam in wartbare Systeme überführen."],
|
|
||||||
];
|
|
||||||
|
|
||||||
export default async function Home() {
|
|
||||||
const updates = await getUpdates();
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-grid-pattern">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-background/90 to-background pointer-events-none" />
|
|
||||||
<div className="relative z-10 mx-auto w-full max-w-6xl px-6 py-8 md:px-10 md:py-12">
|
|
||||||
<nav className="flex items-center justify-between border-b border-slate-800/80 pb-6" aria-label="Hauptnavigation">
|
|
||||||
<a href="#top" className="font-mono text-sm tracking-[0.22em] text-primary">W-MAKE</a>
|
|
||||||
<div className="flex gap-4 text-sm text-slate-400 md:gap-6">
|
|
||||||
<a href="#projekte" className="hover:text-white">Projekte</a>
|
|
||||||
<a href="#arbeitsweise" className="hover:text-white">Arbeitsweise</a>
|
|
||||||
<a href="#kontakt" className="hover:text-white">Kontakt</a>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<section id="top" className="grid gap-12 py-24 md:grid-cols-[1.25fr_0.75fr] md:items-end md:py-32">
|
|
||||||
<div>
|
|
||||||
<p className="mb-6 font-mono text-sm uppercase tracking-[0.22em] text-primary">Jan Wagner · Software Engineering</p>
|
|
||||||
<h1 className="max-w-4xl text-5xl font-semibold tracking-tight text-white md:text-7xl">Software für Prozesse, auf die man sich verlassen muss.</h1>
|
|
||||||
<p className="mt-8 max-w-2xl text-lg leading-8 text-slate-300 md:text-xl">Ich entwickle robuste digitale Systeme für industrielle und anspruchsvolle Geschäftsprozesse — mit Domänenverständnis, klarer Architektur und Verantwortung für den Betrieb.</p>
|
|
||||||
<div className="mt-10 flex flex-wrap gap-4">
|
|
||||||
<a href="#projekte" className="rounded-full bg-primary px-6 py-3 font-medium text-slate-950 transition hover:bg-cyan-300">Arbeiten ansehen</a>
|
|
||||||
<a href="#kontakt" className="rounded-full border border-slate-700 px-6 py-3 font-medium text-white transition hover:border-primary hover:text-primary">Projekt besprechen</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<aside className="border-l border-primary/50 pl-6 text-sm leading-7 text-slate-400">
|
|
||||||
<p className="font-mono text-primary">WAS ICH VERBINDE</p>
|
|
||||||
<p className="mt-4 text-base text-slate-200">Glass Technology, Full-Stack Engineering und Systems Thinking.</p>
|
|
||||||
<p className="mt-6">Der gemeinsame Nenner: reale Abläufe in Software übersetzen, ohne die fachliche Wahrheit zu verlieren.</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="projekte" className="scroll-mt-8 border-t border-slate-800 py-20">
|
|
||||||
<div className="mb-10 flex flex-wrap items-end justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="font-mono text-sm uppercase tracking-[0.22em] text-primary">Ausgewählte Arbeit</p>
|
|
||||||
<h2 className="mt-3 text-3xl font-semibold text-white md:text-4xl">Nicht nur Features. Systeme.</h2>
|
|
||||||
</div>
|
|
||||||
<p className="max-w-md text-sm leading-6 text-slate-400">Jedes Projekt zeigt eine andere Seite derselben Arbeit: Domäne verstehen, Entscheidungen explizit machen und ein System in der Realität betreiben.</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-5 md:grid-cols-3">
|
|
||||||
{projects.map((project) => (
|
|
||||||
<article key={project.title} className="flex flex-col rounded-2xl border border-slate-800 bg-slate-950/60 p-7 transition hover:-translate-y-1 hover:border-primary/60">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<p className="font-mono text-xs uppercase tracking-[0.16em] text-primary">{project.label}</p>
|
|
||||||
<span className="font-mono text-xs text-slate-600">{project.number}</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="mt-5 text-2xl font-semibold text-white">{project.title}</h3>
|
|
||||||
<p className="mt-4 text-sm leading-7 text-slate-300">{project.summary}</p>
|
|
||||||
<p className="mt-4 flex-1 text-sm leading-7 text-slate-500">{project.details}</p>
|
|
||||||
<div className="mt-6 space-y-3 border-t border-slate-800 pt-4 text-xs leading-5">
|
|
||||||
<p><span className="font-mono uppercase text-slate-600">Rolle</span><br /><span className="text-slate-400">{project.role}</span></p>
|
|
||||||
<p><span className="font-mono uppercase text-slate-600">Werkzeuge</span><br /><span className="text-slate-400">{project.stack}</span></p>
|
|
||||||
<p><span className="font-mono uppercase text-slate-600">Status</span><br /><span className="text-primary/80">{project.status}</span></p>
|
|
||||||
</div>
|
|
||||||
<a href={project.href} className="mt-6 text-sm font-medium text-primary hover:text-cyan-300">{project.cta} <span aria-hidden="true">→</span></a>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="batchmaker-studio" className="scroll-mt-8 border-t border-slate-800 py-20">
|
|
||||||
<div className="grid gap-10 md:grid-cols-[0.75fr_1.25fr] md:items-start">
|
|
||||||
<div>
|
|
||||||
<p className="font-mono text-sm uppercase tracking-[0.22em] text-primary">Batchmaker Studio</p>
|
|
||||||
<h2 className="mt-3 text-3xl font-semibold text-white md:text-4xl">Recipe Engineering ohne Produktions-Overhead.</h2>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-6 text-sm leading-7 text-slate-400 md:grid-cols-2">
|
|
||||||
<p>Studio fokussiert die fachliche Arbeit vor der Produktion: Rezepte entwickeln, Rohstoffanalysen verwalten, Glaschemie berechnen und Satzzettel druckfertig machen.</p>
|
|
||||||
<p>Die eigenständige Variante nutzt denselben Domänenkern, bleibt aber bewusst kleiner: eigener Express-Server, eigene SQLite-Datenbank und eine schlanke Browser-Oberfläche.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-10 grid gap-5 md:grid-cols-3">
|
|
||||||
{[
|
|
||||||
["Rezepte", "Versionieren, vergleichen, importieren und exportieren."],
|
|
||||||
["Glaschemie", "Oxide, Redox, Physik, Spektral- und NNLS-Berechnungen."],
|
|
||||||
["Satzzettel", "Entwürfe, Skalierung, Vorschau und Schmelzer-Druck."],
|
|
||||||
].map(([title, text]) => (
|
|
||||||
<div key={title} className="rounded-2xl border border-slate-800 bg-slate-950/50 p-6">
|
|
||||||
<h3 className="text-lg font-medium text-white">{title}</h3>
|
|
||||||
<p className="mt-3 text-sm leading-6 text-slate-400">{text}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-8 flex flex-wrap items-center gap-5 text-xs text-slate-500">
|
|
||||||
<p>Status: eigenständig betreibbares Produkt · Browser-Ansichten verifiziert.</p>
|
|
||||||
<a href="/projects/batchmaker-studio" className="text-primary hover:text-cyan-300">Zur Case Study →</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="updates" className="scroll-mt-8 border-t border-slate-800 py-20">
|
|
||||||
<p className="font-mono text-sm uppercase tracking-[0.22em] text-primary">Produkt-Updates</p>
|
|
||||||
<h2 className="mt-3 text-3xl font-semibold text-white md:text-4xl">Was sich bei Batchmaker bewegt.</h2>
|
|
||||||
{updates.length ? (
|
|
||||||
<div className="mt-10 grid gap-5 md:grid-cols-3">
|
|
||||||
{updates.map((update) => (
|
|
||||||
<article key={update.slug} className="rounded-2xl border border-slate-800 bg-slate-950/50 p-6">
|
|
||||||
<p className="font-mono text-xs uppercase tracking-[0.16em] text-primary">{update.product === "standalone" ? "Standalone" : "Batchmaker"}</p>
|
|
||||||
<h3 className="mt-4 text-xl font-medium text-white">{update.title}</h3>
|
|
||||||
<p className="mt-3 text-sm leading-6 text-slate-400">{update.summary}</p>
|
|
||||||
<time className="mt-5 block text-xs text-slate-600" dateTime={update.published_at}>{new Date(update.published_at).toLocaleDateString("de-DE")}</time>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="mt-8 max-w-xl text-sm leading-6 text-slate-500">Noch keine veröffentlichten Updates. Neue Releases und fachliche Fortschritte erscheinen hier.</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="arbeitsweise" className="scroll-mt-8 border-t border-slate-800 py-20">
|
|
||||||
<div className="grid gap-12 md:grid-cols-[0.8fr_1.2fr]">
|
|
||||||
<div>
|
|
||||||
<p className="font-mono text-sm uppercase tracking-[0.22em] text-primary">Arbeitsweise</p>
|
|
||||||
<h2 className="mt-3 text-3xl font-semibold text-white md:text-4xl">Senior heißt: Entscheidungen verantworten.</h2>
|
|
||||||
<p className="mt-6 max-w-md leading-7 text-slate-400">Technologie ist Mittel zum Zweck. Entscheidend ist, ob ein System die richtigen fachlichen Zustände abbildet und im Alltag erklärbar bleibt.</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-8">
|
|
||||||
{principles.map(([title, text], index) => (
|
|
||||||
<div key={title} className="grid gap-3 border-b border-slate-800 pb-7 md:grid-cols-[3rem_1fr]">
|
|
||||||
<span className="font-mono text-sm text-primary">0{index + 1}</span>
|
|
||||||
<div><h3 className="text-xl font-medium text-white">{title}</h3><p className="mt-2 max-w-xl leading-7 text-slate-400">{text}</p></div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="leistungen" className="scroll-mt-8 border-t border-slate-800 py-20">
|
|
||||||
<p className="font-mono text-sm uppercase tracking-[0.22em] text-primary">Zusammenarbeit</p>
|
|
||||||
<h2 className="mt-3 max-w-2xl text-3xl font-semibold text-white md:text-4xl">Wo ich am meisten beitragen kann.</h2>
|
|
||||||
<div className="mt-10 grid gap-5 md:grid-cols-3">
|
|
||||||
{offers.map(([title, text], index) => (
|
|
||||||
<div key={title} className="border-l border-slate-700 pl-5">
|
|
||||||
<span className="font-mono text-xs text-primary">0{index + 1}</span>
|
|
||||||
<h3 className="mt-4 text-xl font-medium text-white">{title}</h3>
|
|
||||||
<p className="mt-3 text-sm leading-7 text-slate-400">{text}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="kontakt" className="scroll-mt-8 border-t border-slate-800 py-20">
|
|
||||||
<div className="rounded-3xl border border-primary/30 bg-primary/5 p-8 md:p-12">
|
|
||||||
<p className="font-mono text-sm uppercase tracking-[0.22em] text-primary">Kontakt</p>
|
|
||||||
<h2 className="mt-4 max-w-2xl text-3xl font-semibold text-white md:text-5xl">Sie haben einen Prozess, der bessere Software verdient?</h2>
|
|
||||||
<p className="mt-6 max-w-2xl leading-7 text-slate-300">Lassen Sie uns klären, wo der größte Hebel liegt — in einem neuen Produkt, einer bestehenden Anwendung oder einer technischen Entscheidung.</p>
|
|
||||||
<p className="mt-8 text-sm text-slate-400">Kontaktkanal und Impressum werden vor dem öffentlichen Launch ergänzt.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer className="flex flex-wrap justify-between gap-4 border-t border-slate-800 py-8 text-xs text-slate-500">
|
|
||||||
<span>© {new Date().getFullYear()} Jan Wagner · w-make.com</span>
|
|
||||||
<span>Software Engineering · Glass Technology · Systems Thinking</span>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ponytail: Kontaktadresse und rechtliche Angaben erst ergänzen, wenn die UG-/Domain-Daten feststehen.
|
|
||||||
// ponytail: Detailseiten erst abspalten, wenn mehrere veröffentlichbare Case Studies existieren.
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Batchmaker Studio — Case Study | Jan Wagner",
|
|
||||||
description: "Case Study zum eigenständig betreibbaren Recipe Studio für Glaschemie und Satzzettel.",
|
|
||||||
};
|
|
||||||
|
|
||||||
const screens = [
|
|
||||||
["Rezeptarbeitsplatz", "Rezepte, Rohstoffe und Versionen als fokussierter Arbeitsbereich.", "/portfolio/batchmaker-recipes.jpg"],
|
|
||||||
["Validierung", "Fachliche Prüfung als eigener Schritt statt versteckter Nebenwirkung.", "/portfolio/batchmaker-validation.jpg"],
|
|
||||||
["Konfiguration", "Redox-Grenzwerte, Kalibrierung und Oxid-Panel bleiben sichtbar steuerbar.", "/portfolio/batchmaker-settings.jpg"],
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function BatchmakerStudioCaseStudy() {
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-grid-pattern">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-background/90 to-background pointer-events-none" />
|
|
||||||
<div className="relative z-10 mx-auto w-full max-w-6xl px-6 py-8 md:px-10 md:py-12">
|
|
||||||
<nav className="flex items-center justify-between border-b border-slate-800/80 pb-6" aria-label="Hauptnavigation">
|
|
||||||
<Link href="/" className="font-mono text-sm tracking-[0.22em] text-primary">W-MAKE</Link>
|
|
||||||
<Link href="/#projekte" className="text-sm text-slate-400 hover:text-white">← Projekte</Link>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<section className="max-w-4xl py-20 md:py-28">
|
|
||||||
<p className="font-mono text-sm uppercase tracking-[0.22em] text-primary">Case Study · Standalone Product</p>
|
|
||||||
<h1 className="mt-5 text-5xl font-semibold tracking-tight text-white md:text-7xl">Batchmaker Studio</h1>
|
|
||||||
<p className="mt-8 max-w-3xl text-xl leading-9 text-slate-300">Recipe Engineering für die Glasindustrie — bewusst kleiner als ein Produktionssystem, aber fachlich belastbar genug für echte Rezeptarbeit.</p>
|
|
||||||
<div className="mt-10 grid gap-6 border-y border-slate-800 py-6 text-sm md:grid-cols-3">
|
|
||||||
<p><span className="font-mono text-xs uppercase text-slate-600">Rolle</span><br /><span className="text-slate-300">Produktarchitektur und Full-Stack-Entwicklung</span></p>
|
|
||||||
<p><span className="font-mono text-xs uppercase text-slate-600">Stack</span><br /><span className="text-slate-300">Node.js · Express · SQLite · Vanilla JS · Vite</span></p>
|
|
||||||
<p><span className="font-mono text-xs uppercase text-slate-600">Evidenz</span><br /><span className="text-primary/80">Lokale Browser-Session verifiziert</span></p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="grid gap-10 border-t border-slate-800 py-20 md:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<p className="font-mono text-sm uppercase tracking-[0.22em] text-primary">Der Zuschnitt</p>
|
|
||||||
<h2 className="mt-3 text-3xl font-semibold text-white">Ein klarer fachlicher Kern statt Produktions-Overhead.</h2>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-5 leading-7 text-slate-400">
|
|
||||||
<p>Batchmaker Studio konzentriert sich auf Rezepte, Rohstoffe, Glaschemie, Validierung und druckfertige Satzzettel. Silos, Schichten und laufende Produktionsbuchungen bleiben bewusst außerhalb des Produkts.</p>
|
|
||||||
<p>Diese Grenze ist eine Designentscheidung: Das Studio kann eigenständig betrieben und für Rezeptentwicklung eingesetzt werden, ohne die vollständige Betriebslogik von W-Make Batch mitzuschleppen.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="border-t border-slate-800 py-20">
|
|
||||||
<p className="font-mono text-sm uppercase tracking-[0.22em] text-primary">Verifizierte Ansichten</p>
|
|
||||||
<h2 className="mt-3 text-3xl font-semibold text-white md:text-4xl">Die Oberfläche zeigt den fachlichen Prozess.</h2>
|
|
||||||
<div className="mt-10 grid gap-8">
|
|
||||||
{screens.map(([title, text, image]) => (
|
|
||||||
<figure key={title} className="overflow-hidden rounded-2xl border border-slate-800 bg-slate-950/70">
|
|
||||||
<Image src={image} alt={`${title} im Batchmaker Studio`} width={948} height={403} className="block h-auto w-full" />
|
|
||||||
<figcaption className="grid gap-2 p-6 md:grid-cols-[0.4fr_1fr]">
|
|
||||||
<h3 className="text-xl font-medium text-white">{title}</h3>
|
|
||||||
<p className="text-sm leading-6 text-slate-400">{text}</p>
|
|
||||||
</figcaption>
|
|
||||||
</figure>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="border-t border-slate-800 py-20">
|
|
||||||
<div className="rounded-3xl border border-primary/30 bg-primary/5 p-8 md:p-12">
|
|
||||||
<p className="font-mono text-sm uppercase tracking-[0.22em] text-primary">Engineering-Lektion</p>
|
|
||||||
<h2 className="mt-4 max-w-3xl text-3xl font-semibold text-white md:text-5xl">Produktgrenzen sind Teil der Architektur.</h2>
|
|
||||||
<p className="mt-6 max-w-3xl leading-7 text-slate-300">Ein gutes Werkzeug muss nicht den gesamten Nachbarprozess abbilden. Der kleinere Zuschnitt macht die fachliche Aufgabe verständlicher, testbarer und eigenständig betreibbar.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer className="flex flex-wrap justify-between gap-4 border-t border-slate-800 py-8 text-xs text-slate-500">
|
|
||||||
<Link href="/" className="hover:text-white">Jan Wagner · W-MAKE</Link>
|
|
||||||
<span>Software Engineering · Glass Technology · Systems Thinking</span>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
import { siteUrl } from "@/content/site";
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
rules: { userAgent: "*", allow: "/" },
|
||||||
|
sitemap: siteUrl("/sitemap.xml"),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
import { noteSlugs } from "@/content/notes";
|
||||||
|
import { projectSlugs } from "@/content/projects";
|
||||||
|
import { siteUrl } from "@/content/site";
|
||||||
|
import { locales, publicPath, type RouteId } from "@/i18n/routes";
|
||||||
|
|
||||||
|
const staticRoutes: RouteId[] = ["home", "about", "work", "notes", "contact", "legal", "privacy"];
|
||||||
|
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
const entries: MetadataRoute.Sitemap = [];
|
||||||
|
|
||||||
|
for (const locale of locales) {
|
||||||
|
for (const route of staticRoutes) {
|
||||||
|
entries.push({
|
||||||
|
url: siteUrl(publicPath(locale, route)),
|
||||||
|
changeFrequency: route === "home" ? "weekly" : "monthly",
|
||||||
|
priority: route === "home" ? 1 : 0.7,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const slug of projectSlugs) {
|
||||||
|
entries.push({
|
||||||
|
url: siteUrl(publicPath(locale, "workItem", slug)),
|
||||||
|
changeFrequency: "monthly",
|
||||||
|
priority: 0.8,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const slug of noteSlugs) {
|
||||||
|
entries.push({
|
||||||
|
url: siteUrl(publicPath(locale, "noteItem", slug)),
|
||||||
|
changeFrequency: "monthly",
|
||||||
|
priority: 0.6,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { submitInquiry } from "@/lib/contact/actions";
|
||||||
|
import type { Dictionary } from "@/i18n/dictionaries";
|
||||||
|
import type { Locale } from "@/i18n/routes";
|
||||||
|
|
||||||
|
export function ContactForm({ locale, dict }: { locale: Locale; dict: Dictionary }) {
|
||||||
|
const [status, setStatus] = useState<"idle" | "sending" | "success" | "error">("idle");
|
||||||
|
const [field, setField] = useState<string | undefined>();
|
||||||
|
|
||||||
|
async function onSubmit(formData: FormData) {
|
||||||
|
setStatus("sending");
|
||||||
|
setField(undefined);
|
||||||
|
const result = await submitInquiry(formData);
|
||||||
|
if (result.ok) {
|
||||||
|
setStatus("success");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("error");
|
||||||
|
setField(result.field);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "success") {
|
||||||
|
return (
|
||||||
|
<p className="border border-copper/40 bg-copper/10 px-6 py-8 text-lg text-paper">
|
||||||
|
{dict.contact.success}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalid = (name: string) => field === name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={onSubmit} className="grid gap-6">
|
||||||
|
<input type="hidden" name="locale" value={locale} />
|
||||||
|
<label className="honeypot">
|
||||||
|
{dict.contact.honeypot}
|
||||||
|
<input type="text" name="website" tabIndex={-1} autoComplete="off" />
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-2 text-sm">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.16em] text-mute">{dict.contact.name}</span>
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
autoComplete="name"
|
||||||
|
className={`border bg-ink-raised px-4 py-3 text-paper outline-none focus:border-copper ${invalid("name") ? "border-copper" : "border-line-strong"}`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-2 text-sm">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.16em] text-mute">{dict.contact.email}</span>
|
||||||
|
<input
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
className={`border bg-ink-raised px-4 py-3 text-paper outline-none focus:border-copper ${invalid("email") ? "border-copper" : "border-line-strong"}`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-2 text-sm">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.16em] text-mute">{dict.contact.company}</span>
|
||||||
|
<input
|
||||||
|
name="company"
|
||||||
|
autoComplete="organization"
|
||||||
|
className="border border-line-strong bg-ink-raised px-4 py-3 text-paper outline-none focus:border-copper"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-2 text-sm">
|
||||||
|
<span className="font-mono text-xs uppercase tracking-[0.16em] text-mute">{dict.contact.message}</span>
|
||||||
|
<textarea
|
||||||
|
name="message"
|
||||||
|
required
|
||||||
|
rows={7}
|
||||||
|
className={`border bg-ink-raised px-4 py-3 text-paper outline-none focus:border-copper ${invalid("message") ? "border-copper" : "border-line-strong"}`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{status === "error" ? <p className="text-sm text-copper">{dict.contact.error}</p> : null}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={status === "sending"}
|
||||||
|
className="justify-self-start border border-copper bg-copper px-6 py-3 text-sm font-medium text-ink transition hover:bg-copper-deep disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{status === "sending" ? dict.actions.sending : dict.actions.send}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { site } from "@/content/site";
|
||||||
|
|
||||||
|
export function HomeJsonLd() {
|
||||||
|
const data = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "ProfessionalService",
|
||||||
|
name: "W-MAKE",
|
||||||
|
url: site.url,
|
||||||
|
founder: {
|
||||||
|
"@type": "Person",
|
||||||
|
name: "Jan Wagner",
|
||||||
|
jobTitle: "Software Engineer",
|
||||||
|
},
|
||||||
|
areaServed: "DE",
|
||||||
|
description:
|
||||||
|
"Software für industrielle und anspruchsvolle Geschäftsprozesse — Domänenverständnis, Architektur, Betrieb.",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import type { Dictionary } from "@/i18n/dictionaries";
|
||||||
|
import { switchLocale, toPublicFromInternal, type Locale } from "@/i18n/routes";
|
||||||
|
|
||||||
|
export function LanguageSwitcher({
|
||||||
|
locale,
|
||||||
|
dict,
|
||||||
|
}: {
|
||||||
|
locale: Locale;
|
||||||
|
dict: Dictionary;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname() || "/";
|
||||||
|
const publicPath = toPublicFromInternal(pathname) ?? pathname;
|
||||||
|
const target = locale === "de" ? "en" : "de";
|
||||||
|
const href = switchLocale(publicPath, target) ?? (target === "en" ? "/en" : "/");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label={dict.language.switch} className="flex items-center gap-3 font-mono text-[0.7rem] uppercase tracking-[0.18em]">
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
hrefLang={target}
|
||||||
|
className="text-mute-strong transition hover:text-paper"
|
||||||
|
>
|
||||||
|
{target === "en" ? dict.language.en : dict.language.de}
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export function Mark({ className = "h-7 w-7" }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 48 48" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M8 36V12l8 16 8-16 8 16 8-16v24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path d="M6 38h36" stroke="currentColor" strokeWidth="1.2" opacity="0.55" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export function ProcessRail({
|
||||||
|
stages,
|
||||||
|
}: {
|
||||||
|
stages: readonly { title: string; text: string }[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ol className="grid gap-px bg-line md:grid-cols-3">
|
||||||
|
{stages.map((stage, index) => (
|
||||||
|
<li key={stage.title} className="bg-ink px-6 py-8">
|
||||||
|
<p className="font-mono text-xs text-copper">0{index + 1}</p>
|
||||||
|
<h3 className="mt-4 font-serif text-2xl text-paper">{stage.title}</h3>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-mute-strong">{stage.text}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { Dictionary } from "@/i18n/dictionaries";
|
||||||
|
import { publicPath, type Locale } from "@/i18n/routes";
|
||||||
|
|
||||||
|
export function SiteFooter({ locale, dict }: { locale: Locale; dict: Dictionary }) {
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-line">
|
||||||
|
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-4 px-6 py-8 text-xs text-mute md:px-10">
|
||||||
|
<p>© {new Date().getFullYear()} Jan Wagner · w-make.com</p>
|
||||||
|
<p className="hidden sm:block">{dict.footer.line}</p>
|
||||||
|
<div className="flex gap-5">
|
||||||
|
<Link href={publicPath(locale, "legal")} className="link-quiet">
|
||||||
|
{dict.footer.imprint}
|
||||||
|
</Link>
|
||||||
|
<Link href={publicPath(locale, "privacy")} className="link-quiet">
|
||||||
|
{dict.footer.privacy}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
|
import { Mark } from "@/components/mark";
|
||||||
|
import type { Dictionary } from "@/i18n/dictionaries";
|
||||||
|
import { publicPath, type Locale } from "@/i18n/routes";
|
||||||
|
|
||||||
|
export function SiteHeader({ locale, dict }: { locale: Locale; dict: Dictionary }) {
|
||||||
|
const links = [
|
||||||
|
[dict.nav.work, publicPath(locale, "work")],
|
||||||
|
[dict.nav.about, publicPath(locale, "about")],
|
||||||
|
[dict.nav.notes, publicPath(locale, "notes")],
|
||||||
|
[dict.nav.contact, publicPath(locale, "contact")],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-20 border-b border-line bg-ink/80 backdrop-blur-md">
|
||||||
|
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-4 px-6 py-4 md:px-10">
|
||||||
|
<Link href={publicPath(locale, "home")} className="flex items-center gap-3 text-copper">
|
||||||
|
<Mark />
|
||||||
|
<span className="font-mono text-sm tracking-[0.28em]">W-MAKE</span>
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-wrap items-center gap-5 md:gap-8">
|
||||||
|
<nav aria-label="Hauptnavigation" className="flex flex-wrap items-center gap-4 text-sm md:gap-6">
|
||||||
|
{links.map(([label, href]) => (
|
||||||
|
<Link key={href} href={href} className="link-quiet">
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<LanguageSwitcher locale={locale} dict={dict} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Locale } from "@/i18n/routes";
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
de: {
|
||||||
|
imprint: [
|
||||||
|
"Verantwortlich für diese Website ist Jan Wagner.",
|
||||||
|
"E-Mail: eldov@w-make.de",
|
||||||
|
"Website: https://w-make.com",
|
||||||
|
"Anschrift und Firmierung nach § 5 DDG werden ergänzt, sobald die Unternehmensdaten für den öffentlichen Auftritt feststehen. Bis dahin gilt die genannte E-Mail als Kontakt für redaktionelle und rechtliche Anliegen.",
|
||||||
|
"Inhalte zu Projekten enthalten keine vertraulichen Kundendaten. Externe Links, etwa zur Produkt-Demo unter batch.w-make.com, liegen außerhalb dieser Site.",
|
||||||
|
],
|
||||||
|
privacy: [
|
||||||
|
"Diese Website speichert Anfragen aus dem Kontaktformular, um sie zu beantworten. Pflichtfelder sind Name, E-Mail und Nachricht. Das optionale Feld Unternehmen hilft bei der Einordnung.",
|
||||||
|
"Die Daten werden in einer lokalen SQLite-Datei auf dem Server abgelegt und per E-Mail an eldov@w-make.de weitergeleitet. Es gibt kein Tracking-Pixel, kein Marketing-Cookie und keine Weitergabe an Werbenetzwerke.",
|
||||||
|
"Server-Logs des Hosters können IP-Adresse und Zeitpunkt einer Anfrage enthalten. Das ist Betrieb, nicht Analyse.",
|
||||||
|
"Sie können Auskunft, Berichtigung oder Löschung Ihrer Anfrage verlangen, indem Sie an eldov@w-make.de schreiben oder das Formular mit einem Löschwunsch nutzen. Eine vollständige Datenschutzerklärung nach DSGVO folgt mit der Anschrift vor dem öffentlichen Launch.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
imprint: [
|
||||||
|
"This website is published by Jan Wagner.",
|
||||||
|
"Email: eldov@w-make.de",
|
||||||
|
"Website: https://w-make.com",
|
||||||
|
"Street address and legal entity under German law will be added once the public company data is final. Until then the email above is the contact for editorial and legal matters.",
|
||||||
|
"Project pages do not include confidential client data. External links, such as the product demo at batch.w-make.com, live outside this site.",
|
||||||
|
],
|
||||||
|
privacy: [
|
||||||
|
"This website stores inquiries from the contact form in order to reply. Required fields are name, email, and message. The optional company field helps with context.",
|
||||||
|
"Data is written to a local SQLite file on the server and forwarded by email to eldov@w-make.de. There is no tracking pixel, no marketing cookie, and no sharing with advertising networks.",
|
||||||
|
"The host’s server logs may include IP address and request time. That is operations, not analytics.",
|
||||||
|
"You can request access, correction, or deletion of your inquiry by writing to eldov@w-make.de or through the form. A full GDPR privacy notice will accompany the street address before public launch.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function getLegal(locale: Locale) {
|
||||||
|
return copy[locale];
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { Locale } from "@/i18n/routes";
|
||||||
|
|
||||||
|
export type NoteSlug = "buchungen" | "satzzettel";
|
||||||
|
|
||||||
|
export type LocalizedNote = {
|
||||||
|
slug: NoteSlug;
|
||||||
|
date: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
body: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const notes: Record<NoteSlug, { date: string; de: Omit<LocalizedNote, "slug" | "date">; en: Omit<LocalizedNote, "slug" | "date"> }> = {
|
||||||
|
buchungen: {
|
||||||
|
date: "2026-08-01",
|
||||||
|
de: {
|
||||||
|
title: "Warum fachliche Buchungen nicht einfach gelöscht werden dürfen",
|
||||||
|
summary: "In einem Produktionsmodell ist Löschen selten die richtige Korrektur. Meist braucht es einen Gegenbeleg.",
|
||||||
|
body: [
|
||||||
|
"In vielen Anwendungen ist Delete der schnellste Weg, einen Fehler zu korrigieren. In einem Batch- oder Materialfluss ist das gefährlich: Eine Buchung hat bereits Mengen, Zeiten und nachgelagerte Zustände verändert.",
|
||||||
|
"Die bessere Operation ist eine Gegenbuchung. Sie erhält die Geschichte, macht die Korrektur sichtbar und hält Auswertungen ehrlich. Soft-Delete allein reicht nicht, wenn der fachliche Effekt schon eingetreten ist.",
|
||||||
|
"Das klingt nach mehr Aufwand. Es spart den größeren: nächtliche Differenzen zwischen Silo, Rezept und Protokoll, die niemand mehr erklären kann.",
|
||||||
|
"Für technische Entscheider: Modellieren Sie Irreversibilität dort, wo die Domäne sie verlangt — nicht dort, wo das ORM eine Cascade anbietet.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: "Why domain bookings must not simply be deleted",
|
||||||
|
summary: "In a production model, delete is rarely the right correction. Usually you need a reversing entry.",
|
||||||
|
body: [
|
||||||
|
"In many applications, delete is the fastest way to fix a mistake. In a batch or material flow that is dangerous: a booking has already changed quantities, times, and downstream state.",
|
||||||
|
"The better operation is a reversing entry. It keeps history, makes the correction visible, and keeps reports honest. Soft-delete alone is not enough once the domain effect has occurred.",
|
||||||
|
"It looks like more work. It avoids the larger kind: overnight gaps between silo, recipe, and log that nobody can explain.",
|
||||||
|
"For technical decision-makers: model irreversibility where the domain requires it — not where the ORM offers a cascade.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
satzzettel: {
|
||||||
|
date: "2026-08-10",
|
||||||
|
de: {
|
||||||
|
title: "Vom Satzzettel zum Tagesprotokoll",
|
||||||
|
summary: "Zwei Dokumente, ein Modell. Wenn sie getrennt leben, entsteht der Bruch genau dort, wo die Schicht übergibt.",
|
||||||
|
body: [
|
||||||
|
"Der Satzzettel sagt, was in die Wanne soll. Das Tagesprotokoll sagt, was der Tag daraus gemacht hat. In Papierstapeln sind das zwei Welten. In Software dürfen sie das nicht bleiben.",
|
||||||
|
"Ein durchgängiges Modell verbindet Rezept, Satz, Verbrauch und Abschluss. Nicht, weil Integration modern ist, sondern weil die Schichtübergabe sonst wieder in Kopien und Nebenrechnungen stattfindet.",
|
||||||
|
"Die Oberfläche kann schlank sein. Die Verbindung im Datenmodell darf es nicht. Wer nur Screens liefert, liefert den Bruch mit.",
|
||||||
|
"Lernpunkt: Zeichnen Sie zuerst den Tag eines Schmelzers, nicht die Sitemap der Anwendung.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: "From batch ticket to daily log",
|
||||||
|
summary: "Two documents, one model. If they live apart, the fracture appears exactly where the shift hands over.",
|
||||||
|
body: [
|
||||||
|
"The batch ticket says what should go into the furnace. The daily log says what the day made of it. On paper these are two worlds. In software they must not stay that way.",
|
||||||
|
"A continuous model connects recipe, batch, consumption, and close. Not because integration is fashionable, but because otherwise the handover happens again in copies and side calculations.",
|
||||||
|
"The interface can stay lean. The connection in the data model cannot. Screens alone ship the fracture with them.",
|
||||||
|
"Takeaway: draw a melter’s day first, not the application sitemap.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const noteSlugs = Object.keys(notes) as NoteSlug[];
|
||||||
|
|
||||||
|
export function getNote(slug: string, locale: Locale): LocalizedNote | null {
|
||||||
|
if (!(slug in notes)) return null;
|
||||||
|
const entry = notes[slug as NoteSlug];
|
||||||
|
return { slug: slug as NoteSlug, date: entry.date, ...entry[locale] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNotes(locale: Locale): LocalizedNote[] {
|
||||||
|
return noteSlugs
|
||||||
|
.map((slug) => getNote(slug, locale)!)
|
||||||
|
.sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Locale } from "@/i18n/routes";
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
de: [
|
||||||
|
{
|
||||||
|
title: "Prozessanalyse",
|
||||||
|
text: "Ich analysiere einen bestehenden digitalen Prozess, identifiziere fachliche Bruchstellen und übersetze die Ergebnisse in eine priorisierte technische Roadmap.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Produkt- und Backend-Entwicklung",
|
||||||
|
text: "Ich entwickle oder stabilisiere Anwendungen, bei denen Datenmodell, Geschäftslogik und Betrieb wichtiger sind als ein schneller Prototyp.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Technische Zusammenarbeit",
|
||||||
|
text: "Ich unterstütze Teams bei Architekturentscheidungen, schwierigen Integrationen und der Überführung von gewachsenem Code in ein wartbares System.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
en: [
|
||||||
|
{
|
||||||
|
title: "Process analysis",
|
||||||
|
text: "I analyse an existing digital process, make domain fractures visible, and turn the findings into a prioritised technical roadmap.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Product and backend development",
|
||||||
|
text: "I build or stabilise applications where the data model, domain logic, and operations matter more than a fast prototype.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Technical collaboration",
|
||||||
|
text: "I help teams with architecture decisions, difficult integrations, and turning accumulated code into a maintainable system.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function getOffers(locale: Locale) {
|
||||||
|
return copy[locale];
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { Locale } from "@/i18n/routes";
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
de: {
|
||||||
|
role: "Software Engineer für industrielle Prozesse",
|
||||||
|
location: "Deutschland · remote und vor Ort",
|
||||||
|
story: [
|
||||||
|
"Mein Ausgangspunkt ist die Behälterglas-Produktion: Rezepte, Rohstoffe, Silos, Schichten, Satzzettel, Tagesprotokolle. Das sind keine abstrakten Entities. Es sind Zustände, die Geld, Material und Sicherheit berühren.",
|
||||||
|
"Daraus folgt, wie ich Software baue. Fachliche Buchungen werden nicht einfach gelöscht. Berechnungen bleiben nachvollziehbar. Fehlerpfade sind Teil des Modells, nicht ein nachträglicher Dialog.",
|
||||||
|
"Für Kunden heißt das: jemand, der den Prozess zuerst versteht und dann ein System hinterlässt, das ein anderer Engineer erklären kann. Für Arbeitgeber heißt das: Ownership statt Ticket-Abarbeitung.",
|
||||||
|
],
|
||||||
|
principles: [
|
||||||
|
{
|
||||||
|
title: "Fachlichkeit zuerst",
|
||||||
|
text: "Ich beginne beim realen Prozess, nicht beim Framework. Regeln werden sichtbar, prüfbar und nachvollziehbar.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Verlässliche Zustände",
|
||||||
|
text: "Buchungen, Migrationen und Fehlerpfade so, dass das System unter realen Bedingungen verständlich bleibt.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Kleine, tragfähige Lösungen",
|
||||||
|
text: "Keine Architektur um ihrer selbst willen. Erst die kleinste Lösung, die den Prozess dauerhaft besser macht.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
role: "Software engineer for industrial processes",
|
||||||
|
location: "Germany · remote and on site",
|
||||||
|
story: [
|
||||||
|
"I start from container-glass production: recipes, raw materials, silos, shifts, batch tickets, daily logs. These are not abstract entities. They are states that touch money, material, and safety.",
|
||||||
|
"That shapes how I build software. Domain bookings are not simply deleted. Calculations stay traceable. Failure paths belong in the model, not in an afterthought dialog.",
|
||||||
|
"For clients that means someone who understands the process first and leaves a system another engineer can explain. For employers it means ownership, not ticket throughput.",
|
||||||
|
],
|
||||||
|
principles: [
|
||||||
|
{
|
||||||
|
title: "Domain first",
|
||||||
|
text: "I start from the real process, not the framework. Rules become visible, testable, and traceable.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Reliable states",
|
||||||
|
text: "Bookings, migrations, and failure paths designed so the system stays intelligible under real conditions.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Small, durable solutions",
|
||||||
|
text: "No architecture for its own sake. The smallest design that lastingly improves the process.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function getPerson(locale: Locale) {
|
||||||
|
return copy[locale];
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import type { Locale } from "@/i18n/routes";
|
||||||
|
|
||||||
|
export type ProjectSlug = "w-make-batch" | "batchmaker-studio";
|
||||||
|
|
||||||
|
export type ProjectImage = {
|
||||||
|
src: string;
|
||||||
|
de: string;
|
||||||
|
en: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LocalizedProject = {
|
||||||
|
slug: ProjectSlug;
|
||||||
|
number: string;
|
||||||
|
href?: string;
|
||||||
|
images: ProjectImage[];
|
||||||
|
status: string;
|
||||||
|
label: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
stack: string;
|
||||||
|
role: string;
|
||||||
|
situation: string;
|
||||||
|
decisions: string[];
|
||||||
|
outcome: string;
|
||||||
|
proof: string;
|
||||||
|
lesson: string;
|
||||||
|
stages: { title: string; text: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const projects: Record<ProjectSlug, { number: string; href?: string; images: ProjectImage[]; de: Omit<LocalizedProject, "slug" | "number" | "href" | "images">; en: Omit<LocalizedProject, "slug" | "number" | "href" | "images"> }> = {
|
||||||
|
"w-make-batch": {
|
||||||
|
number: "01",
|
||||||
|
href: "https://batch.w-make.com",
|
||||||
|
images: [
|
||||||
|
{ src: "/portfolio/batch-start.jpg", de: "Dashboard: Schicht, Alarme und der Weg in Satzzettel und Protokoll.", en: "Dashboard: shift, alerts, and the path into tickets and the daily log." },
|
||||||
|
{ src: "/portfolio/batch-satzzettel.jpg", de: "Satzzettel mit Rezept, Zielsatz und sichtbarer Redox-Lage.", en: "Batch ticket with recipe, target weight, and visible redox state." },
|
||||||
|
{ src: "/portfolio/batch-rezepte.jpg", de: "Rezeptarbeitsplatz: Versionen, Temperatur und Kosten als Stammdaten.", en: "Recipe workspace: versions, temperature, and cost as master data." },
|
||||||
|
{ src: "/portfolio/batch-tagesprotokoll.jpg", de: "Tagesprotokoll mit Schichtübergabe und Tagesabschluss.", en: "Daily log with shift handover and day close." },
|
||||||
|
],
|
||||||
|
de: {
|
||||||
|
status: "Öffentliche Produkt-Demo",
|
||||||
|
label: "Industrie-Software · Produktentwicklung",
|
||||||
|
title: "W-Make Batch",
|
||||||
|
summary: "Batchmanagement für die Behälterglas-Produktion — vom Satzzettel bis zum Tagesprotokoll.",
|
||||||
|
stack: "Node.js · Express · SQLite · Web Components · native Tests · Playwright",
|
||||||
|
role: "Domänenmodell, Full-Stack-Entwicklung und technische Qualität.",
|
||||||
|
situation: "Ein Produktionsprozess lebt von Rezepten, Materialverbrauch, Silos, Schichten und Berechnungen. Wird das als lose Sammlung von Masken gebaut, gehen fachliche Zusammenhänge verloren — und Buchungen werden zu editierbaren Tabellenzeilen.",
|
||||||
|
decisions: [
|
||||||
|
"Fachliche Buchungen sind Zustände, keine CRUD-Datensätze zum beliebigen Löschen.",
|
||||||
|
"Der Satzzettel und das Tagesprotokoll gehören in dasselbe Modell, nicht in getrennte Tools.",
|
||||||
|
"Berechnungen und Materialflüsse bleiben nachvollziehbar, auch wenn die Oberfläche schlank bleibt.",
|
||||||
|
],
|
||||||
|
outcome: "Ein betreibbares System mit öffentlicher Demo. Vertrauliche Kundendaten, Mengen und interne Kennzahlen bleiben bewusst außen vor.",
|
||||||
|
proof: "Öffentliche Produkt-Demo unter batch.w-make.com. Die Case Study beschreibt das Modell, nicht den Kundenstand.",
|
||||||
|
lesson: "Ein Produktionssystem wird besser, wenn die fachliche Irreversibilität Teil der Architektur ist — nicht eine Validierung im Formular.",
|
||||||
|
stages: [
|
||||||
|
{ title: "Rezept", text: "Zusammensetzung und Version bleiben mit dem späteren Verbrauch verbunden." },
|
||||||
|
{ title: "Satz", text: "Der Satzzettel ist ein fachlicher Auftrag, kein Ausdruck einer Tabelle." },
|
||||||
|
{ title: "Protokoll", text: "Der Tag schließt als nachvollziehbarer Zustand, nicht als exportierte Liste." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
status: "Public product demo",
|
||||||
|
label: "Industrial software · product development",
|
||||||
|
title: "W-Make Batch",
|
||||||
|
summary: "Batch management for container-glass production — from the batch ticket to the daily log.",
|
||||||
|
stack: "Node.js · Express · SQLite · Web Components · native tests · Playwright",
|
||||||
|
role: "Domain model, full-stack development, and technical quality.",
|
||||||
|
situation: "A production process lives on recipes, material use, silos, shifts, and calculations. Built as a loose set of screens, the domain relationships disappear — and bookings become editable table rows.",
|
||||||
|
decisions: [
|
||||||
|
"Domain bookings are states, not CRUD records that can be deleted at will.",
|
||||||
|
"The batch ticket and the daily log belong in one model, not in separate tools.",
|
||||||
|
"Calculations and material flows stay traceable even when the interface stays lean.",
|
||||||
|
],
|
||||||
|
outcome: "An operable system with a public demo. Confidential client data, volumes, and internal metrics stay unpublished.",
|
||||||
|
proof: "Public product demo at batch.w-make.com. This case study describes the model, not a client installation.",
|
||||||
|
lesson: "A production system improves when domain irreversibility is part of the architecture — not a form validation.",
|
||||||
|
stages: [
|
||||||
|
{ title: "Recipe", text: "Composition and version stay tied to later consumption." },
|
||||||
|
{ title: "Batch", text: "The ticket is a domain order, not a printout of a table." },
|
||||||
|
{ title: "Log", text: "The day closes as a traceable state, not an exported list." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"batchmaker-studio": {
|
||||||
|
number: "02",
|
||||||
|
images: [
|
||||||
|
{ src: "/portfolio/batchmaker-recipes.jpg", de: "Rezeptarbeitsplatz von Batchmaker Studio.", en: "Batchmaker Studio recipe workspace." },
|
||||||
|
{ src: "/portfolio/batchmaker-validation.jpg", de: "Validierung als eigener Schritt.", en: "Validation as a dedicated step." },
|
||||||
|
{ src: "/portfolio/batchmaker-settings.jpg", de: "Konfiguration für Redox, Kalibrierung und Oxide.", en: "Configuration for redox, calibration, and oxides." },
|
||||||
|
],
|
||||||
|
de: {
|
||||||
|
status: "Eigenständig betreibbares Produkt",
|
||||||
|
label: "Standalone · Recipe Engineering",
|
||||||
|
title: "Batchmaker Studio",
|
||||||
|
summary: "Schlankes Recipe-Studio für Rezepte, Rohstoffe, Satzzettel und Glaschemie.",
|
||||||
|
stack: "Node.js · Express · node:sqlite · Vanilla JS · Vite · native Tests",
|
||||||
|
role: "Produktarchitektur, Full-Stack-Entwicklung und Calculation Engine.",
|
||||||
|
situation: "Rezeptarbeit braucht denselben fachlichen Kern wie die Produktion — aber nicht Silos, Schichten und laufende Buchungen. Ein volles Betriebssystem ist für die Entwicklung am Schreibtisch zu schwer.",
|
||||||
|
decisions: [
|
||||||
|
"Derselbe Domänenkern, bewusst kleinerer Zuschnitt.",
|
||||||
|
"Eigener Server, eigene SQLite-Datei, eigene schlanke Oberfläche.",
|
||||||
|
"Validierung als sichtbarer Schritt, nicht als versteckte Nebenwirkung.",
|
||||||
|
],
|
||||||
|
outcome: "Ein eigenständig betreibbares Werkzeug für Rezeptentwicklung, Glaschemie und druckfertige Satzzettel. Keine unbelegten Effizienzversprechen.",
|
||||||
|
proof: "Öffentliche Case Study und der bewusst getrennte Produktzuschnitt gegenüber W-Make Batch.",
|
||||||
|
lesson: "Produktgrenzen sind Architektur. Ein gutes Werkzeug muss den Nachbarprozess nicht vollständig abbilden.",
|
||||||
|
stages: [
|
||||||
|
{ title: "Rezepte", text: "Versionieren, vergleichen, importieren und exportieren." },
|
||||||
|
{ title: "Glaschemie", text: "Oxide, Redox, Physik und nachvollziehbare Berechnungen." },
|
||||||
|
{ title: "Satzzettel", text: "Entwurf, Skalierung, Vorschau, druckfertige Ausgabe." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
status: "Independently operable product",
|
||||||
|
label: "Standalone · recipe engineering",
|
||||||
|
title: "Batchmaker Studio",
|
||||||
|
summary: "A lean recipe studio for recipes, raw materials, batch tickets, and glass chemistry.",
|
||||||
|
stack: "Node.js · Express · node:sqlite · Vanilla JS · Vite · native tests",
|
||||||
|
role: "Product architecture, full-stack development, and calculation engine.",
|
||||||
|
situation: "Recipe work needs the same domain core as production — but not silos, shifts, and live bookings. A full operations system is too heavy for desk-side development.",
|
||||||
|
decisions: [
|
||||||
|
"The same domain core, deliberately smaller scope.",
|
||||||
|
"Its own server, SQLite file, and lean browser interface.",
|
||||||
|
"Validation as a visible step, not a hidden side effect.",
|
||||||
|
],
|
||||||
|
outcome: "An independently operable tool for recipe work, glass chemistry, and print-ready batch tickets. No unverifiable efficiency claims.",
|
||||||
|
proof: "This public case study and the deliberate product boundary against W-Make Batch.",
|
||||||
|
lesson: "Product boundaries are architecture. A good tool does not have to model the neighbouring process in full.",
|
||||||
|
stages: [
|
||||||
|
{ title: "Recipes", text: "Version, compare, import, and export." },
|
||||||
|
{ title: "Chemistry", text: "Oxides, redox, physics, and traceable calculations." },
|
||||||
|
{ title: "Tickets", text: "Draft, scale, preview, print-ready output." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const projectSlugs = Object.keys(projects) as ProjectSlug[];
|
||||||
|
|
||||||
|
export function getProject(slug: string, locale: Locale): LocalizedProject | null {
|
||||||
|
if (!(slug in projects)) return null;
|
||||||
|
const entry = projects[slug as ProjectSlug];
|
||||||
|
return {
|
||||||
|
slug: slug as ProjectSlug,
|
||||||
|
number: entry.number,
|
||||||
|
href: entry.href,
|
||||||
|
images: entry.images,
|
||||||
|
...entry[locale],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjects(locale: Locale): LocalizedProject[] {
|
||||||
|
return projectSlugs.map((slug) => getProject(slug, locale)!);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export const site = {
|
||||||
|
name: "W-MAKE",
|
||||||
|
person: "Jan Wagner",
|
||||||
|
email: "eldov@w-make.de",
|
||||||
|
domain: "w-make.com",
|
||||||
|
url: process.env.NEXT_PUBLIC_SITE_URL || "https://w-make.com",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function siteUrl(path = "/"): string {
|
||||||
|
const base = site.url.replace(/\/$/, "");
|
||||||
|
if (path === "/") return base;
|
||||||
|
return `${base}${path.startsWith("/") ? path : `/${path}`}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import type { Locale } from "./routes";
|
||||||
|
|
||||||
|
export const dictionaries = {
|
||||||
|
de: {
|
||||||
|
nav: {
|
||||||
|
work: "Arbeit",
|
||||||
|
about: "Über mich",
|
||||||
|
notes: "Notizen",
|
||||||
|
contact: "Kontakt",
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
viewWork: "Arbeit ansehen",
|
||||||
|
startConversation: "Gespräch beginnen",
|
||||||
|
openCase: "Case Study",
|
||||||
|
openNote: "Notiz lesen",
|
||||||
|
send: "Anfrage senden",
|
||||||
|
sending: "Wird gesendet…",
|
||||||
|
backToWork: "Zur Übersicht",
|
||||||
|
allNotes: "Alle Notizen",
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
kicker: "Jan Wagner · Software Engineering",
|
||||||
|
title: "Software für Prozesse, auf die man sich verlassen muss.",
|
||||||
|
lede: "Ich entwickle robuste digitale Systeme für industrielle und anspruchsvolle Geschäftsprozesse — mit Domänenverständnis, klarer Architektur und Verantwortung für den Betrieb.",
|
||||||
|
asideLabel: "Was ich verbinde",
|
||||||
|
asideTitle: "Glass Technology, Full-Stack Engineering und Systems Thinking.",
|
||||||
|
asideBody: "Der gemeinsame Nenner: reale Abläufe in Software übersetzen, ohne die fachliche Wahrheit zu verlieren.",
|
||||||
|
workKicker: "Ausgewählte Arbeit",
|
||||||
|
workTitle: "Nicht Features. Systeme.",
|
||||||
|
workLede: "Jeder Beleg zeigt dieselbe Haltung: Domäne verstehen, Entscheidungen explizit machen, ein System im Alltag betreiben.",
|
||||||
|
methodKicker: "Arbeitsweise",
|
||||||
|
methodTitle: "Senior heißt: Entscheidungen verantworten.",
|
||||||
|
methodLede: "Technologie ist Mittel. Entscheidend ist, ob ein System die richtigen fachlichen Zustände abbildet und erklärbar bleibt.",
|
||||||
|
offerKicker: "Zusammenarbeit",
|
||||||
|
offerTitle: "Wo ich am meisten beitragen kann.",
|
||||||
|
notesKicker: "Engineering Notes",
|
||||||
|
notesTitle: "Entscheidungen, nicht News.",
|
||||||
|
ctaKicker: "Gespräch",
|
||||||
|
ctaTitle: "Sie haben einen Prozess, der bessere Software verdient?",
|
||||||
|
ctaBody: "Lassen Sie uns klären, wo der größte Hebel liegt — in einem neuen Produkt, einer bestehenden Anwendung oder einer technischen Entscheidung.",
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
kicker: "Über mich",
|
||||||
|
title: "Von der Glaswanne in die Architektur.",
|
||||||
|
lede: "Ich komme aus der Behälterglas-Produktion und baue Software so, wie ein guter Prozess gedacht ist: nachvollziehbar, irreversibel wo nötig, und im Betrieb erklärbar.",
|
||||||
|
},
|
||||||
|
work: {
|
||||||
|
kicker: "Arbeit",
|
||||||
|
title: "Veröffentlichbare Referenzen.",
|
||||||
|
lede: "Nur das, was öffentlich stehen darf. Keine Kundennamen, keine unbelegten Kennzahlen, keine internen Systeme.",
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
kicker: "Notizen",
|
||||||
|
title: "Was im Betrieb zählt.",
|
||||||
|
lede: "Kurze, substanzielle Texte zu Entscheidungen. Kein Feed, keine Ankündigungen.",
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
kicker: "Kontakt",
|
||||||
|
title: "Ein Prozess. Eine klare nächste Frage.",
|
||||||
|
lede: "Beschreiben Sie den Ablauf, der heute reibt. Ich antworte, wenn ich wirklich helfen kann.",
|
||||||
|
name: "Name",
|
||||||
|
email: "E-Mail",
|
||||||
|
company: "Unternehmen (optional)",
|
||||||
|
message: "Worum geht es?",
|
||||||
|
honeypot: "Website",
|
||||||
|
success: "Danke. Die Anfrage ist eingegangen.",
|
||||||
|
error: "Bitte prüfen Sie die markierten Felder.",
|
||||||
|
network: "Senden gerade nicht möglich. Bitte später erneut versuchen.",
|
||||||
|
},
|
||||||
|
legal: {
|
||||||
|
imprintKicker: "Impressum",
|
||||||
|
imprintTitle: "Rechtliche Angaben",
|
||||||
|
privacyKicker: "Datenschutz",
|
||||||
|
privacyTitle: "Umgang mit Daten",
|
||||||
|
},
|
||||||
|
case: {
|
||||||
|
situation: "Ausgangslage",
|
||||||
|
role: "Verantwortung",
|
||||||
|
decisions: "Entscheidungen",
|
||||||
|
outcome: "Ergebnis",
|
||||||
|
proof: "Beweis",
|
||||||
|
lesson: "Lernpunkt",
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
line: "Software Engineering · Glass Technology · Systems Thinking",
|
||||||
|
imprint: "Impressum",
|
||||||
|
privacy: "Datenschutz",
|
||||||
|
},
|
||||||
|
language: {
|
||||||
|
de: "Deutsch",
|
||||||
|
en: "English",
|
||||||
|
switch: "Sprache",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
nav: {
|
||||||
|
work: "Work",
|
||||||
|
about: "About",
|
||||||
|
notes: "Notes",
|
||||||
|
contact: "Contact",
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
viewWork: "See the work",
|
||||||
|
startConversation: "Start a conversation",
|
||||||
|
openCase: "Case study",
|
||||||
|
openNote: "Read note",
|
||||||
|
send: "Send inquiry",
|
||||||
|
sending: "Sending…",
|
||||||
|
backToWork: "All work",
|
||||||
|
allNotes: "All notes",
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
kicker: "Jan Wagner · Software Engineering",
|
||||||
|
title: "Software for processes that have to hold.",
|
||||||
|
lede: "I build durable digital systems for industrial and demanding business processes — with domain understanding, explicit architecture, and ownership of operations.",
|
||||||
|
asideLabel: "What I connect",
|
||||||
|
asideTitle: "Glass technology, full-stack engineering, and systems thinking.",
|
||||||
|
asideBody: "The common thread: translate real operations into software without losing the domain truth.",
|
||||||
|
workKicker: "Selected work",
|
||||||
|
workTitle: "Not features. Systems.",
|
||||||
|
workLede: "Each reference shows the same stance: understand the domain, make decisions explicit, run the system in real conditions.",
|
||||||
|
methodKicker: "How I work",
|
||||||
|
methodTitle: "Senior means owning the decision.",
|
||||||
|
methodLede: "Technology is a means. What matters is whether a system models the right domain states and stays explainable.",
|
||||||
|
offerKicker: "Working together",
|
||||||
|
offerTitle: "Where I contribute most.",
|
||||||
|
notesKicker: "Engineering notes",
|
||||||
|
notesTitle: "Decisions, not news.",
|
||||||
|
ctaKicker: "Conversation",
|
||||||
|
ctaTitle: "A process that deserves better software?",
|
||||||
|
ctaBody: "Let’s find the highest-leverage point — a new product, an existing system, or a technical decision.",
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
kicker: "About",
|
||||||
|
title: "From the glass furnace to the architecture.",
|
||||||
|
lede: "I come from container-glass production and design software the way a good process is designed: traceable, irreversible where it must be, and explainable in operations.",
|
||||||
|
},
|
||||||
|
work: {
|
||||||
|
kicker: "Work",
|
||||||
|
title: "References I can publish.",
|
||||||
|
lede: "Only what is cleared for the public web. No client names, no unverifiable metrics, no internal systems.",
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
kicker: "Notes",
|
||||||
|
title: "What matters in operations.",
|
||||||
|
lede: "Short, substantial writing about decisions. Not a feed, not announcements.",
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
kicker: "Contact",
|
||||||
|
title: "One process. One clear next question.",
|
||||||
|
lede: "Describe the flow that is fraying. I reply when I can actually help.",
|
||||||
|
name: "Name",
|
||||||
|
email: "Email",
|
||||||
|
company: "Company (optional)",
|
||||||
|
message: "What is the process?",
|
||||||
|
honeypot: "Website",
|
||||||
|
success: "Thank you. The inquiry has been received.",
|
||||||
|
error: "Please check the highlighted fields.",
|
||||||
|
network: "Sending is unavailable right now. Please try again later.",
|
||||||
|
},
|
||||||
|
legal: {
|
||||||
|
imprintKicker: "Legal notice",
|
||||||
|
imprintTitle: "Legal information",
|
||||||
|
privacyKicker: "Privacy",
|
||||||
|
privacyTitle: "How data is handled",
|
||||||
|
},
|
||||||
|
case: {
|
||||||
|
situation: "Situation",
|
||||||
|
role: "Responsibility",
|
||||||
|
decisions: "Decisions",
|
||||||
|
outcome: "Outcome",
|
||||||
|
proof: "Evidence",
|
||||||
|
lesson: "Takeaway",
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
line: "Software Engineering · Glass Technology · Systems Thinking",
|
||||||
|
imprint: "Legal notice",
|
||||||
|
privacy: "Privacy",
|
||||||
|
},
|
||||||
|
language: {
|
||||||
|
de: "Deutsch",
|
||||||
|
en: "English",
|
||||||
|
switch: "Language",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Dictionary = (typeof dictionaries)[Locale];
|
||||||
|
|
||||||
|
export function getDictionary(locale: Locale): Dictionary {
|
||||||
|
return dictionaries[locale];
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { resolvePublicPath, switchLocale, toInternalPath } from "./routes.ts";
|
||||||
|
|
||||||
|
describe("resolvePublicPath", () => {
|
||||||
|
it("maps German home and localized slugs", () => {
|
||||||
|
assert.deepEqual(resolvePublicPath("/"), { locale: "de", route: "home" });
|
||||||
|
assert.deepEqual(resolvePublicPath("/ueber-mich"), { locale: "de", route: "about" });
|
||||||
|
assert.deepEqual(resolvePublicPath("/arbeit/w-make-batch"), {
|
||||||
|
locale: "de",
|
||||||
|
route: "workItem",
|
||||||
|
slug: "w-make-batch",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps English prefixed routes", () => {
|
||||||
|
assert.deepEqual(resolvePublicPath("/en"), { locale: "en", route: "home" });
|
||||||
|
assert.deepEqual(resolvePublicPath("/en/contact"), { locale: "en", route: "contact" });
|
||||||
|
assert.deepEqual(resolvePublicPath("/en/notes/bookings"), {
|
||||||
|
locale: "en",
|
||||||
|
route: "noteItem",
|
||||||
|
slug: "bookings",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown paths", () => {
|
||||||
|
assert.equal(resolvePublicPath("/about"), null);
|
||||||
|
assert.equal(resolvePublicPath("/de"), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toInternalPath", () => {
|
||||||
|
it("rewrites unprefixed German URLs into [lang] segments", () => {
|
||||||
|
assert.equal(toInternalPath("/"), "/de");
|
||||||
|
assert.equal(toInternalPath("/kontakt"), "/de/contact");
|
||||||
|
assert.equal(toInternalPath("/arbeit/batchmaker-studio"), "/de/work/batchmaker-studio");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps English URLs aligned with the file tree", () => {
|
||||||
|
assert.equal(toInternalPath("/en"), "/en");
|
||||||
|
assert.equal(toInternalPath("/en/about"), "/en/about");
|
||||||
|
assert.equal(toInternalPath("/en/work/w-make-batch"), "/en/work/w-make-batch");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("switchLocale", () => {
|
||||||
|
it("switches a case study between public locales", () => {
|
||||||
|
assert.equal(switchLocale("/arbeit/w-make-batch", "en"), "/en/work/w-make-batch");
|
||||||
|
assert.equal(switchLocale("/en/work/w-make-batch", "de"), "/arbeit/w-make-batch");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches home without leaking a /de prefix", () => {
|
||||||
|
assert.equal(switchLocale("/", "en"), "/en");
|
||||||
|
assert.equal(switchLocale("/en", "de"), "/");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
export const locales = ["de", "en"] as const;
|
||||||
|
export type Locale = (typeof locales)[number];
|
||||||
|
export const defaultLocale: Locale = "de";
|
||||||
|
|
||||||
|
export type RouteId =
|
||||||
|
| "home"
|
||||||
|
| "about"
|
||||||
|
| "work"
|
||||||
|
| "workItem"
|
||||||
|
| "notes"
|
||||||
|
| "noteItem"
|
||||||
|
| "contact"
|
||||||
|
| "legal"
|
||||||
|
| "privacy";
|
||||||
|
|
||||||
|
export type ResolvedPath =
|
||||||
|
| { locale: Locale; route: Exclude<RouteId, "workItem" | "noteItem"> }
|
||||||
|
| { locale: Locale; route: "workItem" | "noteItem"; slug: string };
|
||||||
|
|
||||||
|
const staticPublic: Record<Locale, Record<Exclude<RouteId, "workItem" | "noteItem">, string>> = {
|
||||||
|
de: {
|
||||||
|
home: "/",
|
||||||
|
about: "/ueber-mich",
|
||||||
|
work: "/arbeit",
|
||||||
|
notes: "/notizen",
|
||||||
|
contact: "/kontakt",
|
||||||
|
legal: "/impressum",
|
||||||
|
privacy: "/datenschutz",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
home: "/en",
|
||||||
|
about: "/en/about",
|
||||||
|
work: "/en/work",
|
||||||
|
notes: "/en/notes",
|
||||||
|
contact: "/en/contact",
|
||||||
|
legal: "/en/legal",
|
||||||
|
privacy: "/en/privacy",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const prefixes: Record<Locale, { work: string; notes: string }> = {
|
||||||
|
de: { work: "/arbeit", notes: "/notizen" },
|
||||||
|
en: { work: "/en/work", notes: "/en/notes" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const internalSegment: Record<Exclude<RouteId, "home" | "workItem" | "noteItem">, string> = {
|
||||||
|
about: "about",
|
||||||
|
work: "work",
|
||||||
|
notes: "notes",
|
||||||
|
contact: "contact",
|
||||||
|
legal: "legal",
|
||||||
|
privacy: "privacy",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isLocale(value: string): value is Locale {
|
||||||
|
return locales.includes(value as Locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePath(pathname: string): string {
|
||||||
|
if (!pathname) return "/";
|
||||||
|
if (pathname.length > 1 && pathname.endsWith("/")) return pathname.slice(0, -1);
|
||||||
|
return pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publicPath(locale: Locale, route: RouteId, slug?: string): string {
|
||||||
|
if (route === "workItem") return `${prefixes[locale].work}/${slug ?? ""}`;
|
||||||
|
if (route === "noteItem") return `${prefixes[locale].notes}/${slug ?? ""}`;
|
||||||
|
return staticPublic[locale][route];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePublicPath(pathname: string): ResolvedPath | null {
|
||||||
|
const path = normalizePath(pathname);
|
||||||
|
|
||||||
|
for (const locale of locales) {
|
||||||
|
for (const [route, url] of Object.entries(staticPublic[locale])) {
|
||||||
|
if (path === url) {
|
||||||
|
return { locale, route: route as Exclude<RouteId, "workItem" | "noteItem"> };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workPrefix = prefixes[locale].work;
|
||||||
|
if (path.startsWith(`${workPrefix}/`)) {
|
||||||
|
const slug = path.slice(workPrefix.length + 1);
|
||||||
|
if (slug && !slug.includes("/")) return { locale, route: "workItem", slug };
|
||||||
|
}
|
||||||
|
|
||||||
|
const notesPrefix = prefixes[locale].notes;
|
||||||
|
if (path.startsWith(`${notesPrefix}/`)) {
|
||||||
|
const slug = path.slice(notesPrefix.length + 1);
|
||||||
|
if (slug && !slug.includes("/")) return { locale, route: "noteItem", slug };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toInternalPath(pathname: string): string | null {
|
||||||
|
const resolved = resolvePublicPath(pathname);
|
||||||
|
if (!resolved) return null;
|
||||||
|
if (resolved.route === "home") return `/${resolved.locale}`;
|
||||||
|
if (resolved.route === "workItem") return `/${resolved.locale}/work/${resolved.slug}`;
|
||||||
|
if (resolved.route === "noteItem") return `/${resolved.locale}/notes/${resolved.slug}`;
|
||||||
|
return `/${resolved.locale}/${internalSegment[resolved.route]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function switchLocale(pathname: string, target: Locale): string | null {
|
||||||
|
const resolved = resolvePublicPath(pathname);
|
||||||
|
if (!resolved) return null;
|
||||||
|
if (resolved.route === "workItem" || resolved.route === "noteItem") {
|
||||||
|
return publicPath(target, resolved.route, resolved.slug);
|
||||||
|
}
|
||||||
|
return publicPath(target, resolved.route);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toPublicFromInternal(pathname: string): string | null {
|
||||||
|
const path = normalizePath(pathname);
|
||||||
|
const match = path.match(/^\/(de|en)(?:\/(.*))?$/);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const locale = match[1] as Locale;
|
||||||
|
const rest = match[2] ?? "";
|
||||||
|
if (!rest) return publicPath(locale, "home");
|
||||||
|
|
||||||
|
const [segment, slug] = rest.split("/");
|
||||||
|
if (slug && segment === "work") return publicPath(locale, "workItem", slug);
|
||||||
|
if (slug && segment === "notes") return publicPath(locale, "noteItem", slug);
|
||||||
|
if (slug) return null;
|
||||||
|
|
||||||
|
const route = (Object.keys(internalSegment) as Array<keyof typeof internalSegment>)
|
||||||
|
.find((key) => internalSegment[key] === segment);
|
||||||
|
return route ? publicPath(locale, route) : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { allowInquiry } from "./rate-limit";
|
||||||
|
import { sendInquiryMail } from "./mail";
|
||||||
|
import { validateInquiry } from "./schema";
|
||||||
|
import { saveInquiry } from "./store";
|
||||||
|
|
||||||
|
export type InquiryActionResult =
|
||||||
|
| { ok: true }
|
||||||
|
| { ok: false; code: "invalid" | "rate" | "error"; field?: string };
|
||||||
|
|
||||||
|
export async function submitInquiry(formData: FormData): Promise<InquiryActionResult> {
|
||||||
|
const parsed = validateInquiry({
|
||||||
|
name: formData.get("name"),
|
||||||
|
email: formData.get("email"),
|
||||||
|
company: formData.get("company"),
|
||||||
|
message: formData.get("message"),
|
||||||
|
locale: formData.get("locale"),
|
||||||
|
website: formData.get("website"),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parsed.ok && parsed.code === "spam") return { ok: true };
|
||||||
|
if (!parsed.ok) return { ok: false, code: "invalid", field: parsed.field };
|
||||||
|
|
||||||
|
const headerList = await headers();
|
||||||
|
const forwarded = headerList.get("x-forwarded-for")?.split(",")[0]?.trim();
|
||||||
|
const ip = forwarded || headerList.get("x-real-ip") || "unknown";
|
||||||
|
if (!allowInquiry(ip)) return { ok: false, code: "rate" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
saveInquiry(parsed.data);
|
||||||
|
await sendInquiryMail(parsed.data);
|
||||||
|
return { ok: true };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, code: "error" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { createConnection } from "node:net";
|
||||||
|
import { connect as tlsConnect } from "node:tls";
|
||||||
|
import type { Inquiry } from "./schema";
|
||||||
|
|
||||||
|
type SmtpConfig = {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
user: string;
|
||||||
|
pass: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function smtpConfig(): SmtpConfig | null {
|
||||||
|
const to = process.env.CONTACT_TO;
|
||||||
|
const host = process.env.SMTP_HOST;
|
||||||
|
const user = process.env.SMTP_USER;
|
||||||
|
const pass = process.env.SMTP_PASS;
|
||||||
|
if (!to || !host || !user || !pass) return null;
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
port: Number(process.env.SMTP_PORT || 465),
|
||||||
|
user,
|
||||||
|
pass,
|
||||||
|
from: process.env.CONTACT_FROM || user,
|
||||||
|
to,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeAuth(value: string): string {
|
||||||
|
return Buffer.from(value, "utf8").toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function smtpSend(config: SmtpConfig, inquiry: Inquiry): Promise<void> {
|
||||||
|
const body = [
|
||||||
|
`Name: ${inquiry.name}`,
|
||||||
|
`E-Mail: ${inquiry.email}`,
|
||||||
|
`Unternehmen: ${inquiry.company || "—"}`,
|
||||||
|
`Sprache: ${inquiry.locale}`,
|
||||||
|
"",
|
||||||
|
inquiry.message,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const subject = `W-MAKE Anfrage · ${inquiry.name}`.replace(/[\r\n]/g, " ");
|
||||||
|
const message = [
|
||||||
|
`From: ${config.from}`,
|
||||||
|
`To: ${config.to}`,
|
||||||
|
`Reply-To: ${inquiry.email}`,
|
||||||
|
`Subject: =?UTF-8?B?${Buffer.from(subject, "utf8").toString("base64")}?=`,
|
||||||
|
"MIME-Version: 1.0",
|
||||||
|
"Content-Type: text/plain; charset=utf-8",
|
||||||
|
"Content-Transfer-Encoding: 8bit",
|
||||||
|
"",
|
||||||
|
body,
|
||||||
|
"",
|
||||||
|
].join("\r\n");
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const socket =
|
||||||
|
config.port === 465
|
||||||
|
? tlsConnect({ host: config.host, port: config.port, servername: config.host })
|
||||||
|
: createConnection({ host: config.host, port: config.port });
|
||||||
|
|
||||||
|
let buffer = "";
|
||||||
|
let step = 0;
|
||||||
|
const commands = [
|
||||||
|
`EHLO w-make.com`,
|
||||||
|
`AUTH LOGIN`,
|
||||||
|
encodeAuth(config.user),
|
||||||
|
encodeAuth(config.pass),
|
||||||
|
`MAIL FROM:<${config.user}>`,
|
||||||
|
`RCPT TO:<${config.to}>`,
|
||||||
|
`DATA`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const write = (line: string) => socket.write(`${line}\r\n`);
|
||||||
|
const fail = (error: Error) => {
|
||||||
|
socket.destroy();
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLine = (line: string) => {
|
||||||
|
const code = Number(line.slice(0, 3));
|
||||||
|
if (code >= 400) {
|
||||||
|
fail(new Error(`SMTP ${line}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step < commands.length) {
|
||||||
|
write(commands[step]);
|
||||||
|
step += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (step === commands.length) {
|
||||||
|
write(`${message}\r\n.`);
|
||||||
|
step += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
write("QUIT");
|
||||||
|
socket.end();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.setEncoding("utf8");
|
||||||
|
socket.on("data", (chunk: string) => {
|
||||||
|
buffer += chunk;
|
||||||
|
const parts = buffer.split("\r\n");
|
||||||
|
buffer = parts.pop() || "";
|
||||||
|
for (const line of parts) {
|
||||||
|
if (line && (line[3] === " " || line.length === 3)) onLine(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on("error", fail);
|
||||||
|
socket.setTimeout(20000, () => fail(new Error("SMTP timeout")));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendInquiryMail(inquiry: Inquiry): Promise<boolean> {
|
||||||
|
const config = smtpConfig();
|
||||||
|
if (!config) return false;
|
||||||
|
try {
|
||||||
|
await smtpSend(config, inquiry);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
const WINDOW_MS = 60 * 60 * 1000;
|
||||||
|
const MAX_PER_WINDOW = 3;
|
||||||
|
const hits = new Map<string, number[]>();
|
||||||
|
|
||||||
|
export function allowInquiry(key: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const recent = (hits.get(key) || []).filter((time) => now - time < WINDOW_MS);
|
||||||
|
if (recent.length >= MAX_PER_WINDOW) {
|
||||||
|
hits.set(key, recent);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
recent.push(now);
|
||||||
|
hits.set(key, recent);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { validateInquiry } from "./schema.ts";
|
||||||
|
|
||||||
|
describe("validateInquiry", () => {
|
||||||
|
const valid = {
|
||||||
|
name: "Anna Müller",
|
||||||
|
email: "anna@example.com",
|
||||||
|
company: "Glaswerk",
|
||||||
|
message: "Wir brauchen eine belastbare Abbildung unseres Satzprozesses.",
|
||||||
|
locale: "de",
|
||||||
|
website: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("accepts a complete inquiry", () => {
|
||||||
|
const result = validateInquiry(valid);
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
if (result.ok) {
|
||||||
|
assert.equal(result.data.email, "anna@example.com");
|
||||||
|
assert.equal(result.data.company, "Glaswerk");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a filled honeypot", () => {
|
||||||
|
const result = validateInquiry({ ...valid, website: "https://spam.test" });
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
if (!result.ok) assert.equal(result.code, "spam");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects short messages and invalid email", () => {
|
||||||
|
assert.equal(validateInquiry({ ...valid, message: "Hallo" }).ok, false);
|
||||||
|
assert.equal(validateInquiry({ ...valid, email: "not-an-email" }).ok, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows an empty company field", () => {
|
||||||
|
const result = validateInquiry({ ...valid, company: " " });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
if (result.ok) assert.equal(result.data.company, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
export type InquiryInput = {
|
||||||
|
name: unknown;
|
||||||
|
email: unknown;
|
||||||
|
company?: unknown;
|
||||||
|
message: unknown;
|
||||||
|
locale: unknown;
|
||||||
|
website?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Inquiry = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
company?: string;
|
||||||
|
message: string;
|
||||||
|
locale: "de" | "en";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ValidationResult =
|
||||||
|
| { ok: true; data: Inquiry }
|
||||||
|
| { ok: false; code: "spam" | "invalid"; field?: string };
|
||||||
|
|
||||||
|
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
export function validateInquiry(input: InquiryInput): ValidationResult {
|
||||||
|
if (typeof input.website === "string" && input.website.trim() !== "") {
|
||||||
|
return { ok: false, code: "spam" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = typeof input.name === "string" ? input.name.trim() : "";
|
||||||
|
const email = typeof input.email === "string" ? input.email.trim() : "";
|
||||||
|
const message = typeof input.message === "string" ? input.message.trim() : "";
|
||||||
|
const companyRaw = typeof input.company === "string" ? input.company.trim() : "";
|
||||||
|
const locale = input.locale === "en" || input.locale === "de" ? input.locale : null;
|
||||||
|
|
||||||
|
if (name.length < 2 || name.length > 80) return { ok: false, code: "invalid", field: "name" };
|
||||||
|
if (!EMAIL.test(email) || email.length > 160) return { ok: false, code: "invalid", field: "email" };
|
||||||
|
if (message.length < 20 || message.length > 4000) return { ok: false, code: "invalid", field: "message" };
|
||||||
|
if (!locale) return { ok: false, code: "invalid", field: "locale" };
|
||||||
|
if (companyRaw.length > 120) return { ok: false, code: "invalid", field: "company" };
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
message,
|
||||||
|
locale,
|
||||||
|
...(companyRaw ? { company: companyRaw } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { Inquiry } from "./schema";
|
||||||
|
|
||||||
|
let db: DatabaseSync | null = null;
|
||||||
|
|
||||||
|
function getDb(): DatabaseSync {
|
||||||
|
if (db) return db;
|
||||||
|
const dbPath = process.env.INQUIRIES_DB_PATH || "./data/inquiries.sqlite";
|
||||||
|
mkdirSync(dirname(dbPath), { recursive: true });
|
||||||
|
db = new DatabaseSync(dbPath);
|
||||||
|
db.exec("PRAGMA journal_mode = WAL;");
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS inquiries (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
company TEXT,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
locale TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)`);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveInquiry(inquiry: Inquiry): string {
|
||||||
|
const id = randomUUID();
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO inquiries (id, name, email, company, message, locale, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
id,
|
||||||
|
inquiry.name,
|
||||||
|
inquiry.email,
|
||||||
|
inquiry.company ?? null,
|
||||||
|
inquiry.message,
|
||||||
|
inquiry.locale,
|
||||||
|
new Date().toISOString(),
|
||||||
|
);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { isLocale, type Locale } from "@/i18n/routes";
|
||||||
|
|
||||||
|
export function requireLocale(value: string): Locale {
|
||||||
|
if (!isLocale(value)) notFound();
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { siteUrl } from "@/content/site";
|
||||||
|
import { publicPath, type Locale, type RouteId } from "@/i18n/routes";
|
||||||
|
|
||||||
|
export function pageMetadata(
|
||||||
|
locale: Locale,
|
||||||
|
route: RouteId,
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
slug?: string,
|
||||||
|
): Metadata {
|
||||||
|
const path = publicPath(locale, route, slug);
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
alternates: {
|
||||||
|
canonical: siteUrl(path),
|
||||||
|
languages: {
|
||||||
|
de: siteUrl(publicPath("de", route, slug)),
|
||||||
|
en: siteUrl(publicPath("en", route, slug)),
|
||||||
|
"x-default": siteUrl(publicPath("de", route, slug)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
url: siteUrl(path),
|
||||||
|
siteName: "W-MAKE",
|
||||||
|
locale: locale === "de" ? "de_DE" : "en_GB",
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export type Update = {
|
||||||
|
slug: string;
|
||||||
|
product: "batchmaker" | "standalone";
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
published_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getUpdates(): Promise<Update[]> {
|
||||||
|
const baseUrl = process.env.UPDATES_API_URL || "http://updates-api:8080";
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/v1/updates?limit=3`, {
|
||||||
|
next: { revalidate: 60 },
|
||||||
|
signal: AbortSignal.timeout(1500),
|
||||||
|
});
|
||||||
|
if (!response.ok) return [];
|
||||||
|
const payload = (await response.json()) as { data?: Update[] };
|
||||||
|
return payload.data || [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { resolvePublicPath, toInternalPath, toPublicFromInternal, type Locale } from "./i18n/routes";
|
||||||
|
|
||||||
|
function localeFromPath(pathname: string): Locale {
|
||||||
|
if (pathname === "/en" || pathname.startsWith("/en/")) return "en";
|
||||||
|
const resolved = resolvePublicPath(pathname);
|
||||||
|
if (resolved) return resolved.locale;
|
||||||
|
if (pathname === "/de" || pathname.startsWith("/de/")) return "de";
|
||||||
|
return "de";
|
||||||
|
}
|
||||||
|
|
||||||
|
function withLocale(response: NextResponse, pathname: string) {
|
||||||
|
response.headers.set("x-locale", localeFromPath(pathname));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function proxy(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
if (pathname === "/de" || pathname.startsWith("/de/")) {
|
||||||
|
const publicPath = toPublicFromInternal(pathname);
|
||||||
|
if (publicPath && publicPath !== pathname) {
|
||||||
|
const url = request.nextUrl.clone();
|
||||||
|
url.pathname = publicPath;
|
||||||
|
return withLocale(NextResponse.redirect(url), publicPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const internal = toInternalPath(pathname);
|
||||||
|
if (internal && internal !== pathname) {
|
||||||
|
const url = request.nextUrl.clone();
|
||||||
|
url.pathname = internal;
|
||||||
|
return withLocale(NextResponse.rewrite(url), pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
return withLocale(NextResponse.next(), pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|txt|xml)$).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
declare module "node:sqlite" {
|
||||||
|
export class DatabaseSync {
|
||||||
|
constructor(path: string);
|
||||||
|
exec(sql: string): void;
|
||||||
|
prepare(sql: string): {
|
||||||
|
run(...params: unknown[]): unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -30,5 +30,5 @@
|
|||||||
".next/dev/types/**/*.ts",
|
".next/dev/types/**/*.ts",
|
||||||
"**/*.mts"
|
"**/*.mts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules", "**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user