diff --git a/apps/web/app/api/og/[[...slug]]/route.tsx b/apps/web/app/api/og/[[...slug]]/route.tsx index 8a36f60..8279bc6 100644 --- a/apps/web/app/api/og/[[...slug]]/route.tsx +++ b/apps/web/app/api/og/[[...slug]]/route.tsx @@ -1,5 +1,6 @@ import { ImageResponse } from "next/og"; import { blogPosts } from "../../../../src/data/blogPosts"; +import { blogThumbnails } from "../../../../src/data/blogThumbnails"; import { OGImageTemplate } from "../../../../src/components/OGImageTemplate"; import { getOgFonts, OG_IMAGE_SIZE } from "../../../../src/lib/og-helper"; @@ -15,6 +16,8 @@ export async function GET( let title: string; let description: string; let label: string | undefined; + let accentColor: string | undefined; + let keyword: string | undefined; if (slug === "home") { title = "Marc Mintel"; @@ -23,17 +26,26 @@ export async function GET( label = "Engineering"; } else { const post = blogPosts.find((p) => p.slug === slug); + const thumbnail = blogThumbnails[slug]; title = post?.title || "Marc Mintel"; description = post?.description || "Technical problem solver's blog - practical insights and learning notes"; label = post ? "Blog Post" : "Engineering"; + accentColor = thumbnail?.accent; + keyword = thumbnail?.keyword; } const fonts = await getOgFonts(); return new ImageResponse( - , + , { ...OG_IMAGE_SIZE, fonts: fonts as any, diff --git a/apps/web/app/blog/[slug]/page.tsx b/apps/web/app/blog/[slug]/page.tsx index b8258ce..7436b9e 100644 --- a/apps/web/app/blog/[slug]/page.tsx +++ b/apps/web/app/blog/[slug]/page.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { blogPosts } from "../../../src/data/blogPosts"; import { BlogPostHeader } from "../../../src/components/blog/BlogPostHeader"; @@ -15,6 +16,41 @@ export async function generateStaticParams() { })); } +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }>; +}): Promise { + const { slug } = await params; + const post = blogPosts.find((p) => p.slug === slug); + + if (!post) return {}; + + return { + title: post.title, + description: post.description, + openGraph: { + title: post.title, + description: post.description, + type: "article", + images: [ + { + url: `/api/og/${slug}`, + width: 1200, + height: 630, + alt: post.title, + }, + ], + }, + twitter: { + card: "summary_large_image", + title: post.title, + description: post.description, + images: [`/api/og/${slug}`], + }, + }; +} + export default async function BlogPostPage({ params, }: { diff --git a/apps/web/src/components/MediumCard.tsx b/apps/web/src/components/MediumCard.tsx index ef3e902..76b54fc 100644 --- a/apps/web/src/components/MediumCard.tsx +++ b/apps/web/src/components/MediumCard.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import Link from "next/link"; import { Card } from "./Layout"; import { ArrowRight } from "lucide-react"; +import { BlogThumbnailSVG } from "./blog/BlogThumbnailSVG"; interface Post { title: string; @@ -31,35 +32,46 @@ export const MediumCard: React.FC = ({ post }) => { techBorder={false} className="relative overflow-hidden transition-all duration-300 border-slate-100 hover:border-slate-300 bg-white/30 backdrop-blur-sm p-5 md:p-6" > -
-
- -
- {tags?.slice(0, 2).map((tag) => ( - - #{tag} - - ))} +
+ {/* Thumbnail */} +
+ +
+ +
+
+ +
+ {tags?.slice(0, 2).map((tag) => ( + + #{tag} + + ))} +
-
-
-

- {title} -

-

- {description} -

-
+
+

+ {title} +

+

+ {description} +

+
-
- Beitrag öffnen - +
+ Beitrag öffnen + +
diff --git a/apps/web/src/components/OGImageTemplate.tsx b/apps/web/src/components/OGImageTemplate.tsx index 4ccfc0d..3db1300 100644 --- a/apps/web/src/components/OGImageTemplate.tsx +++ b/apps/web/src/components/OGImageTemplate.tsx @@ -4,16 +4,19 @@ interface OGImageTemplateProps { title: string; description?: string; label?: string; + accentColor?: string; + keyword?: string; } export function OGImageTemplate({ title, description, label, + accentColor, + keyword, }: OGImageTemplateProps) { - const accentBlue = "#3b82f6"; + const accent = accentColor || "#3b82f6"; const slateDark = "#0f172a"; - const slateText = "#1e293b"; const slateLight = "#64748b"; const containerStyle: React.CSSProperties = { @@ -31,16 +34,71 @@ export function OGImageTemplate({ return (
- {/* Background Technographic Accent */} + {/* Background Grid Pattern */}
+ + {/* Accent geometric block (right side) */} +
+
+ {/* Small accent circles */} +
+
@@ -51,15 +109,16 @@ export function OGImageTemplate({ flexDirection: "column", position: "relative", zIndex: 10, + maxWidth: "850px", }} > {/* Label / Category */} {label && (
40 ? "64px" : "82px", + fontSize: title.length > 40 ? "56px" : "72px", fontWeight: 700, color: slateDark, lineHeight: "1.1", - maxWidth: "950px", - marginBottom: "32px", + marginBottom: "28px", display: "flex", - letterSpacing: "-0.025em", + letterSpacing: "-0.03em", }} > {title} @@ -90,16 +148,15 @@ export function OGImageTemplate({ {description && (
- {description.length > 160 - ? description.substring(0, 157) + "..." + {description.length > 120 + ? description.substring(0, 117) + "..." : description}
)} @@ -109,7 +166,7 @@ export function OGImageTemplate({
- {/* Blue Brand Strip */} + {/* Keyword badge (bottom-right) */} + {keyword && ( +
+
+
+ {keyword} +
+
+ )} + + {/* Accent Strip */}
diff --git a/apps/web/src/components/blog/BlogThumbnailSVG.tsx b/apps/web/src/components/blog/BlogThumbnailSVG.tsx new file mode 100644 index 0000000..5f563a9 --- /dev/null +++ b/apps/web/src/components/blog/BlogThumbnailSVG.tsx @@ -0,0 +1,1111 @@ +import React from "react"; +import type { + ThumbnailIcon, + BlogThumbnailConfig, +} from "../../data/blogThumbnails"; +import { blogThumbnails } from "../../data/blogThumbnails"; + +interface BlogThumbnailSVGProps { + slug: string; + variant?: "square" | "banner"; + className?: string; +} + +// Grid pattern used in the background +const GridPattern: React.FC<{ size: number }> = ({ size }) => ( + + + + + +); + +// ─── Icon Renderers ─────────────────────────────────────────────── + +function renderGauge(cx: number, cy: number, accent: string) { + const r = 52; + // Arc from ~210° to ~330° (lower half open) + const startAngle = (210 * Math.PI) / 180; + const endAngle = (330 * Math.PI) / 180; + const x1 = cx + r * Math.cos(startAngle); + const y1 = cy + r * Math.sin(startAngle); + const x2 = cx + r * Math.cos(endAngle); + const y2 = cy + r * Math.sin(endAngle); + // Needle at ~280° (pointing upper-right = fast/danger zone) + const needleAngle = (280 * Math.PI) / 180; + const nx = cx + (r - 14) * Math.cos(needleAngle); + const ny = cy + (r - 14) * Math.sin(needleAngle); + + return ( + + + + + + {/* Tick marks */} + {[210, 240, 270, 300, 330].map((deg) => { + const rad = (deg * Math.PI) / 180; + const tx1 = cx + (r + 6) * Math.cos(rad); + const ty1 = cy + (r + 6) * Math.sin(rad); + const tx2 = cx + (r + 12) * Math.cos(rad); + const ty2 = cy + (r + 12) * Math.sin(rad); + return ( + + ); + })} + + ); +} + +function renderBottleneck(cx: number, cy: number, accent: string) { + return ( + + {/* Wide top */} + + {/* Narrow middle (bottleneck) */} + + + {/* Flow lines */} + {[-20, 0, 20].map((offset) => ( + + ))} + + {/* Wide bottom */} + + {/* Arrow down through throttle */} + + + ); +} + +function renderPlugin(cx: number, cy: number, accent: string) { + const s = 28; + return ( + + {/* Connected piece 1 */} + + {/* Connected piece 2 */} + + {/* Connected piece 3 */} + + {/* Disconnected piece (offset) */} + + {/* Connector dots */} + + + {/* Warning */} + + ! + + + ); +} + +function renderShield(cx: number, cy: number, accent: string) { + return ( + + + + {/* Checkmark */} + + + ); +} + +function renderCookie(cx: number, cy: number, accent: string) { + return ( + + {/* Cookie circle */} + + {/* Chips */} + + + + + {/* Strikethrough */} + + + ); +} + +function renderCloud(cx: number, cy: number, accent: string) { + return ( + + + {/* Lock icon inside cloud */} + + + + + ); +} + +function renderLock(cx: number, cy: number, accent: string) { + return ( + + {/* Lock body */} + + {/* Lock shackle */} + + {/* Keyhole */} + + + {/* Chain links */} + {[-42, 42].map((offset) => ( + + + 0 ? 14 : -14)} + cy={cy + 12} + rx="10" + ry="6" + fill="none" + stroke="#cbd5e1" + strokeWidth="1.5" + /> + + ))} + + ); +} + +function renderChart(cx: number, cy: number, accent: string) { + const barWidth = 14; + const heights = [30, 50, 38, 55, 42]; + const baseY = cy + 35; + return ( + + {/* Bars */} + {heights.map((h, i) => ( + + ))} + {/* Base line */} + + {/* Eye with strikethrough */} + + + + + ); +} + +function renderLeaf(cx: number, cy: number, accent: string) { + return ( + + {/* Leaf shape */} + + + {/* Stem/vein */} + + {/* Circuit nodes on leaf veins */} + {[-20, 0, 20].map((offset, i) => ( + + + + + ))} + + ); +} + +function renderPrice(cx: number, cy: number, accent: string) { + return ( + + {/* Tag shape */} + + {/* Hole */} + + {/* Equals sign */} + + + + ); +} + +function renderPrototype(cx: number, cy: number, accent: string) { + return ( + + {/* Browser frame */} + + {/* Title bar */} + + {/* Dots */} + + + + {/* Wireframe lines */} + + + + {/* Accent wireframe block */} + + + + ); +} + +function renderGear(cx: number, cy: number, accent: string) { + const teeth = 8; + const innerR = 24; + const outerR = 36; + const toothWidth = 0.2; + + let d = ""; + for (let i = 0; i < teeth; i++) { + const angle = (i * 2 * Math.PI) / teeth; + const a1 = angle - toothWidth; + const a2 = angle + toothWidth; + const midAngle = (a1 + a2) / 2; + + if (i === 0) { + d += `M ${cx + outerR * Math.cos(a1)} ${cy + outerR * Math.sin(a1)} `; + } + d += `L ${cx + outerR * Math.cos(a2)} ${cy + outerR * Math.sin(a2)} `; + + const nextAngle = ((i + 1) * 2 * Math.PI) / teeth; + const na1 = nextAngle - toothWidth; + d += `L ${cx + innerR * Math.cos(a2)} ${cy + innerR * Math.sin(a2)} `; + d += `L ${cx + innerR * Math.cos(na1)} ${cy + innerR * Math.sin(na1)} `; + d += `L ${cx + outerR * Math.cos(na1)} ${cy + outerR * Math.sin(na1)} `; + } + d += "Z"; + + return ( + + + + {/* Infinity loop */} + + + ); +} + +function renderHourglass(cx: number, cy: number, accent: string) { + return ( + + {/* Top triangle */} + + + {/* Bottom triangle */} + + {/* "Sand" fill in bottom */} + + {/* Top and bottom lines */} + + + {/* Circuit nodes as "digital sand" */} + {[10, 22, 34].map((y, i) => ( + + ))} + + ); +} + +function renderCode(cx: number, cy: number, accent: string) { + return ( + + {/* < */} + + {/* / */} + + {/* > */} + + + ); +} + +function renderResponsive(cx: number, cy: number, accent: string) { + return ( + + {/* Outer desktop */} + + {/* Tablet */} + + {/* Phone */} + + {/* Phone screen */} + + {/* Phone home button */} + + + ); +} + +function renderServer(cx: number, cy: number, accent: string) { + const unitH = 22; + return ( + + {/* Server units */} + {[0, 1, 2].map((i) => ( + + + {/* Status LEDs */} + + + {/* Drive bays */} + {[0, 1, 2, 3].map((j) => ( + + ))} + + ))} + {/* Signal waves */} + {[10, 18, 26].map((r, i) => ( + + ))} + + ); +} + +function renderTemplate(cx: number, cy: number, accent: string) { + return ( + + {/* Grid layout */} + {[0, 1, 2, 3].map((i) => { + const row = Math.floor(i / 2); + const col = i % 2; + return ( + + ); + })} + {/* Strikethrough diagonal */} + + + + ); +} + +function renderSync(cx: number, cy: number, accent: string) { + const r = 36; + return ( + + {/* Circular arrows */} + + + {/* Arrowheads */} + + + {/* Data dots flowing */} + + + {/* Center node */} + + + + ); +} + +// ─── Icon dispatcher ────────────────────────────────────────────── + +const iconRenderers: Record< + ThumbnailIcon, + (cx: number, cy: number, accent: string) => React.ReactNode +> = { + gauge: renderGauge, + bottleneck: renderBottleneck, + plugin: renderPlugin, + shield: renderShield, + cookie: renderCookie, + cloud: renderCloud, + lock: renderLock, + chart: renderChart, + leaf: renderLeaf, + price: renderPrice, + prototype: renderPrototype, + gear: renderGear, + hourglass: renderHourglass, + code: renderCode, + responsive: renderResponsive, + server: renderServer, + template: renderTemplate, + sync: renderSync, +}; + +// ─── Main Component ────────────────────────────────────────────── + +export const BlogThumbnailSVG: React.FC = ({ + slug, + variant = "square", + className, +}) => { + const config = blogThumbnails[slug]; + if (!config) return null; + + const isBanner = variant === "banner"; + const vbWidth = isBanner ? 480 : 240; + const vbHeight = isBanner ? 160 : 240; + + // For banner, we shift the icon more to the right + const iconCx = isBanner ? 340 : vbWidth / 2; + const iconCy = vbHeight / 2; + + return ( + + {/* Background */} + + + + + {/* Accent strip at top */} + + + {/* Decorative circuit lines */} + + + {/* Corner markers */} + + + + + + + + {/* Icon - Scale down for banner to fit height better */} + + {iconRenderers[config.icon](iconCx, iconCy, config.accent)} + + + {/* Keyword label */} + + {config.keyword} + + + {/* Accent dot */} + + + ); +}; diff --git a/apps/web/src/data/blogThumbnails.ts b/apps/web/src/data/blogThumbnails.ts new file mode 100644 index 0000000..ed72f5c --- /dev/null +++ b/apps/web/src/data/blogThumbnails.ts @@ -0,0 +1,139 @@ +export type ThumbnailIcon = + | "gauge" + | "bottleneck" + | "plugin" + | "shield" + | "cookie" + | "cloud" + | "lock" + | "chart" + | "leaf" + | "price" + | "prototype" + | "gear" + | "hourglass" + | "code" + | "responsive" + | "server" + | "template" + | "sync"; + +export interface BlogThumbnailConfig { + icon: ThumbnailIcon; + accent: string; + keyword: string; +} + +/** + * Mapping of blog post slugs to their unique thumbnail configuration. + * Each entry defines the abstract SVG illustration style for a given post. + */ +export const blogThumbnails: Record = { + // Group 1: Pain Points & Troubleshooting + "why-pagespeed-fails": { + icon: "gauge", + accent: "#ef4444", + keyword: "SPEED", + }, + "slow-loading-costs-customers": { + icon: "gauge", + accent: "#f97316", + keyword: "LATENCY", + }, + "why-agencies-are-slow": { + icon: "bottleneck", + accent: "#8b5cf6", + keyword: "PROCESS", + }, + "hidden-costs-of-wordpress-plugins": { + icon: "plugin", + accent: "#ec4899", + keyword: "PLUGINS", + }, + "why-websites-break-after-updates": { + icon: "shield", + accent: "#f59e0b", + keyword: "STABILITY", + }, + + // Group 2: Sovereignty & Law + "website-without-cookie-banners": { + icon: "cookie", + accent: "#10b981", + keyword: "PRIVACY", + }, + "no-us-cloud-platforms": { + icon: "cloud", + accent: "#3b82f6", + keyword: "SOVEREIGN", + }, + "gdpr-conformity-system-approach": { + icon: "shield", + accent: "#06b6d4", + keyword: "DSGVO", + }, + "builder-systems-threaten-independence": { + icon: "lock", + accent: "#f43f5e", + keyword: "LOCK-IN", + }, + "analytics-without-tracking": { + icon: "chart", + accent: "#8b5cf6", + keyword: "ANALYTICS", + }, + + // Group 3: Efficiency & Investment + "fast-website-carbon-footprint": { + icon: "leaf", + accent: "#22c55e", + keyword: "GREEN", + }, + "fixed-price-vs-hourly-rate": { + icon: "price", + accent: "#0ea5e9", + keyword: "PRICING", + }, + "build-first-talk-later": { + icon: "prototype", + accent: "#a855f7", + keyword: "PROTOTYPE", + }, + "maintenance-without-cms": { + icon: "gear", + accent: "#64748b", + keyword: "MAINTAIN", + }, + "timeless-websites": { + icon: "hourglass", + accent: "#0d9488", + keyword: "LONGEVITY", + }, + + // Group 4: Tech & Craft + "clean-code-success": { + icon: "code", + accent: "#2563eb", + keyword: "QUALITY", + }, + "responsive-design-scaling": { + icon: "responsive", + accent: "#7c3aed", + keyword: "ADAPTIVE", + }, + "hosting-and-operation": { + icon: "server", + accent: "#475569", + keyword: "INFRA", + }, + "no-ready-made-templates": { + icon: "template", + accent: "#e11d48", + keyword: "CUSTOM", + }, + "seamless-crm-sync": { + icon: "sync", + accent: "#0891b2", + keyword: "SYNC", + }, +};