diff --git a/.gitignore b/.gitignore index 46b8466..830f05d 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,10 @@ # production /build +# local data +/data/*.sqlite +/data/*.sqlite-* + # misc .DS_Store *.pem diff --git a/README.md b/README.md index 71ef06a..5537919 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,27 @@ # 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 - -First, run the development server: +## Lokal ```bash +npm install +npm test 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. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -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. +| DE | EN | +|---|---| +| `/` | `/en` | +| `/ueber-mich` | `/en/about` | +| `/arbeit` | `/en/work` | +| `/notizen` | `/en/notes` | +| `/kontakt` | `/en/contact` | +| `/impressum` | `/en/legal` | +| `/datenschutz` | `/en/privacy` | diff --git a/next.config.ts b/next.config.ts index 68a6c64..81577a5 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,15 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", + async redirects() { + return [ + { + source: "/projects/batchmaker-studio", + destination: "/arbeit/batchmaker-studio", + permanent: true, + }, + ]; + }, }; export default nextConfig; diff --git a/package.json b/package.json index d5a3415..6725b1b 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "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": { "next": "16.3.1", diff --git a/public/portfolio/batch-rezepte.jpg b/public/portfolio/batch-rezepte.jpg new file mode 100644 index 0000000..f2abe4b Binary files /dev/null and b/public/portfolio/batch-rezepte.jpg differ diff --git a/public/portfolio/batch-satzzettel.jpg b/public/portfolio/batch-satzzettel.jpg new file mode 100644 index 0000000..9b25941 Binary files /dev/null and b/public/portfolio/batch-satzzettel.jpg differ diff --git a/public/portfolio/batch-start.jpg b/public/portfolio/batch-start.jpg new file mode 100644 index 0000000..ed70afb Binary files /dev/null and b/public/portfolio/batch-start.jpg differ diff --git a/public/portfolio/batch-tagesprotokoll.jpg b/public/portfolio/batch-tagesprotokoll.jpg new file mode 100644 index 0000000..aa11666 Binary files /dev/null and b/public/portfolio/batch-tagesprotokoll.jpg differ diff --git a/src/app/[lang]/about/page.tsx b/src/app/[lang]/about/page.tsx new file mode 100644 index 0000000..7610f39 --- /dev/null +++ b/src/app/[lang]/about/page.tsx @@ -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 ( +
+

{dict.about.kicker}

+

{dict.about.title}

+

{dict.about.lede}

+

+ {person.role} · {person.location} +

+
+ {person.story.map((paragraph) => ( +

+ {paragraph} +

+ ))} +
+
    + {person.principles.map((principle, index) => ( +
  1. + 0{index + 1} +
    +

    {principle.title}

    +

    {principle.text}

    +
    +
  2. + ))} +
