Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
123ab8e354 |
@@ -28,6 +28,9 @@ App lives in `apps/web`. Concept and scope: `apps/web/plans/freelance-relaunch.m
|
||||
- One page: everything lives on the homepage. The header has only "Get in touch", which jumps to the contact
|
||||
section (`#contact`). Contact is one email address: no calendar integration, no form. The only other
|
||||
pages are `/imprint` and `/privacy`. URLs of the previous site redirect to `/` (see `next.config.mjs`).
|
||||
- Title, description and link previews come from `SITE_TITLE` and `SITE_DESCRIPTION` in `src/content/site.ts`.
|
||||
The share image is a screenshot of the real hero: after changing the hero headline or its design, run
|
||||
`pnpm --filter @mintel/web share-image` and commit the two PNGs in `app/(site)/`.
|
||||
- The email address is kept in parts (`src/content/site.ts`) and assembled in the browser by `EmailLink`.
|
||||
Never write the full address into markup, metadata or copy.
|
||||
- The hero design (binary-digit headline) is approved: do not change its design.
|
||||
|
||||
@@ -4,6 +4,12 @@ import { Analytics } from "@/src/components/Analytics";
|
||||
import { Footer } from "@/src/components/Footer";
|
||||
import { Header } from "@/src/components/Header";
|
||||
import { InteractiveElements } from "@/src/components/InteractiveElements";
|
||||
import {
|
||||
SITE_DESCRIPTION,
|
||||
SITE_NAME,
|
||||
SITE_TITLE,
|
||||
SITE_URL,
|
||||
} from "@/src/content/site";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||
@@ -20,15 +26,32 @@ const newsreader = Newsreader({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
default: "Marc Mintel — Senior developer for web apps and websites",
|
||||
template: "%s | Marc Mintel",
|
||||
default: SITE_TITLE,
|
||||
template: `%s | ${SITE_NAME}`,
|
||||
},
|
||||
description:
|
||||
"Senior developer for web apps, interfaces and websites. Book weekly hours for your product, get an unreliable app back on track, or have a website built for you.",
|
||||
metadataBase: new URL("https://mintel.me"),
|
||||
description: SITE_DESCRIPTION,
|
||||
applicationName: SITE_NAME,
|
||||
authors: [{ name: SITE_NAME, url: SITE_URL }],
|
||||
creator: SITE_NAME,
|
||||
icons: {
|
||||
icon: "/favicon.svg",
|
||||
apple: "/apple-touch-icon.png",
|
||||
},
|
||||
// The images come from opengraph-image.png and twitter-image.png next to this file.
|
||||
openGraph: {
|
||||
type: "website",
|
||||
url: "/",
|
||||
siteName: SITE_NAME,
|
||||
locale: "en_US",
|
||||
title: SITE_TITLE,
|
||||
description: SITE_DESCRIPTION,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: SITE_TITLE,
|
||||
description: SITE_DESCRIPTION,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Web apps and websites. Marc Mintel, freelance developer with 15 years of experience.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
@@ -1,23 +0,0 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import { OGImageTemplate } from "../../src/components/OGImageTemplate";
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from "../../src/lib/og-helper";
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = "image/png";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export default async function Image() {
|
||||
const fonts = await getOgFonts();
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate
|
||||
title="Marc Mintel"
|
||||
description="Freelance developer. I build web apps, interfaces and websites."
|
||||
label="Web apps and websites"
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts: fonts as any,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -17,12 +17,12 @@ import {
|
||||
} from "@/src/components/illustrations/WorkShowcase";
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
import { CAREER, FIT, TECH } from "@/src/content/about";
|
||||
import { SITE_DESCRIPTION, SITE_TITLE } from "@/src/content/site";
|
||||
import { AUTOMATION_NOTES } from "@/src/content/automation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Marc Mintel — Senior developer for web apps and websites",
|
||||
description:
|
||||
"Freelance developer with 15 years of experience. I build web apps, interfaces and websites: by the hour for product teams, or a complete website for your business.",
|
||||
title: { absolute: SITE_TITLE },
|
||||
description: SITE_DESCRIPTION,
|
||||
alternates: { canonical: "/" },
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Web apps and websites. Marc Mintel, freelance developer with 15 years of experience.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
@@ -2,7 +2,7 @@
|
||||
"name": "@mintel/web",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"description": "Technical problem solver's blog - practical insights and learning notes",
|
||||
"description": "Website of Marc Mintel, freelance developer for web apps and websites",
|
||||
"scripts": {
|
||||
"dev": "next dev --webpack --hostname 0.0.0.0",
|
||||
"dev:native": "next dev --webpack",
|
||||
@@ -14,7 +14,8 @@
|
||||
"pagespeed:test": "npx tsx ./scripts/pagespeed-sitemap.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:mutation": "stryker run",
|
||||
"check:og": "tsx scripts/check-og-images.ts"
|
||||
"check:og": "tsx scripts/check-og-images.ts",
|
||||
"share-image": "node scripts/render-share-image.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.750.0",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 3.5 MiB |
@@ -1,4 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://mintel.me/sitemap.xml
|
||||
Sitemap: https://mintel.me/sitemap.xml
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Renders the share image (Open Graph and Twitter) from the real hero, so it always
|
||||
// matches the site. Run it after the hero headline or its design changes:
|
||||
//
|
||||
// pnpm --filter @mintel/web share-image (uses the live site)
|
||||
// pnpm --filter @mintel/web share-image http://mintel.localhost/
|
||||
//
|
||||
// It drives the locally installed Chrome through Playwright and writes
|
||||
// app/(site)/opengraph-image.png and app/(site)/twitter-image.png.
|
||||
import { copyFileSync } from "node:fs";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const url = process.argv[2] ?? "https://mintel.me/";
|
||||
const out = "app/(site)/opengraph-image.png";
|
||||
const browser = await chromium.launch({ channel: "chrome", headless: true });
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1200, height: 630 },
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
await page.goto(url, { waitUntil: "load", timeout: 90000 });
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
header, footer, #hero-cta, [class*="fixed"] { display: none !important; }
|
||||
main > div > section:first-of-type > div.relative { min-height: 630px !important; height: 630px !important; padding: 0 72px !important; justify-content: center !important; max-width: none !important; }
|
||||
main > div > section:first-of-type ul { display: none !important; }
|
||||
main > div > section:first-of-type h1 { font-size: 150px !important; }
|
||||
main > div > section:first-of-type p { font-size: 34px !important; max-width: 900px !important; margin-top: 36px !important; }
|
||||
main > div > section:first-of-type .grid { margin-top: 0 !important; display: block !important; }
|
||||
main > div > section:first-of-type h1 span:nth-of-type(2) { color: rgba(15, 23, 42, 0.2) !important; }
|
||||
main > div > section:first-of-type > div.relative { padding-top: 34px !important; }
|
||||
html, body { overflow: hidden !important; }
|
||||
`,
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
const section = document.querySelector("main section");
|
||||
const tag = document.createElement("div");
|
||||
tag.textContent = "MARC MINTEL";
|
||||
tag.style.cssText =
|
||||
"position:absolute;left:72px;top:54px;z-index:20;font:700 15px ui-monospace,Menlo,monospace;letter-spacing:.3em;color:#0f172a";
|
||||
const site = document.createElement("div");
|
||||
site.textContent = "mintel.me";
|
||||
site.style.cssText =
|
||||
"position:absolute;right:72px;top:54px;z-index:20;font:500 15px ui-monospace,Menlo,monospace;letter-spacing:.2em;color:#64748b";
|
||||
section.style.position = "relative";
|
||||
section.append(tag, site);
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
await page.waitForTimeout(4500);
|
||||
await page.screenshot({
|
||||
path: out,
|
||||
clip: { x: 0, y: 0, width: 1200, height: 630 },
|
||||
});
|
||||
await browser.close();
|
||||
copyFileSync(out, "app/(site)/twitter-image.png");
|
||||
console.log("written:", out, "and app/(site)/twitter-image.png");
|
||||
@@ -1,248 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
interface OGImageTemplateProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
label?: string;
|
||||
accentColor?: string;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
export function OGImageTemplate({
|
||||
title,
|
||||
description,
|
||||
label,
|
||||
accentColor,
|
||||
keyword,
|
||||
}: OGImageTemplateProps) {
|
||||
const accent = accentColor || "#3b82f6";
|
||||
const slateDark = "#0f172a";
|
||||
const slateLight = "#64748b";
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#ffffff",
|
||||
padding: "80px",
|
||||
position: "relative",
|
||||
fontFamily: "Inter",
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
{/* Background Grid Pattern */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: `linear-gradient(rgba(148,163,184,0.06) 1px, transparent 1px), linear-gradient(90deg, rgba(148,163,184,0.06) 1px, transparent 1px)`,
|
||||
backgroundSize: "40px 40px",
|
||||
display: "flex",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Accent geometric block (right side) */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "60px",
|
||||
right: "80px",
|
||||
width: "200px",
|
||||
height: "200px",
|
||||
borderRadius: "24px",
|
||||
border: `3px solid ${accent}`,
|
||||
opacity: 0.15,
|
||||
display: "flex",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "100px",
|
||||
right: "120px",
|
||||
width: "160px",
|
||||
height: "160px",
|
||||
borderRadius: "20px",
|
||||
backgroundColor: accent,
|
||||
opacity: 0.08,
|
||||
display: "flex",
|
||||
}}
|
||||
/>
|
||||
{/* Small accent circles */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "80px",
|
||||
right: "100px",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: accent,
|
||||
opacity: 0.5,
|
||||
display: "flex",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "240px",
|
||||
right: "90px",
|
||||
width: "10px",
|
||||
height: "10px",
|
||||
borderRadius: "5px",
|
||||
backgroundColor: accent,
|
||||
opacity: 0.3,
|
||||
display: "flex",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
zIndex: 10,
|
||||
maxWidth: "850px",
|
||||
}}
|
||||
>
|
||||
{/* Label / Category */}
|
||||
{label && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "18px",
|
||||
fontWeight: 700,
|
||||
color: accent,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.2em",
|
||||
marginBottom: "24px",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: title.length > 40 ? "56px" : "72px",
|
||||
fontWeight: 700,
|
||||
color: slateDark,
|
||||
lineHeight: "1.1",
|
||||
marginBottom: "28px",
|
||||
display: "flex",
|
||||
letterSpacing: "-0.03em",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "26px",
|
||||
color: slateLight,
|
||||
lineHeight: "1.45",
|
||||
display: "flex",
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{description.length > 120
|
||||
? description.substring(0, 117) + "..."
|
||||
: description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Brand Footer */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "60px",
|
||||
left: "80px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "50px",
|
||||
height: "3px",
|
||||
backgroundColor: slateDark,
|
||||
borderRadius: "2px",
|
||||
marginRight: "16px",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "20px",
|
||||
fontWeight: 700,
|
||||
color: slateDark,
|
||||
textTransform: "lowercase",
|
||||
letterSpacing: "0.05em",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
mintel.me
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyword badge (bottom-right) */}
|
||||
{keyword && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "60px",
|
||||
right: "80px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: accent,
|
||||
display: "flex",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "14px",
|
||||
fontWeight: 700,
|
||||
color: "#94a3b8",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.15em",
|
||||
fontFamily: "ui-monospace, monospace",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
{keyword}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Accent Strip */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "5px",
|
||||
backgroundColor: accent,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,37 @@
|
||||
import { SITE_NAME, SITE_URL } from "@/src/content/site";
|
||||
import {
|
||||
OWNER,
|
||||
SITE_DESCRIPTION,
|
||||
SITE_NAME,
|
||||
SITE_URL,
|
||||
} from "@/src/content/site";
|
||||
|
||||
const STRUCTURED_DATA = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ProfessionalService",
|
||||
name: SITE_NAME,
|
||||
url: SITE_URL,
|
||||
description:
|
||||
"Senior freelance developer for web apps, interfaces and websites: weekly capacity for product teams, making unreliable apps dependable, and websites built end to end.",
|
||||
founder: { "@type": "Person", name: SITE_NAME },
|
||||
description: SITE_DESCRIPTION,
|
||||
image: `${SITE_URL}/marc-mintel.png`,
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
streetAddress: OWNER.street,
|
||||
postalCode: OWNER.city.split(" ")[0],
|
||||
addressLocality: OWNER.city.split(" ").slice(1).join(" "),
|
||||
addressCountry: "DE",
|
||||
},
|
||||
founder: {
|
||||
"@type": "Person",
|
||||
name: SITE_NAME,
|
||||
jobTitle: "Freelance developer",
|
||||
url: SITE_URL,
|
||||
},
|
||||
knowsAbout: [
|
||||
"Web applications",
|
||||
"User interfaces",
|
||||
"Websites",
|
||||
"Process automation",
|
||||
"Web3 frontends",
|
||||
],
|
||||
} as const;
|
||||
|
||||
/** Serialized server-side from constants only; "<" is escaped to keep it inert inside a script tag. */
|
||||
|
||||
@@ -3,6 +3,12 @@ import { parseEmailParts, type EmailParts } from "@/src/domain/email";
|
||||
export const SITE_NAME = "Marc Mintel";
|
||||
export const SITE_URL = "https://mintel.me";
|
||||
|
||||
/** One wording for the browser title, search results and link previews. */
|
||||
export const SITE_TITLE =
|
||||
"Marc Mintel — Freelance developer for web apps and websites";
|
||||
export const SITE_DESCRIPTION =
|
||||
"Freelance developer with 15 years of experience. I build web apps, interfaces and websites, and automate processes between your systems.";
|
||||
|
||||
// The contact address, kept in parts on purpose: the complete address must never
|
||||
// appear as one string in the page source (see src/components/EmailLink.tsx).
|
||||
const parsedEmail = parseEmailParts({
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
/**
|
||||
* Loads the Inter fonts for use in Satori (Next.js OG Image generation).
|
||||
* Since we are using runtime = 'nodejs', we can read them from the filesystem.
|
||||
*/
|
||||
export async function getOgFonts() {
|
||||
// Use relative path from process.cwd() which usually is the monorepo root or apps/web
|
||||
// We try both to be safe during different execution contexts (dev vs build vs runtime)
|
||||
const pathsToTry = [
|
||||
join(process.cwd(), "apps/web/public/fonts"),
|
||||
join(process.cwd(), "public/fonts"),
|
||||
];
|
||||
|
||||
let boldFontPath = "";
|
||||
let regularFontPath = "";
|
||||
|
||||
for (const basePath of pathsToTry) {
|
||||
const b = join(basePath, "Inter-Bold.woff");
|
||||
const r = join(basePath, "Inter-Regular.woff");
|
||||
try {
|
||||
if (readFileSync(b) && readFileSync(r)) {
|
||||
boldFontPath = b;
|
||||
regularFontPath = r;
|
||||
break;
|
||||
}
|
||||
} catch (_e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!boldFontPath || !regularFontPath) {
|
||||
console.error(
|
||||
`[OG] Could not find fonts in either ${pathsToTry.join(" or ")}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`[OG] Loading fonts: bold=${boldFontPath}, regular=${regularFontPath}`,
|
||||
);
|
||||
const boldFont = readFileSync(boldFontPath);
|
||||
const regularFont = readFileSync(regularFontPath);
|
||||
console.log(
|
||||
`[OG] Fonts loaded successfully (${boldFont.length} and ${regularFont.length} bytes)`,
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
name: "Inter",
|
||||
data: boldFont,
|
||||
weight: 700 as const,
|
||||
style: "normal" as const,
|
||||
},
|
||||
{
|
||||
name: "Inter",
|
||||
data: regularFont,
|
||||
weight: 400 as const,
|
||||
style: "normal" as const,
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
console.error(`[OG] Failed to load fonts:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common configuration for OG images
|
||||
*/
|
||||
export const OG_IMAGE_SIZE = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
};
|
||||
Reference in New Issue
Block a user