feat: dark theme foundation, new header/footer, shared site primitives
Adds dark design tokens, Inter + JetBrains Mono, a header with Notes, Process, About and a Book a call button, and a footer with the availability line driven by the tested domain core. Shared primitives live in src/components/site, site constants in src/content/site.ts. Legacy pages (blog, legal, contact, tags, technologies) keep a light surface via LegacyLightSurface. Stub routes for /notes, /process and /book. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
import { LegacyLightSurface } from "@/src/components/LegacyLightSurface";
|
||||||
|
|
||||||
|
export default function LegacyLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <LegacyLightSurface>{children}</LegacyLightSurface>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Container } from "@/src/components/site/Container";
|
||||||
|
import { SectionHeader } from "@/src/components/site/SectionHeader";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Book a call" };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<Container className="py-16 md:py-24">
|
||||||
|
<SectionHeader
|
||||||
|
as="h1"
|
||||||
|
kicker="/book"
|
||||||
|
title="Book a call"
|
||||||
|
lead="This page is coming soon."
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { LegacyLightSurface } from "@/src/components/LegacyLightSurface";
|
||||||
|
|
||||||
|
export default function LegacyLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <LegacyLightSurface>{children}</LegacyLightSurface>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { LegacyLightSurface } from "@/src/components/LegacyLightSurface";
|
||||||
|
|
||||||
|
export default function LegacyLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <LegacyLightSurface>{children}</LegacyLightSurface>;
|
||||||
|
}
|
||||||
@@ -2,6 +2,40 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* Theme tokens. Dark is the site default; legacy pages (blog, legal, ...) opt into
|
||||||
|
the light variant by wrapping their content in `.theme-light` (LegacyLightSurface). */
|
||||||
|
:root {
|
||||||
|
--color-bg: #0a0d12;
|
||||||
|
--color-surface: #0d1219;
|
||||||
|
--color-fg: #e6edf3;
|
||||||
|
--color-muted: #8b98a8;
|
||||||
|
--color-faint: #5b6879;
|
||||||
|
--color-border: #1f2630;
|
||||||
|
--color-border-strong: #2b3544;
|
||||||
|
--color-accent: #3fb98a;
|
||||||
|
--color-accent-hover: #55c99d;
|
||||||
|
--color-accent-fg: #0a0d12;
|
||||||
|
--color-code-bg: #0d1219;
|
||||||
|
--color-code-border: #1f2630;
|
||||||
|
--color-scrollbar-track: #0a0d12;
|
||||||
|
--color-scrollbar-thumb: #2b3544;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light {
|
||||||
|
--color-bg: #ffffff;
|
||||||
|
--color-surface: #f8fafc;
|
||||||
|
--color-fg: #0f172a;
|
||||||
|
--color-muted: #334155;
|
||||||
|
--color-faint: #64748b;
|
||||||
|
--color-border: #e2e8f0;
|
||||||
|
--color-border-strong: #cbd5e1;
|
||||||
|
--color-code-bg: #f8fafc;
|
||||||
|
--color-code-border: #f1f5f9;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: #1e293b;
|
||||||
|
font-family: var(--font-newsreader), Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
/* Base styles - Tailwind only */
|
/* Base styles - Tailwind only */
|
||||||
@layer base {
|
@layer base {
|
||||||
html {
|
html {
|
||||||
@@ -9,7 +43,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-white text-slate-800 font-serif antialiased selection:bg-slate-900 selection:text-white;
|
@apply bg-bg text-fg font-sans antialiased;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,7 +54,7 @@
|
|||||||
h4,
|
h4,
|
||||||
h5,
|
h5,
|
||||||
h6 {
|
h6 {
|
||||||
@apply font-sans font-bold text-slate-900 tracking-tighter;
|
@apply font-sans font-bold text-fg tracking-tighter;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
@@ -40,7 +74,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
@apply mb-6 text-base leading-relaxed text-slate-700;
|
@apply mb-6 text-base leading-relaxed text-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lead {
|
.lead {
|
||||||
@@ -49,7 +83,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@apply text-slate-900 hover:text-slate-700 transition-colors no-underline;
|
@apply text-fg hover:text-muted transition-colors no-underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul,
|
ul,
|
||||||
@@ -62,22 +96,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
code:not([class*="language-"]) {
|
code:not([class*="language-"]) {
|
||||||
@apply bg-slate-50 px-1.5 py-0.5 rounded-md font-mono text-[0.9em] text-slate-800 border border-slate-100;
|
@apply px-1.5 py-0.5 rounded-md font-mono text-[0.9em] text-fg border;
|
||||||
|
background-color: var(--color-code-bg);
|
||||||
|
border-color: var(--color-code-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
blockquote {
|
blockquote {
|
||||||
@apply border-l-4 border-slate-900 pl-6 italic text-slate-700 my-8 text-lg md:text-2xl font-serif;
|
@apply border-l-4 border-fg pl-6 italic text-muted my-8 text-lg md:text-2xl font-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Focus states */
|
/* Focus states */
|
||||||
a:focus,
|
|
||||||
button:focus,
|
|
||||||
input:focus,
|
input:focus,
|
||||||
textarea:focus {
|
textarea:focus {
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keep mouse focus quiet, but never hide the keyboard focus ring */
|
||||||
|
a:focus:not(:focus-visible),
|
||||||
|
button:focus:not(:focus-visible) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:focus-visible,
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Interactive elements pointer */
|
/* Interactive elements pointer */
|
||||||
a,
|
a,
|
||||||
button,
|
button,
|
||||||
@@ -328,11 +374,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: #f1f5f9;
|
background: var(--color-scrollbar-track);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #cbd5e1;
|
background: var(--color-scrollbar-thumb);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
}
|
}
|
||||||
@@ -343,8 +389,8 @@
|
|||||||
|
|
||||||
/* Selection color */
|
/* Selection color */
|
||||||
::selection {
|
::selection {
|
||||||
background: #0f172a;
|
background: var(--color-accent);
|
||||||
color: #ffffff;
|
color: var(--color-accent-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tag Styles */
|
/* Tag Styles */
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter, Newsreader } from "next/font/google";
|
import { Inter, JetBrains_Mono, Newsreader } from "next/font/google";
|
||||||
import { Analytics } from "@/src/components/Analytics";
|
import { Analytics } from "@/src/components/Analytics";
|
||||||
import { Footer } from "@/src/components/Footer";
|
import { Footer } from "@/src/components/Footer";
|
||||||
import { Header } from "@/src/components/Header";
|
import { Header } from "@/src/components/Header";
|
||||||
@@ -7,6 +7,11 @@ import { InteractiveElements } from "@/src/components/InteractiveElements";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||||
|
const jetbrainsMono = JetBrains_Mono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-jetbrains-mono",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
const newsreader = Newsreader({
|
const newsreader = Newsreader({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
variable: "--font-newsreader",
|
variable: "--font-newsreader",
|
||||||
@@ -14,14 +19,25 @@ const newsreader = Newsreader({
|
|||||||
display: "swap",
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const DESCRIPTION =
|
||||||
|
"Senior engineer for teams that build fast with AI and don't want chaos. Strong in design and frontend, provably clean in architecture and tests. Remote, booked in weekly capacity blocks.";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
default: "Marc Mintel — Professionelle Websites für Unternehmen",
|
default: "Marc Mintel — Senior engineer for AI-built software",
|
||||||
template: "%s | Marc Mintel",
|
template: "%s | Marc Mintel",
|
||||||
},
|
},
|
||||||
description:
|
description: DESCRIPTION,
|
||||||
"Professionelle Websites zum Fixpreis. Ein Entwickler, ein Ansprechpartner — von der Idee bis zum fertigen Ergebnis. Inklusive Sorglos-Paket: Änderungen, Wartung und Sicherheit komplett abgedeckt.",
|
|
||||||
metadataBase: new URL("https://mintel.me"),
|
metadataBase: new URL("https://mintel.me"),
|
||||||
|
openGraph: {
|
||||||
|
type: "website",
|
||||||
|
siteName: "Marc Mintel",
|
||||||
|
title: "Marc Mintel — Senior engineer for AI-built software",
|
||||||
|
description: DESCRIPTION,
|
||||||
|
url: "https://mintel.me",
|
||||||
|
locale: "en_US",
|
||||||
|
},
|
||||||
|
twitter: { card: "summary_large_image" },
|
||||||
icons: {
|
icons: {
|
||||||
icon: "/favicon.svg",
|
icon: "/favicon.svg",
|
||||||
},
|
},
|
||||||
@@ -33,8 +49,11 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="de" className={`${inter.variable} ${newsreader.variable}`}>
|
<html
|
||||||
<body className="min-h-screen bg-white">
|
lang="en"
|
||||||
|
className={`${inter.variable} ${jetbrainsMono.variable} ${newsreader.variable}`}
|
||||||
|
>
|
||||||
|
<body className="min-h-screen bg-bg font-sans text-fg antialiased">
|
||||||
<Header />
|
<Header />
|
||||||
<main>{children}</main>
|
<main>{children}</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { LegacyLightSurface } from "@/src/components/LegacyLightSurface";
|
||||||
|
|
||||||
|
export default function LegacyLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <LegacyLightSurface>{children}</LegacyLightSurface>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Container } from "@/src/components/site/Container";
|
||||||
|
import { SectionHeader } from "@/src/components/site/SectionHeader";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Notes" };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<Container className="py-16 md:py-24">
|
||||||
|
<SectionHeader
|
||||||
|
as="h1"
|
||||||
|
kicker="/notes"
|
||||||
|
title="Notes"
|
||||||
|
lead="This page is coming soon."
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Container } from "@/src/components/site/Container";
|
||||||
|
import { SectionHeader } from "@/src/components/site/SectionHeader";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Process" };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<Container className="py-16 md:py-24">
|
||||||
|
<SectionHeader
|
||||||
|
as="h1"
|
||||||
|
kicker="/process"
|
||||||
|
title="Process"
|
||||||
|
lead="This page is coming soon."
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { LegacyLightSurface } from "@/src/components/LegacyLightSurface";
|
||||||
|
|
||||||
|
export default function LegacyLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <LegacyLightSurface>{children}</LegacyLightSurface>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { LegacyLightSurface } from "@/src/components/LegacyLightSurface";
|
||||||
|
|
||||||
|
export default function LegacyLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <LegacyLightSurface>{children}</LegacyLightSurface>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { LegacyLightSurface } from "@/src/components/LegacyLightSurface";
|
||||||
|
|
||||||
|
export default function LegacyLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <LegacyLightSurface>{children}</LegacyLightSurface>;
|
||||||
|
}
|
||||||
@@ -1,66 +1,71 @@
|
|||||||
import Image from "next/image";
|
import Link from "next/link";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import LogoBlack from "../assets/logo/Logo-Black-Transparent.svg";
|
import { AVAILABILITY_CONFIG } from "../content/site";
|
||||||
|
import {
|
||||||
|
formatAvailability,
|
||||||
|
resolveAvailability,
|
||||||
|
} from "../domain/availability";
|
||||||
|
|
||||||
|
const NAV_LINKS = [
|
||||||
|
{ href: "/notes", label: "Notes" },
|
||||||
|
{ href: "/process", label: "Process" },
|
||||||
|
{ href: "/about", label: "About" },
|
||||||
|
{ href: "/book", label: "Book a call" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const LEGAL_LINKS = [
|
||||||
|
{ href: "/legal/impressum", label: "Imprint" },
|
||||||
|
{ href: "/legal/datenschutz", label: "Privacy" },
|
||||||
|
{ href: "/legal/agb", label: "Terms" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const LINK_CLASS = "text-muted transition-colors hover:text-fg";
|
||||||
|
|
||||||
export const Footer: React.FC = () => {
|
export const Footer: React.FC = () => {
|
||||||
const currentYear = new Date().getFullYear();
|
const now = new Date();
|
||||||
|
const today = now.toISOString().slice(0, 10);
|
||||||
|
const availability = formatAvailability(
|
||||||
|
resolveAvailability(AVAILABILITY_CONFIG, today),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="relative py-10 md:py-16 mt-8 md:mt-24 border-t border-slate-100 bg-white z-10">
|
<footer className="relative z-10 mt-16 border-t border-border bg-bg py-10 md:mt-24 md:py-14">
|
||||||
{/* Tech border at top */}
|
<div className="mx-auto w-full max-w-6xl px-5 md:px-8">
|
||||||
<div className="absolute top-0 left-0 right-0 h-px overflow-hidden">
|
<p className="!mb-8 flex items-center gap-2 font-mono text-sm text-fg">
|
||||||
<div
|
<span aria-hidden="true" className="h-2 w-2 rounded-full bg-accent" />
|
||||||
className="h-full w-full"
|
{availability}
|
||||||
style={{
|
</p>
|
||||||
background:
|
<div className="flex flex-col gap-8 md:flex-row md:items-end md:justify-between">
|
||||||
"linear-gradient(90deg, transparent 0%, rgba(148, 163, 184, 0.15) 25%, rgba(191, 206, 228, 0.1) 50%, rgba(148, 163, 184, 0.15) 75%, transparent 100%)",
|
<div className="space-y-4">
|
||||||
}}
|
<p className="!mb-0 font-mono text-lg font-semibold text-fg">
|
||||||
/>
|
mintel<span className="text-accent">.</span>me
|
||||||
</div>
|
</p>
|
||||||
|
<nav
|
||||||
<div className="narrow-container">
|
aria-label="Footer"
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12 items-end">
|
className="flex flex-wrap gap-x-6 gap-y-2 text-sm"
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Image
|
|
||||||
src={LogoBlack}
|
|
||||||
alt="Marc Mintel"
|
|
||||||
height={64}
|
|
||||||
className="h-12 md:h-16 w-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col md:items-end gap-3 md:gap-4 text-xs md:text-sm font-mono text-slate-300 uppercase tracking-widest">
|
|
||||||
<span>© {currentYear}</span>
|
|
||||||
<div className="flex gap-8">
|
|
||||||
<a
|
|
||||||
href="/about"
|
|
||||||
className="hover:text-slate-900 transition-colors duration-300 no-underline"
|
|
||||||
>
|
|
||||||
Über mich
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/contact"
|
|
||||||
className="hover:text-slate-900 transition-colors duration-300 no-underline"
|
|
||||||
>
|
|
||||||
Kontakt
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://github.com/marcmintel"
|
|
||||||
className="hover:text-slate-900 transition-colors duration-300 no-underline"
|
|
||||||
>
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{/* Subtle binary decoration */}
|
|
||||||
<span
|
|
||||||
className="text-[8px] text-slate-200 tracking-[0.5em] select-none font-mono"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
>
|
||||||
01001101 01001001 01001110
|
{NAV_LINKS.map((link) => (
|
||||||
</span>
|
<Link key={link.href} href={link.href} className={LINK_CLASS}>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 text-sm md:text-right">
|
||||||
|
<nav
|
||||||
|
aria-label="Legal"
|
||||||
|
className="flex flex-wrap gap-x-6 gap-y-2 md:justify-end"
|
||||||
|
>
|
||||||
|
{LEGAL_LINKS.map((link) => (
|
||||||
|
<Link key={link.href} href={link.href} className={LINK_CLASS}>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<p className="!mb-0 font-mono text-xs text-faint">
|
||||||
|
© {now.getFullYear()} Marc Mintel
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,25 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSafePathname } from "./analytics/useSafePathname";
|
import { useSafePathname } from "./analytics/useSafePathname";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import IconWhite from "../assets/logo/Icon-White-Transparent.svg";
|
import { ButtonLink } from "./site/ButtonLink";
|
||||||
|
|
||||||
export const Header: React.FC = () => {
|
export const Header: React.FC = () => {
|
||||||
const pathname = useSafePathname();
|
const pathname = useSafePathname();
|
||||||
const [isScrolled, setIsScrolled] = React.useState(false);
|
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const handleScroll = () => {
|
|
||||||
setIsScrolled(window.scrollY > 20);
|
|
||||||
};
|
|
||||||
window.addEventListener("scroll", handleScroll);
|
|
||||||
return () => window.removeEventListener("scroll", handleScroll);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Close mobile menu on pathname change and handle body scroll lock
|
// Close mobile menu on pathname change and handle body scroll lock
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setIsMobileMenuOpen(false);
|
setIsMobileMenuOpen(false);
|
||||||
@@ -36,139 +26,93 @@ export const Header: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, [isMobileMenuOpen]);
|
}, [isMobileMenuOpen]);
|
||||||
|
|
||||||
const isActive = (path: string) => pathname === path;
|
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ href: "/about", label: "Über mich" },
|
{ href: "/notes", label: "Notes" },
|
||||||
{ href: "/websites", label: "Websites" },
|
{ href: "/process", label: "Process" },
|
||||||
{ href: "/case-studies", label: "Case Studies", prefix: true },
|
{ href: "/about", label: "About" },
|
||||||
{ href: "/blog", label: "Blog", prefix: true },
|
] as const;
|
||||||
];
|
|
||||||
|
const linkActive = (href: string) =>
|
||||||
|
pathname === href || Boolean(pathname?.startsWith(`${href}/`));
|
||||||
|
|
||||||
|
const closeMenu = () => setIsMobileMenuOpen(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-[100] w-full">
|
<header className="sticky top-0 z-[100] w-full border-b border-border bg-bg">
|
||||||
{/* Decoupled Background Layer - Prevents backdrop-filter parent context bugs */}
|
<div className="mx-auto flex w-full max-w-6xl items-center justify-between px-5 py-4 md:px-8">
|
||||||
<div
|
<Link
|
||||||
className={`absolute inset-0 transition-all duration-500 -z-10 ${isScrolled
|
href="/"
|
||||||
? "bg-white/70 backdrop-blur-xl border-b border-slate-100 shadow-sm shadow-slate-100/50"
|
aria-label="mintel.me home"
|
||||||
: "bg-white/80 backdrop-blur-md border-b border-slate-50"
|
className="font-mono text-lg font-semibold text-fg"
|
||||||
}`}
|
>
|
||||||
/>
|
mintel<span className="text-accent">.</span>me
|
||||||
|
|
||||||
{/* Animated tech border at bottom */}
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-px overflow-hidden pointer-events-none">
|
|
||||||
<div
|
|
||||||
className="h-full w-full"
|
|
||||||
style={{
|
|
||||||
background: isScrolled
|
|
||||||
? "linear-gradient(90deg, transparent 0%, rgba(148, 163, 184, 0.15) 30%, rgba(191, 206, 228, 0.1) 50%, rgba(148, 163, 184, 0.15) 70%, transparent 100%)"
|
|
||||||
: "transparent",
|
|
||||||
transition: "background 0.5s ease",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="narrow-container py-4 flex items-center justify-between relative z-10">
|
|
||||||
<Link href="/" className="flex items-center gap-4 group">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 md:w-12 md:h-12 bg-black rounded-xl flex items-center justify-center group-hover:scale-105 transition-all duration-500 shadow-sm shrink-0 relative overflow-hidden">
|
|
||||||
<Image
|
|
||||||
src={IconWhite}
|
|
||||||
alt="Marc Mintel Icon"
|
|
||||||
width={32}
|
|
||||||
height={32}
|
|
||||||
className="w-6 h-6 md:w-8 md:h-8 relative z-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Desktop Navigation */}
|
{/* Desktop Navigation */}
|
||||||
<nav className="hidden md:flex items-center gap-8">
|
<nav aria-label="Main" className="hidden items-center gap-8 md:flex">
|
||||||
{navLinks.map((link) => {
|
{navLinks.map((link) => (
|
||||||
const active = link.prefix
|
<Link
|
||||||
? isActive(link.href) || pathname?.startsWith(`${link.href}/`)
|
key={link.href}
|
||||||
: isActive(link.href);
|
href={link.href}
|
||||||
|
aria-current={linkActive(link.href) ? "page" : undefined}
|
||||||
return (
|
className={`text-sm transition-colors ${
|
||||||
<Link
|
linkActive(link.href) ? "text-fg" : "text-muted hover:text-fg"
|
||||||
key={link.href}
|
}`}
|
||||||
href={link.href}
|
>
|
||||||
className={`text-xs font-bold uppercase tracking-widest transition-colors duration-300 relative ${active
|
{link.label}
|
||||||
? "text-slate-900"
|
</Link>
|
||||||
: "text-slate-400 hover:text-slate-900"
|
))}
|
||||||
}`}
|
<ButtonLink href="/book">Book a call</ButtonLink>
|
||||||
>
|
|
||||||
{active && (
|
|
||||||
<span className="absolute -bottom-1 left-0 right-0 flex justify-center">
|
|
||||||
<span className="w-1 h-1 rounded-full bg-slate-900 animate-circuit-pulse" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{link.label}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<Link
|
|
||||||
href="/contact"
|
|
||||||
className="text-[10px] font-bold uppercase tracking-[0.2em] text-slate-900 border border-slate-200 px-5 py-2.5 rounded-full hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
|
|
||||||
style={{
|
|
||||||
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Anfrage
|
|
||||||
</Link>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Mobile Toggle */}
|
{/* Mobile Toggle */}
|
||||||
<button
|
<button
|
||||||
className="md:hidden relative z-[110] p-2 w-10 h-10 flex items-center justify-center rounded-xl bg-slate-900 text-white active:scale-90 transition-all duration-300 shadow-lg shadow-slate-200"
|
className="relative z-[110] flex h-10 w-10 items-center justify-center rounded-md border border-border-strong text-fg md:hidden"
|
||||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||||
aria-label="Toggle Menu"
|
aria-label="Toggle Menu"
|
||||||
|
aria-expanded={isMobileMenuOpen}
|
||||||
>
|
>
|
||||||
<div className="w-5 h-3.5 relative flex flex-col justify-between">
|
<div className="relative flex h-3.5 w-5 flex-col justify-between">
|
||||||
<motion.span
|
<motion.span
|
||||||
animate={
|
animate={
|
||||||
isMobileMenuOpen ? { rotate: 45, y: 7 } : { rotate: 0, y: 0 }
|
isMobileMenuOpen ? { rotate: 45, y: 7 } : { rotate: 0, y: 0 }
|
||||||
}
|
}
|
||||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||||
className="w-full h-0.5 bg-current rounded-full origin-center"
|
className="h-0.5 w-full origin-center rounded-full bg-current"
|
||||||
/>
|
/>
|
||||||
<motion.span
|
<motion.span
|
||||||
animate={
|
animate={
|
||||||
isMobileMenuOpen ? { opacity: 0, x: -10 } : { opacity: 1, x: 0 }
|
isMobileMenuOpen ? { opacity: 0, x: -10 } : { opacity: 1, x: 0 }
|
||||||
}
|
}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className="w-full h-0.5 bg-current rounded-full"
|
className="h-0.5 w-full rounded-full bg-current"
|
||||||
/>
|
/>
|
||||||
<motion.span
|
<motion.span
|
||||||
animate={
|
animate={
|
||||||
isMobileMenuOpen ? { rotate: -45, y: -7 } : { rotate: 0, y: 0 }
|
isMobileMenuOpen ? { rotate: -45, y: -7 } : { rotate: 0, y: 0 }
|
||||||
}
|
}
|
||||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||||
className="w-full h-0.5 bg-current rounded-full origin-center"
|
className="h-0.5 w-full origin-center rounded-full bg-current"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Navigation - Bottom-Anchored Control Center */}
|
{/* Mobile Navigation - bottom sheet */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isMobileMenuOpen && (
|
{isMobileMenuOpen && (
|
||||||
<React.Fragment key="mobile-control-center">
|
<React.Fragment key="mobile-menu">
|
||||||
{/* Dimmed Backdrop */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
key="cc-backdrop"
|
key="menu-backdrop"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.25 }}
|
transition={{ duration: 0.25 }}
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={closeMenu}
|
||||||
className="fixed inset-0 z-[101] bg-black/30 backdrop-blur-sm md:hidden"
|
className="fixed inset-0 z-[101] bg-black/60 md:hidden"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Bottom Sheet */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
key="cc-sheet"
|
key="menu-sheet"
|
||||||
initial={{ y: "100%" }}
|
initial={{ y: "100%" }}
|
||||||
animate={{ y: 0 }}
|
animate={{ y: 0 }}
|
||||||
exit={{ y: "100%" }}
|
exit={{ y: "100%" }}
|
||||||
@@ -183,125 +127,44 @@ export const Header: React.FC = () => {
|
|||||||
dragElastic={0.15}
|
dragElastic={0.15}
|
||||||
onDragEnd={(_, info) => {
|
onDragEnd={(_, info) => {
|
||||||
if (info.offset.y > 80 || info.velocity.y > 300) {
|
if (info.offset.y > 80 || info.velocity.y > 300) {
|
||||||
setIsMobileMenuOpen(false);
|
closeMenu();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="fixed inset-x-0 bottom-0 z-[102] md:hidden bg-white rounded-t-[2rem] shadow-[0_-8px_40px_rgba(0,0,0,0.12)] flex flex-col max-h-[85vh] overflow-hidden"
|
className="fixed inset-x-0 bottom-0 z-[102] flex max-h-[85vh] flex-col overflow-hidden rounded-t-xl border-t border-border-strong bg-surface md:hidden"
|
||||||
>
|
>
|
||||||
{/* Grab Handle */}
|
<div className="flex cursor-grab justify-center pb-2 pt-3 active:cursor-grabbing">
|
||||||
<div className="flex justify-center pt-3 pb-2 cursor-grab active:cursor-grabbing">
|
<div className="h-1 w-10 rounded-full bg-border-strong" />
|
||||||
<div className="w-10 h-1 bg-slate-200 rounded-full" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Bar */}
|
<nav
|
||||||
<div className="px-6 py-3 flex justify-between items-center border-b border-slate-100/80">
|
aria-label="Mobile"
|
||||||
<div className="flex items-center gap-2 text-[9px] font-mono font-bold tracking-[0.15em] text-slate-400 uppercase">
|
className="flex-1 overflow-y-auto px-5 pb-3 pt-2"
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
>
|
||||||
Online
|
<ul className="m-0 list-none p-0">
|
||||||
</div>
|
{navLinks.map((item) => (
|
||||||
<div className="text-[9px] font-mono font-bold tracking-widest text-slate-400 uppercase">
|
<li key={item.href} className="!mb-0">
|
||||||
{pathname === "/"
|
<Link
|
||||||
? "HOME"
|
href={item.href}
|
||||||
: pathname.toUpperCase().replace(/^\//, "")}
|
onClick={closeMenu}
|
||||||
</div>
|
aria-current={
|
||||||
</div>
|
linkActive(item.href) ? "page" : undefined
|
||||||
|
}
|
||||||
{/* Tiled Navigation Grid */}
|
className={`flex items-center justify-between border-b border-border py-4 text-lg ${
|
||||||
<div className="px-5 pt-5 pb-3 flex-1 overflow-y-auto">
|
linkActive(item.href) ? "text-accent" : "text-fg"
|
||||||
<div className="grid grid-cols-2 gap-2.5">
|
}`}
|
||||||
{[
|
|
||||||
{ href: "/about", label: "Über mich", sub: "Architect" },
|
|
||||||
{ href: "/websites", label: "Websites", sub: "Systems" },
|
|
||||||
{
|
|
||||||
href: "/case-studies",
|
|
||||||
label: "Cases",
|
|
||||||
sub: "Solutions",
|
|
||||||
prefix: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/blog",
|
|
||||||
label: "Blog",
|
|
||||||
sub: "Insights",
|
|
||||||
prefix: true,
|
|
||||||
},
|
|
||||||
].map((item, i) => {
|
|
||||||
const active = item.prefix
|
|
||||||
? pathname?.startsWith(item.href)
|
|
||||||
: pathname === item.href;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
key={item.href}
|
|
||||||
initial={{ opacity: 0, scale: 0.85, y: 15 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
transition={{
|
|
||||||
delay: 0.05 + i * 0.04,
|
|
||||||
type: "spring",
|
|
||||||
stiffness: 400,
|
|
||||||
damping: 25,
|
|
||||||
}}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
>
|
>
|
||||||
<Link
|
{item.label}
|
||||||
href={item.href}
|
</Link>
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
</li>
|
||||||
className={`relative flex flex-col justify-center p-6 h-[110px] rounded-2xl border transition-all duration-200 ${active
|
))}
|
||||||
? "bg-slate-50 border-slate-200 ring-1 ring-slate-200"
|
</ul>
|
||||||
: "bg-white border-slate-100 active:bg-slate-50"
|
</nav>
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<span className="text-[15px] font-black tracking-tight text-slate-900 block leading-tight mb-1">
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
<span className="text-[9px] font-mono font-bold text-slate-400 uppercase tracking-[0.2em]">
|
|
||||||
{item.sub}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{active && (
|
|
||||||
<div className="absolute top-4 right-4 w-1.5 h-1.5 rounded-full bg-slate-900" />
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Primary CTA */}
|
|
||||||
<div className="px-5 pb-5 pt-2">
|
<div className="px-5 pb-5 pt-2">
|
||||||
<motion.div
|
<ButtonLink href="/book" className="w-full">
|
||||||
initial={{ opacity: 0, y: 10 }}
|
Book a call
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</ButtonLink>
|
||||||
transition={{ delay: 0.25, type: "spring", stiffness: 300 }}
|
|
||||||
whileTap={{ scale: 0.97 }}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href="/contact"
|
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
|
||||||
className="flex items-center justify-between w-full p-4 bg-slate-900 text-white rounded-2xl active:bg-slate-800 transition-colors"
|
|
||||||
>
|
|
||||||
<span className="text-[13px] font-bold uppercase tracking-[0.15em]">
|
|
||||||
Projekt anfragen
|
|
||||||
</span>
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4 text-slate-400"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2.5"
|
|
||||||
d="M14 5l7 7m0 0l-7 7m7-7H3"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Safe-Area Spacer (iOS home indicator) */}
|
|
||||||
<div className="h-[env(safe-area-inset-bottom,0px)]" />
|
<div className="h-[env(safe-area-inset-bottom,0px)]" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
/** Keeps pre-relaunch pages (blog, legal, ...) legible on the dark site shell. */
|
||||||
|
export const LegacyLightSurface: React.FC<{ children: React.ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
}) => <div className="theme-light min-h-[60vh]">{children}</div>;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
type ButtonVariant = "primary" | "secondary";
|
||||||
|
|
||||||
|
type ButtonLinkProps = {
|
||||||
|
readonly href: string;
|
||||||
|
readonly children: React.ReactNode;
|
||||||
|
readonly variant?: ButtonVariant;
|
||||||
|
readonly className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BASE =
|
||||||
|
"inline-flex items-center justify-center rounded-md px-5 py-2.5 text-sm font-semibold transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent";
|
||||||
|
|
||||||
|
const VARIANTS: Record<ButtonVariant, string> = {
|
||||||
|
primary: "bg-accent !text-accent-fg hover:bg-accent-hover",
|
||||||
|
secondary:
|
||||||
|
"border border-border-strong !text-fg hover:border-muted hover:bg-surface",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ButtonLink: React.FC<ButtonLinkProps> = ({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
variant = "primary",
|
||||||
|
className = "",
|
||||||
|
}) => (
|
||||||
|
<Link href={href} className={`${BASE} ${VARIANTS[variant]} ${className}`}>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
type ContainerProps = {
|
||||||
|
readonly children: React.ReactNode;
|
||||||
|
readonly className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Container: React.FC<ContainerProps> = ({
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
}) => (
|
||||||
|
<div className={`mx-auto w-full max-w-6xl px-5 md:px-8 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
type PillProps = { readonly children: React.ReactNode };
|
||||||
|
|
||||||
|
export const Pill: React.FC<PillProps> = ({ children }) => (
|
||||||
|
<span className="inline-flex items-center rounded-full border border-accent px-3 py-1 font-mono text-xs text-accent">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
type SectionHeaderProps = {
|
||||||
|
readonly kicker: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly lead?: string;
|
||||||
|
readonly as?: "h1" | "h2";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SectionHeader: React.FC<SectionHeaderProps> = ({
|
||||||
|
kicker,
|
||||||
|
title,
|
||||||
|
lead,
|
||||||
|
as: Heading = "h2",
|
||||||
|
}) => (
|
||||||
|
<header className="max-w-2xl">
|
||||||
|
<p className="mb-3 font-mono text-xs uppercase tracking-widest text-accent">
|
||||||
|
{kicker}
|
||||||
|
</p>
|
||||||
|
<Heading className="!mb-4 !mt-0 !text-3xl !leading-tight md:!text-4xl">
|
||||||
|
{title}
|
||||||
|
</Heading>
|
||||||
|
{lead ? (
|
||||||
|
<p className="!mb-0 text-base leading-relaxed text-muted md:text-lg">
|
||||||
|
{lead}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
type StatProps = {
|
||||||
|
readonly value: string;
|
||||||
|
readonly label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Stat: React.FC<StatProps> = ({ value, label }) => (
|
||||||
|
<div>
|
||||||
|
<p className="!mb-1 font-mono text-2xl font-semibold text-fg md:text-3xl">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
<p className="!mb-0 text-sm text-muted">{label}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
type TerminalCardProps = {
|
||||||
|
readonly title: string;
|
||||||
|
readonly children: React.ReactNode;
|
||||||
|
readonly className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TerminalCard: React.FC<TerminalCardProps> = ({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
}) => (
|
||||||
|
<figure
|
||||||
|
className={`overflow-hidden rounded-lg border border-border bg-surface ${className}`}
|
||||||
|
>
|
||||||
|
<figcaption className="flex items-center gap-2 border-b border-border px-4 py-2.5 font-mono text-xs text-faint">
|
||||||
|
<span aria-hidden="true" className="text-accent">
|
||||||
|
●
|
||||||
|
</span>
|
||||||
|
{title}
|
||||||
|
</figcaption>
|
||||||
|
<div className="overflow-x-auto p-4 font-mono text-sm leading-relaxed text-fg">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export { ButtonLink } from "./ButtonLink";
|
||||||
|
export { Container } from "./Container";
|
||||||
|
export { Pill } from "./Pill";
|
||||||
|
export { SectionHeader } from "./SectionHeader";
|
||||||
|
export { Stat } from "./Stat";
|
||||||
|
export { TerminalCard } from "./TerminalCard";
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
parseAvailabilityConfig,
|
||||||
|
type AvailabilityConfig,
|
||||||
|
} from "@/src/domain/availability";
|
||||||
|
|
||||||
|
export const SITE_NAME = "Marc Mintel";
|
||||||
|
export const SITE_URL = "https://mintel.me";
|
||||||
|
|
||||||
|
// TODO(owner): replace with the real Cal.com link before launch.
|
||||||
|
export const BOOKING_URL = "https://cal.com/PLACEHOLDER/intro";
|
||||||
|
|
||||||
|
// TODO(owner): replace with the real contact address before launch.
|
||||||
|
export const CONTACT_EMAIL = "hello@example.com";
|
||||||
|
|
||||||
|
// PLACEHOLDER VALUES: the owner must confirm the next free date and weekly hours.
|
||||||
|
const RAW_AVAILABILITY_CONFIG = {
|
||||||
|
freeFrom: "2026-11-01",
|
||||||
|
hoursPerWeek: 20,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const parsedAvailability = parseAvailabilityConfig(RAW_AVAILABILITY_CONFIG);
|
||||||
|
if (parsedAvailability.ok === false) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid AVAILABILITY_CONFIG in src/content/site.ts: ${parsedAvailability.error}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export const AVAILABILITY_CONFIG: AvailabilityConfig = parsedAvailability.value;
|
||||||
|
|
||||||
|
export type EngagementBlock = {
|
||||||
|
readonly name: string;
|
||||||
|
readonly hoursPerWeek: string;
|
||||||
|
readonly pricePerMonth: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ENGAGEMENT_BLOCKS: readonly EngagementBlock[] = [
|
||||||
|
{
|
||||||
|
name: "Focus",
|
||||||
|
hoursPerWeek: "15 h/week",
|
||||||
|
pricePerMonth: "6,000 EUR/month",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Half-time",
|
||||||
|
hoursPerWeek: "20 h/week",
|
||||||
|
pricePerMonth: "8,000 EUR/month",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Full-time",
|
||||||
|
hoursPerWeek: "35-40 h/week",
|
||||||
|
pricePerMonth: "14,000-16,000 EUR/month",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const STACK: readonly string[] = [
|
||||||
|
"TypeScript",
|
||||||
|
"Next.js",
|
||||||
|
"React",
|
||||||
|
"Node.js",
|
||||||
|
"Rust",
|
||||||
|
"Solidity",
|
||||||
|
"PostgreSQL",
|
||||||
|
"Tailwind CSS",
|
||||||
|
];
|
||||||
+45
-33
@@ -1,66 +1,78 @@
|
|||||||
import typography from '@tailwindcss/typography';
|
import typography from "@tailwindcss/typography";
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
content: [
|
content: ["./app/**/*.{js,ts,jsx,tsx,mdx}", "./src/**/*.{js,ts,jsx,tsx,mdx}"],
|
||||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
|
||||||
'./src/**/*.{js,ts,jsx,tsx,mdx}',
|
|
||||||
],
|
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
'xl': '1rem',
|
xl: "1rem",
|
||||||
'2xl': '1.5rem',
|
"2xl": "1.5rem",
|
||||||
'3xl': '2rem',
|
"3xl": "2rem",
|
||||||
'full': '9999px',
|
full: "9999px",
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
|
bg: "var(--color-bg)",
|
||||||
|
surface: "var(--color-surface)",
|
||||||
|
fg: "var(--color-fg)",
|
||||||
|
muted: "var(--color-muted)",
|
||||||
|
faint: "var(--color-faint)",
|
||||||
|
border: "var(--color-border)",
|
||||||
|
"border-strong": "var(--color-border-strong)",
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "var(--color-accent)",
|
||||||
|
hover: "var(--color-accent-hover)",
|
||||||
|
fg: "var(--color-accent-fg)",
|
||||||
|
},
|
||||||
slate: {
|
slate: {
|
||||||
850: '#1e293b',
|
850: "#1e293b",
|
||||||
900: '#0f172a',
|
900: "#0f172a",
|
||||||
950: '#020617',
|
950: "#020617",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['var(--font-inter)', 'Inter', 'system-ui', 'sans-serif'],
|
sans: ["var(--font-inter)", "Inter", "system-ui", "sans-serif"],
|
||||||
serif: ['var(--font-newsreader)', 'Georgia', 'serif'],
|
serif: ["var(--font-newsreader)", "Georgia", "serif"],
|
||||||
mono: ['JetBrains Mono', 'monospace'],
|
mono: [
|
||||||
|
"var(--font-jetbrains-mono)",
|
||||||
|
"JetBrains Mono",
|
||||||
|
"ui-monospace",
|
||||||
|
"monospace",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
typography: (theme) => ({
|
typography: (theme) => ({
|
||||||
DEFAULT: {
|
DEFAULT: {
|
||||||
css: {
|
css: {
|
||||||
color: theme('colors.slate.800'),
|
color: theme("colors.slate.800"),
|
||||||
a: {
|
a: {
|
||||||
color: theme('colors.slate.900'),
|
color: theme("colors.slate.900"),
|
||||||
'&:hover': {
|
"&:hover": {
|
||||||
color: theme('colors.slate.700'),
|
color: theme("colors.slate.700"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
animation: {
|
animation: {
|
||||||
'fade-in': 'fadeIn 0.5s ease-in-out',
|
"fade-in": "fadeIn 0.5s ease-in-out",
|
||||||
'slide-up': 'slideUp 0.6s ease-out',
|
"slide-up": "slideUp 0.6s ease-out",
|
||||||
'slide-down': 'slideDown 0.6s ease-out',
|
"slide-down": "slideDown 0.6s ease-out",
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
fadeIn: {
|
fadeIn: {
|
||||||
'0%': { opacity: '0' },
|
"0%": { opacity: "0" },
|
||||||
'100%': { opacity: '1' },
|
"100%": { opacity: "1" },
|
||||||
},
|
},
|
||||||
slideUp: {
|
slideUp: {
|
||||||
'0%': { transform: 'translateY(20px)', opacity: '0' },
|
"0%": { transform: "translateY(20px)", opacity: "0" },
|
||||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
"100%": { transform: "translateY(0)", opacity: "1" },
|
||||||
},
|
},
|
||||||
slideDown: {
|
slideDown: {
|
||||||
'0%': { transform: 'translateY(-20px)', opacity: '0' },
|
"0%": { transform: "translateY(-20px)", opacity: "0" },
|
||||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
"100%": { transform: "translateY(0)", opacity: "1" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [typography],
|
||||||
typography,
|
};
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user