+
+ ); +} diff --git a/src/app/[lang]/contact/page.tsx b/src/app/[lang]/contact/page.tsx new file mode 100644 index 0000000..10e23dc --- /dev/null +++ b/src/app/[lang]/contact/page.tsx @@ -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 ( +
+
+

{dict.contact.kicker}

+

{dict.contact.title}

+

{dict.contact.lede}

+
+ +
+
+ +
+ ); +} diff --git a/src/app/[lang]/layout.tsx b/src/app/[lang]/layout.tsx new file mode 100644 index 0000000..4ba8b2d --- /dev/null +++ b/src/app/[lang]/layout.tsx @@ -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 ( +
+ +
{children}
+ +
+ ); +} diff --git a/src/app/[lang]/legal/page.tsx b/src/app/[lang]/legal/page.tsx new file mode 100644 index 0000000..b1ed74f --- /dev/null +++ b/src/app/[lang]/legal/page.tsx @@ -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 ( +
+

{dict.legal.imprintKicker}

+

{dict.legal.imprintTitle}

+
+ {legal.imprint.map((paragraph) => ( +

+ {paragraph} +

+ ))} +
+
+ ); +} diff --git a/src/app/[lang]/notes/[slug]/page.tsx b/src/app/[lang]/notes/[slug]/page.tsx new file mode 100644 index 0000000..523b04f --- /dev/null +++ b/src/app/[lang]/notes/[slug]/page.tsx @@ -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 ( +
+ + ← {dict.actions.allNotes} + + +

{note.title}

+

{note.summary}

+
+ {note.body.map((paragraph) => ( +

+ {paragraph} +

+ ))} +
+
+ ); +} diff --git a/src/app/[lang]/notes/page.tsx b/src/app/[lang]/notes/page.tsx new file mode 100644 index 0000000..bd342e0 --- /dev/null +++ b/src/app/[lang]/notes/page.tsx @@ -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 ( +
+

{dict.notes.kicker}

+

{dict.notes.title}

+

{dict.notes.lede}

+
+ {notes.map((note) => ( +
+ +

{note.title}

+

{note.summary}

+ + {dict.actions.openNote} → + +
+ ))} +
+
+ ); +} diff --git a/src/app/[lang]/opengraph-image.tsx b/src/app/[lang]/opengraph-image.tsx new file mode 100644 index 0000000..924dc80 --- /dev/null +++ b/src/app/[lang]/opengraph-image.tsx @@ -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( + ( +
+
+ W-MAKE +
+
+
{title}
+
Jan Wagner
+
+
+ ), + size, + ); +} diff --git a/src/app/[lang]/page.tsx b/src/app/[lang]/page.tsx new file mode 100644 index 0000000..25d07d1 --- /dev/null +++ b/src/app/[lang]/page.tsx @@ -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 ( +
+ +
+
+

{dict.home.kicker}

+

{dict.home.title}

+

{dict.home.lede}

+
+ + {dict.actions.viewWork} + + + {dict.actions.startConversation} + +
+
+ +
+ +
+
+
+
+

{dict.home.workKicker}

+

{dict.home.workTitle}

+
+

{dict.home.workLede}

+
+
+ {projects.map((project) => ( +
+
+

{project.label}

+ {project.number} +
+

{project.title}

+

{project.summary}

+

{project.status}

+ + {dict.actions.openCase} → + +
+ ))} +
+
+
+ +
+
+
+

{dict.home.methodKicker}

+

{dict.home.methodTitle}

+

{dict.home.methodLede}

+
+
    + {person.principles.map((principle, index) => ( +
  1. + 0{index + 1} +
    +

    {principle.title}

    +

    {principle.text}

    +
    +
  2. + ))} +
+
+
+ +
+
+

{dict.home.offerKicker}

+

{dict.home.offerTitle}

+
+ {offers.map((offer, index) => ( +
+ 0{index + 1} +

{offer.title}

+

{offer.text}

+
+ ))} +
+
+
+ +
+
+

{dict.home.notesKicker}

+

{dict.home.notesTitle}

+
+ {notes.map((note) => ( +
+ +

{note.title}

+

{note.summary}

+ + {dict.actions.openNote} → + +
+ ))} +
+ {updates.length ? ( +
+

Batchmaker

+
+ {updates.map((update) => ( +
+

{update.title}

+

{update.summary}

+
+ ))} +
+
+ ) : null} +
+
+ +
+
+
+

{dict.home.ctaKicker}

+

{dict.home.ctaTitle}

+

{dict.home.ctaBody}

+ + {dict.actions.startConversation} + +
+
+
+
+ ); +} diff --git a/src/app/[lang]/privacy/page.tsx b/src/app/[lang]/privacy/page.tsx new file mode 100644 index 0000000..f021c21 --- /dev/null +++ b/src/app/[lang]/privacy/page.tsx @@ -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 ( +
+

{dict.legal.privacyKicker}

+

{dict.legal.privacyTitle}

+
+ {legal.privacy.map((paragraph) => ( +

+ {paragraph} +

+ ))} +
+
+ ); +} diff --git a/src/app/[lang]/work/[slug]/page.tsx b/src/app/[lang]/work/[slug]/page.tsx new file mode 100644 index 0000000..0fe090c --- /dev/null +++ b/src/app/[lang]/work/[slug]/page.tsx @@ -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 ( +
+ + ← {dict.actions.backToWork} + +

{project.label}

+

{project.title}

+

{project.summary}

+
+
+
{dict.case.role}
+
{project.role}
+
+
+
Stack
+
{project.stack}
+
+
+
{dict.case.proof}
+
{project.status}
+
+
+ +
+

{dict.case.situation}

+

{project.situation}

+
+ +
+

{dict.case.decisions}

+
    + {project.decisions.map((decision, index) => ( +
  1. + 0{index + 1} +

    {decision}

    +
  2. + ))} +
+
+ + {project.images.length ? ( +
+

{dict.case.proof}

+

{dict.home.workTitle}

+
+ {project.images.map((image) => ( +
+ {image[locale]} +
{image[locale]}
+
+ ))} +
+
+ ) : null} + +
+

{dict.case.decisions}

+

+ {locale === "de" ? "Der fachliche Kern als Modell." : "The domain core as a model."} +

+
+ +
+
+ +
+
+

{dict.case.outcome}

+

{project.outcome}

+
+
+

{dict.case.lesson}

+

{project.lesson}

+ {project.href ? ( + + {project.href.replace(/^https?:\/\//, "")} → + + ) : null} +
+
+ +
+
+

{dict.home.ctaKicker}

+

{dict.home.ctaTitle}

+ + {dict.actions.startConversation} + +
+
+
+ ); +} diff --git a/src/app/[lang]/work/page.tsx b/src/app/[lang]/work/page.tsx new file mode 100644 index 0000000..bcbea34 --- /dev/null +++ b/src/app/[lang]/work/page.tsx @@ -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 ( +
+

{dict.work.kicker}

+

{dict.work.title}

+

{dict.work.lede}

+
+ {projects.map((project) => ( +
+
+

{project.number}

+

{project.title}

+

{project.status}

+
+
+

{project.label}

+

{project.summary}

+

{project.stack}

+ + {dict.actions.openCase} → + +
+
+ ))} +
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 91e527f..88dc417 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,45 +1,109 @@ @import "tailwindcss"; :root { - /* Tech Style Dark Theme by Default */ - --background: #020617; /* Slate 950 */ - --foreground: #f8fafc; /* Slate 50 */ - - /* Primary Action Colors (Electric Blue / Cyan) */ - --primary: #0ea5e9; - --primary-foreground: #ffffff; - - /* Metallic / Professional Accents */ - --accent: #334155; /* Slate 700 */ - --accent-foreground: #f8fafc; - - --border: #1e293b; /* Slate 800 */ + --ink: #12100e; + --ink-raised: #1b1814; + --paper: #f4efe4; + --paper-dim: #e4dccb; + --copper: #c9843a; + --copper-deep: #a86b2a; + --mute: #9a9184; + --mute-strong: #c9c1b3; + --line: rgba(244, 239, 228, 0.12); + --line-strong: rgba(244, 239, 228, 0.22); } @theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-border: var(--border); - + --color-ink: var(--ink); + --color-ink-raised: var(--ink-raised); + --color-paper: var(--paper); + --color-paper-dim: var(--paper-dim); + --color-copper: var(--copper); + --color-copper-deep: var(--copper-deep); + --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-serif: var(--font-newsreader), "Iowan Old Style", "Palatino Linotype", serif; --font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace; } -body { - background-color: var(--background); - color: var(--foreground); - font-family: var(--font-sans); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; +html { + scroll-behavior: smooth; } -/* Subtle Grid Background for that "Tech" feel */ -.bg-grid-pattern { - background-image: linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px), - linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px); - background-size: 40px 40px; +body { + background: var(--ink); + color: var(--paper); + font-family: var(--font-sans); + -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; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index fcf9629..18a6d15 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,8 @@ 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"; const geistSans = Geist({ @@ -12,17 +15,34 @@ const geistMono = Geist_Mono({ subsets: ["latin"], }); +const newsreader = Newsreader({ + variable: "--font-newsreader", + subsets: ["latin"], + style: ["normal", "italic"], +}); + export const metadata: Metadata = { - title: "Jan Wagner — Software für anspruchsvolle Prozesse", - description: "Portfolio von Jan Wagner: industrielle Software, Full-Stack Engineering, AI-Tooling und Glass Technology.", + metadataBase: new URL(site.url), + 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 ( - - {children} + + {children} ); } - -// ponytail: Open Graph, structured data and canonical URL follow after the public domain is final. diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..1218f48 --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,14 @@ +import Link from "next/link"; + +export default function NotFound() { + return ( +
+

404

+

Seite nicht gefunden.

+

Die Adresse existiert nicht oder wurde verschoben.

+ + Zur Startseite → + +
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx deleted file mode 100644 index b0ceb0c..0000000 --- a/src/app/page.tsx +++ /dev/null @@ -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 { - 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 ( -
-
-
- - -
-
-

Jan Wagner · Software Engineering

-

Software für Prozesse, auf die man sich verlassen muss.

-

Ich entwickle robuste digitale Systeme für industrielle und anspruchsvolle Geschäftsprozesse — mit Domänenverständnis, klarer Architektur und Verantwortung für den Betrieb.

- -
- -
- -
-
-
-

Ausgewählte Arbeit

-

Nicht nur Features. Systeme.

-
-

Jedes Projekt zeigt eine andere Seite derselben Arbeit: Domäne verstehen, Entscheidungen explizit machen und ein System in der Realität betreiben.

-
-
- {projects.map((project) => ( -
-
-

{project.label}

- {project.number} -
-

{project.title}

-

{project.summary}

-

{project.details}

-
-

Rolle
{project.role}

-

Werkzeuge
{project.stack}

-

Status
{project.status}

-
- {project.cta} -
- ))} -
-
- -
-
-
-

Batchmaker Studio

-

Recipe Engineering ohne Produktions-Overhead.

-
-
-

Studio fokussiert die fachliche Arbeit vor der Produktion: Rezepte entwickeln, Rohstoffanalysen verwalten, Glaschemie berechnen und Satzzettel druckfertig machen.

-

Die eigenständige Variante nutzt denselben Domänenkern, bleibt aber bewusst kleiner: eigener Express-Server, eigene SQLite-Datenbank und eine schlanke Browser-Oberfläche.

-
-
-
- {[ - ["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]) => ( -
-

{title}

-

{text}

-
- ))} -
-
-

Status: eigenständig betreibbares Produkt · Browser-Ansichten verifiziert.

- Zur Case Study → -
-
- -
-

Produkt-Updates

-

Was sich bei Batchmaker bewegt.

- {updates.length ? ( -
- {updates.map((update) => ( -
-

{update.product === "standalone" ? "Standalone" : "Batchmaker"}

-

{update.title}

-

{update.summary}

- -
- ))} -
- ) : ( -

Noch keine veröffentlichten Updates. Neue Releases und fachliche Fortschritte erscheinen hier.

- )} -
- -
-
-
-

Arbeitsweise

-

Senior heißt: Entscheidungen verantworten.

-

Technologie ist Mittel zum Zweck. Entscheidend ist, ob ein System die richtigen fachlichen Zustände abbildet und im Alltag erklärbar bleibt.

-
-
- {principles.map(([title, text], index) => ( -
- 0{index + 1} -

{title}

{text}

-
- ))} -
-
-
- -
-

Zusammenarbeit

-

Wo ich am meisten beitragen kann.

-
- {offers.map(([title, text], index) => ( -
- 0{index + 1} -

{title}

-

{text}

-
- ))} -
-
- -
-
-

Kontakt

-

Sie haben einen Prozess, der bessere Software verdient?

-

Lassen Sie uns klären, wo der größte Hebel liegt — in einem neuen Produkt, einer bestehenden Anwendung oder einer technischen Entscheidung.

-

Kontaktkanal und Impressum werden vor dem öffentlichen Launch ergänzt.

-
-
- -
- © {new Date().getFullYear()} Jan Wagner · w-make.com - Software Engineering · Glass Technology · Systems Thinking -
-
-
- ); -} - -// 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. diff --git a/src/app/projects/batchmaker-studio/page.tsx b/src/app/projects/batchmaker-studio/page.tsx deleted file mode 100644 index 591026e..0000000 --- a/src/app/projects/batchmaker-studio/page.tsx +++ /dev/null @@ -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 ( -
-
-
- - -
-

Case Study · Standalone Product

-

Batchmaker Studio

-

Recipe Engineering für die Glasindustrie — bewusst kleiner als ein Produktionssystem, aber fachlich belastbar genug für echte Rezeptarbeit.

-
-

Rolle
Produktarchitektur und Full-Stack-Entwicklung

-

Stack
Node.js · Express · SQLite · Vanilla JS · Vite

-

Evidenz
Lokale Browser-Session verifiziert

-
-
- -
-
-

Der Zuschnitt

-

Ein klarer fachlicher Kern statt Produktions-Overhead.

-
-
-

Batchmaker Studio konzentriert sich auf Rezepte, Rohstoffe, Glaschemie, Validierung und druckfertige Satzzettel. Silos, Schichten und laufende Produktionsbuchungen bleiben bewusst außerhalb des Produkts.

-

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.

-
-
- -
-

Verifizierte Ansichten

-

Die Oberfläche zeigt den fachlichen Prozess.

-
- {screens.map(([title, text, image]) => ( -
- {`${title} -
-

{title}

-

{text}

-
-
- ))} -
-
- -
-
-

Engineering-Lektion

-

Produktgrenzen sind Teil der Architektur.

-

Ein gutes Werkzeug muss nicht den gesamten Nachbarprozess abbilden. Der kleinere Zuschnitt macht die fachliche Aufgabe verständlicher, testbarer und eigenständig betreibbar.

-
-
- -
- Jan Wagner · W-MAKE - Software Engineering · Glass Technology · Systems Thinking -
-
-
- ); -} \ No newline at end of file diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 0000000..4358ef9 --- /dev/null +++ b/src/app/robots.ts @@ -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"), + }; +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 0000000..fae44cd --- /dev/null +++ b/src/app/sitemap.ts @@ -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; +} diff --git a/src/components/contact-form.tsx b/src/components/contact-form.tsx new file mode 100644 index 0000000..5be9dd7 --- /dev/null +++ b/src/components/contact-form.tsx @@ -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(); + + 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 ( +

+ {dict.contact.success} +

+ ); + } + + const invalid = (name: string) => field === name; + + return ( +
+ + + + + +