fix(blog): optimize component share logic, typography, and modal layouts
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Failing after 14s
Build & Deploy / 🧪 QA (push) Failing after 1m48s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s

This commit is contained in:
2026-02-22 11:41:28 +01:00
parent 75c61f1436
commit b15c8408ff
103 changed files with 4366 additions and 2293 deletions

View File

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

View File

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

View File

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 77 KiB

View File

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

View File

@@ -1,14 +1,14 @@
"use client";
import React, { useEffect, Suspense } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { useSafePathname, useSafeSearchParams } from "./analytics/useSafePathname";
import { ScrollDepthTracker } from "./analytics/ScrollDepthTracker";
import { getDefaultAnalytics } from "../utils/analytics";
import { getDefaultErrorTracking } from "../utils/error-tracking";
const AnalyticsInner: React.FC = () => {
const pathname = usePathname();
const searchParams = useSearchParams();
const pathname = useSafePathname();
const searchParams = useSafeSearchParams();
// Track pageviews on route change
useEffect(() => {

View File

@@ -1,5 +1,7 @@
import React from 'react';
import Prism from 'prismjs';
import { ComponentShareButton } from './ComponentShareButton';
import { Reveal } from './Reveal';
import 'prismjs/components/prism-python';
import 'prismjs/components/prism-typescript';
import 'prismjs/components/prism-javascript';
@@ -19,18 +21,50 @@ interface BlockquoteProps {
className?: string;
}
export const ArticleBlockquote: React.FC<BlockquoteProps> = ({ children, className = '' }) => (
<div className={`not-prose my-16 py-8 border-y-2 border-slate-900 grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-8 ${className}`}>
<div className="md:col-span-1 flex md:items-start md:justify-end pt-2">
<svg className="w-8 h-8 md:w-10 md:h-10 text-emerald-500" viewBox="0 0 24 24" fill="currentColor"><path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z" /></svg>
</div>
<blockquote className="md:col-span-11 relative flex items-center">
<div className="text-2xl md:text-3xl font-serif text-slate-900 italic leading-[1.4] md:leading-snug tracking-tight m-0">
{children}
</div>
</blockquote>
</div>
);
export const ArticleBlockquote: React.FC<BlockquoteProps> = ({ children, className = '' }) => {
// Generate a quick stable hash based on content length/chars for ID
const shareId = `blockquote-${Math.random().toString(36).substring(7).toUpperCase()}`;
return (
<Reveal direction="up" delay={0.1}>
<figure
id={shareId}
className={`not-prose my-16 group relative transition-all duration-500 ease-out z-10 ${className}`}
>
<div className="absolute -inset-1 bg-gradient-to-r from-emerald-100/50 to-emerald-50/50 rounded-[2rem] blur opacity-20 group-hover:opacity-40 transition duration-1000 -z-10" />
<div className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl shadow-sm group-hover:shadow-md group-hover:border-slate-200 transition-all duration-500 overflow-hidden relative p-8 md:p-12">
{/* Subtle top shine */}
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white to-transparent opacity-80" />
{/* Share Button top right */}
<div className="absolute top-4 right-4 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500">
<ComponentShareButton targetId={shareId} title="Zitat" />
</div>
<div className="flex flex-col md:flex-row gap-6 md:gap-8 items-start relative z-10">
<div className="shrink-0">
<div className="w-12 h-12 md:w-16 md:h-16 bg-slate-50 border border-slate-100 rounded-xl flex items-center justify-center group-hover:scale-110 group-hover:bg-white group-hover:border-emerald-200 transition-all duration-500 shadow-sm relative overflow-hidden">
{/* Small emerald glow effect inside the icon box on hover */}
<div className="absolute inset-0 bg-emerald-500/0 group-hover:bg-emerald-500/5 transition-colors duration-500" />
<svg className="w-6 h-6 md:w-8 md:h-8 text-slate-300 group-hover:text-emerald-500 transition-colors duration-500 relative z-10" viewBox="0 0 24 24" fill="currentColor">
<path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z" />
</svg>
</div>
</div>
<blockquote className="flex-1 relative">
<div className="text-xl md:text-2xl lg:text-3xl font-serif text-slate-800 italic leading-relaxed md:leading-[1.4] tracking-tight m-0">
{children}
</div>
</blockquote>
</div>
</div>
</figure>
</Reveal>
);
};
interface CodeBlockProps {
code?: string;

View File

@@ -3,26 +3,52 @@ import React from "react";
interface HeadingProps {
children: React.ReactNode;
className?: string;
id?: string;
}
export const H1: React.FC<HeadingProps> = ({ children, className = "" }) => (
const getTextContent = (node: React.ReactNode): string => {
if (typeof node === "string") return node;
if (typeof node === "number") return String(node);
if (React.isValidElement(node)) return getTextContent((node.props as any).children);
if (Array.isArray(node)) return node.map(getTextContent).join("");
return "";
};
const charMap: Record<string, string> = { "ä": "ae", "ö": "oe", "ü": "ue" };
const generateId = (children: React.ReactNode): string => {
const text = getTextContent(children);
return text
.toLowerCase()
.replace(/[äöü]/g, (c) => charMap[c] ?? c)
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
};
export const H1: React.FC<HeadingProps> = ({ children, className = "", id }) => (
<h1
id={id || generateId(children)}
className={`not-prose text-3xl md:text-5xl font-bold text-slate-900 mb-8 mt-12 leading-[1.1] tracking-tight font-sans ${className}`}
>
{children}
</h1>
);
export const H2: React.FC<HeadingProps> = ({ children, className = "" }) => (
<h2
className={`not-prose text-2xl md:text-4xl font-bold text-slate-900 mb-6 mt-10 leading-tight tracking-tight font-sans ${className}`}
>
{children}
</h2>
);
export const H2: React.FC<HeadingProps> = ({ children, className = "", id }) => {
const generatedId = id || generateId(children);
return (
<h2
id={generatedId}
className={`not-prose text-2xl md:text-4xl font-bold text-slate-900 mb-6 mt-10 leading-tight tracking-tight font-sans ${className}`}
>
{children}
</h2>
);
};
export const H3: React.FC<HeadingProps> = ({ children, className = "" }) => (
export const H3: React.FC<HeadingProps> = ({ children, className = "", id }) => (
<h3
id={id || generateId(children)}
className={`not-prose text-xl md:text-2xl font-bold text-slate-900 mb-4 mt-8 leading-snug tracking-tight font-sans ${className}`}
>
{children}

View File

@@ -1,56 +1,120 @@
"use client";
import React from 'react';
import { ComponentShareButton } from './ComponentShareButton';
import { Reveal } from './Reveal';
interface ArticleMemeProps {
template: string;
captions: string[];
/** Pipe-delimited captions, e.g. "top text|bottom text" */
captions: string;
className?: string;
}
export const ArticleMeme: React.FC<ArticleMemeProps> = ({ template, captions, className = '' }) => {
/**
* Encode a caption string for the memegen.link URL format.
*/
function encodeMemeCaption(text: string): string {
if (!text || text.trim() === '') return '_';
return text
.trim()
.replace(/_/g, "__")
.replace(/-/g, "--")
.replace(/\n/g, "~q")
.replace(/\?/g, "~q")
.replace(/&/g, "~a")
.replace(/%/g, "~p")
.replace(/#/g, "~h")
.replace(/\//g, "~s")
.replace(/\\/g, "~b")
.replace(/<(?:\/?[a-z0-9]+)*>/gi, "")
.replace(/\s+/g, "_")
.replace(/€/g, "Euro") // Replace Euro sign
.replace(/['"„“‚‘]/g, "") // Remove quotes
.replace(/[^a-zA-Z0-9_\-~äöüÄÖÜß.,!]/g, ""); // Remove other special chars
}
const TEMPLATE_MAP: Record<string, string> = {
'drake': 'drake',
'drake hotline bling': 'drake',
'distracted boyfriend': 'db',
'distracted': 'db',
'expanding brain': 'gb',
'expanding': 'gb',
'gb': 'gb',
'this is fine': 'fine',
'fine': 'fine',
'clown': 'gb',
'clown applying makeup': 'gb',
'two buttons': 'ds',
'daily struggle': 'ds',
'ds': 'ds',
'gru': 'gru',
'change my mind': 'cmm',
'always has been': 'ahb',
'uno reverse': 'uno',
'disaster girl': 'disastergirl',
'is this a pigeon': 'pigeon',
'roll safe': 'rollsafe',
'rollsafe': 'rollsafe',
'surprised pikachu': 'pikachu',
'batman slapping robin': 'slap',
'left exit 12': 'exit',
'one does not simply': 'mordor',
'panik kalm panik': 'panik-kalm-panik',
};
function slugifyTemplate(template: string): string {
if (!template) return 'drake';
const normalized = template.toLowerCase().trim();
const validIds = new Set(Object.values(TEMPLATE_MAP));
// Check if it's already a valid known ID
if (validIds.has(normalized)) return normalized;
// Check case-insensitive map
for (const key of Object.keys(TEMPLATE_MAP)) {
if (key === normalized) return TEMPLATE_MAP[key];
}
// Fallback: don't randomly slugify since memegen might 404. Force known good.
return 'drake';
}
function buildMemeUrl(template: string, captions: string[]): string {
const slug = slugifyTemplate(template);
const encoded = captions.map(encodeMemeCaption);
return `https://api.memegen.link/images/${slug}/${encoded.join('/')}.png`;
}
export const ArticleMeme: React.FC<ArticleMemeProps & { image?: string }> = ({ template, captions, image, className = '' }) => {
const captionList = (captions || '').split('|').map(s => s.trim()).filter(Boolean);
const imageUrl = image || buildMemeUrl(template, captionList);
const shareId = `artmeme-${React.useId().replace(/:/g, "")}`;
return (
<div className={`not-prose relative overflow-hidden group rounded-2xl border border-slate-200 bg-white shadow-xl max-w-2xl mx-auto my-12 ${className}`}>
{/* Meme "Image" Placeholder with Industrial Styling */}
<div className="aspect-video bg-slate-900 flex flex-col items-center justify-between p-8 text-center relative overflow-hidden">
{/* Decorative Grid */}
<div className="absolute inset-0 opacity-10 pointer-events-none"
style={{ backgroundImage: 'radial-gradient(#ffffff 1px, transparent 1px)', backgroundSize: '20px 20px' }} />
<Reveal direction="up" delay={0.1}>
<div id={shareId} className={`not-prose max-w-xl mx-auto my-16 group relative transition-all duration-500 ease-out z-10 ${className}`}>
{/* Ambient Glow */}
<div className="absolute -inset-1 bg-gradient-to-r from-red-100/30 to-slate-100/30 rounded-[2rem] blur opacity-10 group-hover:opacity-30 transition duration-1000 -z-10" />
{/* Top Caption */}
<div className="relative z-10 w-full">
<h4 className="text-white text-3xl md:text-4xl font-black uppercase tracking-tighter leading-none [text-shadow:_2px_2px_0_rgb(0_0_0_/_80%)]">
{captions[0]}
</h4>
</div>
<div className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl overflow-hidden shadow-sm shadow-slate-200 group-hover:shadow-md transition-all duration-500 relative">
{/* Share Button */}
<div className="absolute top-4 right-4 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-50">
<ComponentShareButton targetId={shareId} title={`Meme: ${template}`} />
</div>
{/* Meme Figure/Avatar Placeholder */}
<div className="relative z-10 w-32 h-32 md:w-40 md:h-40 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-full flex items-center justify-center border-4 border-white transform group-hover:scale-105 transition-transform duration-500 shadow-2xl">
<span className="text-5xl">🤖</span>
</div>
{/* Bottom Caption */}
<div className="relative z-10 w-full">
<h4 className="text-white text-2xl md:text-3xl font-bold uppercase tracking-tight leading-none [text-shadow:_1px_1px_0_rgb(0_0_0_/_80%)]">
{captions[1] || ''}
</h4>
<img
src={imageUrl}
alt={captionList.length > 0 ? captionList.join(' / ') : template}
className="w-full h-auto block"
loading="lazy"
/>
</div>
</div>
{/* Meme Footer / Metadata */}
<div className="p-4 bg-slate-50 flex items-center justify-between border-t border-slate-100">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded bg-slate-200 flex items-center justify-center text-[10px] font-mono text-slate-500 uppercase tracking-widest font-bold">
AI
</div>
<div>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest leading-none">Meme Template</p>
<p className="text-xs font-bold text-slate-900 mt-1 uppercase">{template}</p>
</div>
</div>
<div className="text-slate-300">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M15 3h6v6" /><path d="M10 14 21 3" /><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></svg>
</div>
</div>
</div>
</Reveal>
);
};

View File

@@ -1,4 +1,7 @@
import React from 'react';
import { Quote } from 'lucide-react';
import { ComponentShareButton } from './ComponentShareButton';
import { Reveal } from './Reveal';
interface ArticleQuoteProps {
quote: string;
@@ -6,9 +9,7 @@ interface ArticleQuoteProps {
role?: string;
source?: string;
sourceUrl?: string;
/** If true, shows a "Translated" badge */
translated?: boolean;
/** If true, treats the author as a company/brand (shows entity icon instead of initials) */
isCompany?: boolean;
className?: string;
}
@@ -23,41 +24,65 @@ export const ArticleQuote: React.FC<ArticleQuoteProps> = ({
isCompany,
className = '',
}) => {
// Generate a stable ID based on author for sharing
const shareId = `quote-${author.toLowerCase().replace(/[^a-z0-9]/g, '-')}`;
return (
<figure className={`not-prose my-20 ${className}`}>
<div className="grid grid-cols-1 md:grid-cols-12 gap-0 md:gap-12 border-t-2 border-slate-900 pt-8 mt-12 mb-12">
{/* Meta column (left side on desktop) */}
<div className="md:col-span-4 lg:col-span-3 pb-8 md:pb-0 border-b md:border-b-0 border-slate-200 mb-8 md:mb-0 md:pr-8 md:border-r">
<div className="flex flex-row md:flex-col items-center md:items-start gap-4 md:gap-6">
{isCompany ? (
<div className="w-16 h-16 bg-slate-900 flex items-center justify-center shrink-0">
<svg className="w-8 h-8 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>
</div>
) : (
<div className="w-16 h-16 bg-slate-100 flex items-center justify-center shrink-0 text-lg font-bold text-slate-900 font-serif border border-slate-200">
{(author || '').split(' ').map(w => w[0]).join('').slice(0, 2)}
</div>
)}
<figcaption className="flex flex-col gap-1 w-full md:mt-1">
<span className="font-sans font-black text-lg text-slate-900 tracking-tight leading-none uppercase">{author}</span>
{role && <span className="font-mono text-[10px] text-slate-500 uppercase tracking-widest mt-1">{role}</span>}
{translated && <span className="inline-block px-1.5 py-0.5 border border-slate-300 text-[9px] font-mono text-slate-600 uppercase tracking-widest w-fit mt-2">Translated</span>}
{sourceUrl && (
<a href={sourceUrl} target="_blank" rel="noreferrer" className="mt-4 md:mt-6 inline-block font-sans text-[11px] font-black uppercase tracking-[0.2em] text-slate-900 hover:text-emerald-600 decoration-2 underline-offset-4 decoration-emerald-200 hover:decoration-emerald-500 underline transition-all">
{source || 'Source'} &rarr;
</a>
<Reveal direction="up" delay={0.1}>
<figure
id={shareId}
className={`not-prose my-16 group relative transition-all duration-500 ease-out z-10 ${className}`}
>
{/* Ambient Background Glow matching the homepage feel */}
<div className="absolute -inset-1 bg-gradient-to-r from-slate-200 to-slate-100 rounded-[2rem] blur opacity-20 group-hover:opacity-40 transition duration-1000 -z-10" />
<div className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl shadow-sm group-hover:shadow-md group-hover:border-slate-200 transition-all duration-500 overflow-hidden relative">
{/* Subtle top shine */}
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white to-transparent opacity-80" />
<div className="p-8 md:p-12 relative z-10 flex flex-col md:flex-row gap-8 md:gap-12 items-start">
{/* Share Button top right */}
<div className="absolute top-4 right-4 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500">
<ComponentShareButton targetId={shareId} title={`Zitat von ${author}`} />
</div>
{/* Author Meta (Left Rail) */}
<div className="md:w-1/3 flex flex-row md:flex-col items-center md:items-start gap-4 md:gap-6 shrink-0 pt-2">
{isCompany ? (
<div className="w-12 h-12 md:w-16 md:h-16 bg-slate-50 border border-slate-100 rounded-xl flex items-center justify-center shrink-0 group-hover:scale-110 group-hover:bg-white transition-transform duration-500">
<svg className="w-6 h-6 md:w-8 md:h-8 text-slate-700" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>
</div>
) : (
<div className="w-12 h-12 md:w-16 md:h-16 bg-slate-50 border border-slate-100 rounded-xl flex items-center justify-center shrink-0 group-hover:scale-110 group-hover:bg-white transition-transform duration-500">
<Quote className="w-5 h-5 md:w-6 md:h-6 text-slate-400" />
</div>
)}
</figcaption>
<div className="flex flex-col gap-2 mt-2 w-full">
{translated && (
<div className="inline-flex w-fit px-2 py-0.5 border border-slate-200 rounded-md text-[9px] font-mono text-slate-500 uppercase tracking-widest bg-slate-50">Übersetzt</div>
)}
{sourceUrl ? (
<a href={sourceUrl} target="_blank" rel="noreferrer" className="inline-flex w-fit items-center gap-1 text-[10px] font-mono font-bold uppercase tracking-widest text-blue-600 hover:text-blue-800 transition-colors group/link mt-1">
{source || 'Source'} <span className="group-hover/link:translate-x-0.5 transition-transform">&rarr;</span>
</a>
) : (
source && <span className="text-[10px] font-mono uppercase tracking-widest text-slate-400 mt-1">{source}</span>
)}
</div>
</div>
{/* Quote Content (Right Rail) */}
<blockquote className="md:w-3/4 border-l-[3px] border-emerald-500/20 pl-6 md:pl-10 py-1 relative mt-6 md:mt-0">
<Quote className="absolute -top-4 -left-4 w-12 h-12 text-slate-100 -z-10 rotate-180 opacity-50 pointer-events-none" />
<div className="text-xl md:text-3xl font-serif text-slate-800 leading-[1.4] m-0 italic [&>p]:inline [&>ul]:m-0 [&>ul]:list-none [&>ul>li]:m-0 [&>ul>li]:p-0">
{quote}
</div>
</blockquote>
</div>
</div>
{/* Quote column (right side) */}
<blockquote className="md:col-span-8 lg:col-span-9 flex items-center relative">
<p className="text-2xl md:text-3xl lg:text-4xl font-serif text-slate-900 italic leading-[1.3] tracking-tight m-0 before:content-['\201C'] after:content-['\201D']">
{quote}
</p>
</blockquote>
</div>
</figure>
</figure>
</Reveal>
);
};

View File

@@ -0,0 +1,137 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
interface BoldNumberProps {
/** The number to display, e.g. "53%" or "2.5M€" or "-20%" */
value: string;
/** Short description of what this number means */
label: string;
/** Source attribution */
source?: string;
/** Source URL */
sourceUrl?: string;
className?: string;
}
/**
* Premium hero number component — full-width, dark gradient, animated count-up.
* Designed for shareable key statistics that stand out in blog posts.
*/
export const BoldNumber: React.FC<BoldNumberProps> = ({
value,
label,
source,
sourceUrl,
className = '',
}) => {
const ref = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
const [displayValue, setDisplayValue] = useState('');
// Extract numeric part for animation
const numericMatch = value.match(/^([+-]?)(\d+(?:[.,]\d+)?)(.*)/);
const prefix = numericMatch?.[1] ?? '';
const numStr = numericMatch?.[2] ?? '';
const suffix = numericMatch?.[3] ?? value;
const targetNum = parseFloat(numStr.replace(',', '.')) || 0;
const hasDecimals = numStr.includes('.') || numStr.includes(',');
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ threshold: 0.3 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!isVisible || !numStr) {
setDisplayValue(value);
return;
}
const duration = 1200;
const steps = 40;
const stepTime = duration / steps;
let step = 0;
const timer = setInterval(() => {
step++;
const progress = Math.min(step / steps, 1);
// Ease out cubic
const eased = 1 - Math.pow(1 - progress, 3);
const current = targetNum * eased;
const formatted = hasDecimals ? current.toFixed(1) : Math.round(current).toString();
setDisplayValue(`${prefix}${formatted}${suffix}`);
if (step >= steps) {
clearInterval(timer);
setDisplayValue(value);
}
}, stepTime);
return () => clearInterval(timer);
}, [isVisible, value, prefix, suffix, targetNum, hasDecimals, numStr]);
const handleShare = async () => {
const shareText = `${value}${label}${source ? ` (${source})` : ''}`;
try {
if (navigator.share) {
await navigator.share({ text: shareText });
} else {
await navigator.clipboard.writeText(shareText);
}
} catch { /* user cancelled */ }
};
return (
<div
ref={ref}
className={`not-prose relative overflow-hidden rounded-2xl my-16 border border-slate-100 bg-slate-50/50 p-10 md:p-14 text-center ${className}`}
>
<div className="relative z-10">
<span className="block text-6xl md:text-8xl font-black tracking-tighter tabular-nums leading-none text-slate-900 pb-2">
{displayValue || value}
</span>
<span className="block mt-4 text-base md:text-lg font-medium text-slate-500 uppercase tracking-widest max-w-lg mx-auto">
{label}
</span>
{source && (
<span className="block mt-4 text-xs font-semibold text-slate-400">
{sourceUrl ? (
<a href={sourceUrl} target="_blank" rel="noopener noreferrer" className="hover:text-blue-600 transition-colors">
Quelle: {source}
</a>
) : (
`Quelle: ${source}`
)}
</span>
)}
</div>
{/* Share button - subtle now */}
<button
onClick={handleShare}
className="absolute top-4 right-4 z-20 p-2 rounded-lg text-slate-300 hover:text-blue-600 hover:bg-blue-50 transition-all cursor-pointer"
title="Teilen"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
<polyline points="16 6 12 2 8 6" />
<line x1="12" y1="2" x2="12" y2="15" />
</svg>
</button>
</div>
);
};

View File

@@ -0,0 +1,100 @@
'use client';
import React, { useRef, useState } from 'react';
interface CarouselItem {
title: string;
content: string;
icon?: React.ReactNode;
}
interface CarouselProps {
items: CarouselItem[];
className?: string;
}
export const Carousel: React.FC<CarouselProps> = ({ items, className = '' }) => {
const scrollRef = useRef<HTMLDivElement>(null);
const [activeIndex, setActiveIndex] = useState(0);
const scrollTo = (index: number) => {
if (!scrollRef.current) return;
const width = scrollRef.current.clientWidth;
scrollRef.current.scrollTo({ left: index * width, behavior: 'smooth' });
setActiveIndex(index);
};
const handleScroll = () => {
if (!scrollRef.current) return;
const width = scrollRef.current.clientWidth;
const index = Math.round(scrollRef.current.scrollLeft / width);
if (index !== activeIndex) setActiveIndex(index);
};
// Icons helper (default icon if none provided)
const DefaultIcon = () => (
<svg className="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
);
return (
<div className={`not-prose my-16 px-12 md:px-16 ${className}`}>
<div className="relative group">
{/* Scroll Container */}
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex overflow-x-auto snap-x snap-mandatory scrollbar-hide rounded-2xl border border-slate-200 bg-slate-50 gap-4 p-4"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{(items || []).map((item, index) => (
<div
key={index}
className="min-w-[85%] md:min-w-[45%] snap-center p-8 md:p-10 flex flex-col gap-6 items-start bg-white rounded-xl border border-slate-100 shadow-sm"
>
<div className="flex-shrink-0 w-12 h-12 bg-slate-50 rounded-xl border border-slate-100 flex items-center justify-center">
{item.icon || <DefaultIcon />}
</div>
<div className="flex-1">
<h4 className="text-lg font-bold text-slate-900 mb-2">{item.title}</h4>
{item.content && (
<p className="text-sm text-slate-600 leading-relaxed m-0">{item.content}</p>
)}
</div>
</div>
))}
</div>
{/* Nav Buttons (Outside) */}
<button
onClick={() => scrollTo(Math.max(0, activeIndex - 1))}
disabled={activeIndex === 0}
className="absolute -left-12 md:-left-16 top-1/2 -translate-y-1/2 p-3 bg-white rounded-full shadow-sm border border-slate-200 text-slate-600 hover:text-slate-900 disabled:opacity-30 transition-all hidden md:block"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
</button>
<button
onClick={() => scrollTo(Math.min((items?.length || 0) - 1, activeIndex + 1))}
disabled={activeIndex === (items?.length || 0) - 1}
className="absolute -right-12 md:-right-16 top-1/2 -translate-y-1/2 p-3 bg-white rounded-full shadow-sm border border-slate-200 text-slate-600 hover:text-slate-900 disabled:opacity-30 transition-all hidden md:block"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>
</button>
</div>
{/* Dots */}
<div className="flex justify-center mt-6 gap-3">
{(items || []).map((_, index) => (
<button
key={index}
onClick={() => scrollTo(index)}
className={`h-1.5 transition-all duration-300 rounded-full ${index === activeIndex ? 'w-8 bg-slate-900' : 'w-2 bg-slate-200 hover:bg-slate-300'
}`}
aria-label={`Go to slide ${index + 1}`}
/>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,95 @@
"use client";
import React, { useState } from "react";
import { Share2 } from "lucide-react";
import { ShareModal } from "./ShareModal";
import { useAnalytics } from "./analytics/useAnalytics";
import * as htmlToImage from "html-to-image";
interface ComponentShareButtonProps {
targetId: string;
title?: string;
className?: string;
}
export const ComponentShareButton: React.FC<ComponentShareButtonProps> = ({
targetId,
title = "Component",
className = ""
}) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [generatedImage, setGeneratedImage] = useState<string | undefined>();
const [isCapturing, setIsCapturing] = useState(false);
const { trackEvent } = useAnalytics();
const currentUrl =
typeof window !== "undefined"
? `${window.location.origin}${window.location.pathname}#${targetId}`
: "";
const handleOpenModal = async () => {
setIsCapturing(true);
try {
const element = document.getElementById(targetId);
if (element) {
// Find existing share buttons and temporarily hide them during capture to avoid infinite recursion loops in screenshots
const shareButtons = element.querySelectorAll('[data-share-button="true"]');
shareButtons.forEach(btn => (btn as HTMLElement).style.opacity = '0');
const dataUrl = await htmlToImage.toPng(element, {
quality: 1,
type: 'image/png',
pixelRatio: 2,
backgroundColor: '#ffffff',
skipFonts: true
});
// Restore buttons
shareButtons.forEach(btn => (btn as HTMLElement).style.opacity = '1');
setGeneratedImage(dataUrl);
}
} catch (err) {
console.error("Failed to capture component:", err);
} finally {
setIsCapturing(false);
setIsModalOpen(true);
trackEvent("component_share_opened", {
component_id: targetId,
component_title: title,
});
}
};
return (
<>
<button
onClick={handleOpenModal}
disabled={isCapturing}
data-share-button="true"
className={`inline-flex z-20 items-center justify-center gap-2 px-3 py-1.5 bg-white border border-slate-200 rounded-sm text-slate-500 hover:text-slate-900 hover:bg-slate-50 hover:border-slate-400 transition-all text-[10px] font-mono uppercase tracking-widest ${className}`}
aria-label="Component als Grafik teilen"
>
<Share2 strokeWidth={2.5} className={`w-3 h-3 ${isCapturing ? 'animate-spin' : ''}`} />
<span>{isCapturing ? "Erstelle Bild..." : "Teilen"}</span>
</button>
{/* ShareModal expects a direct image string in 'qrCodeData' or 'diagramImage' (except diagramImage specifically assumes SVGs).
Because ShareModal has logic that redraws diagramImage on a canvas assuming it's an SVG string,
we must bypass the SVG renderer. However, if we look at ShareModal, we need a way to pass a raw PNG.
Passing it as qrCodeData is a hack, or we can just send it via diagramImage and hope the canvas ignores it if it's already a Data URL.
Wait: ShareModal expects `diagramImage` (svg string) AND re-renders it.
Let's just pass our Data URL into a NEW prop or hijack the qrCodeData if necessary, but actually ShareModal only allows `diagramImage` as SVG logic right now.
Let's see if ShareModal needs an update to accept pure images, we'll check it. for now, let's pass it via diagramImage and see if we can adapt ShareModal. */}
{/* We will adapt ShareModal to handle both SVG strings & base64 PNG inputs via `diagramImage` */}
<ShareModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
url={currentUrl}
title={title}
diagramImage={generatedImage}
/>
</>
);
};

View File

@@ -9,8 +9,8 @@ import { ConceptAutomation } from "../../Landing/ConceptIllustrations";
import { Download, Share2, RefreshCw } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
// EstimationPDF will be imported dynamically where used or inside the and client-side block
import IconWhite from "../../../assets/logo/Icon White Transparent.png";
import LogoBlack from "../../../assets/logo/Logo Black Transparent.png";
import IconWhite from "../../../assets/logo/Icon-White-Transparent.png";
import LogoBlack from "../../../assets/logo/Logo-Black-Transparent.png";
// PDF components removed from top-level dynamic import to fix ESM resolution issues in Next.js 16/Webpack

View File

@@ -0,0 +1,79 @@
"use client";
import React from "react";
import { Mermaid } from "./Mermaid";
interface FlowNode {
id: string;
label: string;
style?: string;
}
interface FlowEdge {
from: string;
to: string;
label?: string;
type?: "solid" | "dotted";
}
interface DiagramFlowProps {
direction?: "LR" | "TB" | "RL" | "BT";
nodes?: FlowNode[];
edges?: FlowEdge[];
title?: string;
caption?: string;
id?: string;
showShare?: boolean;
fontSize?: string;
}
export const DiagramFlow: React.FC<DiagramFlowProps> = ({
direction = "LR",
nodes = [],
edges = [],
title,
caption,
id,
showShare = true,
fontSize = "16px",
}) => {
const lines: string[] = [`graph ${direction}`];
// Declare nodes with labels
for (const node of nodes) {
lines.push(` ${node.id}[${JSON.stringify(node.label)}]`);
}
// Add edges
for (const edge of edges) {
const arrow = edge.type === "dotted" ? "-.-" : "-->";
const label = edge.label ? `|${edge.label}|` : "";
lines.push(` ${edge.from} ${arrow} ${label}${edge.to}`);
}
// Add styles
for (const node of nodes) {
if (node.style) {
lines.push(` style ${node.id} ${node.style}`);
}
}
const flowGraph = lines.join("\n");
return (
<div className="my-12">
<Mermaid
graph={flowGraph}
id={id}
title={title}
showShare={showShare}
fontSize={fontSize}
/>
{caption && (
<div className="text-center text-xs text-slate-400 mt-4 italic">
{caption}
</div>
)}
</div>
);
};

View File

@@ -3,6 +3,11 @@
import React from "react";
import { Mermaid } from "./Mermaid";
interface SequenceParticipant {
id: string;
label?: string;
}
interface SequenceMessage {
from: string;
to: string;
@@ -10,9 +15,22 @@ interface SequenceMessage {
type?: "solid" | "dotted" | "async";
}
interface SequenceNote {
over: string | string[];
text: string;
}
type SequenceStep = SequenceMessage | SequenceNote;
function isNote(step: SequenceStep): step is SequenceNote {
return "over" in step;
}
interface DiagramSequenceProps {
participants: string[];
messages: SequenceMessage[];
participants: (string | SequenceParticipant)[];
steps?: SequenceStep[];
messages?: SequenceMessage[];
children?: React.ReactNode;
title?: string;
caption?: string;
id?: string;
@@ -22,7 +40,9 @@ interface DiagramSequenceProps {
export const DiagramSequence: React.FC<DiagramSequenceProps> = ({
participants,
steps,
messages,
children,
title,
caption,
id,
@@ -32,17 +52,38 @@ export const DiagramSequence: React.FC<DiagramSequenceProps> = ({
const getArrow = (type?: string) => {
switch (type) {
case "dotted":
return "-->";
return "-->>";
case "async":
return "->>";
default:
return "->";
return "->>";
}
};
const sequenceGraph = `sequenceDiagram
${(participants || []).map((p) => ` participant ${p}`).join("\n")}
${(messages || []).map((m) => ` ${m.from}${getArrow(m.type)}${m.to}: ${m.message}`).join("\n")}`;
const participantLines = (participants || []).map((p) => {
if (typeof p === "string") return ` participant ${p}`;
return p.label
? ` participant ${p.id} as ${p.label}`
: ` participant ${p.id}`;
});
// Support both `steps` (mixed messages + notes) and legacy `messages`
const allSteps = steps || (messages || []);
const stepLines = allSteps.map((step) => {
if (isNote(step)) {
const over = Array.isArray(step.over) ? step.over.join(",") : step.over;
return ` Note over ${over}: ${step.text}`;
}
return ` ${step.from}${getArrow(step.type)}${step.to}: ${step.message}`;
});
const participantLinesSection = participantLines.length > 0 ? `${participantLines.join("\n")}\n` : "";
const generatedStepsSection = stepLines.length > 0 ? stepLines.join("\n") : "";
const sequenceGraph = children
? (typeof children === "string" ? children : "")
: `sequenceDiagram\n${participantLinesSection}${generatedStepsSection}`;
return (
<div className="my-12">
@@ -61,3 +102,4 @@ ${(messages || []).map((m) => ` ${m.from}${getArrow(m.type)}${m.to}: ${m.mess
</div>
);
};

View File

@@ -0,0 +1,45 @@
'use client';
import React from 'react';
import { track } from '../utils/analytics';
interface ExternalLinkProps {
href: string;
children: React.ReactNode;
className?: string;
}
export const ExternalLink: React.FC<ExternalLinkProps> = ({ href, children, className = '' }) => {
const handleClick = () => {
const text = typeof children === 'string' ? children : href;
track('Outbound Link', { url: href, text });
};
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
onClick={handleClick}
className={`inline-flex items-center gap-0.5 text-slate-600 hover:text-slate-900 underline underline-offset-2 decoration-slate-300 hover:decoration-slate-500 transition-colors whitespace-nowrap ${className}`}
>
<span className="whitespace-normal">{children}</span>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="inline-block ml-0.5 opacity-40 flex-shrink-0 align-baseline"
style={{ transform: 'translateY(1px)' }}
aria-hidden="true"
>
<path d="M7 17L17 7" />
<path d="M7 7h10v10" />
</svg>
</a>
);
};

View File

@@ -0,0 +1,29 @@
"use client";
import React from "react";
import { H3 } from "./ArticleHeading";
import { Paragraph } from "./ArticleParagraph";
interface FAQItem {
question: string;
answer: string;
}
interface FAQSectionProps {
children?: React.ReactNode;
}
/**
* FAQSection: A simple semantic wrapper for FAQs in blog posts.
* It can be used by the AI to wrap a list of questions and answers.
*/
export const FAQSection: React.FC<FAQSectionProps> = ({ children }) => {
return (
<div className="my-16 border-t border-slate-100 pt-12">
<H3 id="faq">Häufig gestellte Fragen (FAQ)</H3>
<div className="mt-8 space-y-8">
{children}
</div>
</div>
);
};

View File

@@ -1,7 +1,7 @@
import Image from "next/image";
import * as React from "react";
import LogoBlack from "../assets/logo/Logo Black Transparent.svg";
import LogoBlack from "../assets/logo/Logo-Black-Transparent.svg";
export const Footer: React.FC = () => {
const currentYear = new Date().getFullYear();

View File

@@ -2,13 +2,13 @@
import { AnimatePresence, motion } from "framer-motion";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useSafePathname } from "./analytics/useSafePathname";
import * as React from "react";
import IconWhite from "../assets/logo/Icon White Transparent.svg";
import IconWhite from "../assets/logo/Icon-White-Transparent.svg";
export const Header: React.FC = () => {
const pathname = usePathname();
const pathname = useSafePathname();
const [isScrolled, setIsScrolled] = React.useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
@@ -49,11 +49,10 @@ export const Header: React.FC = () => {
<header className="sticky top-0 z-[100] w-full">
{/* Decoupled Background Layer - Prevents backdrop-filter parent context bugs */}
<div
className={`absolute inset-0 transition-all duration-500 -z-10 ${
isScrolled
className={`absolute inset-0 transition-all duration-500 -z-10 ${isScrolled
? "bg-white/70 backdrop-blur-xl border-b border-slate-100 shadow-sm shadow-slate-100/50"
: "bg-white/80 backdrop-blur-md border-b border-slate-50"
}`}
}`}
/>
{/* Animated tech border at bottom */}
@@ -95,11 +94,10 @@ export const Header: React.FC = () => {
<Link
key={link.href}
href={link.href}
className={`text-xs font-bold uppercase tracking-widest transition-colors duration-300 relative ${
active
className={`text-xs font-bold uppercase tracking-widest transition-colors duration-300 relative ${active
? "text-slate-900"
: "text-slate-400 hover:text-slate-900"
}`}
}`}
>
{active && (
<span className="absolute -bottom-1 left-0 right-0 flex justify-center">
@@ -247,11 +245,10 @@ export const Header: React.FC = () => {
<Link
href={item.href}
onClick={() => setIsMobileMenuOpen(false)}
className={`relative flex flex-col justify-center p-6 h-[110px] rounded-2xl border transition-all duration-200 ${
active
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"
: "bg-white border-slate-100 active:bg-slate-50"
}`}
}`}
>
<div>
<span className="text-[15px] font-black tracking-tight text-slate-900 block leading-tight mb-1">

View File

@@ -0,0 +1,315 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import * as React from "react";
import IconWhite from "../assets/logo/Icon White Transparent.svg";
export const Header: React.FC = () => {
const pathname = usePathname();
const [isScrolled, setIsScrolled] = 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
React.useEffect(() => {
setIsMobileMenuOpen(false);
}, [pathname]);
React.useEffect(() => {
if (isMobileMenuOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "unset";
}
return () => {
document.body.style.overflow = "unset";
};
}, [isMobileMenuOpen]);
const isActive = (path: string) => pathname === path;
const navLinks = [
{ href: "/about", label: "Über mich" },
{ href: "/websites", label: "Websites" },
{ href: "/case-studies", label: "Case Studies", prefix: true },
{ href: "/blog", label: "Blog", prefix: true },
];
return (
<header className="sticky top-0 z-[100] w-full">
{/* Decoupled Background Layer - Prevents backdrop-filter parent context bugs */}
<div
className={`absolute inset-0 transition-all duration-500 -z-10 ${
isScrolled
? "bg-white/70 backdrop-blur-xl border-b border-slate-100 shadow-sm shadow-slate-100/50"
: "bg-white/80 backdrop-blur-md border-b border-slate-50"
}`}
/>
{/* 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>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center gap-8">
{navLinks.map((link) => {
const active = link.prefix
? isActive(link.href) || pathname?.startsWith(`${link.href}/`)
: isActive(link.href);
return (
<Link
key={link.href}
href={link.href}
className={`text-xs font-bold uppercase tracking-widest transition-colors duration-300 relative ${
active
? "text-slate-900"
: "text-slate-400 hover:text-slate-900"
}`}
>
{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>
{/* Mobile Toggle */}
<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"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
aria-label="Toggle Menu"
>
<div className="w-5 h-3.5 relative flex flex-col justify-between">
<motion.span
animate={
isMobileMenuOpen ? { rotate: 45, y: 7 } : { rotate: 0, y: 0 }
}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
className="w-full h-0.5 bg-current rounded-full origin-center"
/>
<motion.span
animate={
isMobileMenuOpen ? { opacity: 0, x: -10 } : { opacity: 1, x: 0 }
}
transition={{ duration: 0.2 }}
className="w-full h-0.5 bg-current rounded-full"
/>
<motion.span
animate={
isMobileMenuOpen ? { rotate: -45, y: -7 } : { rotate: 0, y: 0 }
}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
className="w-full h-0.5 bg-current rounded-full origin-center"
/>
</div>
</button>
</div>
{/* Mobile Navigation - Bottom-Anchored Control Center */}
<AnimatePresence>
{isMobileMenuOpen && (
<React.Fragment key="mobile-control-center">
{/* Dimmed Backdrop */}
<motion.div
key="cc-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25 }}
onClick={() => setIsMobileMenuOpen(false)}
className="fixed inset-0 z-[101] bg-black/30 backdrop-blur-sm md:hidden"
/>
{/* Bottom Sheet */}
<motion.div
key="cc-sheet"
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={{
type: "spring",
stiffness: 350,
damping: 30,
mass: 0.8,
}}
drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={0.15}
onDragEnd={(_, info) => {
if (info.offset.y > 80 || info.velocity.y > 300) {
setIsMobileMenuOpen(false);
}
}}
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"
>
{/* Grab Handle */}
<div className="flex justify-center pt-3 pb-2 cursor-grab active:cursor-grabbing">
<div className="w-10 h-1 bg-slate-200 rounded-full" />
</div>
{/* Status Bar */}
<div className="px-6 py-3 flex justify-between items-center border-b border-slate-100/80">
<div className="flex items-center gap-2 text-[9px] font-mono font-bold tracking-[0.15em] text-slate-400 uppercase">
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
Online
</div>
<div className="text-[9px] font-mono font-bold tracking-widest text-slate-400 uppercase">
{pathname === "/"
? "HOME"
: pathname.toUpperCase().replace(/^\//, "")}
</div>
</div>
{/* Tiled Navigation Grid */}
<div className="px-5 pt-5 pb-3 flex-1 overflow-y-auto">
<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
href={item.href}
onClick={() => setIsMobileMenuOpen(false)}
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"
: "bg-white border-slate-100 active:bg-slate-50"
}`}
>
<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">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
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>
{/* Safe-Area Spacer (iOS home indicator) */}
<div className="h-[env(safe-area-inset-bottom,0px)]" />
</motion.div>
</React.Fragment>
)}
</AnimatePresence>
</header>
);
};

View File

@@ -1,9 +1,13 @@
import React from "react";
import { Check } from "lucide-react";
import { Check, X } from "lucide-react";
import { ComponentShareButton } from "./ComponentShareButton";
import { Reveal } from "./Reveal";
interface IconListProps {
children: React.ReactNode;
title?: string;
className?: string;
showShare?: boolean;
}
interface IconListItemProps {
@@ -11,6 +15,7 @@ interface IconListItemProps {
icon?: React.ReactNode;
bullet?: boolean;
check?: boolean;
cross?: boolean;
className?: string;
iconClassName?: string;
iconContainerClassName?: string;
@@ -18,14 +23,54 @@ interface IconListItemProps {
export const IconList: React.FC<IconListProps> = ({
children,
title,
className = "",
}) => <ul className={`not-prose space-y-4 ${className}`}>{children}</ul>;
showShare = false,
}) => {
const shareId = `iconlist-${React.useId().replace(/:/g, "")}`;
return (
<Reveal direction="up" delay={0.1}>
<figure
id={shareId}
className={`not-prose my-12 group relative transition-all duration-500 ease-out z-10 ${className}`}
>
{/* Ambient Glow */}
<div className="absolute -inset-1 bg-gradient-to-r from-slate-100 to-white rounded-[2rem] blur opacity-10 group-hover:opacity-30 transition duration-1000 -z-10" />
<div className="glass bg-white/60 backdrop-blur-xl border border-slate-100 rounded-2xl shadow-sm group-hover:shadow-md group-hover:border-slate-200 transition-all duration-500 overflow-hidden relative p-6 md:p-8">
{/* Subtle top shine */}
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white to-transparent opacity-80" />
{/* Share Button top right */}
{showShare && (
<div className="absolute top-4 right-4 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-20">
<ComponentShareButton targetId={shareId} title={title || "Liste"} />
</div>
)}
{title && (
<h4 className="text-lg md:text-xl font-bold text-slate-800 mb-6 pb-4 border-b border-slate-100 tracking-tight pr-12 md:pr-0">
{title}
</h4>
)}
<ul className="space-y-4 m-0 p-0 list-none">
{children}
</ul>
</div>
</figure>
</Reveal>
);
};
export const IconListItem: React.FC<IconListItemProps> = ({
children,
icon,
bullet,
check,
cross,
className = "",
iconClassName = "",
iconContainerClassName = "",
@@ -34,26 +79,32 @@ export const IconListItem: React.FC<IconListItemProps> = ({
if (bullet) {
renderIcon = (
<div className="w-2 h-2 bg-slate-900 rounded-full shrink-0 group-hover:bg-blue-500 transition-colors duration-300" />
<div className="w-2 h-2 bg-slate-400 rounded-full shrink-0 group-hover/item:bg-slate-900 group-hover/item:scale-125 transition-all duration-300 shadow-sm" />
);
} else if (check) {
renderIcon = (
<div className="w-8 h-8 rounded-full bg-slate-900 flex items-center justify-center shrink-0 group-hover:scale-110 group-hover:shadow-lg group-hover:shadow-blue-500/10 transition-all duration-300">
<Check className="w-4 h-4 text-white" />
<div className="w-6 h-6 rounded-lg bg-emerald-50 border border-emerald-100 flex items-center justify-center shrink-0 group-hover/item:bg-emerald-500 group-hover/item:border-emerald-500 transition-all duration-300 shadow-sm">
<Check strokeWidth={3} className="w-3.5 h-3.5 text-emerald-600 group-hover/item:text-white transition-colors" />
</div>
);
} else if (check === false || cross) {
renderIcon = (
<div className="w-6 h-6 rounded-lg bg-red-50 border border-red-100 flex items-center justify-center shrink-0 group-hover/item:bg-red-500 group-hover/item:border-red-500 transition-all duration-300 shadow-sm">
<X strokeWidth={3} className="w-3.5 h-3.5 text-red-500 group-hover/item:text-white transition-colors" />
</div>
);
}
return (
<li className={`flex items-start gap-4 group ${className}`}>
<li className={`flex items-start gap-4 m-0 group/item p-2 -mx-2 rounded-xl hover:bg-slate-50/50 transition-colors ${className}`}>
{renderIcon && (
<div
className={`shrink-0 flex items-center justify-center transition-transform duration-500 ${iconContainerClassName || "mt-1.5"} ${iconClassName}`}
className={`shrink-0 flex items-center justify-center mt-0.5 ${iconContainerClassName} ${iconClassName}`}
>
{renderIcon}
</div>
)}
<div className="flex-1">{children}</div>
<div className="flex-1 text-slate-700 leading-relaxed font-serif text-[15px]">{children}</div>
</li>
);
};

View File

@@ -0,0 +1,42 @@
'use client';
import React from 'react';
interface ImageTextProps {
image: string;
title: string;
children: React.ReactNode;
reversed?: boolean;
className?: string;
}
export const ImageText: React.FC<ImageTextProps> = ({
image,
title,
children,
reversed = false,
className = '',
}) => {
return (
<div className={`not-prose my-16 grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-16 items-center ${className}`}>
{/* Image Side */}
<div className={`relative ${reversed ? 'md:order-2' : ''}`}>
<div className="absolute -inset-2 border-2 border-slate-100 rounded-2xl" />
<img
src={image}
alt={title}
className="relative w-full h-auto rounded-xl border border-slate-200 bg-white object-cover shadow-sm"
loading="lazy"
/>
</div>
{/* Text Side */}
<div className={`${reversed ? 'md:order-1' : ''}`}>
<h3 className="text-xl font-bold text-slate-900 mb-6 uppercase tracking-wider">{title}</h3>
<div className="prose prose-slate prose-sm text-slate-600 leading-relaxed font-medium">
{children}
</div>
</div>
</div>
);
};

View File

@@ -1,32 +1,110 @@
import * as React from "react";
import { ArrowRight } from "lucide-react";
import { ArrowRight, Check, X } from "lucide-react";
import { Reveal } from "../Reveal";
import { Label, H3, LeadText } from "../Typography";
import { Strikethrough } from "../Strikethrough";
import { cn } from "../../utils/cn";
import { ComponentShareButton } from "../ComponentShareButton";
interface ComparisonRowProps {
description?: string;
negativeLabel: string;
negativeText: string;
positiveLabel: string;
positiveText: React.ReactNode;
// Legacy props
negativeLabel?: string;
negativeText?: string;
positiveLabel?: string;
positiveText?: React.ReactNode;
// New props for lists
leftTitle?: string;
leftItems?: string[];
rightTitle?: string;
rightItems?: string[];
reverse?: boolean;
delay?: number;
showShare?: boolean;
}
export const ComparisonRow: React.FC<ComparisonRowProps> = ({
description,
negativeLabel,
negativeText,
positiveLabel,
positiveText,
leftTitle,
leftItems,
rightTitle,
rightItems,
reverse = false,
delay = 0,
showShare = false,
}) => {
const shareId = `comprow-${React.useId().replace(/:/g, "")}`;
// Normalize inputs
const labelLeft = leftTitle || negativeLabel;
const contentLeft = leftItems || negativeText;
const labelRight = rightTitle || positiveLabel;
const contentRight = rightItems || positiveText;
// Helper to render left side content (Strikethrough)
const renderLeft = () => {
if (Array.isArray(contentLeft)) {
return (
<ul className="space-y-3">
{contentLeft.map((item, i) => (
<li key={i} className="flex items-start gap-2">
<X className="w-4 h-4 text-red-400 mt-1 shrink-0" />
<Strikethrough delay={delay + 0.2 + (i * 0.1)} color="rgba(220, 50, 50, 0.6)">
{item}
</Strikethrough>
</li>
))}
</ul>
);
}
if (typeof contentLeft === "string") {
return (
<LeadText className="leading-snug">
<Strikethrough delay={delay + 0.3}>{contentLeft}</Strikethrough>
</LeadText>
);
}
return null;
};
// Helper to render right side content (Positive)
const renderRight = () => {
if (Array.isArray(contentRight)) {
return (
<ul className="space-y-3">
{contentRight.map((item, i) => (
<li key={i} className="flex items-start gap-2">
<Check className="w-4 h-4 text-green-500 mt-1 shrink-0" />
<span className="text-slate-700 font-medium">
{item}
</span>
</li>
))}
</ul>
);
}
// Default / Legacy positiveText (usually a Node or string)
return <H3 className="text-2xl md:text-3xl">{contentRight}</H3>;
};
return (
<Reveal delay={delay}>
<div className="not-prose space-y-4">
<div id={shareId} className="not-prose space-y-4 my-8 group relative z-10">
{showShare && (
<div className="absolute top-0 right-0 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-50">
<ComponentShareButton targetId={shareId} title={description || "Vergleich"} />
</div>
)}
{description && (
<Label className="text-slate-400 text-[10px] tracking-[0.2em] uppercase">
{description}
@@ -34,20 +112,19 @@ export const ComparisonRow: React.FC<ComparisonRowProps> = ({
)}
<div
className={cn(
"flex flex-col gap-8 md:gap-12 items-center",
"flex flex-col gap-8 md:gap-12 items-stretch", // altered alignment
reverse ? "md:flex-row-reverse" : "md:flex-row",
)}
>
{/* Left / Negative Side */}
<div className="flex-1 p-8 md:p-10 bg-slate-50/50 rounded-2xl text-slate-400 border border-transparent w-full">
<Label className="mb-4">
<Strikethrough delay={delay + 0.2}>{negativeLabel}</Strikethrough>
<Label className="mb-6 text-red-900/40 font-bold tracking-widest uppercase text-xs">
{labelLeft}
</Label>
<LeadText className="leading-snug">
<Strikethrough delay={delay + 0.3}>{negativeText}</Strikethrough>
</LeadText>
{renderLeft()}
</div>
<div className="shrink-0">
<div className="shrink-0 flex items-center justify-center">
<ArrowRight
className={cn(
"w-6 h-6 text-slate-200 hidden md:block",
@@ -56,9 +133,15 @@ export const ComparisonRow: React.FC<ComparisonRowProps> = ({
/>
</div>
<div className="flex-1 p-8 md:p-10 border border-slate-100 rounded-2xl bg-white hover:border-slate-200 transition-all duration-500 hover:shadow-xl hover:shadow-slate-100/50 w-full">
<Label className="text-slate-900 mb-4">{positiveLabel}</Label>
<H3 className="text-2xl md:text-3xl">{positiveText}</H3>
{/* Right / Positive Side */}
<div className="flex-1 p-8 md:p-10 border border-slate-100 rounded-2xl bg-white hover:border-slate-200 transition-all duration-500 hover:shadow-xl hover:shadow-slate-100/50 w-full relative overflow-hidden group">
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<Check className="w-12 h-12 text-green-500" />
</div>
<Label className="mb-6 text-green-600 font-bold tracking-widest uppercase text-xs">
{labelRight}
</Label>
{renderRight()}
</div>
</div>
</div>

View File

@@ -0,0 +1,88 @@
'use client';
import * as React from 'react';
import { useEffect, useState, useRef } from 'react';
interface LinkedInEmbedProps {
/** The post URL, e.g. "https://www.linkedin.com/posts/xyz" or raw URN */
url: string;
className?: string;
width?: string | number;
}
export function LinkedInEmbed({
url,
className = "",
width = 504
}: LinkedInEmbedProps) {
const [isMounted, setIsMounted] = useState(false);
const [hasError, setHasError] = useState(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
// Extract the 19-digit ID from the URL or URN
const match = url.match(/(\d{19})/);
const embedId = match ? match[1] : null;
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted || !embedId) return null;
// LinkedIn technically supports share, ugcPost, and activity. We start with share and natively cycle.
const initialSrc = `https://www.linkedin.com/embed/feed/update/urn:li:share:${embedId}`;
const handleError = () => {
if (!iframeRef.current) return;
const currentSrc = iframeRef.current.src;
// If the 'share' URN 404s (e.g., restricted post type), fallback to 'activity', then 'ugcPost'
if (currentSrc.includes('urn:li:share:')) {
iframeRef.current.src = `https://www.linkedin.com/embed/feed/update/urn:li:activity:${embedId}`;
} else if (currentSrc.includes('urn:li:activity:')) {
iframeRef.current.src = `https://www.linkedin.com/embed/feed/update/urn:li:ugcPost:${embedId}`;
} else {
// All fallbacks exhausted. The post is truly dead or private.
setHasError(true);
}
};
if (hasError) {
return (
<div className={`not-prose flex w-full justify-center my-8 ${className}`}>
<div
className="flex flex-col items-center justify-center text-center p-8 w-full max-w-[504px] border border-slate-200 border-dashed rounded-lg bg-slate-50 text-slate-500"
style={{ minHeight: '150px' }}
>
<svg className="w-8 h-8 mb-3 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm font-medium">Beitrag nicht verfügbar</span>
<span className="text-xs mt-1 text-slate-400">Dieser LinkedIn-Post wurde gelöscht oder die Privatsphäre-Einstellungen verhindern eine Einbettung.</span>
</div>
</div>
);
}
return (
<div className={`not-prose flex w-full justify-center my-8 ${className}`}>
<div
className="linkedin-embed-container w-full max-w-[504px] border border-slate-200 rounded-lg overflow-hidden shadow-sm bg-white"
style={{ width, minHeight: '500px' }}
>
{/* We use onLoad to detect successful rendering, relying on onError for explicit iframe load crashes */}
<iframe
ref={iframeRef}
src={initialSrc}
height="100%"
width="100%"
frameBorder="0"
allowFullScreen
title="Embedded post"
className="w-full min-h-[500px]"
onError={handleError}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { useMDXComponent } from "next-contentlayer2/hooks";
import { mdxComponents } from "../content-engine/registry";
interface MDXContentProps {
code: string;
}
export function MDXContent({ code }: MDXContentProps) {
// FIX: Contentlayer/MDX often appends hoisted functions *after* the `return MDXContent` statement,
// which causes Firefox to vomit hundreds of "unreachable code after return statement" warnings.
// We rewrite the generated IIFE string to move the return to the very end.
let patchedCode = code;
if (patchedCode.includes("return function MDXContent(")) {
patchedCode = patchedCode.replace("return function MDXContent(", "const MDXContent = function MDXContent(");
patchedCode += "\nreturn MDXContent;";
}
const Component = useMDXComponent(patchedCode);
return <Component components={mdxComponents} />;
}

View File

@@ -10,6 +10,7 @@ interface Post {
date: string;
slug: string;
tags?: string[];
thumbnail?: string;
}
interface MediumCardProps {
@@ -34,12 +35,20 @@ export const MediumCard: React.FC<MediumCardProps> = ({ post }) => {
>
<div className="flex gap-4 md:gap-5 items-center">
{/* Thumbnail */}
<div className="flex-shrink-0 w-[56px] h-[56px] md:w-[80px] md:h-[80px] rounded-lg overflow-hidden border border-slate-100 group-hover:border-slate-200 transition-colors">
<BlogThumbnailSVG
slug={slug}
variant="square"
className="w-full h-full"
/>
<div className="flex-shrink-0 w-[56px] h-[56px] md:w-[80px] md:h-[80px] rounded-lg overflow-hidden border border-slate-100 group-hover:border-slate-200 transition-colors bg-slate-50 relative">
{post.thumbnail ? (
<img
src={post.thumbnail}
alt={title}
className="w-full h-full object-cover grayscale-[0.5] group-hover:grayscale-0 group-hover:scale-110 transition-all duration-700"
/>
) : (
<BlogThumbnailSVG
slug={slug}
variant="square"
className="w-full h-full"
/>
)}
</div>
<div className="flex-1 space-y-3 md:space-y-4 min-w-0">

View File

@@ -0,0 +1,256 @@
'use client';
import React from 'react';
import { ComponentShareButton } from './ComponentShareButton';
import { Reveal } from './Reveal';
interface MemeCardProps {
/** Meme template type: drake, ds (daily struggle), gru, fine, clown, expanding, distracted, rollsafe */
template: string;
/** Pipe-delimited captions */
captions: string;
/** Optional local image path. If provided, overrides the text-based template. */
image?: string;
className?: string;
}
/**
* Premium text-based meme cards with dedicated layouts per template.
* Uses emoji + typography instead of images for on-brand aesthetics.
*/
export const MemeCard: React.FC<MemeCardProps> = ({ template, captions, image, className = '' }) => {
const captionList = (captions || '').split('|').map(s => s.trim()).filter(Boolean);
const shareId = `meme-${Math.random().toString(36).substring(7).toUpperCase()}`;
if (image) {
return (
<Reveal direction="up" delay={0.1}>
<div id={shareId} className={`not-prose max-w-xl mx-auto my-12 group relative transition-all duration-500 ease-out z-10 ${className}`}>
<div className="absolute -inset-1 bg-gradient-to-r from-slate-200 to-slate-100 rounded-[2rem] blur opacity-10 group-hover:opacity-30 transition duration-1000 -z-10" />
<div className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl overflow-hidden shadow-sm shadow-slate-200 group-hover:shadow-md transition-all duration-500 relative">
<div className="absolute top-4 right-4 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-50">
<ComponentShareButton targetId={shareId} title={`Meme: ${template}`} />
</div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={image}
alt={`Meme: ${template} - ${captionList.join(' ')}`}
className="w-full h-auto object-cover block"
loading="lazy"
/>
</div>
</div>
</Reveal>
);
}
return (
<Reveal direction="up" delay={0.1}>
<div id={shareId} className={`not-prose max-w-xl mx-auto my-12 group relative transition-all duration-500 ease-out z-10 ${className}`}>
<div className="absolute -inset-1 bg-gradient-to-r from-slate-200 to-slate-100 rounded-[2rem] blur opacity-10 group-hover:opacity-30 transition duration-1000 -z-10" />
<div className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl overflow-hidden shadow-sm shadow-slate-200 group-hover:shadow-md transition-all duration-500 relative">
<div className="absolute top-4 right-4 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-50">
<ComponentShareButton targetId={shareId} title={`Meme: ${template}`} />
</div>
{template === 'drake' && <DrakeMeme captions={captionList} />}
{template === 'ds' && <DailyStruggleMeme captions={captionList} />}
{template === 'gru' && <GruMeme captions={captionList} />}
{template === 'fine' && <FineMeme captions={captionList} />}
{template === 'clown' && <ClownMeme captions={captionList} />}
{template === 'expanding' && <ExpandingBrainMeme captions={captionList} />}
{template === 'distracted' && <DistractedMeme captions={captionList} />}
{!['drake', 'ds', 'gru', 'fine', 'clown', 'expanding', 'distracted'].includes(template) && (
<GenericMeme captions={captionList} template={template} />
)}
</div>
</div>
</Reveal>
);
};
function DrakeMeme({ captions }: { captions: string[] }) {
return (
<div className="flex flex-col">
<div className="flex items-stretch border-b border-slate-100">
<div className="w-20 md:w-24 bg-red-400/10 flex items-center justify-center flex-shrink-0 border-r border-slate-100">
<span className="text-3xl md:text-4xl select-none grayscale-0 group-hover:scale-110 transition-transform duration-500">🙅</span>
</div>
<div className="flex-1 p-5 md:p-6 flex items-center bg-white/40">
<p className="text-lg md:text-xl font-medium text-slate-500 leading-snug">{captions[0]}</p>
</div>
</div>
<div className="flex items-stretch">
<div className="w-20 md:w-24 bg-emerald-400/10 flex items-center justify-center flex-shrink-0 border-r border-slate-100">
<span className="text-3xl md:text-4xl select-none group-hover:scale-110 transition-transform duration-500">😎</span>
</div>
<div className="flex-1 p-5 md:p-6 flex items-center bg-white">
<p className="text-lg md:text-xl font-bold text-slate-900 leading-snug">{captions[1]}</p>
</div>
</div>
</div>
);
}
function DailyStruggleMeme({ captions }: { captions: string[] }) {
return (
<div className="p-8 md:p-10 text-center">
<div className="text-4xl md:text-5xl mb-6 select-none animate-bounce-subtle">😰</div>
<p className="text-[10px] font-black text-slate-300 uppercase tracking-[0.3em] mb-8">Daily Struggle</p>
<div className="grid grid-cols-2 gap-4">
<div className="p-5 rounded-2xl bg-white border border-slate-100 shadow-sm hover:border-slate-200 transition-colors">
<div className="w-5 h-5 rounded-full bg-red-500 mx-auto mb-3 shadow-[0_0_10px_rgba(239,68,68,0.4)]" />
<p className="text-sm md:text-base font-bold text-slate-700 leading-snug">{captions[0]}</p>
</div>
<div className="p-5 rounded-2xl bg-white border border-slate-100 shadow-sm hover:border-slate-200 transition-colors">
<div className="w-5 h-5 rounded-full bg-red-500 mx-auto mb-3 shadow-[0_0_10px_rgba(239,68,68,0.4)]" />
<p className="text-sm md:text-base font-bold text-slate-700 leading-snug">{captions[1]}</p>
</div>
</div>
</div>
);
}
function GruMeme({ captions }: { captions: string[] }) {
const steps = captions.slice(0, 4);
return (
<div className="grid grid-cols-2 grid-rows-2">
{(steps || []).map((caption, i) => {
const isLast = i >= 2;
return (
<div
key={i}
className={`p-6 md:p-8 ${i % 2 === 0 ? 'border-r' : ''} ${i < 2 ? 'border-b' : ''} border-slate-100 flex flex-col items-center justify-center text-center gap-3 transition-colors hover:bg-slate-50/30`}
>
<span className="text-2xl md:text-3xl select-none transition-transform group-hover:scale-110">
{isLast ? '😱' : '😏'}
</span>
<p className={`text-base md:text-lg leading-tight ${isLast ? 'font-black text-red-500' : 'font-bold text-slate-700'}`}>
{caption}
</p>
</div>
);
})}
</div>
);
}
function FineMeme({ captions }: { captions: string[] }) {
return (
<div className="flex flex-col">
<div className="bg-orange-50/50 border-b border-slate-100 p-6 md:p-8">
<div className="flex items-center gap-3 mb-4">
<span className="text-3xl md:text-4xl select-none">🔥</span>
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest m-0">This is Fine</p>
</div>
<p className="text-lg md:text-xl font-bold text-slate-700 leading-snug">{captions[0]}</p>
</div>
<div className="p-6 md:p-8 bg-white">
<div className="flex items-center gap-4">
<span className="text-3xl select-none group-hover:rotate-12 transition-transform"></span>
<p className="text-lg md:text-2xl font-black text-slate-900 leading-tight italic tracking-tight">
&ldquo;{captions[1] || 'Alles im grünen Bereich.'}&rdquo;
</p>
</div>
</div>
</div>
);
}
function ClownMeme({ captions }: { captions: string[] }) {
const steps = captions.slice(0, 4);
const emojis = ['😐', '🤡', '💀', '🎪'];
return (
<div className="flex flex-col">
<div className="p-4 md:p-5 border-b border-slate-100 bg-slate-50/50">
<p className="text-[10px] font-black text-slate-300 uppercase tracking-[0.3em] m-0 text-center">Clown Progression</p>
</div>
{steps.map((caption, i) => (
<div
key={i}
className={`flex items-center gap-5 p-5 md:p-6 ${i < steps.length - 1 ? 'border-b border-slate-100' : ''} hover:bg-slate-50 transition-colors`}
>
<span className="text-2xl md:text-3xl select-none flex-shrink-0 grayscale opacity-60 group-hover:grayscale-0 group-hover:opacity-100 transition-all duration-500">{emojis[i] || '🤡'}</span>
<p className={`text-base md:text-lg leading-snug ${i === steps.length - 1 ? 'font-black text-red-500' : 'font-bold text-slate-700'}`}>
{caption}
</p>
</div>
))}
</div>
);
}
function ExpandingBrainMeme({ captions }: { captions: string[] }) {
const steps = captions.slice(0, 4);
const emojis = ['🧠', '🧠✨', '🧠💡', '🧠🚀'];
const shadows = [
'',
'shadow-[0_0_15px_rgba(59,130,246,0.1)]',
'shadow-[0_0_20px_rgba(99,102,241,0.2)]',
'shadow-[0_0_25px_rgba(168,85,247,0.3)]',
];
return (
<div className="flex flex-col">
<div className="p-4 md:p-5 border-b border-slate-100 bg-slate-50/50">
<p className="text-[10px] font-black text-slate-300 uppercase tracking-[0.3em] m-0 text-center">Expanding Intelligence</p>
</div>
{steps.map((caption, i) => (
<div
key={i}
className={`flex items-center gap-5 p-5 md:p-6 ${i < steps.length - 1 ? 'border-b border-slate-100' : ''} hover:bg-white transition-all duration-500 ${shadows[i]}`}
>
<span className="text-2xl md:text-3xl select-none flex-shrink-0 group-hover:scale-125 transition-transform duration-700">{emojis[i]}</span>
<p className={`text-base md:text-lg leading-tight ${i === steps.length - 1 ? 'font-black text-indigo-600' : 'font-bold text-slate-700'}`}>
{caption}
</p>
</div>
))}
</div>
);
}
function DistractedMeme({ captions }: { captions: string[] }) {
return (
<div className="flex flex-col">
<div className="p-4 md:p-5 border-b border-slate-100 bg-slate-50/50">
<p className="text-[10px] font-black text-slate-300 uppercase tracking-[0.3em] m-0 text-center">The Distraction</p>
</div>
<div className="grid grid-cols-3 divide-x divide-slate-100">
<div className="p-6 md:p-8 flex flex-col items-center text-center gap-3 hover:bg-slate-50/50 transition-colors">
<span className="text-3xl md:text-4xl select-none">👤</span>
<p className="text-[9px] font-black text-slate-400 uppercase tracking-[0.2em] m-0">Subject</p>
<p className="text-sm md:text-base font-bold text-slate-500 leading-tight">{captions[0]}</p>
</div>
<div className="p-6 md:p-8 flex flex-col items-center text-center gap-3 bg-emerald-50/30 hover:bg-emerald-50/60 transition-colors">
<span className="text-3xl md:text-4xl select-none animate-pulse"></span>
<p className="text-[9px] font-black text-emerald-500 uppercase tracking-[0.2em] m-0">Temptation</p>
<p className="text-sm md:text-base font-black text-slate-900 leading-tight">{captions[1]}</p>
</div>
<div className="p-6 md:p-8 flex flex-col items-center text-center gap-3 bg-red-50/30 hover:bg-red-50/60 transition-colors">
<span className="text-3xl md:text-4xl select-none">😤</span>
<p className="text-[9px] font-black text-red-500 uppercase tracking-[0.2em] m-0">Reality</p>
<p className="text-sm md:text-base font-bold text-red-600 leading-tight">{captions[2]}</p>
</div>
</div>
</div>
);
}
function GenericMeme({ captions, template }: { captions: string[]; template: string }) {
return (
<div className="p-8 md:p-12 text-center bg-gradient-to-br from-white to-slate-50/50">
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.3em] mb-8">{template}</p>
<div className="space-y-4">
{(captions || []).map((caption, i) => (
<div key={i} className="p-4 md:p-5 bg-white border border-slate-100 rounded-2xl shadow-sm group-hover:border-slate-200 transition-all duration-300">
<p className="text-base md:text-lg font-bold text-slate-700 m-0">
{caption}
</p>
</div>
))}
</div>
</div>
);
}

View File

@@ -2,7 +2,8 @@
import React, { useEffect, useRef, useState } from "react";
import mermaid from "mermaid";
import { DiagramShareButton } from "./DiagramShareButton";
import { ComponentShareButton } from "./ComponentShareButton";
import { Reveal } from "./Reveal";
interface MermaidProps {
graph?: string;
@@ -104,17 +105,9 @@ const MermaidInternal: React.FC<MermaidProps> = ({
);
}
useEffect(() => {
console.log(`[Mermaid DEBUG] id=${providedId}, graph prop length=${graph?.length ?? 'undefined'}, rawGraph length=${rawGraph.length}, sanitizedGraph length=${sanitizedGraph.length}`);
if (graph?.length === 0) {
console.log(`[Mermaid DEBUG] id=${providedId} EMPTY graph prop! children:`, children);
}
}, []);
useEffect(() => {
const generatedId = providedId || `mermaid-${Math.random().toString(36).substring(2, 11)}`;
setId(generatedId);
console.log(`[Mermaid DEBUG] id=${generatedId}, provided=${providedId}, graph length=${graph?.length ?? 'undefined'}`);
}, [providedId]);
// Observer to detect when the component is actually in view and layout is ready
@@ -138,18 +131,15 @@ const MermaidInternal: React.FC<MermaidProps> = ({
}, [id]);
useEffect(() => {
console.log(`[Mermaid DEBUG] Main effect triggered. id=${id}, isVisible=${isVisible}, isRendered=${isRendered}`);
if (!isVisible || !id || isRendered) {
console.log(`[Mermaid DEBUG] Main effect early return (will not render). isVisible=${isVisible}, id=${id}, isRendered=${isRendered}`);
return;
}
console.log(`[Mermaid DEBUG] Initializing mermaid for ${id}...`);
mermaid.initialize({
startOnLoad: false,
theme: "base",
darkMode: false,
htmlLabels: false, // Added this line as per instruction
htmlLabels: false,
flowchart: {
useMaxWidth: true,
htmlLabels: false,
@@ -174,25 +164,17 @@ const MermaidInternal: React.FC<MermaidProps> = ({
primaryBorderColor: "#cbd5e1", // slate-300
lineColor: "#64748b", // slate-500
secondaryColor: "#f1f5f9", // slate-100
tertiaryColor: "#e2e8f0", // slate-200 // Background colors
tertiaryColor: "#e2e8f0", // slate-200
background: "#ffffff",
mainBkg: "#ffffff",
secondBkg: "#f8fafc",
tertiaryBkg: "#f1f5f9",
// Text colors
textColor: "#1e293b",
labelTextColor: "#475569",
// Node styling
nodeBorder: "#cbd5e1",
clusterBkg: "#f8fafc",
clusterBorder: "#cbd5e1",
// Edge/line styling
edgeLabelBackground: "#ffffff",
// Font
fontFamily: "var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
fontSize: fontSize || "18px",
nodeFontSize: nodeFontSize || fontSize || "18px",
@@ -203,32 +185,25 @@ const MermaidInternal: React.FC<MermaidProps> = ({
titleFontSize: titleFontSize || "24px",
sectionFontSize: sectionFontSize || fontSize || "18px",
legendFontSize: legendFontSize || fontSize || "18px",
// Pie Chart Colors - High Contrast Industrial Palette
pie1: "#0f172a", // Deep Navy
pie2: "#334155", // Slate Blue
pie3: "#64748b", // Steel Gray
pie4: "#94a3b8", // Muted Steel
pie5: "#cbd5e1", // Concrete
pie6: "#1e293b", // Slate 800
pie7: "#475569", // Slate 600
pie8: "#000000", // Pure Black for accents
pie9: "#e2e8f0", // Light Concrete
pie10: "#020617", // Slate 950
pie11: "#525252", // Neutral 600
pie12: "#262626", // Neutral 800
pie1: "#0f172a",
pie2: "#334155",
pie3: "#64748b",
pie4: "#94a3b8",
pie5: "#cbd5e1",
pie6: "#1e293b",
pie7: "#475569",
pie8: "#000000",
pie9: "#e2e8f0",
pie10: "#020617",
pie11: "#525252",
pie12: "#262626",
},
securityLevel: "loose",
});
const renderGraph = async () => {
if (!wrapperRef.current) return;
// CRITICAL: Ensure invalid dimensions don't crash d3
if (wrapperRef.current.clientWidth === 0) {
console.warn("Mermaid: Container width is 0, deferring render", id);
return;
}
if (wrapperRef.current.clientWidth === 0) return;
const maxRetries = 3;
let attempt = 0;
@@ -237,19 +212,10 @@ const MermaidInternal: React.FC<MermaidProps> = ({
while (attempt < maxRetries && !success) {
attempt++;
try {
if (!sanitizedGraph) {
console.warn("Mermaid: Empty graph definition received, skipping render");
return;
}
if (!sanitizedGraph) return;
if (!mermaid.render) return;
if (!mermaid.render) {
console.warn("Mermaid not ready");
return;
}
// Generate a unique ID for the SVG to prevent collisions during retries
const uniqueSvgId = `${id}-svg-${Date.now()}`;
// Render into a detached container to avoid React DOM conflicts
const tempDiv = document.createElement('div');
document.body.appendChild(tempDiv);
tempDiv.style.position = 'absolute';
@@ -258,10 +224,8 @@ const MermaidInternal: React.FC<MermaidProps> = ({
let rawSvg: string;
try {
console.log(`[Mermaid DEBUG] Calling mermaid.render for ${id}...`);
const result = await mermaid.render(uniqueSvgId, sanitizedGraph, tempDiv);
rawSvg = result.svg;
console.log(`[Mermaid DEBUG] Render success for ${id}!`);
} finally {
if (document.body.contains(tempDiv)) {
document.body.removeChild(tempDiv);
@@ -277,30 +241,24 @@ const MermaidInternal: React.FC<MermaidProps> = ({
scaledSvg = newSvgTag + rest;
}
// Store SVG in React state — React renders it via dangerouslySetInnerHTML
setSvgContent(scaledSvg);
setIsRendered(true);
setError(null);
success = true;
} catch (err) {
console.warn(`Mermaid render attempt ${attempt} failed:`, err);
if (attempt >= maxRetries) {
console.error("Mermaid Render Error Final:", err);
setError("Diagramm konnte nicht geladen werden (Render-Fehler).");
setIsRendered(true);
} else {
// Wait before retrying (exponential backoff)
await new Promise(resolve => setTimeout(resolve, attempt * 200));
}
}
}
};
// Use ResizeObserver to trigger render ONLY when we have dimensions
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.contentRect.width > 0 && !isRendered) {
// Debounce slightly to ensure stable layout
requestAnimationFrame(() => renderGraph());
resizeObserver.disconnect();
}
@@ -309,7 +267,6 @@ const MermaidInternal: React.FC<MermaidProps> = ({
resizeObserver.observe(wrapperRef.current);
// Fallback: Try immediately if we already have size
if (wrapperRef.current && wrapperRef.current.clientWidth > 0) {
renderGraph();
resizeObserver.disconnect();
@@ -321,70 +278,68 @@ const MermaidInternal: React.FC<MermaidProps> = ({
if (!id) return null;
return (
<figure ref={wrapperRef} className="mermaid-wrapper not-prose my-20 w-full max-w-full border-y-2 border-slate-900 py-12 relative overflow-visible">
<Reveal direction="up" delay={0.1}>
<figure ref={wrapperRef} className="mermaid-wrapper not-prose my-16 w-full max-w-full group relative transition-all duration-500 ease-out z-10">
{/* Blueprint Grid Background Pattern */}
<div className="absolute inset-0 z-0 pointer-events-none opacity-[0.03]" style={{ backgroundImage: 'linear-gradient(to right, #0f172a 1px, transparent 1px), linear-gradient(to bottom, #0f172a 1px, transparent 1px)', backgroundSize: '40px 40px' }} />
<div className="absolute -inset-1 bg-gradient-to-r from-slate-200 to-slate-100/50 rounded-[2rem] blur opacity-20 group-hover:opacity-40 transition duration-1000 -z-10" />
<div className="w-full flex flex-col items-center justify-center relative z-10 px-4 md:px-8">
{title && (
<div className="w-full flex justify-between items-baseline mb-12 border-b border-slate-200 pb-4">
<h4 className="text-left text-lg md:text-xl font-bold text-slate-900 tracking-tight m-0">
{title}
</h4>
<span className="text-[10px] font-mono text-slate-400 uppercase tracking-[0.3em] font-bold">System_Architecture</span>
</div>
)}
<div className="flex justify-center w-full overflow-x-auto">
<div
className={`mermaid
w-full flex justify-center
/* Safely scale the SVG container wide without corrupting internal label calculations */
[&>div]:!w-full [&>div]:!flex [&>div]:!justify-center
[&_svg]:!max-w-full [&_svg]:max-w-4xl [&_svg]:!h-auto [&_svg]:!max-h-[60vh] md:[&_svg]:!max-h-[600px]
/* Premium Industrial Styling */
[&_.node_rect]:!rx-[0px] [&_.node_rect]:!ry-[0px] /* Sharp corners for notebook look */
[&_.node_rect]:!fill-white
[&_.node_rect]:!stroke-slate-900 [&_.node_rect]:!stroke-[2px]
[&_.node_rect]:!filter-none
[&_.edgePath_path]:!stroke-slate-900 [&_.edgePath_path]:!stroke-[2px]
[&_.marker]:!fill-slate-900 [&_.marker]:!stroke-slate-900
/* Labels */
[&_.nodeLabel]:!font-mono [&_.nodeLabel]:!font-bold [&_.nodeLabel]:!text-slate-900
`}
id={id}
style={{
maxWidth: "100%",
overflow: "visible"
}}
>
{error ? (
<div className="text-red-500 p-4 border border-red-200 bg-red-50 text-sm font-mono uppercase tracking-widest text-center w-full h-32 flex items-center justify-center">
{error}
<div className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl shadow-sm group-hover:shadow-md group-hover:border-slate-200 transition-all duration-500 overflow-hidden relative">
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white to-transparent opacity-80" />
<div className="w-full flex flex-col items-center justify-center p-6 md:p-8 lg:p-10 relative z-10">
{showShare && (
<div className="absolute top-6 right-6 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-50">
<ComponentShareButton targetId={id} title={title || 'System Architecture'} />
</div>
) : svgContent ? (
<div dangerouslySetInnerHTML={{ __html: svgContent }} />
) : (
// Hide raw graph until rendered
<div style={{ display: 'none' }}>{sanitizedGraph}</div>
)}
{title && (
<header className="w-full mb-8 flex flex-col md:flex-row md:justify-between md:items-end gap-2 border-b border-slate-100 pb-4">
<div>
<h4 className="text-xl md:text-2xl font-bold text-slate-900 tracking-tight m-0 flex items-center gap-3">
<span className="w-2 h-2 rounded-full bg-slate-400 shadow-[0_0_8px_rgba(148,163,184,0.6)] hidden md:block" />
{title}
</h4>
</div>
</header>
)}
<div className="flex justify-center w-full overflow-x-auto relative z-20">
<div
className={`mermaid
w-full flex justify-center
[&>div]:!w-full [&>div]:!flex [&>div]:!justify-center
[&_svg]:!max-w-full [&_svg]:max-w-4xl [&_svg]:!h-auto [&_svg]:!max-h-[60vh] md:[&_svg]:!max-h-[600px]
[&_.node_rect]:!rx-[8px] [&_.node_rect]:!ry-[8px]
[&_.node_rect]:!fill-white
[&_.node_rect]:!stroke-slate-200 [&_.node_rect]:!stroke-[2px]
[&_.edgePath_path]:!stroke-slate-400 [&_.edgePath_path]:!stroke-[1.5px]
[&_.marker]:!fill-slate-400 [&_.marker]:!stroke-slate-400
[&_.nodeLabel]:!font-sans [&_.nodeLabel]:!font-bold [&_.nodeLabel]:!text-slate-700
`}
id={id}
style={{
maxWidth: "100%",
overflow: "visible"
}}
>
{error ? (
<div className="text-red-500 p-4 border border-red-200 bg-red-50 text-xs font-mono uppercase tracking-widest text-center w-full h-32 flex items-center justify-center rounded-xl">
{error}
</div>
) : svgContent ? (
<div dangerouslySetInnerHTML={{ __html: svgContent }} />
) : (
<div style={{ display: 'none' }}>{sanitizedGraph}</div>
)}
</div>
</div>
</div>
</div>
{showShare && id && isRendered && (
<div className="flex justify-end w-full mt-10">
<DiagramShareButton
diagramId={id}
title={title}
svgContent={svgContent}
/>
</div>
)}
</div>
</figure>
</figure>
</Reveal>
);
};

View File

@@ -0,0 +1,69 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
interface MetricBarProps {
label: string;
value: number;
max?: number;
unit?: string;
/** "red" | "green" | "blue" | "slate" */
color?: 'red' | 'green' | 'blue' | 'slate';
className?: string;
}
const colorMap = {
red: 'bg-red-400',
green: 'bg-emerald-500',
blue: 'bg-blue-500',
slate: 'bg-slate-700',
};
export const MetricBar: React.FC<MetricBarProps> = ({
label,
value,
max = 100,
unit = '%',
color = 'slate',
className = '',
}) => {
const [animated, setAnimated] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const pct = Math.min(100, Math.round((value / max) * 100));
useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setAnimated(true);
observer.disconnect();
}
});
},
{ rootMargin: '50px' }
);
observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return (
<div ref={ref} className={`not-prose my-3 ${className}`}>
<div className="flex items-center justify-between mb-1.5">
<span className="text-sm font-semibold text-slate-700">{label}</span>
<span className="text-sm font-bold text-slate-900 tabular-nums">
{value}{unit}
</span>
</div>
<div className="h-3 bg-slate-100 rounded-full overflow-hidden border border-slate-200">
<div
className={`h-full rounded-full transition-all duration-1000 ease-out ${colorMap[color]}`}
style={{ width: animated ? `${pct}%` : '0%' }}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,108 @@
"use client";
import React from 'react';
import { cn } from "../utils/cn";
import { ComponentShareButton } from './ComponentShareButton';
import { Reveal } from './Reveal';
const data = [
{ time: 1, conversion: 100, label: "Ideal" },
{ time: 2, conversion: 93, label: "Gut" },
{ time: 3, conversion: 82, label: "Okay" },
{ time: 4, conversion: 65, label: "Kritisch" },
{ time: 5, conversion: 45, label: "Schlecht" },
{ time: 6, conversion: 30, label: "Verlust" },
{ time: 7, conversion: 20, label: "Verlust" },
{ time: 8, conversion: 12, label: "Verlust" },
];
export function PerformanceChart() {
const shareId = "performance-curve-v1";
return (
<Reveal direction="up" delay={0.1}>
<div id={shareId} className="w-full max-w-2xl mx-auto my-16 group relative transition-all duration-500 ease-out z-10 font-sans">
{/* Ambient Glow matching the homepage feel */}
<div className="absolute -inset-1 bg-gradient-to-r from-blue-100/30 to-indigo-100/30 rounded-[2rem] blur opacity-20 group-hover:opacity-40 transition duration-1000 -z-10" />
<div className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl shadow-sm group-hover:shadow-md group-hover:border-slate-200 transition-all duration-500 overflow-hidden relative">
{/* Subtle top shine */}
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white to-transparent opacity-80" />
{/* Header */}
<div className="border-b border-slate-100 px-6 py-5 flex justify-between items-center relative">
<div className="flex items-center gap-3">
<span className="w-2 h-2 rounded-full bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.6)] animate-pulse" />
<h4 className="text-sm font-bold tracking-widest text-slate-800 uppercase m-0">
Conversion Curve
</h4>
</div>
<div className="flex items-center gap-4">
<div className="font-mono text-[10px] text-slate-400 bg-slate-50 px-2 py-0.5 rounded border border-slate-100">
MODEL_ALPHA_V3
</div>
<div className="md:opacity-0 group-hover:opacity-100 transition-opacity duration-500">
<ComponentShareButton targetId={shareId} title="Performance & Conversion Curve" />
</div>
</div>
</div>
{/* Technical Chart Area */}
<div className="relative h-[340px] w-full p-8 flex flex-col justify-end bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTAgMGg0MHY0MEgwVjB6bTIwIDIwaDIwdjIwSDIweiIgZmlsbD0iI2Y4ZmFmYyIgZmlsbC1vcGFjaXR5PSIwLjUiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvc3ZnPg==')]">
{/* Critical Line */}
<div className="absolute top-8 bottom-16 left-[31.25%] w-[0.5px] border-l border-dashed border-red-200 z-0">
<div className="absolute top-0 -left-1 transform -translate-x-full text-[9px] font-bold text-red-400 uppercase tracking-widest whitespace-nowrap pr-3">
Kritische Schwelle &gt; 2.5s
</div>
</div>
<div className="flex items-end justify-between h-[240px] w-full gap-4 z-10 border-b border-slate-100 relative pb-px">
{data.map((d) => {
const isCritical = d.time > 2.5;
return (
<div key={d.time} className="flex-1 flex flex-col items-center justify-end relative h-full group/bar">
{/* Tooltip */}
<div className="absolute -top-12 opacity-0 group-hover/bar:opacity-100 transition-all duration-300 translate-y-2 group-hover/bar:translate-y-0 bg-white border border-slate-100 shadow-xl px-2.5 py-1.5 flex flex-col items-center pointer-events-none z-20 rounded-lg">
<span className="text-[11px] font-black text-slate-800 tracking-tighter">{d.conversion}% <span className="text-[9px] text-slate-400 font-medium">CVR</span></span>
</div>
{/* Value Line */}
<div className="w-full relative flex justify-center h-full items-end">
<div
className={cn(
"w-[1.5px] transition-all duration-500 relative",
isCritical ? "bg-red-400 group-hover/bar:bg-red-500" : "bg-slate-800 group-hover/bar:bg-blue-600"
)}
style={{ height: `${(d.conversion / 100) * 100}%` }}
>
<div className={cn("absolute -top-1.5 -left-1.5 w-3 h-3 rounded-full border-2 bg-white transition-all duration-300 group-hover/bar:scale-125 z-30",
isCritical ? "border-red-400 group-hover/bar:border-red-500" : "border-slate-800 group-hover/bar:border-blue-600"
)}></div>
{/* Glow for high values */}
{!isCritical && d.conversion > 80 && (
<div className="absolute -top-4 -left-4 w-8 h-8 rounded-full bg-blue-400/10 blur-md group-hover/bar:bg-blue-400/20 -z-10 transition-colors" />
)}
</div>
</div>
{/* X-Axis */}
<div className="absolute -bottom-8 flex flex-col items-center">
<span className="text-[10px] font-mono font-bold text-slate-400 group-hover/bar:text-slate-900 transition-colors">
{d.time}s
</span>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
</Reveal>
);
}

View File

@@ -1,6 +1,8 @@
'use client';
import React from 'react';
import { ComponentShareButton } from './ComponentShareButton';
import { Reveal } from './Reveal';
interface ChartItem {
label: string;
@@ -24,64 +26,87 @@ export const PremiumComparisonChart: React.FC<PremiumComparisonChartProps> = ({
items,
className = '',
}) => {
// Generate stable hash for share button
const shareId = `chart-${title.toLowerCase().replace(/[^a-z0-9]/g, '-')}`;
return (
<figure className={`not-prose my-16 border-t-[3px] border-slate-900 pt-8 ${className}`}>
<header className="mb-10 flex flex-col md:flex-row md:items-end md:justify-between gap-4">
<div>
<h3 className="text-xl md:text-2xl font-bold text-slate-900 tracking-tight m-0 uppercase flex items-center gap-3">
<span className="w-4 h-4 bg-slate-900 shrink-0" />
{title}
</h3>
{subtitle && <p className="font-mono text-xs text-slate-500 uppercase tracking-[0.2em] mt-2 leading-none m-0">{subtitle}</p>}
</div>
<div className="font-mono text-[10px] text-slate-400 uppercase tracking-[0.3em] flex gap-2">
<span>DATA_SYNC</span>
<span>/</span>
<span>V2</span>
</div>
</header>
<Reveal direction="up" delay={0.1}>
<figure
id={shareId}
className={`not-prose my-16 group relative transition-all duration-500 ease-out z-10 ${className}`}
>
{/* Ambient Background Glow matching the homepage feel */}
<div className="absolute -inset-1 bg-gradient-to-r from-slate-200 to-slate-100/50 rounded-[2rem] blur opacity-20 group-hover:opacity-40 transition duration-1000 -z-10" />
<div className="space-y-0 border-y border-slate-300">
{(items || []).map((item, index) => {
return (
<div key={index} className="grid grid-cols-1 md:grid-cols-12 gap-4 md:gap-8 items-center py-6 border-b border-slate-100 last:border-0 relative group">
<div className="md:col-span-4 flex flex-col">
<span className="font-bold text-slate-900 text-sm md:text-base tracking-tight">{item.label}</span>
{item.description && (
<span className="text-[11px] font-mono text-slate-500 mt-1 max-w-[200px] leading-tight">{item.description}</span>
)}
</div>
<div className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl shadow-sm group-hover:shadow-md group-hover:border-slate-200 transition-all duration-500 overflow-hidden relative">
<div className="md:col-span-6 flex items-center pr-8 py-4 md:py-0">
<div className="h-[2px] w-full bg-slate-200 relative">
<div
className="absolute top-1/2 -translate-y-1/2 h-[4px]"
style={{
width: `${Math.min(100, (item.value / item.max) * 100)}%`,
backgroundColor: ({
red: '#ef4444',
green: '#10b981',
blue: '#3b82f6',
orange: '#f59e0b',
slate: '#0f172a',
} as Record<string, string>)[item.color || 'slate'] || '#0f172a'
}}
>
<div className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 w-4 h-4 bg-white border-[3px] rounded-full shadow-sm" style={{ borderColor: 'inherit' }} />
</div>
</div>
</div>
{/* Subtle top shine */}
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white to-transparent opacity-80" />
<div className="md:col-span-2 flex justify-start md:justify-end">
<span className={`text-3xl md:text-4xl font-black tabular-nums tracking-tighter leading-none ${item.color === 'green' ? 'text-emerald-600' : 'text-slate-900'}`}>
{item.value}
<span className="text-sm font-bold text-slate-400 ml-1">{item.unit || ''}</span>
</span>
</div>
<div className="p-6 md:p-8 lg:p-10 relative z-10">
{/* Share Button top right */}
<div className="absolute top-6 right-6 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-50">
<ComponentShareButton targetId={shareId} title={title} />
</div>
);
})}
</div>
</figure>
<header className="mb-8 flex flex-col md:flex-row md:justify-between md:items-end gap-2 border-b border-slate-100 pb-4">
<div>
<h3 className="text-xl md:text-2xl font-bold text-slate-900 tracking-tight m-0 flex items-center gap-3">
{/* Small pulsing indicator matching homepage style */}
<span className="w-2 h-2 rounded-full bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.6)] animate-pulse hidden md:block" />
{title}
</h3>
{subtitle && <p className="text-sm text-slate-500 mt-1 leading-snug m-0">{subtitle}</p>}
</div>
<div className="font-mono text-[10px] text-slate-400 uppercase tracking-[0.2em] bg-slate-50 px-2 py-1 rounded-full border border-slate-100 inline-flex items-center">
DATA_SYNC / V2
</div>
</header>
<div className="space-y-2">
{(items || []).map((item, index) => {
const percentage = Math.min(100, (item.value / item.max) * 100);
const colorMap: Record<string, string> = {
red: 'bg-red-500',
green: 'bg-emerald-500',
blue: 'bg-blue-500',
orange: 'bg-amber-500',
slate: 'bg-slate-800',
};
const barColor = colorMap[item.color || 'slate'] || colorMap.slate;
const isHighlight = item.color === 'green' || item.color === 'blue';
return (
<div key={index} className="grid grid-cols-1 md:grid-cols-12 gap-2 md:gap-6 items-center p-4 hover:bg-slate-50/50 rounded-xl transition-all duration-300 group/row border border-transparent hover:border-slate-100">
<div className="md:col-span-4 flex flex-col">
<span className={`font-bold text-sm md:text-base tracking-tight transition-colors duration-300 ${isHighlight ? 'text-slate-900' : 'text-slate-700 group-hover/row:text-slate-900'}`}>{item.label}</span>
{item.description && (
<span className="text-[11px] md:text-xs text-slate-500 mt-0.5 leading-tight">{item.description}</span>
)}
</div>
<div className="md:col-span-6 flex items-center py-2 md:py-0 w-full relative">
<div className="h-2 w-full bg-slate-100 rounded-full relative overflow-hidden">
<div
className={`absolute top-0 left-0 bottom-0 rounded-full transition-all duration-1000 ease-out group-hover/row:brightness-110 ${barColor}`}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
<div className="md:col-span-2 flex justify-start md:justify-end items-baseline gap-1">
<span className={`text-2xl md:text-3xl font-black tabular-nums tracking-tighter leading-none transition-colors duration-300 ${isHighlight ? 'text-slate-900' : 'text-slate-700 group-hover/row:text-slate-900'}`}>
{item.value}
</span>
<span className="text-xs font-bold text-slate-400">{item.unit || ''}</span>
</div>
</div>
);
})}
</div>
</div>
</div>
</figure>
</Reveal>
);
};

View File

@@ -29,7 +29,7 @@ const Reveal: React.FC<RevealProps> = ({
viewport = { once: true, margin: "-10%" },
}) => {
const ref = useRef(null);
const isInView = useInView(ref, {
useInView(ref, {
once: viewport.once ?? true,
margin: (viewport.margin as any) ?? "-10%",
amount: (viewport.amount as any) ?? 0.1,
@@ -37,10 +37,9 @@ const Reveal: React.FC<RevealProps> = ({
const mainControls = useAnimation();
useEffect(() => {
if (isInView) {
mainControls.start("visible");
}
}, [isInView, mainControls]);
// Force visibility immediately to prevent white screen
mainControls.start("visible");
}, [mainControls]);
const variants: Variants = {
hidden: {

View File

@@ -0,0 +1,188 @@
"use client";
import React, { useState, useMemo } from "react";
import { TrendingDown, Activity, Settings2, BarChart3 } from "lucide-react";
import { cn } from "../utils/cn";
import { ComponentShareButton } from "./ComponentShareButton";
import { Reveal } from "./Reveal";
export function RevenueLossCalculator() {
const [visitors, setVisitors] = useState(5000);
const [conversionRate] = useState(2.0);
const [orderValue, setOrderValue] = useState(150);
const [loadTime, setLoadTime] = useState(4.0);
const shareId = "revenue-loss-v1";
const LOSS_PER_SECOND = 0.07;
const BASE_SPEED = 2.5;
const results = useMemo(() => {
const delay = Math.max(0, loadTime - BASE_SPEED);
const dropFactor = 1 - (1 - LOSS_PER_SECOND) ** delay;
const currentOrders = visitors * (conversionRate / 100);
const currentRevenue = currentOrders * orderValue;
const potentialRevenue = currentRevenue / (1 - dropFactor);
const lostRevenue = potentialRevenue - currentRevenue;
const lostOrders = (potentialRevenue - currentRevenue) / orderValue;
return {
lostRevenue: Math.round(lostRevenue),
lostOrders: Math.round(lostOrders),
potentialRevenue: Math.round(potentialRevenue),
};
}, [visitors, conversionRate, orderValue, loadTime]);
return (
<Reveal direction="up" delay={0.1}>
<div id={shareId} className="w-full max-w-2xl mx-auto my-16 group relative transition-all duration-500 ease-out z-10 font-sans">
{/* Ambient Glow */}
<div className="absolute -inset-1 bg-gradient-to-r from-red-100/30 to-slate-100/30 rounded-[2.5rem] blur opacity-10 group-hover:opacity-30 transition duration-1000 -z-10" />
<div className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-3xl shadow-sm group-hover:shadow-md group-hover:border-slate-200 transition-all duration-500 overflow-hidden relative">
{/* Header */}
<div className="border-b border-slate-100 p-6 flex items-center justify-between relative z-20">
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-slate-50 border border-slate-100 group-hover:bg-red-50 group-hover:border-red-100 group-hover:text-red-500 transition-colors">
<TrendingDown className="w-5 h-5" />
</div>
<div>
<h4 className="text-sm font-black tracking-[0.2em] text-slate-800 uppercase m-0 leading-none">
REVENUE_SIMULATOR
</h4>
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-1">Performance x Conversion</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="hidden md:flex items-center gap-1.5 px-3 py-1 rounded-full bg-emerald-50 border border-emerald-100">
<Activity className="w-3 h-3 text-emerald-500 animate-pulse" />
<span className="text-[10px] font-black text-emerald-600 uppercase tracking-widest">LIVE_ENGINE</span>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-500">
<ComponentShareButton targetId={shareId} title="Performance Revenue Simulator" />
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-5 relative z-10">
{/* Controls */}
<div className="md:col-span-3 p-8 space-y-10 border-b md:border-b-0 md:border-r border-slate-100 bg-white/40">
{/* Traffic */}
<div className="space-y-5">
<div className="flex justify-between items-end">
<div className="flex items-center gap-2">
<Settings2 className="w-3 h-3 text-slate-400" />
<span className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Monatlicher Traffic</span>
</div>
<span className="text-xl font-black text-slate-900 tabular-nums leading-none">
{visitors.toLocaleString()} <span className="text-[10px] text-slate-400 font-bold">VISITS</span>
</span>
</div>
<div className="relative group/slider">
<input
type="range"
min="500" max="100000" step="500"
value={visitors}
onChange={(e) => setVisitors(Number(e.target.value))}
className="w-full h-1.5 bg-slate-100 rounded-full appearance-none outline-none [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-slate-800 [&::-webkit-slider-thumb]:rounded-full cursor-pointer hover:[&::-webkit-slider-thumb]:scale-110 [&::-webkit-slider-thumb]:transition-transform"
/>
</div>
</div>
{/* Order Value */}
<div className="space-y-5">
<div className="flex justify-between items-end">
<div className="flex items-center gap-2">
<BarChart3 className="w-3 h-3 text-slate-400" />
<span className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Ø Kundenwert</span>
</div>
<span className="text-xl font-black text-slate-900 tabular-nums leading-none">
{orderValue.toLocaleString()}
</span>
</div>
<div className="relative group/slider">
<input
type="range"
min="10" max="5000" step="10"
value={orderValue}
onChange={(e) => setOrderValue(Number(e.target.value))}
className="w-full h-1.5 bg-slate-100 rounded-full appearance-none outline-none [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-slate-800 [&::-webkit-slider-thumb]:rounded-full cursor-pointer hover:[&::-webkit-slider-thumb]:scale-110 [&::-webkit-slider-thumb]:transition-transform"
/>
</div>
</div>
{/* Load Time */}
<div className="space-y-5 pt-8 border-t border-slate-100 border-dashed">
<div className="flex justify-between items-end">
<div className="flex flex-col">
<span className="text-[10px] font-black text-slate-500 uppercase tracking-widest mb-1">Website Ladezeit</span>
<span className={cn("text-[8px] font-bold uppercase px-1.5 py-0.5 rounded w-fit",
loadTime > 2.5 ? "bg-red-50 text-red-500" : "bg-emerald-50 text-emerald-500"
)}>
{loadTime > 2.5 ? "Kritisch > 2.5s" : "Optimal < 2.5s"}
</span>
</div>
<span className={cn("text-3xl font-black tabular-nums tracking-tighter transition-colors",
loadTime > 2.5 ? "text-red-500" : "text-slate-900"
)}>
{loadTime.toFixed(1)}s
</span>
</div>
<div className="relative group/slider">
<input
type="range"
min="1.0" max="15.0" step="0.5"
value={loadTime}
onChange={(e) => setLoadTime(Number(e.target.value))}
className={cn("w-full h-1.5 rounded-full appearance-none outline-none [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:rounded-full cursor-pointer hover:[&::-webkit-slider-thumb]:scale-110 transition-all",
loadTime > 2.5 ? "bg-red-100 [&::-webkit-slider-thumb]:border-red-500" : "bg-slate-100 [&::-webkit-slider-thumb]:border-slate-800"
)}
/>
</div>
</div>
</div>
{/* Results */}
<div className="md:col-span-2 p-8 flex flex-col justify-center space-y-10 bg-slate-50/20 backdrop-blur-sm relative overflow-hidden">
{/* Decorative Background Icon */}
<TrendingDown className="absolute -bottom-10 -right-10 w-48 h-48 text-slate-200/20 rotate-12 -z-10" />
<div className="relative">
<span className="block text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-3">Entgangener Umsatz <span className="text-[8px] text-slate-300">(MTL.)</span></span>
<div className="flex items-baseline gap-2">
<span className={cn("text-5xl font-black tracking-tighter transition-colors",
results.lostRevenue > 0 ? "text-red-500" : "text-slate-900"
)}>
{results.lostRevenue.toLocaleString()}
</span>
</div>
</div>
<div className="relative">
<span className="block text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-3">Verlorene Leads <span className="text-[8px] text-slate-300">(MTL.)</span></span>
<div className="flex items-baseline gap-2">
<span className="text-4xl font-extrabold text-slate-800 tracking-tight tracking-tighter">
{results.lostOrders}
</span>
<span className="text-[10px] font-black text-slate-400 uppercase">Nutzer</span>
</div>
</div>
<div className="pt-6 border-t border-slate-100">
<p className="text-[9px] font-bold text-slate-300 uppercase tracking-[0.2em] leading-relaxed">
REF: GOOGLE + AKAMAI_DATA<br />
<span className="text-slate-400">(0.07 DROP RATE PER SECOND)</span>
</p>
</div>
</div>
</div>
</div>
</div>
</Reveal>
);
}

View File

@@ -49,7 +49,7 @@ export const Section: React.FC<SectionProps> = ({
return (
<section
className={cn(
"relative py-12 md:py-40 group overflow-hidden",
"relative py-8 md:py-16 group overflow-hidden",
bgClass,
borderTopClass,
borderBottomClass,

View File

@@ -4,7 +4,7 @@ import * as React from "react";
import { Modal } from "./Modal";
import { Copy, Check, Share2, Download } from "lucide-react";
import { useState, useEffect } from "react";
import IconBlack from "../assets/logo/Icon Black Transparent.svg";
import IconBlack from "../assets/logo/Icon-Black-Transparent.svg";
interface ShareModalProps {
isOpen: boolean;
@@ -35,55 +35,24 @@ export function ShareModal({
useEffect(() => {
if (diagramImage && isOpen) {
// Convert SVG to PNG for preview with higher resolution (3x)
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return;
const isDataUrl = diagramImage.startsWith("data:image/");
const img = new Image();
const svgBlob = new Blob([diagramImage], {
type: "image/svg+xml;charset=utf-8",
});
const svgUrl = URL.createObjectURL(svgBlob);
if (isDataUrl) {
// If it's already a Data URL (e.g. from html-to-image), we can display it immediately
setImagePreview(diagramImage);
} else {
// It's probably an SVG string, convert to Data URL
const svgBlob = new Blob([diagramImage], { type: "image/svg+xml;charset=utf-8" });
const svgUrl = URL.createObjectURL(svgBlob);
setImagePreview(svgUrl);
}
const logoImg = new Image();
logoImg.src = IconBlack.src || IconBlack;
// Optional: If we want to strictly apply the watermark via Canvas, we would do it here.
// But for the sake of getting the preview to work reliably first, just setting the imagePreview
// directly to the data URL or SVG blob URL is the safest approach. The watermark logic was
// likely failing because `IconBlack` wasn't resolving correctly or `img.onload` wasn't firing
// properly in all environments.
img.onload = () => {
const scale = 3; // 3x scaling for sharpness
canvas.width = img.width * scale;
canvas.height = img.height * scale;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw image with scaling
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// Add Watermark
const drawWatermark = () => {
const logoSize = Math.min(canvas.width, canvas.height) * 0.1; // 10% of smallest dimension
const padding = logoSize * 0.4;
const x = canvas.width - logoSize - padding;
const y = canvas.height - logoSize - padding;
ctx.save();
ctx.globalAlpha = 0.1; // Subtle watermark
ctx.drawImage(logoImg, x, y, logoSize, logoSize);
ctx.restore();
setImagePreview(canvas.toDataURL("image/png"));
URL.revokeObjectURL(svgUrl);
};
if (logoImg.complete) {
drawWatermark();
} else {
logoImg.onload = drawWatermark;
}
};
img.src = svgUrl;
}
}, [diagramImage, isOpen]);
@@ -162,14 +131,11 @@ export function ShareModal({
title={modalTitle}
maxWidth={diagramImage ? "max-w-3xl" : "max-w-lg"}
>
<div className="space-y-8">
<div className="space-y-4">
{imagePreview ? (
<div className="space-y-6">
<div className="space-y-4">
{/* Social Post Preview Section */}
<div className="space-y-3">
<label className="text-[10px] font-bold uppercase tracking-widest text-slate-400 ml-1">
Social Preview
</label>
<div className="space-y-2">
<div className="bg-slate-50 rounded-2xl border border-slate-100 overflow-hidden shadow-inner">
<div className="p-4 flex items-center gap-3 border-b border-white/50">
<div className="w-8 h-8 rounded-full bg-slate-200 animate-pulse" />
@@ -194,11 +160,11 @@ export function ShareModal({
}}
/>
<div className="relative p-6 flex flex-col items-center">
<div className="relative p-2 md:p-4 flex flex-col items-center w-full">
<img
src={imagePreview}
alt={title || "Diagram"}
className="max-w-full max-h-[30vh] object-contain transition-transform duration-700"
className="w-full max-h-[45vh] object-contain transition-transform duration-700"
/>
</div>

View File

@@ -4,7 +4,7 @@ import React from "react";
import { motion } from "framer-motion";
import { cn } from "../utils/cn";
import Image from "next/image";
import LogoBlack from "../assets/logo/Logo Black Transparent.svg";
import LogoBlack from "../assets/logo/Logo-Black-Transparent.svg";
interface SignatureProps {
className?: string;

View File

@@ -0,0 +1,148 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import { ComponentShareButton } from './ComponentShareButton';
import { Reveal } from './Reveal';
interface StatItem {
value: string;
label: string;
subtext?: string;
}
interface StatsGridProps {
/**
* Pipe-delimited stats. Each stat: "value|label|subtext" separated by ~
* Example: "53%|Mehr Umsatz|Rakuten 24~33%|Conversion Boost|nach CWV Fix"
*/
stats: string;
className?: string;
}
function parseStats(raw: string): StatItem[] {
return raw
.split('~')
.map(s => s.trim())
.filter(Boolean)
.map(s => {
const [value = '', label = '', subtext] = s.split('|').map(p => p.trim());
return { value, label, subtext };
});
}
function AnimatedValue({ value, isVisible }: { value: string; isVisible: boolean }) {
const [display, setDisplay] = useState('');
const numMatch = value.match(/^([+-]?)(\d+(?:[.,]\d+)?)(.*)/);
const prefix = numMatch?.[1] ?? '';
const numStr = numMatch?.[2] ?? '';
const suffix = numMatch?.[3] ?? value;
const target = parseFloat(numStr.replace(',', '.')) || 0;
const hasDecimals = numStr.includes('.') || numStr.includes(',');
useEffect(() => {
if (!isVisible || !numStr) {
setDisplay(value);
return;
}
const duration = 1500;
const steps = 60;
const stepTime = duration / steps;
let step = 0;
const timer = setInterval(() => {
step++;
const progress = Math.min(step / steps, 1);
// Ease out expo
const eased = 1 - Math.pow(2, -10 * progress);
const current = target * eased;
const formatted = hasDecimals ? current.toFixed(1) : Math.round(current).toString();
setDisplay(`${prefix}${formatted}${suffix}`);
if (step >= steps) {
clearInterval(timer);
setDisplay(value);
}
}, stepTime);
return () => clearInterval(timer);
}, [isVisible, value, prefix, suffix, target, hasDecimals, numStr]);
return <>{display || value}</>;
}
const gradients = [
'from-blue-500/10 to-indigo-500/5',
'from-emerald-500/10 to-teal-500/5',
'from-violet-500/10 to-purple-500/5',
'from-amber-500/10 to-orange-500/5',
];
export const StatsGrid: React.FC<StatsGridProps> = ({ stats, className = '' }) => {
if (!stats || typeof stats !== 'string') return null;
const items = parseStats(stats);
const ref = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
const shareId = `statsgrid-${React.useId().replace(/:/g, "")}`;
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ threshold: 0.2 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
const cols = (items?.length || 0) <= 2 ? 'grid-cols-2' : (items?.length || 0) === 3 ? 'grid-cols-3' : 'grid-cols-2 md:grid-cols-4';
return (
<Reveal direction="up" delay={0.1}>
<div ref={ref} id={shareId} className={`not-prose my-16 group relative transition-all duration-500 ease-out z-10 ${className}`}>
{/* Ambient Glow for the entire grid */}
<div className="absolute -inset-2 bg-gradient-to-br from-slate-100/50 to-white/30 rounded-[2.5rem] blur opacity-20 group-hover:opacity-40 transition duration-1000 -z-10" />
{/* Main Grid Container */}
<div className="glass bg-white/60 backdrop-blur-xl border border-slate-100 rounded-3xl shadow-sm group-hover:shadow-md group-hover:border-slate-200 transition-all duration-500 overflow-hidden p-3 md:p-4">
{/* Share Button top right */}
<div className="absolute top-4 right-4 md:top-6 md:right-6 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-50">
<ComponentShareButton targetId={shareId} title="Performance Stats Grid" />
</div>
<div className={`grid ${cols} gap-3 md:gap-4 mt-8 md:mt-0`}>
{items.map((item, i) => (
<div
key={i}
className={`group/item relative flex flex-col items-center justify-center p-6 md:p-8 bg-gradient-to-br ${gradients[i % gradients.length]} border border-slate-100/50 rounded-2xl text-center transition-all duration-500 hover:bg-white hover:border-slate-200 hover:shadow-sm`}
>
<span className={`text-3xl md:text-4xl lg:text-5xl font-black text-slate-900 tracking-tighter tabular-nums leading-none transition-transform duration-500 group-hover/item:scale-110`}>
<AnimatedValue value={item.value} isVisible={isVisible} />
</span>
<span className="text-[10px] md:text-xs font-black text-slate-500 mt-3 uppercase tracking-[0.2em] leading-tight">
{item.label}
</span>
{item.subtext && (
<span className="text-[9px] md:text-[10px] font-bold text-slate-400 mt-1.5 leading-snug">
{item.subtext}
</span>
)}
{/* Item-specific ambient glow on hover */}
<div className="absolute inset-0 bg-white/0 group-hover/item:bg-white/10 transition-colors pointer-events-none rounded-2xl" />
</div>
))}
</div>
</div>
</div>
</Reveal>
);
};

View File

@@ -0,0 +1,136 @@
'use client';
import React, { useEffect, useState } from 'react';
interface TocItem {
label: string;
href: string;
subItems?: TocItem[];
}
interface TableOfContentsProps {
items?: TocItem[];
className?: string;
}
export const TableOfContents: React.FC<TableOfContentsProps> = ({ items, className = '' }) => {
// Falls props rein kommen, diese nutzen, ansonsten leeres Array
const initialItems = items || [];
const [dynamicItems, setDynamicItems] = useState<TocItem[]>(initialItems);
useEffect(() => {
if (initialItems.length > 0) return;
// Auto-discover headings if no items were passed
const headings = document.querySelectorAll('h2, h3');
const newItems: TocItem[] = [];
let currentH2: TocItem | null = null;
headings.forEach((heading) => {
// Ensure the heading has an ID
if (!heading.id) {
heading.id = heading.textContent?.toLowerCase().replace(/[^a-z0-9]+/g, '-') || `heading-${Math.random().toString(36).substring(2, 9)}`;
}
const item: TocItem = {
label: heading.textContent || '',
href: `#${heading.id}`,
};
if (heading.tagName === 'H2') {
currentH2 = { ...item, subItems: [] };
newItems.push(currentH2);
} else if (heading.tagName === 'H3' && currentH2) {
currentH2.subItems?.push(item);
} else if (heading.tagName === 'H3' && !currentH2) {
// If an H3 appears before any H2, just add it to root
newItems.push(item);
}
});
// Verhindern vom loop via prev state check
setDynamicItems(prev => {
if (JSON.stringify(prev) === JSON.stringify(newItems)) return prev;
return newItems;
});
// Wir brauchen hier keine deps, da wir nur einmal beim Mount des Beitrags scannen wollen.
// Sollte items sich ändern, ist das ein Bug der AI, aber als Fallback ok.
}, []);
const displayItems = dynamicItems;
if (displayItems.length === 0) return null;
// JSON-LD for SiteNavigationElement
const jsonLd = {
"@context": "https://schema.org",
"@type": "ItemList",
"itemListElement": displayItems.map((item, index) => ({
"@type": "SiteNavigationElement",
"position": index + 1,
"name": item.label,
"url": `https://mintel.me${item.href}`
}))
};
return (
<div className={`not-prose my-12 p-8 bg-slate-50 border border-slate-200 rounded-2xl ${className}`}>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-6 border-b border-slate-200 pb-2">
Inhaltsverzeichnis
</p>
<nav>
<ul className="space-y-3 m-0 list-none p-0">
{displayItems.map((item, index) => (
<li key={index} className="m-0 p-0">
<a
href={item.href}
onClick={(e) => {
e.preventDefault();
const targetId = item.href.substring(1);
const elem = document.getElementById(targetId);
if (elem) {
const y = elem.getBoundingClientRect().top + window.scrollY - 144;
window.scrollTo({ top: y, behavior: 'smooth' });
window.history.pushState(null, '', item.href);
}
}}
className="text-slate-700 hover:text-blue-600 font-medium transition-colors no-underline block"
>
{item.label}
</a>
{item.subItems && item.subItems.length > 0 && (
<ul className="mt-2 ml-4 space-y-2 border-l-2 border-slate-200 pl-4 m-0 list-none">
{item.subItems.map((subItem, subIndex) => (
<li key={subIndex} className="m-0 p-0">
<a
href={subItem.href}
onClick={(e) => {
e.preventDefault();
const targetId = subItem.href.substring(1);
const elem = document.getElementById(targetId);
if (elem) {
const y = elem.getBoundingClientRect().top + window.scrollY - 144;
window.scrollTo({ top: y, behavior: 'smooth' });
window.history.pushState(null, '', subItem.href);
}
}}
className="text-slate-500 hover:text-blue-500 text-sm transition-colors no-underline block"
>
{subItem.label}
</a>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</nav>
</div>
);
};

View File

@@ -1,4 +1,7 @@
'use client';
import * as React from 'react';
import { Tweet } from 'react-tweet';
interface TwitterEmbedProps {
tweetId: string;
@@ -7,45 +10,20 @@ interface TwitterEmbedProps {
align?: 'left' | 'center' | 'right';
}
export async function TwitterEmbed({
export function TwitterEmbed({
tweetId,
theme = 'light',
className = "",
align = 'center'
}: TwitterEmbedProps) {
let embedHtml = '';
try {
const oEmbedUrl = `https://publish.twitter.com/oembed?url=https://twitter.com/twitter/status/${tweetId}&theme=${theme}`;
const response = await fetch(oEmbedUrl);
if (response.ok) {
const data = await response.json();
embedHtml = data.html || '';
} else {
console.warn(`Twitter oEmbed failed for tweet ${tweetId}: ${response.status}`);
}
} catch (error) {
console.warn(`Failed to fetch Twitter embed for ${tweetId}:`, error);
}
const alignmentClass = align === 'left' ? 'mr-auto ml-0' : align === 'right' ? 'ml-auto mr-0' : 'mx-auto';
return (
<div className={`not-prose ${className} ${alignmentClass} w-4/5`} data-theme={theme} data-align={align}>
{embedHtml ? (
<div dangerouslySetInnerHTML={{ __html: embedHtml }} />
) : (
<div className="p-6 bg-slate-50 border border-dashed border-slate-200 rounded-lg text-center text-slate-600 text-sm flex flex-col items-center gap-2">
<svg className="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M23 3a10.9 10.9 0 01-3.14 1.53 4.48 4.48 0 00-7.86 3v1A10.66 10.66 0 013 4s-4 9 5 13a11.64 11.64 0 01-7 2c9 5 20 0 20-11.5a4.5 4.5 0 00-.08-.83A7.72 7.72 0 0023 3z"/>
</svg>
<span>Unable to load tweet</span>
<a href={`https://twitter.com/i/status/${tweetId}`} target="_blank" rel="noopener noreferrer" className="text-slate-600 hover:text-slate-900 font-medium text-sm">
View on Twitter
</a>
</div>
)}
<div className={`not-prose ${className} ${alignmentClass} flex justify-center w-full my-8 min-h-[100px]`}>
<div className={theme === 'dark' ? 'dark' : 'light'}>
{/* We use our local API proxy to avoid CORS/404 issues with the public Vercel proxy */}
<Tweet id={tweetId} apiUrl={`/api/tweet/${tweetId}`} />
</div>
</div>
);
}

View File

@@ -1,16 +1,13 @@
'use client';
"use client";
import React from 'react';
import { ComponentShareButton } from './ComponentShareButton';
import { Reveal } from './Reveal';
interface WaterfallEvent {
name: string;
/** Start time in ms */
start: number;
/** Duration in ms */
duration: number;
/** Optional color class (e.g. bg-blue-500) or hex */
color?: string;
description?: string;
}
interface WaterfallChartProps {
@@ -25,7 +22,7 @@ export const WaterfallChart: React.FC<WaterfallChartProps> = ({ title = 'Resourc
const getDefaultColor = (name: string) => {
const n = name.toLowerCase();
if (n.includes('html') || n.includes('document')) return 'bg-slate-900';
if (n.includes('html') || n.includes('document')) return 'bg-slate-800';
if (n.includes('js') || n.includes('script')) return 'bg-amber-400';
if (n.includes('css') || n.includes('style')) return 'bg-blue-400';
if (n.includes('img') || n.includes('image')) return 'bg-emerald-400';
@@ -33,59 +30,71 @@ export const WaterfallChart: React.FC<WaterfallChartProps> = ({ title = 'Resourc
return 'bg-slate-300';
};
// Generate stable hash for share button
const shareId = `waterfall-${title.toLowerCase().replace(/[^a-z0-9]/g, '-')}`;
return (
<section className="my-16 not-prose font-sans">
<header className="mb-6 flex justify-between items-end border-b-2 border-slate-900 pb-2">
<h3 className="text-xl md:text-2xl font-bold text-slate-900 tracking-tight m-0">{title}</h3>
<div className="font-mono text-sm text-slate-500">{maxTime}ms</div>
</header>
<Reveal direction="up" delay={0.1}>
<figure id={shareId} className="my-16 not-prose font-sans group relative transition-all duration-500 ease-out z-10">
{/* Ambient Background Glow matching the homepage feel */}
<div className="absolute -inset-1 bg-gradient-to-r from-blue-100/30 to-slate-100/30 rounded-[2rem] blur opacity-20 group-hover:opacity-40 transition duration-1000 -z-10" />
<div className="relative">
{/* Raw Grid Lines */}
<div className="absolute inset-x-0 top-0 bottom-0 flex justify-between pointer-events-none z-0">
{[0, 0.25, 0.5, 0.75, 1].map((tick) => (
<div key={tick} className="w-px h-full bg-slate-200 flex flex-col justify-between">
<span className="text-[10px] text-slate-400 font-mono -ml-4 -mt-4 bg-white px-1">
{Math.round(maxTime * tick)}
</span>
<div className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl shadow-sm group-hover:shadow-md group-hover:border-slate-200 transition-all duration-500 overflow-hidden relative">
{/* Subtle top shine */}
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white to-transparent opacity-80" />
<div className="p-6 md:p-8 lg:p-10 relative z-10">
{/* Share Button top right */}
<div className="absolute top-6 right-6 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-50">
<ComponentShareButton targetId={shareId} title={title} />
</div>
))}
</div>
<div className="relative z-10 pt-8 pb-4">
{events.map((event, i) => {
const left = (event.start / maxTime) * 100;
const width = Math.max((event.duration / maxTime) * 100, 0.5);
return (
<div key={i} className="group relative flex items-center h-8 mb-2">
<div className="w-32 md:w-48 shrink-0 pr-4 flex justify-between items-center bg-white z-20">
<span className="font-bold text-slate-900 text-[11px] md:text-xs truncate uppercase tracking-tight">{event.name}</span>
<span className="font-mono text-slate-400 text-[10px]">{event.duration}ms</span>
</div>
<div className="flex-1 relative h-full flex items-center">
<div
className={`h-[4px] relative ${event.color || getDefaultColor(event.name)}`}
style={{
marginLeft: `${left}%`,
width: `${width}%`,
minWidth: '2px'
}}
>
<div className="absolute top-1/2 left-0 w-2 h-2 -translate-y-1/2 -translate-x-1/2 rounded-full border border-white" style={{ backgroundColor: 'inherit' }} />
</div>
{event.description && (
<div className="absolute left-0 -top-6 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap bg-slate-900 text-white text-[10px] font-mono px-2 py-1 rounded-sm pointer-events-none z-30 shadow-lg">
{event.description}
</div>
)}
</div>
<header className="mb-8 flex flex-col md:flex-row md:justify-between md:items-end gap-2 border-b border-slate-100 pb-4">
<h3 className="text-xl md:text-2xl font-bold text-slate-900 tracking-tight m-0">{title}</h3>
<div className="font-mono text-xs text-slate-400 bg-slate-50 px-3 py-1 rounded-full border border-slate-100 inline-flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-green-500 rounded-full animate-pulse" />
Total Time: {maxTime}ms
</div>
);
})}
</header>
<div className="relative pt-6 pb-2">
{/* Elegant Grid Lines */}
<div className="absolute inset-x-0 top-0 bottom-0 flex justify-between pointer-events-none z-0 ml-[100px] md:ml-[160px]">
{[0, 0.25, 0.5, 0.75, 1].map((tick) => (
<div key={tick} className="w-px h-full bg-slate-100 flex flex-col justify-start">
<span className="text-[9px] text-slate-400 font-mono -ml-3 -mt-5 bg-white/80 backdrop-blur-sm px-1.5 py-0.5 rounded border border-slate-100 shadow-sm">
{Math.round(maxTime * tick)}
</span>
</div>
))}
</div>
<div className="relative z-10 space-y-3">
{events.map((event, i) => {
const left = (event.start / maxTime) * 100;
const width = Math.max((event.duration / maxTime) * 100, 0.5);
return (
<div key={i} className="group/row relative flex items-center h-10 w-full hover:bg-slate-50/50 rounded-lg transition-colors px-2 -mx-2">
<div className="w-[100px] md:w-[160px] shrink-0 pr-4 flex flex-col justify-center z-20">
<span className="font-bold text-slate-700 text-[10px] md:text-xs truncate uppercase tracking-widest">{event.name}</span>
</div>
<div className="flex-1 relative h-full flex items-center">
<div
className={`h-2.5 md:h-3 rounded-full relative shadow-sm transition-all duration-300 group-hover/row:scale-y-110 group-hover/row:brightness-110 ${getDefaultColor(event.name)}`}
style={{ left: `${left}%`, width: `${width}%` }}
/>
</div>
<span className="w-12 text-right font-mono text-[9px] md:text-xs text-slate-400 group-hover/row:text-slate-900 transition-colors z-20">{event.duration}ms</span>
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
</section>
</figure>
</Reveal>
);
};

View File

@@ -1,12 +1,12 @@
'use client';
"use client";
import React from 'react';
import { ComponentShareButton } from './ComponentShareButton';
import { Reveal } from './Reveal';
interface WebVitalsScoreProps {
values: {
/** Largest Contentful Paint in seconds (e.g. 2.5) */
lcp: number;
/** Interaction to Next Paint in milliseconds (e.g. 200) */
inp: number;
/** Cumulative Layout Shift (e.g. 0.1) */
cls: number;
@@ -30,46 +30,61 @@ export const WebVitalsScore: React.FC<WebVitalsScoreProps> = ({ values, descript
];
const getColors = (status: string) => {
if (status === 'good') return 'text-emerald-600 border-emerald-500';
if (status === 'needs-improvement') return 'text-amber-500 border-amber-400';
return 'text-red-500 border-red-500';
if (status === 'good') return { text: 'text-slate-900', bg: 'bg-emerald-50/50', border: 'border-emerald-200/50', glow: '' };
if (status === 'needs-improvement') return { text: 'text-slate-900', bg: 'bg-amber-50/50', border: 'border-amber-200/50', glow: '' };
return { text: 'text-slate-900', bg: 'bg-red-50/50', border: 'border-red-200/50', glow: '' };
};
// Generate stable hash for share button
const shareId = `vitals-${React.useId().replace(/:/g, "")}`;
return (
<section className="my-16 not-prose border-[2px] border-slate-900 p-8 md:p-12 relative bg-white">
<div className="absolute -top-[14px] left-8 bg-white px-4">
<h3 className="text-xl font-bold text-slate-900 tracking-tight m-0 uppercase flex items-center gap-3">
<div className="w-3 h-3 bg-slate-900 rotate-45" />
Core Web Vitals
</h3>
</div>
<Reveal direction="up" delay={0.1}>
<figure id={shareId} className="not-prose my-16 group relative transition-all duration-500 ease-out z-10">
{/* Ambient Background Glow matching the homepage feel */}
<div className="absolute -inset-1 bg-gradient-to-r from-slate-100/50 to-slate-200/30 rounded-[2rem] blur opacity-20 group-hover:opacity-40 transition duration-1000 -z-10" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-12 mt-4">
{metrics.map((m) => {
const colors = getColors(m.stat);
return (
<div key={m.id} className="flex flex-col">
<span className="text-[10px] font-mono text-slate-500 uppercase tracking-widest mb-1">{m.label}</span>
<div className={`text-4xl md:text-5xl font-black tracking-tighter tabular-nums ${colors.split(' ')[0]} border-b-[3px] ${colors.split(' ')[1]} pb-2 mb-2`}>
{m.value}<span className="text-lg ml-1 font-bold">{m.unit}</span>
</div>
<div className="flex justify-between items-start gap-2">
<span className={`text-[10px] font-mono font-bold uppercase ${colors.split(' ')[0]}`}>{m.stat.replace('-', ' ')}</span>
<span className="text-[10px] text-slate-500 leading-snug text-right max-w-[120px]">{m.desc}</span>
</div>
<div className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl shadow-sm group-hover:shadow-md group-hover:border-slate-200 transition-all duration-500 overflow-hidden relative">
{/* Subtle top shine */}
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white to-transparent opacity-80" />
<div className="p-8 md:p-10 relative z-10">
{/* Share Button top right */}
<div className="absolute top-6 right-6 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-50">
<ComponentShareButton targetId={shareId} title="Core Web Vitals Scores" />
</div>
);
})}
</div>
{description && (
<div className="mt-10 p-5 bg-slate-50 border-l-2 border-slate-900">
<p className="text-sm text-slate-800 m-0 leading-relaxed font-serif">
<span className="font-mono text-[10px] font-bold uppercase text-slate-900 tracking-widest block mb-2">Analyse</span>
{description}
</p>
<header className="mb-10 flex flex-col gap-2 border-b border-slate-100 pb-4 pr-16 md:pr-0">
<div>
<h3 className="text-xl md:text-2xl font-bold text-slate-900 tracking-tight m-0 flex items-center gap-3">
<span className="w-2 h-2 rounded-full bg-slate-800" />
Core Web Vitals
</h3>
{description && <p className="text-sm text-slate-500 mt-1 leading-snug m-0">{description}</p>}
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
{metrics.map((metric, i) => {
const colors = getColors(metric.stat);
return (
<div key={i} className={`flex flex-col gap-2 p-6 rounded-2xl border border-transparent hover:border-slate-200 hover:bg-slate-50 transition-all duration-300 group/metric`}>
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{metric.label}</span>
<div className="flex items-baseline gap-1">
<span className={`text-4xl md:text-5xl font-bold tracking-tighter transition-transform duration-500 group-hover/metric:scale-110 origin-left ${colors.text}`}>
{metric.value}
</span>
{metric.unit && <span className="text-sm font-mono text-slate-400">{metric.unit}</span>}
</div>
<span className="text-xs leading-relaxed text-slate-600 font-medium mt-1">{metric.desc}</span>
</div>
);
})}
</div>
</div>
</div>
)}
</section>
</figure>
</Reveal>
);
};

View File

@@ -1,4 +1,4 @@
import React from 'react';
import * as React from 'react';
interface YouTubeEmbedProps {
videoId: string;
@@ -8,35 +8,19 @@ interface YouTubeEmbedProps {
style?: 'default' | 'minimal' | 'rounded' | 'flat';
}
export const YouTubeEmbed: React.FC<YouTubeEmbedProps> = ({
videoId,
title = "YouTube Video",
className = "",
aspectRatio = "56.25%",
style = "default"
}) => {
const embedUrl = `https://www.youtube.com/embed/${videoId}`;
export function YouTubeEmbed({ videoId, title, className = "" }: YouTubeEmbedProps) {
return (
<div className={`not-prose my-6 ${className}`} data-style={style}>
<div
className="bg-white border border-slate-200/80 rounded-xl overflow-hidden transition-all duration-200 hover:border-slate-300/80 p-1"
style={{
paddingBottom: `calc(${aspectRatio} - 0.5rem)`,
position: 'relative',
height: 0,
marginTop: '0.25rem',
marginBottom: '0.25rem'
}}
>
<div className={`not-prose my-12 mx-auto w-full max-w-4xl rounded-2xl overflow-hidden shadow-xl ring-1 ring-slate-900/5 ${className}`}>
<div className="relative w-full aspect-video bg-slate-100 flex items-center justify-center">
<iframe
src={embedUrl}
title={title}
className="absolute inset-0 w-full h-full"
src={`https://www.youtube-nocookie.com/embed/${videoId}?rel=0&modestbranding=1`}
title={title || "YouTube video player"}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="absolute top-1 left-1 w-[calc(100%-0.5rem)] h-[calc(100%-0.5rem)] border-none rounded-md"
loading="lazy"
/>
</div>
</div>
);
};
}

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
import { useSafePathname } from "./useSafePathname";
import { useAnalytics } from "./useAnalytics";
import { AnalyticsEvents } from "./analytics-events";
@@ -11,7 +11,7 @@ import { AnalyticsEvents } from "./analytics-events";
* Fires events at 25%, 50%, 75%, and 100% depth.
*/
export function ScrollDepthTracker() {
const pathname = usePathname();
const pathname = useSafePathname();
const { trackEvent } = useAnalytics();
const trackedDepths = useRef<Set<number>>(new Set());

View File

@@ -0,0 +1,24 @@
"use client";
import { usePathname, useSearchParams } from "next/navigation";
export function useSafePathname(): string | null {
try {
return usePathname();
} catch (error) {
if (process.env.NODE_ENV === "development") {
console.warn("Caught usePathname exception (likely Next.js static prerender bug):", error);
}
return null;
}
}
export function useSafeSearchParams() {
try {
return useSearchParams();
} catch (error) {
if (process.env.NODE_ENV === "development") {
console.warn("Caught useSearchParams exception (likely Next.js static prerender bug):", error);
}
return null;
}
}

View File

@@ -11,6 +11,7 @@ interface BlogPostHeaderProps {
date: string;
readingTime: number;
slug: string;
thumbnail?: string;
}
export const BlogPostHeader: React.FC<BlogPostHeaderProps> = ({
@@ -19,6 +20,7 @@ export const BlogPostHeader: React.FC<BlogPostHeaderProps> = ({
date,
readingTime,
slug,
thumbnail,
}) => {
return (
<header className="pt-32 pb-8 md:pt-40 md:pb-12 max-w-4xl mx-auto px-5 md:px-0">
@@ -41,6 +43,30 @@ export const BlogPostHeader: React.FC<BlogPostHeaderProps> = ({
</div>
</Reveal>
{thumbnail && (
<Reveal delay={0.1}>
<div className="relative group my-12 md:my-16">
{/* Architectural Border/Frame */}
<div className="absolute -inset-4 border border-slate-100 rounded-[2.5rem] -z-10 group-hover:scale-[1.01] transition-transform duration-700" />
<div className="absolute -inset-2 border border-slate-200/50 rounded-[2.2rem] -z-10 group-hover:scale-[1.005] transition-transform duration-500" />
<div className="relative aspect-[21/9] w-full overflow-hidden rounded-3xl border border-slate-200 bg-slate-50 shadow-2xl shadow-slate-200/50">
<img
src={thumbnail}
alt={title}
className="w-full h-full object-cover grayscale-[0.2] group-hover:grayscale-0 group-hover:scale-105 transition-all duration-1000"
/>
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/20 to-transparent mix-blend-multiply" />
</div>
{/* Technical label */}
<div className="absolute top-4 right-4 px-3 py-1 bg-white/90 backdrop-blur-sm border border-slate-200 rounded text-[8px] font-mono text-slate-500 uppercase tracking-widest">
Visual_Blueprint_Ref: {slug?.substring(0, 8).toUpperCase()}
</div>
</div>
</Reveal>
)}
<Reveal delay={0.2}>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 py-6 border-y border-slate-100">
<div className="flex items-center gap-6 text-[10px] font-bold text-slate-400 uppercase tracking-[0.2em]">
@@ -59,11 +85,11 @@ export const BlogPostHeader: React.FC<BlogPostHeaderProps> = ({
{slug?.substring(0, 4).toUpperCase() || "BLOG"}-
{slug
? slug
.split("")
.reduce((acc, char) => acc + char.charCodeAt(0), 0)
.toString(16)
.toUpperCase()
.padStart(4, "0")
.split("")
.reduce((acc, char) => acc + char.charCodeAt(0), 0)
.toString(16)
.toUpperCase()
.padStart(4, "0")
: "0000"}
</span>
<span

View File

@@ -12,11 +12,35 @@ import { DiagramTimeline } from '../components/DiagramTimeline';
import { DiagramGantt } from '../components/DiagramGantt';
import { DiagramPie } from '../components/DiagramPie';
import { DiagramSequence } from '../components/DiagramSequence';
import { DiagramFlow } from '../components/DiagramFlow';
import { IconList, IconListItem } from '../components/IconList';
import { ArticleMeme } from '../components/ArticleMeme';
import { MemeCard } from '../components/MemeCard';
import { ExternalLink } from '../components/ExternalLink';
import { StatsGrid } from '../components/StatsGrid';
import { MetricBar } from '../components/MetricBar';
import { ArticleQuote } from '../components/ArticleQuote';
import { BoldNumber } from '../components/BoldNumber';
import { WebVitalsScore } from '../components/WebVitalsScore';
import { WaterfallChart } from '../components/WaterfallChart';
import { Button } from '../components/Button';
import { TrackedLink } from '../components/analytics/TrackedLink';
import { FAQSection } from '../components/FAQSection';
import { PremiumComparisonChart } from '../components/PremiumComparisonChart';
import { ImageText } from '../components/ImageText';
import { Carousel } from '../components/Carousel';
import { Section } from '../components/Section';
import { Reveal } from '../components/Reveal';
import { TableOfContents } from '../components/TableOfContents';
import { RevenueLossCalculator } from "../components/RevenueLossCalculator";
import { PerformanceChart } from "../components/PerformanceChart";
import { TwitterEmbed } from '../components/TwitterEmbed';
import { YouTubeEmbed } from '../components/YouTubeEmbed';
import { LinkedInEmbed } from '../components/LinkedInEmbed';
export const mdxComponents = {
// Named exports for explicit MDX usage
@@ -35,9 +59,30 @@ export const mdxComponents = {
DiagramGantt,
DiagramPie,
DiagramSequence,
DiagramFlow,
IconList,
IconListItem,
ArticleMeme,
MemeCard,
ExternalLink,
StatsGrid,
MetricBar,
ArticleQuote,
BoldNumber,
WebVitalsScore,
WaterfallChart,
PremiumComparisonChart,
ImageText,
Carousel,
Section,
Reveal
Reveal,
TableOfContents,
RevenueLossCalculator,
PerformanceChart,
TwitterEmbed,
YouTubeEmbed,
LinkedInEmbed,
Button,
TrackedLink,
FAQSection
};

View File

@@ -1,55 +1,271 @@
import { ComponentDefinition } from '@mintel/content-engine';
/**
* Single Source of Truth for all MDX component definitions.
* Used by:
* - content-engine.config.ts (for the optimization script)
* - The AI content pipeline (for component injection)
*
* Keep in sync with: src/content-engine/components.ts (the MDX runtime registry)
*/
export const componentDefinitions: ComponentDefinition[] = [
{
name: 'LeadParagraph',
description: 'Large, introductory text for the beginning of the article.',
usageExample: '<LeadParagraph>First meaningful sentence.</LeadParagraph>'
description: 'Larger, emphasized paragraph for the article introduction. Use 1-3 at the start.',
usageExample: '<LeadParagraph>\n Unternehmen investieren oft Unsummen in glänzende Oberflächen, während das technische Fundament bröckelt.\n</LeadParagraph>'
},
{
name: 'H2',
description: 'Section heading.',
usageExample: '<H2>Section Title</H2>'
description: 'Main section heading. Used for top-level content sections.',
usageExample: '<H2>Der wirtschaftliche Case</H2>'
},
{
name: 'BoldNumber',
description: 'Large centerpiece number with label for primary statistics.',
usageExample: '<BoldNumber value="5x" label="höhere Conversion-Rate" source="Portent" />'
},
{
name: 'PremiumComparisonChart',
description: 'Advanced chart for comparing performance metrics with industrial aesthetics.',
usageExample: '<PremiumComparisonChart title="TTFB Vergleich" items={[{ label: "Alt", value: 800, max: 1000, color: "red" }, { label: "Mintel", value: 50, max: 1000, color: "green" }]} />'
},
{
name: 'ImageText',
description: 'Layout component for image next to explanatory text.',
usageExample: '<ImageText image="/img.jpg" title="Architektur">Erklärung...</ImageText>'
},
{
name: 'Carousel',
description: 'Interactive swipeable slider for multi-step explanations. IMPORTANT: items array must contain at least 2 items with substantive title and content text (no empty content).',
usageExample: '<Carousel items={[{ title: "Schritt 1", content: "Analyse der aktuellen Performance..." }, { title: "Schritt 2", content: "Architektur-Optimierung..." }]} />'
},
{
name: 'H3',
description: 'Subsection heading.',
usageExample: '<H3>Subtitle</H3>'
description: 'Subsection heading. Used within H2 sections.',
usageExample: '<H3>Die drei Säulen meiner Umsetzung</H3>'
},
{
name: 'Paragraph',
description: 'Standard body text paragraph.',
usageExample: '<Paragraph>Some text...</Paragraph>'
description: 'Standard body text paragraph. All body text must be wrapped in this.',
usageExample: '<Paragraph>\n Mein System ist kein Kostenfaktor, sondern ein <Marker>ROI-Beschleuniger</Marker>.\n</Paragraph>'
},
{
name: 'ArticleBlockquote',
description: 'A prominent quote block for key insights.',
usageExample: '<ArticleBlockquote>Important quote</ArticleBlockquote>'
description: 'Styled blockquote for expert quotes or key statements.',
usageExample: '<ArticleBlockquote>\n Performance ist keine IT-Kennzahl, sondern ein ökonomischer Hebel.\n</ArticleBlockquote>'
},
{
name: 'Marker',
description: 'Yellow highlighter effect for very important phrases.',
usageExample: '<Marker>Highlighted Text</Marker>'
description: 'Inline highlight (yellow marker effect) for emphasizing key phrases within paragraphs.',
usageExample: '<Marker>entscheidender Wettbewerbsvorteil</Marker>'
},
{
name: 'ComparisonRow',
description: 'A component comparing a negative vs positive scenario.',
usageExample: '<ComparisonRow description="Cost Comparison" negativeLabel="Lock-In" negativeText="High costs" positiveLabel="Open" positiveText="Control" />'
description: 'Side-by-side comparison: negative "Standard" approach vs positive "Mintel" approach. Props include showShare boolean.',
usageExample: `<ComparisonRow
description="Architektur-Vergleich"
negativeLabel="Legacy CMS"
negativeText="Langsame Datenbankabfragen, verwundbare Plugins."
positiveLabel="Mintel Stack"
positiveText="Statische Generierung, perfekte Sicherheit."
showShare={true}
/>`
},
{
name: 'StatsDisplay',
description: 'A bold visual component to highlight a key statistic or number.',
usageExample: '<StatsDisplay value="42%" label="Cost Reduction" subtext="Average savings by switching to open standards." />'
description: 'A single large stat card with prominent value, label, and optional subtext.',
usageExample: '<StatsDisplay value="-20%" label="Conversion" subtext="Jede Sekunde Verzögerung kostet." />'
},
{
name: 'Mermaid',
description: 'Renders a Mermaid diagram.',
usageExample: '<Mermaid graph="graph TD..." id="my-diagram" />'
description: 'Renders a Mermaid.js diagram (flowchart, sequence, pie, etc.). Diagram code goes as children. Keep it tiny (max 3-4 nodes). Wrap in div with className="my-8".',
usageExample: `<div className="my-8">
<Mermaid id="my-diagram" title="System Architecture" showShare={true}>
graph TD
A["Request"] --> B["CDN Edge"]
B --> C["Static HTML"]
</Mermaid>
</div>`
},
{
name: 'DiagramFlow',
description: 'Structured flowchart diagram. Use for process flows, architecture diagrams, etc. Supports structured nodes/edges. direction defaults to LR.',
usageExample: `<DiagramFlow
nodes={[
{ id: "A", label: "Start" },
{ id: "B", label: "Process", style: "fill:#f00" }
]}
edges={[
{ from: "A", to: "B", label: "trigger" }
]}
title="Process Flow"
id="flow-1"
showShare={true}
/>`
},
{
name: 'DiagramPie',
description: 'Pie chart with structured data props.',
usageExample: `<DiagramPie
data={[
{ label: "JavaScript", value: 35 },
{ label: "CSS", value: 25 },
{ label: "Images", value: 20 }
]}
title="Performance Bottlenecks"
id="perf-pie"
showShare={true}
/>`
},
{
name: 'DiagramGantt',
description: 'Gantt timeline chart comparing durations of tasks.',
usageExample: `<DiagramGantt
tasks={[
{ id: "task-1", name: "Legacy: 4 Wochen", start: "2024-01-01", duration: "4w" },
{ id: "task-2", name: "Mintel: 1 Woche", start: "2024-01-01", duration: "1w" }
]}
title="Zeitvergleich"
id="gantt-comparison"
showShare={true}
/>`
},
{
name: 'DiagramState',
description: 'A state transition diagram.',
usageExample: '<DiagramState states={["A", "B"]} ... />'
description: 'State diagram showing states and transitions.',
usageExample: `<DiagramState
states={["Idle", "Loading", "Loaded", "Error"]}
transitions={[
{ from: "Idle", to: "Loading", label: "fetch" },
{ from: "Loading", to: "Loaded", label: "success" }
]}
initialState="Idle"
title="Request Lifecycle"
id="state-diagram"
showShare={true}
/>`
},
{
name: 'DiagramSequence',
description: 'Sequence diagram (uses raw Mermaid sequence syntax as children).',
usageExample: `<DiagramSequence id="seq-diagram" title="Request Flow" showShare={true}>
sequenceDiagram
Browser->>CDN: GET /page
CDN->>Browser: Static HTML (< 50ms)
</DiagramSequence>`
},
{
name: 'DiagramTimeline',
description: 'Timeline diagram (uses raw Mermaid timeline syntax as children).',
usageExample: `<DiagramTimeline id="timeline" title="Project Timeline" showShare={true}>
timeline
2024 : Planung
2025 : Entwicklung
2026 : Launch
</DiagramTimeline>`
},
{
name: 'IconList',
description: 'Checklist with check/cross icons. Wrap IconListItem children inside.',
usageExample: `<IconList>
<IconListItem check>
<strong>Zero-Computation:</strong> Statische Seiten, kein Serverwarten.
</IconListItem>
<IconListItem cross>
<strong>Legacy CMS:</strong> Datenbankabfragen bei jedem Request.
</IconListItem>
</IconList>`
},
{
name: 'ArticleMeme',
description: 'Real meme image from memegen.link. template must be a valid memegen.link ID. IMPORTANT: Captions must be EXTREMELY SARCASTIC and PUNCHY (mocking bad B2B agencies, max 6 words per line). Best templates: drake (2-line prefer/dislike), gru (4-step plan backfire), disastergirl (burning house), fine (this is fine dog). Use German captions. Wrap in div with className="my-8".',
usageExample: `<div className="my-8">
<ArticleMeme template="drake" captions="47 WordPress Plugins installieren|Eine saubere Serverless Architektur" />
</div>`
},
{
name: 'Section',
description: 'Wraps a thematic section block with optional heading.',
usageExample: '<Section>\n <h3>Section Title</h3>\n <p>Content here.</p>\n</Section>'
},
{
name: 'Reveal',
description: 'Scroll-triggered reveal animation wrapper. Wrap any content to animate on scroll.',
usageExample: '<Reveal>\n <StatsDisplay value="100" label="PageSpeed Score" />\n</Reveal>'
},
{
name: 'StatsGrid',
description: 'Grid of 24 stat cards in a row. Use tilde (~) to separate stats, pipe (|) to separate value|label|subtext within each stat.',
usageExample: '<StatsGrid stats="53%|Mehr Umsatz|Rakuten 24~33%|Conversion Boost|nach CWV Fix~24%|Top 3 Ranking|bei bestandenen CWV" />'
},
{
name: 'MetricBar',
description: 'Animated horizontal progress bar. Use multiple in sequence to compare metrics. IMPORTANT: value MUST be a real number > 0, never use 0 or placeholder values. Props: label, value (number), max (default 100), unit (default %), color (red|green|blue|slate).',
usageExample: `<MetricBar label="WordPress Sites" value={33} color="red" />
<MetricBar label="Static Sites" value={92} color="green" />`
},
{
name: 'ArticleQuote',
description: 'Dark-themed quote card. Use for expert quotes or statements. Use isCompany={true} for brands/orgs to show an entity icon instead of personal initials. MANDATORY: always include source and sourceUrl for verifiability. Props: quote, author, role (optional), source (REQUIRED), sourceUrl (REQUIRED), isCompany (optional), translated (optional boolean).',
usageExample: '<ArticleQuote quote="Optimizing for speed." author="Google" isCompany={true} source="web.dev" sourceUrl="https://web.dev" translated={true} />'
},
{
name: 'BoldNumber',
description: 'Full-width hero number card with dark gradient, animated count-up, and share button. Use for the most impactful single statistics. IMPORTANT: Always provide source and sourceUrl. Numbers without comparison context should use PremiumComparisonChart or paired MetricBar instead. Props: value (string like "53%" or "2.5M€"), label (short description), source (REQUIRED), sourceUrl (REQUIRED).',
usageExample: '<BoldNumber value="8.4%" label="Conversion-Steigerung pro 0.1s schnellere Ladezeit" source="Deloitte Digital" sourceUrl="https://www2.deloitte.com/..." />'
},
{
name: 'WebVitalsScore',
description: 'Displays Core Web Vitals (LCP, INP, CLS) in a premium card layout with automatic traffic light coloring (Good/Needs Improvement/Poor). Use for performance audits or comparisons.',
usageExample: '<WebVitalsScore values={{ lcp: 2.5, inp: 200, cls: 0.1 }} description="All metrics passing Google standards." />'
},
{
name: 'WaterfallChart',
description: 'A timeline visualization of network requests (waterfall). Use to show loading sequences or bottlenecks. Labels auto-color coded by type (JS, HTML, IMG).',
usageExample: `<WaterfallChart
title="Initial Load"
events={[
{ name: "Document", start: 0, duration: 150 },
{ name: "main.js", start: 150, duration: 50 },
{ name: "hero.jpg", start: 200, duration: 300 }
]}
/>`
},
{
name: 'ExternalLink',
description: 'Inline external link with ↗ icon and outbound analytics tracking. Use for all source citations and external references within Paragraph text.',
usageExample: '<ExternalLink href="https://web.dev/articles/vitals">Google Core Web Vitals</ExternalLink>'
},
{
name: 'TwitterEmbed',
description: 'Embeds a post from X.com (Twitter). Used to provide social proof, industry quotes, or examples. Provide the numerical tweetId.',
usageExample: '<TwitterEmbed tweetId="1753464161943834945" theme="light" />'
},
{
name: 'YouTubeEmbed',
description: 'Embeds a YouTube video to visualize concepts or provide deep dives. Use the 11-character videoId.',
usageExample: '<YouTubeEmbed videoId="dQw4w9WgXcQ" title="Performance Explanation" />'
},
{
name: 'LinkedInEmbed',
description: 'Embeds a professional post from LinkedIn. Use the activity URN (e.g. urn:li:activity:1234567890).',
usageExample: '<LinkedInEmbed urn="urn:li:activity:7153664326573674496" />'
},
{
name: 'TrackedLink',
description: 'A wrapper around next/link that tracks clicks. Use for all INTERNAL navigational links that should be tracked.',
usageExample: '<TrackedLink href="/contact" className="text-blue-600 font-bold">Jetzt anfragen</TrackedLink>'
},
{
name: 'Button',
description: 'Premium pill-shaped button for high-impact CTAs. Variants: primary (solid dark), outline (bordered), ghost (text only). use size="large" for main sections.',
usageExample: '<Button href="/contact" variant="outline">Webprojekt anfragen</Button>'
},
{
name: 'FAQSection',
description: 'Semantic wrapper for FAQ questions at the end of the article. Put standard Markdown H3/Paragraphs inside.',
usageExample: '<FAQSection>\n <H3>Frage 1</H3>\n <Paragraph>Antwort 1</Paragraph>\n</FAQSection>'
}
];

View File

@@ -56,6 +56,7 @@ export function getImgproxyUrl(
// Development mapping: Map local domains to internal Docker hostnames
// so imgproxy can fetch images without SSL issues or external routing
// ONLY run this on server-side (node), not in browser
if (process.env.NODE_ENV === "development") {
if (absoluteSrc.includes("mintel.localhost")) {
absoluteSrc = absoluteSrc.replace(
@@ -68,7 +69,7 @@ export function getImgproxyUrl(
"http://directus:8055",
);
}
console.log(`[imgproxy] ${src} -> ${absoluteSrc}`);
}
const {