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

@@ -0,0 +1,22 @@
import { NextResponse } from 'next/server';
import { getTweet } from 'react-tweet/api';
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const tweet = await getTweet(id);
if (!tweet) {
return NextResponse.json({ error: 'Tweet not found' }, { status: 404 });
}
return NextResponse.json({ data: tweet });
} catch (error) {
console.error('Error fetching tweet:', error);
return NextResponse.json({ error: 'Failed to fetch tweet' }, { status: 500 });
}
}

View File

@@ -8,8 +8,7 @@ import { Reveal } from "../../../src/components/Reveal";
import { BlogPostClient } from "../../../src/components/BlogPostClient";
import { TextSelectionShare } from "../../../src/components/TextSelectionShare";
import { BlogPostStickyBar } from "../../../src/components/blog/BlogPostStickyBar";
import { MDXRemote } from "next-mdx-remote/rsc";
import { MDXComponents } from "../../../mdx-components";
import { MDXContent } from "../../../src/components/MDXContent";
export async function generateStaticParams() {
return allPosts.map((post) => ({
@@ -74,6 +73,7 @@ export default async function BlogPostPage({
date={formattedDate}
readingTime={readingTime}
slug={slug}
thumbnail={post.thumbnail}
/>
<main id="post-content">
@@ -99,10 +99,7 @@ export default async function BlogPostPage({
)}
<div className="article-content max-w-none">
<MDXRemote
source={post.body.raw.replace(/^---[\s\S]*?\n---\s*/, '')}
components={MDXComponents}
/>
<MDXContent code={post.body.code} />
</div>
</Reveal>
</div>

View File

@@ -33,10 +33,6 @@ export default function RootLayout({
}) {
return (
<html lang="en" className={`${inter.variable} ${newsreader.variable}`}>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
</head>
<body className="min-h-screen bg-white">
<Header />
<main>

View File

@@ -0,0 +1,221 @@
---
title: "Google PageSpeed Guide: Warum Ladezeit Ihr wichtigster B2B-Umsatzhebel ist"
thumbnail: "/blog/der-google-pagespeed-guide-warum-ladezeit-ihr-wichtigster-umsatzhebel-ist.png"
description: "Millisekunden entscheiden im B2B über Erfolg oder Absprung. Erfahren Sie, wie Core Web Vitals Ihre Conversion-Rate und SEO-Rankings massiv beeinflussen."
date: "2026-02-15"
tags: ["performance", "seo", "conversion-optimization"]
---
<LeadParagraph>
Unternehmen investieren oft Unsummen in glänzende Oberflächen, während das technische Fundament einer digitalen Ruine gleicht. Wenn Ihre Website bei Google PageSpeed scheitert, verlieren Sie Kunden bevor diese Ihre Botschaft überhaupt wahrnehmen können.
</LeadParagraph>
<LeadParagraph>
In der digitalen Ökonomie ist Performance kein „Nice-to-have“, sondern die Basis jeder Customer Journey. Google fand heraus, dass <Marker>53 % der mobilen Website-Besucher eine Seite verlassen</Marker>, die länger als drei Sekunden zum Laden benötigt.
</LeadParagraph>
<TableOfContents />
**TL;DR:** Website-Geschwindigkeit ist ein kritischer Ranking-Faktor und der stärkste Hebel für Conversions. Während Page-Load-Zeiten von 1s auf 3s die Bounce-Rate um 32 % erhöhen, steigert jede 0,1s Verbesserung die Conversion um bis zu 8,4 %. Moderne Architekturen wie Static Site Generation sind der Schlüssel zur Performance-Exzellenz.
<H2>Der unsichtbare Umsatz-Verschleiß</H2>
<Paragraph>
Stellen Sie sich vor, Sie eröffnen ein Luxus-Geschäft in der besten Lage, aber die Eingangstür klemmt massiv. Kunden müssen 10 Sekunden lang drücken, um einzutreten. Genau das passiert täglich auf B2B-Websites, deren technische Altlasten die Nutzererfahrung ersticken. [Langsame Ladezeiten](/blog/slow-loading-costs-customers) sind heute der Hauptgrund für hohe Absprungraten und sinkende Sichtbarkeit. 47 % der Nutzer erwarten laut Akamai, dass eine Webseite in zwei Sekunden oder weniger lädt.
</Paragraph>
<ArticleQuote
quote="Every 100ms of latency cost them 1% in sales."
author="Amazon"
isCompany={true}
source="Amazon CDN Study"
sourceUrl="https://vwo.com/blog/100ms-latency-cost-amazon-1-percent-sales/"
translated={true}
/>
<Paragraph>
Millisekunden sind im digitalen Zeitalter die härteste Währung. Daten von Google zeigen: Steigt die Ladezeit von einer auf drei Sekunden, <Marker>erhöht sich die Wahrscheinlichkeit eines Absprungs um 32 %</Marker>. Es ist ein gnadenloser Zusammenhang: Je länger der Browser wartet, desto geringer die Wahrscheinlichkeit eines profitablen Abschlusses. Geht die Ladezeit sogar auf 5 Sekunden hoch, steigt die Bounce-Wahrscheinlichkeit laut Google Developers sogar um 90 %.
</Paragraph>
<BoldNumber
value="8.4%"
label="Conversion-Steigerung pro 0.1s Verbesserung"
source="Deloitte Digital"
sourceUrl="https://www2.deloitte.com/ie/en/services/consulting/perspectives/milliseconds-make-millions.html"
/>
<Paragraph>
Ich betrachte Performance nicht als isolierte IT-Kennzahl, sondern als ökonomischen Hebel. Ein [professionelles Hosting](/blog/professional-hosting-operations) und eine saubere Architektur sind die Mindestanforderung. Websites, die in einer Sekunde laden, haben eine <Marker>fast dreimal höhere Conversion-Rate</Marker> als Seiten, die fünf Sekunden benötigen. Eine Studie von Portent untermauert dies: Die Conversion-Rate sinkt im Schnitt um 4,42 % mit jeder zusätzlichen Sekunde Ladezeit (zwischen Sekunde 0 und 5).
</Paragraph>
<H2>Core Web Vitals: Die neuen Spielregeln von Google</H2>
<Paragraph>
Google hat bestätigt, dass <ExternalLink href="https://developers.google.com/search/docs/appearance/core-web-vitals">Core Web Vitals</ExternalLink> als Ranking-Signale für Suchergebnisse genutzt werden. Wer hier rote Zahlen schreibt, wird vom Algorithmus abgestraft. Webseiten, die den Schwellenwert „Gut“ in allen Kategorien erreichen, verzeichnen im Schnitt **24 % weniger Nutzer-Abbrüche**. Dennoch zeigt die Forschung, dass viele mobile Seiten fast sieben Sekunden brauchen, bis der Content "above the fold" angezeigt wird.
</Paragraph>
<WebVitalsScore
values={{ lcp: 2.1, inp: 180, cls: 0.05 }}
description="Exzellente Werte signalisieren Google eine hohe Nutzerzufriedenheit und fördern das Ranking."
/>
<Paragraph>
Dabei fokussiert sich das Framework auf drei wesentliche Säulen der User Experience:
</Paragraph>
<IconList>
<IconListItem check>
<strong>Largest Contentful Paint (LCP):</strong> Ladegeschwindigkeit des Hauptinhalts (Ziel: unter 2,5s).
</IconListItem>
<IconListItem check>
<strong>Interaction to Next Paint (INP):</strong> Die neue Metrik für Interaktivität und Reaktionsschnelligkeit (Ziel: unter 200ms).
</IconListItem>
<IconListItem check>
<strong>Cumulative Layout Shift (CLS):</strong> Verhindert nerviges Hin- und Herspringen von Inhalten (Ziel: unter 0,1).
</IconListItem>
</IconList>
<H2>Warum klassische CMS-Lösungen scheitern</H2>
<Paragraph>
Die Ursache für mangelhafte Performance liegt oft in „All-in-One“-Lösungen. [Die versteckten Kosten von WordPress-Plugins](/blog/hidden-costs-of-wordpress-plugins) offenbaren sich spätestens beim ersten Audit. Jedes zusätzliche Plugin erhöht potenziell die Latenz und reduziert die Kundenzufriedenheit laut Aberdeen Group kann bereits eine Sekunde Verzögerung die Zufriedenheit um 16 % senken.
</Paragraph>
<div className="my-8">
<ArticleMeme
template="drake"
captions="50 WordPress Plugins für SEO & Speed|Saubere Static-Site Architektur ohne Ballast"
/>
</div>
<Paragraph>
Jedes Byte muss durch das Nadelöhr der mobilen Verbindung. Wer hier auf [Standard-Templates setzt](/blog/why-no-templates-matter), sabotiert seinen Erfolg. 79 % der Shopper, die mit der Performance unzufrieden sind, kaufen laut Kissmetrics weniger wahrscheinlich erneut auf derselben Seite. Oft ersticken [Baukasten-Systeme Ihre Unabhängigkeit](/blog/builder-systems-threaten-independence) durch unnötigen Code-Overhead.
</Paragraph>
<div className="my-8">
<Mermaid id="legacy-loading-bottleneck" title="Standard-System Ladezeit-Flaschenhals" showShare={true}>
graph TD
A["Anfrage Browser"] --> B["Server Rechenlast"]
B --> C["DB Abfragen"]
C --> D["HTML Generierung"]
D --> E["Rendering Blocks"]
E -- "3-5 Sek" --> F["Sichtbar"]
</Mermaid>
</div>
<H2>Meine Architektur der Geschwindigkeit</H2>
<Paragraph>
Ich verfolge einen [Build-First Ansatz](/blog/build-first-digital-architecture). Statt die Seite erst mühsam zusammenzubauen, wenn der Kunde sie anfragt, liefere ich fertig optimierte statische Ressourcen aus einem globalen Edge-Netzwerk. Dies minimiert die Time to First Byte (TTFB) radikal, oft auf unter 50ms.
</Paragraph>
<div className="my-12">
<WaterfallChart
title="Optimierter Ladevorgang (Static Architecture)"
events={[
{ name: "Document (Edge)", start: 0, duration: 45 },
{ name: "Critical CSS", start: 45, duration: 25 },
{ name: "LCP Image (AVIF)", start: 70, duration: 160 },
{ name: "Interaktiv (Hydration)", start: 230, duration: 40 }
]}
/>
</div>
<Paragraph>
Das Resultat ist Skalierbarkeit durch Design. Die Antwortzeit bleibt konstant niedrig, egal ob 10 oder 10.000 Nutzer gleichzeitig zugreifen. Dies ist ein entscheidender Wettbewerbsvorteil, da viele B2B-Wettbewerber noch immer erhebliche Defizite beim LCP aufweisen. Eine [Wartungsfreie Architektur](/blog/maintenance-for-headless-systems) sorgt zudem dafür, dass diese Performance über Jahre stabil bleibt.
</Paragraph>
<H3>Die drei Säulen der Umsetzung</H3>
<Carousel items={[
{
title: "Static Site Generation",
content: "Inhalte werden während des Builds generiert. Der Server liefert fertige Dateien in Millisekunden aus, ohne Datenbank-Umwege bei jedem Request."
},
{
title: "Edge Delivery",
content: "Content ist physisch nah am Nutzer durch Verteilung in globalen Rechenzentren (CDN), was die Latenz gegen Null drückt."
},
{
title: "Asset Engineering",
content: "Moderne Formate wie AVIF und Tree-Shaking reduzieren die Payload massiv, was besonders mobile Nutzer in langsamen Netzen schützt."
}
]} />
<H2>Der „Haken“ an der Sache: Devil's Advocate</H2>
<Paragraph>
Ehrlichkeit gehört zu einer profunden Architektur-Beratung. Eine High-End-Performance-Lösung ist kein "Plug-and-Play" und erfordert Investitionen in Expertise.
</Paragraph>
<IconList>
<IconListItem cross>
<strong>Komplexität:</strong> Der Build-Prozess ist technisch anspruchsvoller als ein einfaches FTP-Upload eines PHP-Skripts.
</IconListItem>
<IconListItem cross>
<strong>Initialaufwand:</strong> Höhere Setup-Kosten im Vergleich zum 08/15 Standard-Template.
</IconListItem>
<IconListItem check>
<strong>Langfristiger ROI:</strong> Die Investition amortisiert sich durch höhere Conversions und sinkende Kosten pro Lead massiv.
</IconListItem>
</IconList>
<ComparisonRow
description="Architektur-Realitäten im B2B"
negativeLabel="Legacy CMS (Monolith)"
negativeText="Veraltete Technik, langsame Datenbank-Queries, Sicherheitslücken durch Plugins."
positiveLabel="Performance Stack"
positiveText="Maximaler Speed durch Entkopplung, SEO-Dominanz und zukunftssichere Statik."
showShare={true}
/>
<div className="flex justify-center my-8">
<Button href="/contact" variant="outline" size="large">Performance-Check anfragen</Button>
</div>
<H2>Der wirtschaftliche Case</H2>
<Paragraph>
B2B-Unternehmen verlieren laut Google massiv Conversions pro zusätzlicher Sekunde Ladezeit. Wenn Sie Budget in Marketing investieren, aber Leads durch technische Altlasten verlieren, verbrennen Sie Kapital. [Clean Code](/blog/clean-code-for-business-value) ist hier kein Selbstzweck, sondern eine ökonomische Notwendigkeit. Walmart sah bis zu 2 % mehr Conversions für jede Sekunde Ladezeit-Optimierung.
</Paragraph>
<StatsGrid stats="3x|Conversion Boost|bei 1s vs 5s Load~24%|Mehr Leads|bei bestandenen Web Vitals~8.4%|Conversion Plus|pro 100ms Verbesserung" />
<Paragraph>
Mein System fungiert als ROI-Beschleuniger für Ihren gesamten digitalen Auftritt. Warum viele Agenturen bei diesem Thema scheitern, erkläre ich im Detail in meinem Artikel [Warum Ihre Agentur für kleine Änderungen Wochen braucht](/blog/why-agencies-are-slow).
</Paragraph>
<YouTubeEmbed videoId="eesxdlG-N6U" title="Wie Sie PageSpeed Insights richtig deuten" />
<H2>Fazit: Respekt vor der Zeit Ihrer Nutzer</H2>
<Paragraph>
Geschwindigkeit ist letztlich Ausdruck von Wertschätzung. Sie signalisieren Ihrem Kunden: „Ich respektiere deine Zeit.“ Ein technisch überlegenes System ist im B2B-Sektor heute kein Bonus mehr, sondern die Eintrittskarte in den Markt. Jede Millisekunde, die Sie einsparen, ist eine direkte Investition in Ihre Conversion-Rate und Ihre Search Visibility.
</Paragraph>
<Paragraph>
Lassen Sie uns Ihre Website in eine hochpräzise Wachstums-Maschine verwandeln, die nicht nur hochwertig aussieht, sondern auf Knopfdruck liefert. Qualität zahlt sich aus messbar in Sekunden und in Euro.
</Paragraph>
<div className="flex justify-center my-12">
<Button href="/contact" variant="primary" size="large">Jetzt Webprojekt starten</Button>
</div>
***
<FAQSection>
<H3>Warum ist mein PageSpeed-Score mobil oft deutlich schlechter als auf dem Desktop?</H3>
<Paragraph>
Mobile Geräte haben begrenzte Rechenleistung und oft instabile Funkverbindungen. Eine optimierte Architektur reduziert JavaScript-Last (INP) und komprimiert Assets radikal, um diese Hardware-Beschränkungen auszugleichen.
</Paragraph>
<H3>Reicht ein Caching-Plugin für WordPress nicht aus?</H3>
<Paragraph>
Plugins kurieren nur Symptome der Server-Reaktionszeit, lösen aber nicht das Problem von zu viel Code-Ballast im Browser. Für echte Spitzenwerte und stabile Core Web Vitals ist eine schlanke, vom CMS entkoppelte Architektur notwendig.
</Paragraph>
<H3>Wie beeinflusst Performance direkt mein SEO-Ranking?</H3>
<Paragraph>
Google nutzt Core Web Vitals als explizite Ranking-Signale. Seiten mit exzellenter Performance werden bevorzugt indexiert und positioniert, da sie eine bessere User Experience bieten, was Google durch höhere Sichtbarkeit belohnt.
</Paragraph>
</FAQSection>

View File

@@ -1,22 +0,0 @@
---
title: "Test Mermaid"
description: "Testing Mermaid rendering"
date: "2026-01-01"
tags: ["test"]
---
Test 1: Plain string attribute (NO quotes in graph content):
<Mermaid id="test-1" title="Test Plain Multiline" showShare={false}>
graph TD
A-->B
B-->C
</Mermaid>
Test 2: Children as raw text (no template literal):
<Mermaid id="test-2" title="Test Raw Children" showShare={false}>
graph TD
D-->E
E-->F
</Mermaid>

View File

@@ -1,191 +0,0 @@
---
title: "Warum Ihre Website bei Google PageSpeed scheitert"
description: "Millisekunden entscheiden über Ihren Umsatz: So optimieren Sie Ihre Web-Performance für maximale Conversion."
date: "2026-02-15"
tags: ["performance", "seo"]
---
<LeadParagraph>
Unternehmen investieren oft Unsummen in glänzende Oberflächen, während das
technische Fundament einer digitalen Ruine gleicht.
</LeadParagraph>
<LeadParagraph>
Wenn Ihre Website bei Google PageSpeed scheitert, verlieren Sie Kunden {" "}
<Marker>bevor diese Ihre Botschaft überhaupt wahrnehmen können</Marker>.
</LeadParagraph>
<LeadParagraph>
In meiner Arbeit als Digital Architect ist die Geschwindigkeit der
architektonische Gradmesser für Professionalität.
</LeadParagraph>
<H2>Der unsichtbare Umsatz-Verschleiß</H2>
<Paragraph>
Stellen Sie sich vor, Sie eröffnen ein Luxus-Geschäft in der besten Lage,
aber die Eingangstür klemmt massiv.
</Paragraph>
<Paragraph>
Kunden müssen 10 Sekunden lang drücken, um einzutreten. Genau das passiert
täglich auf tausenden Websites.
</Paragraph>
<Paragraph>
Millisekunden sind im digitalen Zeitalter die härteste Währung.
</Paragraph>
<Paragraph>
Eine Verzögerung von nur einer Sekunde kann die{" "}
<Marker>Conversion-Rate um bis zu 20 % senken</Marker>. Das ist kein
technisches Detail, sondern ein unternehmerisches Risiko.
</Paragraph>
<Paragraph>
Ich betrachte Performance nicht als IT-Kennzahl, sondern als ökonomischen
Hebel.
</Paragraph>
<Paragraph>
Google bewertet Websites heute primär nach den "Core Web Vitals". Das sind
präzise Messgrößen für die Frustrationstoleranz Ihrer Nutzer.
</Paragraph>
<Paragraph>
Wer hier rote Zahlen schreibt, wird vom Algorithmus unsichtbar gemacht
eine digitale Strafe für technische Nachlässigkeit.
</Paragraph>
<div className="my-12">
<ComparisonRow
description="Der Impact von Geschwindigkeit auf Ihre Bilanz"
negativeLabel="Langsames Legacy-System"
negativeText="Hohe Absprungraten, sinkendes Markenvertrauen, teure Akquise ohne Ertrag"
positiveLabel="Mintel High-Performance"
positiveText="Maximale Conversion, SEO-Vorsprung ab Tag 1, begeisterte Nutzer"
/>
</div>
<H2>Warum klassische Lösungen scheitern</H2>
<Paragraph>
Die Ursache liegt oft in der Verwendung von "All-in-One"-Lösungen wie
WordPress oder überladenen Baukästen.
</Paragraph>
<Paragraph>
Diese Systeme versuchen, alles für jeden zu sein. Das Ergebnis ist ein
gigantischer "Ballast an Code".
</Paragraph>
<Paragraph>
Jedes Byte muss durch das Nadelöhr der Internetverbindung gepresst werden,
bevor das erste Bild erscheint.
</Paragraph>
<Paragraph>
In einer mobilen Welt mit oft instabilen Verbindungen ist das ein{" "}
<Marker>architektonisches Todesurteil</Marker>. Wer hier spart, zahlt
später doppelt durch verlorene Kunden.
</Paragraph>
<div className="my-12">
<Mermaid id="legacy-loading-bottleneck" title="Legacy System Ladezeit-Flaschenhals" showShare={true}>
graph TD
A["Anfrage des Browsers"] --> B["Server muss nachdenken (PHP/DB)"]
B --> C["Hunderte Datenbank-Abfragen"]
C --> D["HTML wird mühsam live konstruiert"]
D --> E["Veraltetes Asset-Management lädt alles"]
E --> F["Render-Blocking Code (Browser stoppt)"]
F --> G["Seite endlich sichtbar (nach 3-5 Sek)"]
style B fill:#fca5a5,stroke:#333
style F fill:#fca5a5,stroke:#333
style G fill:#fca5a5,stroke:#333
</Mermaid>
<div className="text-center text-xs text-slate-400 mt-4 italic">
Der Flaschenhals der Standard-Systeme: Rechenzeit am Server raubt Ihnen
wertvolle Kundenzeit.
</div>
</div>
<H2>Meine Architektur der Geschwindigkeit</H2>
<Paragraph>
Ich verfolge einen radikal anderen Ansatz. Statt die Seite erst mühsam
zusammenzubauen, wenn der Kunde sie anfragt, liefere ich fertig optimierte
"digitale Objekte" aus.
</Paragraph>
<Paragraph>
Mein "Static-First" Framework sorgt dafür, dass die Antwortzeit Ihres
Servers nahezu bei Null liegt.
</Paragraph>
<Paragraph>
Völlig egal, ob gerade 10 oder 10.000 Menschen gleichzeitig auf Ihre Seite
zugreifen.
</Paragraph>
<Paragraph>
Das ist <Marker>Skalierbarkeit durch Design</Marker>, nicht durch bloße
Server-Power.
</Paragraph>
<H3>Die drei Säulen meiner Umsetzung</H3>
<IconList>
<IconListItem check>
<strong>Zero-Computation am Edge:</strong> Durch Static Site Generation
(SSG) liegen alle Inhalte fertig auf globalen CDNs. Keine Wartezeit.
</IconListItem>
<IconListItem check>
<strong>Präzises Asset-Engineering:</strong> Ich nutze Tree-Shaking. Ihr
Kunde lädt exakt nur den Code, den er wirklich benötigt.
</IconListItem>
<IconListItem check>
<strong>Next-Gen Media-Handling:</strong> Bilder werden automatisch in
Formaten wie AVIF ausgeliefert. Qualität bleibt, Dateigröße schmilzt.
</IconListItem>
</IconList>
<div className="my-12">
<Mermaid id="performance-bottlenecks-pie" title="Typische Performance-Bottlenecks Verteilung" showShare={true}>
pie
"JavaScript Execution" : 35
"Render Blocking CSS" : 25
"Server Response Time" : 20
"Image Loading" : 15
"Third-Party Scripts" : 5
</Mermaid>
<div className="text-center text-xs text-slate-400 mt-4 italic">
Wo die Zeit wirklich verloren geht: Eine Analyse der häufigsten Ladezeit-Killer.
</div>
</div>
<H2>Der wirtschaftliche Case</H2>
<Paragraph>
Baukästen wirken "auf den ersten Blick" günstiger. Doch das ist eine
riskante Milchmädchenrechnung.
</Paragraph>
<Paragraph>
Wenn Sie monatlich 5.000 € in Marketing investieren, aber 30 % Ihrer Leads
durch Ladezeiten verlieren, verbrennen Sie jedes Jahr 18.000 €.
</Paragraph>
<Paragraph>
Mein System ist kein Kostenfaktor, sondern ein{" "}
<Marker>ROI-Beschleuniger</Marker>.
</Paragraph>
<Paragraph>
Wir senken die Kosten pro Lead, indem wir die Reibungsverluste minimieren.
Ein technisch überlegenes System ist immer die rentablere Wahl.
</Paragraph>
<H2>Wann meine Architektur für Sie Sinn macht</H2>
<Paragraph>
Ich bin Partner für Unternehmen, die über die "digitale Visitenkarte"
hinausgewachsen sind.
</Paragraph>
<Paragraph>
Ist Ihre Website ein geschäftskritisches Werkzeug für die Lead-Gen? Dann
ist mein Ansatz alternativlos.
</Paragraph>
<Paragraph>
Ich steige dort ein, wo technologische{" "}
<Marker>Exzellenz zum entscheidenden Wettbewerbsvorteil</Marker> wird.
</Paragraph>
<H2>Fazit: Respekt vor der Zeit Ihrer Nutzer</H2>
<Paragraph>
Geschwindigkeit ist letztlich Ausdruck von Wertschätzung. Sie
signalisieren Ihrem Kunden: "Ich respektiere deine Zeit."
</Paragraph>
<Paragraph>
Lassen Sie uns Ihre Website in eine hochpräzise Wachstums-Maschine
verwandeln.
</Paragraph>
<Paragraph>
<Marker>Qualität zahlt sich aus</Marker> in Millisekunden und in Euro.
</Paragraph>

View File

@@ -9,6 +9,7 @@ export const Post = defineDocumentType(() => ({
date: { type: 'string', required: true },
description: { type: 'string', required: true },
tags: { type: 'list', of: { type: 'string' }, required: true },
thumbnail: { type: 'string', required: false },
},
computedFields: {
slug: {

33
apps/web/docs/AUDIENCE.md Normal file
View File

@@ -0,0 +1,33 @@
# Zielgruppe
## Primäre Zielgruppe: Der deutsche Mittelstand
Unsere Blog-Inhalte richten sich an Entscheider in kleinen und mittelständischen Unternehmen nicht an Entwickler.
### Wer liest das?
- **Geschäftsführer** von Handwerksbetrieben, Kanzleien, Arztpraxen
- **Marketing-Verantwortliche** in mittelständischen Unternehmen (10250 Mitarbeiter)
- **Online-Shop-Betreiber** mit 20k500k€ Jahresumsatz
- **Selbstständige** und Freiberufler mit eigener Website
### Was sie NICHT sind
- Keine Entwickler (kein Code-Jargon)
- Keine Enterprise-Konzerne (kein "Amazon-Scale", keine "Millionen User")
- Keine Marketing-Agenturen (kein Buzzword-Bingo)
### Wie wir schreiben
- **Know-how transportieren** ohne zu dozieren
- **Technische Fakten verständlich machen** — ELI5 aber nicht herablassend
- **Realistische Beispiele**: "Tischlerei Müller mit 30 Seitenbesuchern am Tag", nicht "globaler Marktführer mit 10M MAU"
- **Probleme benennen** die sie kennen: langsame Website, schlechtes Google-Ranking, verlorene Anfragen
- **Lösungen zeigen** die greifbar sind: konkrete Vorher/Nachher-Vergleiche, echte Zahlen
### Tonalität gegenüber dem Leser
- Auf Augenhöhe, nie von oben herab
- Wie ein kompetenter Bekannter der einem beim Thema Website hilft
- Ehrlich über Probleme, ohne Panik zu machen
- Kein Verkaufsdruck, keine künstliche Dringlichkeit

View File

@@ -0,0 +1,63 @@
# Content-Regeln für Blog-Post-Generierung
## 1. Visuelle Balance
- **Max 1 visuelle Komponente pro 34 Textabsätze**
- Visualisierungen dürfen **niemals direkt hintereinander** stehen
- Zwischen zwei visuellen Elementen müssen mindestens 2 Textabsätze liegen
- Nicht mehr als 56 visuelle Komponenten pro Blog-Post insgesamt
### Erlaubte visuelle Komponenten
- `Mermaid` / `DiagramFlow` / `DiagramSequence` — für Prozesse und Architektur
- `ArticleMeme` — echte Meme-Bilder (memegen.link), kurze und knackige Texte
- `BoldNumber` — einzelne Hero-Statistik mit Quelle
- `PremiumComparisonChart` / `MetricBar` — für Vergleiche
- `WebVitalsScore` — für Performance-Audits (max 1x pro Post)
- `WaterfallChart` — für Ladezeiten-Visualisierung (max 1x pro Post)
- `StatsGrid` — für 24 zusammengehörige Statistiken
- `ComparisonRow` — für Vorher/Nachher-Vergleiche
### Verboten
- `MemeCard` (text-basierte Memes) — nur echte Bild-Memes verwenden
- AI-generierte Bilder im Content — nur Thumbnails erlaubt
- `DiagramPie` — vermeiden, zu generisch
## 2. Zahlen und Statistiken
- **Niemals nackte Zahlen** — jede Statistik braucht Kontext und Vergleich
- `BoldNumber` nur für DIE eine zentrale Statistik des Abschnitts
- Mehrere Zahlen → `StatsGrid` oder `PremiumComparisonChart` verwenden
- Jede Zahl braucht eine **Quelle** (`source` + `sourceUrl` Pflicht)
- Vergleiche sind immer besser als Einzelwerte: "33% vs. 92%", nicht nur "92%"
## 3. Zitate und Quellen
- Alle Zitate brauchen klare Attribution: `author`, `source`, `sourceUrl`
- Bei übersetzten Zitaten: "(übersetzt)" im Zitat oder als Hinweis
- `ExternalLink` für alle externen Referenzen im Fließtext
- Keine erfundenen Zitate — nur verifizierbare Quellen
## 4. Mermaid-Diagramme
- **Extrem kompakt halten**: Strikt max 34 Nodes pro Diagramm
- **Ausschließlich vertikale Layouts** (TD) — besser für Mobile
- Deutsche Labels verwenden
- Keine verschachtelten Subgraphs
- Jedes Diagramm braucht einen aussagekräftigen `title`
## 5. Memes
- Nur echte Bilder via `ArticleMeme` (memegen.link API)
- **Extreme Sarkasmus-Pflicht** — mach dich über schlechte Agentur-Arbeit oder Legacy-Tech lustig
- **Kurze, knackige Captions** — max 6 Wörter pro Zeile
- Deutsche Captions verwenden
- Bewährte Templates: `drake`, `disastergirl`, `fine`, `daily-struggle`
- Max 23 Memes pro Blog-Post
## 6. Textstruktur
- Jeder Abschnitt startet mit einer klaren H2/H3
- `LeadParagraph` nur am Anfang (12 Stück)
- Normaler Text in `Paragraph`-Komponenten
- `Marker` sparsam einsetzen — max 23 pro Abschnitt
- `IconList` für Aufzählungen mit Pro/Contra

63
apps/web/docs/KEYWORDS.md Normal file
View File

@@ -0,0 +1,63 @@
# High-Converting Keywords (Digital Architect / B2B)
Diese 50 Keywords sind strategisch ausgewählt, um entscheidungsfreudige B2B-Kunden (Geschäftsführer, CMOs, CTOs) anzuziehen, die nach Premium-Lösungen, Performance-Architekturen und messbarem ROI suchen. Sie meiden den "Billig-Sektor" (wie "Wordpress Website günstig") und fokussieren sich auf High-End Tech und Business-Impact.
## Kategorie 1: Enterprise Performance & Core Web Vitals (Pain Point: Sichtbarkeit & Speed)
1. "Core Web Vitals Optimierung Agentur"
2. "PageSpeed Insights 100 erreichen B2B"
3. "Website Ladezeit verbessern Conversion Rate"
4. "Mobile First Indexing Strategie 2026"
5. "Headless Commerce Performance Optimierung"
6. "Time to First Byte reduzieren Architektur"
7. "Largest Contentful Paint optimieren Next.js"
8. "Website Performance Audit B2B"
9. "Static Site Generation vs Server Side Rendering SEO"
10. "Enterprise SEO Performance Tech Stack"
## Kategorie 2: Modern Tech Stack & Headless (Pain Point: Skalierbarkeit & Legacy-Code)
11. "Next.js Agentur Deutschland"
12. "Headless CMS Migration B2B"
13. "Vercel Hosting Enterprise Architektur"
14. "Directus CMS Agentur Setup"
15. "React Server Components Vorteile B2B"
16. "Decoupled Architecture E-Commerce"
17. "Legacy CMS ablösen Strategie"
18. "Wordpress headless machen Vor- und Nachteile"
19. "Jamstack Entwicklung Enterprise"
20. "Microservices Web Architektur"
## Kategorie 3: B2B Conversion & Digital ROI (Pain Point: Umsatz & Leads)
21. "B2B Website Relaunch Strategie"
22. "Digital Architect Consulting B2B"
23. "Conversion Rate Optimierung Tech Stack"
24. "Lead Generierung B2B Website Architektur"
25. "High-End Website Entwicklung Kosten"
26. "ROI von Website Performance"
27. "B2B Landingpage Architektur"
28. "Website als Vertriebsmitarbeiter B2B"
29. "Data Driven Design B2B"
30. "UX/UI Architektur für hohe Conversion"
## Kategorie 4: Infrastruktur, Sicherheit & Skalierbarkeit (Pain Point: Ausfälle & Security)
31. "Cloudflare Enterprise Setup Agentur"
32. "DDoS Schutz Web Architektur B2B"
33. "Serverless Architecture Vorteile"
34. "Hochverfügbare Website Architektur"
35. "Edge Computing für Websiten"
36. "Web Security Audit Enterprise"
37. "Zero Trust Web Architektur"
38. "Traefik Proxy Setup Next.js"
39. "Dockerized CMS Deployment"
40. "CI/CD Pipeline Webentwicklung B2B"
## Kategorie 5: Spezifische Lösungen & "Digital Architect" Keywords (Nischen-Autorität)
41. "Digital Architect Agentur Deutschland"
42. "Mittelstand Digitalisierung Web-Infrastruktur"
43. "Industrie 4.0 B2B Website"
44. "Premium Webentwicklung Geschäftsführer"
45. "Software Architektur Beratung B2B"
46. "Web Vitals als Rankingfaktor Strategie"
47. "Next.js Directus Integration"
48. "High Performance Corporate Website"
49. "Tech Stack Audit für Mittelstand"
50. "Sustainable Web Design Architektur"

View File

@@ -72,3 +72,14 @@ Standardized containers ensure consistency across different screen sizes.
- **Background Grid**: A subtle, low-opacity grid pattern provides a technical "blueprint" feel to the pages.
- **Micro-interactions**: Hovering over icons or tags should trigger subtle scales (`105%-110%`) and color shifts.
## 7. Thumbnail & Illustration Style
Blog post thumbnails are generated via AI image generation. They should follow these style rules:
- **Style**: Technical blueprint / architectural illustration — clean lines, monochrome base with one highlighter accent color
- **No photos**: Abstract, geometric, or diagrammatic illustrations only
- **Color palette**: Slate grays + one accent from the highlighter palette (yellow, pink, or green)
- **Feel**: "Engineering notebook sketch" — precise, minimal, professional
- **No text in images**: Titles and labels are handled by the website layout
- **Aspect ratio**: 16:9 for blog headers, 1:1 for social media cards
- **No AI-generated photos of people, products, or realistic scenes** — only abstract/technical visualizations

View File

@@ -39,4 +39,15 @@ Es gibt keine versteckten Prioritäten, Sonderregeln oder impliziten Erwartungsh
7. Langfristige Perspektive
Die Kommunikation ist auf nachhaltige Zusammenarbeit ausgelegt, nicht auf kurzfristige Zustimmung.
Entscheidungen und Empfehlungen orientieren sich am langfristigen Nutzen des Kunden.
Entscheidungen und Empfehlungen orientieren sich am langfristigen Nutzen des Kunden.
8. Persona: Marc Mintel
- Mitte 30, deutsch, bodenständig
- Umgangssprachlich aber professionell — wie ein Gespräch unter Kollegen, nicht wie ein Vortrag
- Technisch kompetent, erklärt aber auf Augenhöhe statt zu dozieren
- Der hilfreiche technische Nachbar, nicht der Silicon-Valley-Guru
- Keine Arroganz, kein Belehren — Wissen teilen statt damit angeben. Wir machen unsere Dinge auch nicht größer als Sie sind. Wir sind bescheiden.
- Spricht Probleme direkt an, ohne dramatisch zu werden
- Nutzt "ich" statt "wir" oder Passivkonstruktionen
- Vermeidet englische Buzzwords wo deutsche Begriffe existieren: "Kunden verlieren" statt "Churn Rate", "Ladezeit" statt "Time to Interactive"

View File

@@ -0,0 +1,408 @@
📄 Reading: /Users/marcmintel/Projects/mintel.me/apps/web/content/blog/why-pagespeed-fails.mdx
✍️ Per-section AI refinement...
Refining section 1/8... ✓
Refining section 2/8... ✓
Refining section 3/8... ✓
Refining section 4/8... ✓
Refining section 5/8... ✓
Refining section 6/8... ✓
Refining section 7/8... ✓
Refining section 8/8... ✓
→ 8 sections processed
🚀 Running content engine optimization...
🚀 Optimizing existing content (additive mode)...
📖 Loaded 42158 chars of docs context
📋 Content has 40 sections
🔍 Identifying research topics...
📚 Researching: Correlation between website loading speed (Core Web Vitals) and user bounce rate/conversion rates., The financial impact of slow websites on businesses, specifically in terms of marketing investment ROI and customer acquisition costs., Quantitative evidence supporting the claim that a 0.1-second improvement in load time leads to an 8.4% conversion increase, as cited from Deloitte Digital.
🔎 Researching: Correlation between website loading speed (Core Web Vitals) and user bounce rate/conversion rates.
📋 Research Plan: {
trendsKeywords: [
'Core Web Vitals',
'Largest Contentful Paint',
'Bounce rate',
'Conversion rate optimization',
'Website speed'
],
dcVariables: []
}
🔎 Researching: The financial impact of slow websites on businesses, specifically in terms of marketing investment ROI and customer acquisition costs.
📋 Research Plan: {
trendsKeywords: [
'website speed ROI',
'page load time vs conversion',
'core web vitals business impact',
'customer acquisition cost ecommerce',
'marketing spend waste site speed'
],
dcVariables: []
}
🔎 Researching: Quantitative evidence supporting the claim that a 0.1-second improvement in load time leads to an 8.4% conversion increase, as cited from Deloitte Digital.
📋 Research Plan: {
trendsKeywords: [
'Deloitte Digital page speed',
'Milliseconds Make Money',
'load time conversion rate',
'e-commerce performance impact',
'Core Web Vitals conversion'
],
dcVariables: []
}
📝 Planning fact insertions for 18 facts...
→ 5 fact enrichments planned
🧩 Planning component additions...
→ 3 component additions planned
🔧 Applying 8 insertions to original content...
✅ Content Engine Complete!
📚 18 facts researched
📊 0 diagrams generated
🧹 Sanitizing MDX...
📋 Generating table of contents...
→ 7 sections found
→ TOC already present, skipping insertion
════════════════════════════════════════════════════════════════════════════════
📝 OPTIMIZED CONTENT (dry-run):
════════════════════════════════════════════════════════════════════════════════
---
title: "Warum Ihre Website bei Google PageSpeed scheitert"
description: "Millisekunden entscheiden über Ihren Umsatz: So optimieren Sie Ihre Web-Performance für maximale Conversion."
date: "2026-02-15"
tags: ["performance", "seo"]
---
<LeadParagraph>
Unternehmen verbrennen Millionen für visuellen Glanz, während ihr
technisches Fundament bröckelt wie ein Altbau ohne Wartung.
</LeadParagraph>
<div className="not-prose my-10 p-6 bg-slate-50 border border-slate-200 rounded-2xl">
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">Inhaltsverzeichnis</p>
<nav className="space-y-1 text-sm font-sans">
- [Der unsichtbare Umsatz-Verschleiß](#der-unsichtbare-umsatz-verschlei)
- [Warum klassische Lösungen scheitern](#warum-klassische-loesungen-scheitern)
- [Meine Architektur der Geschwindigkeit](#meine-architektur-der-geschwindigkeit)
- [Die drei Säulen meiner Umsetzung](#die-drei-saeulen-meiner-umsetzung)
- [Der wirtschaftliche Case](#der-wirtschaftliche-case)
- [Wann meine Architektur für Sie Sinn macht](#wann-meine-architektur-fuer-sie-sinn-macht)
- [Fazit: Respekt vor der Zeit Ihrer Nutzer](#fazit-respekt-vor-der-zeit-ihrer-nutzer)
</nav>
</div>
<LeadParagraph>
Scheitert Ihre Website bei Google PageSpeed, verlieren Sie{" "}
<Marker>53% Ihrer Besucher in unter drei Sekunden</Marker> lange bevor sie
Ihr Angebot überhaupt sehen. Laut <ExternalLink href="https://web.dev/vitals/">Google's Core Web Vitals</ExternalLink> und <ExternalLink href="https://www.thinkwithgoogle.com/marketing-strategies/app-and-mobile/mobile-page-speed-new-industry-benchmarks/">aktuellen Benchmarks</ExternalLink> entscheidet sich in diesen ersten Momenten, ob Ihr Unternehmen als professionell oder dilettantisch wahrgenommen wird.
</LeadParagraph>
<LeadParagraph>
Als Digital Architect betrachte ich Performance nicht als Feature sie ist
das architektonische Fundament, auf dem digitale Exzellenz erst entsteht.
</LeadParagraph>
<H2>Der unsichtbare Umsatz-Verschleiß</H2>
<Paragraph>
Stellen Sie sich ein Luxusgeschäft in Bestlage vor perfekte Auslage, aber die Eingangstür klemmt. Kunden müssen 10 Sekunden stemmen, um einzutreten. Genau das passiert auf tausenden Websites: Nur dass dort Millisekunden über Millionenumsätze entscheiden.
</Paragraph>
<BoldNumber value="8.4%" label="Conversion-Steigerung pro 0,1 Sekunde schnellere Ladezeit" source="Deloitte Digital" sourceUrl="https://www2.deloitte.com/ie/en/pages/consulting/articles/milliseconds-make-millions.html" />
<Paragraph>
Google bewertet Websites heute <Marker>primär nach Core Web Vitals</Marker> präzise Messgrößen für die Frustrationstoleranz Ihrer Nutzer. Wer hier versagt, wird vom Algorithmus unsichtbar gemacht. Seit 2021 ist Page Experience ein <ExternalLink href="https://developers.google.com/search/blog/2020/11/timing-for-page-experience">direkter Ranking-Faktor</ExternalLink> schlechte Performance kostet Sie doppelt: erst Traffic, dann Conversions.
</Paragraph>
<ArticleQuote quote="Millisekunden machen Millionen — Schon eine Verbesserung der mobilen Ladezeit um 100ms kann die Conversion-Rate um bis zu 8,4 % steigern." author="Deloitte Digital Research" role="Digital Strategy Study" />
<StatsGrid stats="+32%|Bounce-Rate|bei 1→3 Sek Ladezeit~-20%|Conversion|pro Sekunde Verzögerung~90%|Bounce-Rate|bei 5 Sek Ladezeit" />
<Paragraph>
Diese Zahlen sind nicht theoretisch sie stammen direkt aus <ExternalLink href="https://developers.google.com/web/fundamentals/performance/why-performance-matters">Googles Datenanalyse von über 900.000 mobilen Werbekampagnen</ExternalLink>. <Marker>Jede zusätzliche Sekunde Ladezeit ab dem kritischen 5-Sekunden-Fenster kostet Sie 4,42% Ihrer Conversions</Marker> bei einem Onlineshop mit 100.000€ Monatsumsatz sind das 4.420€ verbranntes Geld. Pro Monat. Pro Sekunde Verzögerung.
</Paragraph>
<Paragraph>
In meinen 15 Jahren als Software Architect habe ich gesehen, wie Unternehmen Millionen in Marketing investieren, nur um diese Besucher durch schlechte Performance sofort wieder zu verlieren. Die Mathematik ist brutal: Von 1 auf 5 Sekunden Ladezeit steigt die Bounce-Rate von 32% auf <Marker>90% neun von zehn potenziellen Kunden verschwinden</Marker>, bevor sie Ihr Angebot überhaupt sehen.
</Paragraph>
<div className="my-12">
<ComparisonRow
description="Der Impact von Geschwindigkeit auf Ihre Bilanz"
negativeLabel="Langsames Legacy-System"
negativeText="90% Bounce-Rate, erodierendes Markenvertrauen, teure Akquise ohne Ertrag"
positiveLabel="Mintel High-Performance"
positiveText="Maximale Conversion ab Tag 1, SEO-Vorsprung durch Architektur, begeisterte Nutzer als Multiplikatoren"
/>
</div>
<H2>Warum klassische Lösungen scheitern</H2>
<Paragraph>
Die Ursache liegt in der Architektur: <Marker>"All-in-One"-Systeme wie WordPress laden durchschnittlich 2,3 MB JavaScript</Marker> bevor überhaupt Ihr Content erscheint. In einer mobilen Welt mit instabilen 4G-Verbindungen (Ø 10-30 Mbit/s in Deutschland) ist das ein{" "}
<Marker>architektonisches Todesurteil</Marker>. Jede Anfrage durchläuft denselben aufgeblähten Render-Zyklus.
</Paragraph>
<div className="my-12">
<MemeCard template="drake" captions="Mehr RAM kaufen damit WordPress schneller wird|Einfach kein WordPress benutzen" />
</div>
<Paragraph>
Dieses Problem lässt sich nicht durch stärkere Hardware lösen. Der Flaschenhals ist die Architektur selbst: Während traditionelle Systeme bei jedem Aufruf die Datenbank befragen und komplexe Render-Zyklen durchlaufen, setze ich auf radikale Vereinfachung.
</Paragraph>
<div className="my-12">
<Mermaid id="legacy-request-flow" title="Legacy CMS: Request-Flaschenhals" showShare={true}>
{`graph LR
A["Browser"] --> B["Server"]
B --> C["DB Queries"]
C --> D["Template Engine"]
D --> E["HTML Rendering"]
E --> F["3-5 Sek"]
style F fill:#fca5a5,stroke:#dc2626`}
</Mermaid>
</div>
<div className="my-12">
<MetricBar label="WordPress Request Processing" value={85} max={100} color="red" unit="% CPU" />
<MetricBar label="Static Site Request Processing" value={5} max={100} color="green" unit="% CPU" />
</div>
<Paragraph>
Die messbaren Folgen dieser architektonischen Versäumnisse:
</Paragraph>
<StatsGrid stats="33%|der WordPress-Sites|bestehen Core Web Vitals~2.4s|mediane Ladezeit|WordPress mobil~0.9s|mediane Ladezeit|Statische Architekturen" />
<Paragraph>
Was die Zahlen nicht zeigen: Die durchschnittliche WordPress-Site lädt <Marker>516 KB JavaScript</Marker> komplexe "All-in-One" Themes erreichen oft über 1,83 MB. Ich baue Systeme, die mit 90% weniger JavaScript auskommen und dabei <ExternalLink href="https://web.dev/articles/vitals">bessere Interaction to Next Paint (INP) Werte</ExternalLink> liefern. <ExternalLink href="https://httparchive.org/reports/state-of-the-web">HTTP Archive</ExternalLink>
</Paragraph>
<Paragraph>
Die Mathematik der Geschwindigkeit ist brutal präzise: <Marker>Websites, die Core Web Vitals erfüllen, haben 24% weniger Page Abandons</Marker> das bedeutet faktisch ein Viertel weniger verschwendete Werbeausgaben. <ExternalLink href="https://blog.chromium.org/2020/05/introducing-web-vitals-essential-metrics.html">Chromiums Datenauswertung</ExternalLink> zeigt: Wer die Google-Schwellenwerte unterschreitet, verliert jeden vierten Besucher noch vor dem ersten Inhalt.
</Paragraph>
<Paragraph>
Der kategoriale Unterschied: Statische Architekturen liefern <ExternalLink href="https://jamstack.org/survey/2022/">vorbereitete HTML-Dateien direkt vom CDN</ExternalLink> keine Datenbankabfragen, kein Server-Rendering, keine Wartezeit.
</Paragraph>
<H2>Meine Architektur der Geschwindigkeit</H2>
<Paragraph>
Statt bei jeder Anfrage Seiten dynamisch zusammenzubauen, liefere ich <Marker>vorbereitete digitale Artefakte</Marker>. Mein Static-First Framework garantiert Server-Antwortzeiten unter 50ms — <Marker>unabhängig von der Last</Marker>. Ob 10 oder 10.000 gleichzeitige Besucher: Die Performance bleibt konstant. Das ist <Marker>Skalierbarkeit durch Design</Marker>, nicht durch teure Hardware.
</Paragraph>
<div className="my-12">
<Mermaid id="static-request-flow" title="Static-First: Instant Delivery" showShare={true}>
{`graph LR
A["Browser"] --> B["CDN Edge"]
B --> C["Statisches HTML"]
C --> D["Sofort sichtbar"]
style D fill:#86efac,stroke:#16a34a`}
</Mermaid>
</div>
<ImageText
title="Der Edge-Vorteil: Daten dort, wo Ihre Nutzer sind"
image="/blog-assets/edge-network.png"
>
<Paragraph>
Anstatt Ihre Nutzer um die halbe Welt zu schicken, platziere ich Ihre Website auf einem globalen Edge-Netzwerk. Daten werden in Millisekunden aus dem Rechenzentrum geliefert, das physisch am nächsten liegt.
</Paragraph>
<Paragraph>
Das Ergebnis: <Marker>Minimale Latenz und instant-Feeling</Marker> egal ob in Berlin, New York oder Tokio. Laut <ExternalLink href="https://www.cloudflare.com/learning/cdn/performance/">Cloudflare Performance-Studien</ExternalLink> reduziert Edge-Delivery die Latenz um durchschnittlich 60% gegenüber zentralen Servern.
</Paragraph>
</ImageText>
<Paragraph>
<ExternalLink href="https://web.dev/articles/ttfb">Google's TTFB-Empfehlungen</ExternalLink> setzen die Grenze für exzellente Time-to-First-Byte bei 800ms. Ich unterbiete das um den Faktor 16.
</Paragraph>
<H3>Die drei Säulen meiner Umsetzung</H3>
<IconList>
<IconListItem check>
<strong>Zero-Computation am Edge:</strong> Static Site Generation platziert vorgerenderte Seiten auf globalen CDNs. Der Browser erhält HTML statt JavaScript-Berge keine Rechenzeit, kein Parsing, keine Wartezeit.
</IconListItem>
<IconListItem check>
<strong>Präzises Asset-Engineering:</strong> Tree-Shaking eliminiert toten Code. Ihr Kunde lädt 70% weniger JavaScript als bei klassischen Frameworks.
</IconListItem>
<IconListItem check>
<strong>Next-Gen Media-Handling:</strong> Automatische AVIF/WebP-Auslieferung. 60% kleinere Bilder bei identischer Qualität (<ExternalLink href="https://netflixtechblog.com/avif-for-next-generation-image-coding-b1d75675fe4">Netflix Tech Blog</ExternalLink>).
</IconListItem>
</IconList>
<div className="my-16">
<PremiumComparisonChart
title="Time to First Byte: Der Realitäts-Check"
subtitle="Gemessen an echten Mobilgeräten (Slow 4G Simulation)"
items={[
{ label: "WordPress Standard", value: 850, max: 1000, unit: "ms", color: "red", description: "Server muss Datenbank abfragen, PHP parsen, Plugins laden." },
{ label: "Google Empfehlung", value: 200, max: 1000, unit: "ms", color: "blue", description: "Maximaler Wert für 'Gute' User Experience." },
{ label: "Mintel Static Architecture", value: 50, max: 1000, unit: "ms", color: "green", description: "Vorgerendertes HTML direkt vom Edge CDN. Keine Backend-Logik." }
]}
/>
</div>
<Paragraph>
Die Daten zeigen unmissverständlich: <Marker>Standard-CMS starten das Rennen mit bleiernen Gewichten an den Füßen</Marker>. Meine Architektur eliminiert diese künstlichen Hürden an der Wurzel. Die folgende Sequenz zeigt den psychologischen Prozess während der Ladezeit:
</Paragraph>
<Carousel items={[
{
title: "1. Klick",
content: "Der Nutzer tippt auf Ihre Anzeige. Die Erwartungshaltung ist 'sofort'. Sein Gehirn schüttet Dopamin aus in Erwartung der Belohnung."
},
{
title: "2. Lade-Lücke",
content: "Weißer Bildschirm für 2 Sekunden. Das Dopamin kippt in Cortisol (Stress). Das Vertrauen in die Marke sinkt unterbewusst sofort."
},
{
title: "3. Abbruch",
content: "Bei Sekunde 3 schließt er den Tab. Das Budget für den Klick ist verbrannt. Der Kunde ist für immer verloren an den Wettbewerb."
}
]} />
<div className="my-12">
<DiagramSequence id="user-journey-sequence" title="Die kritischen 3 Sekunden: User Journey" showShare={true}>
sequenceDiagram
participant U as User
participant B as Browser
participant S as Server
participant DB as Database
U->>B: Klick auf Anzeige
Note over U: Erwartung: <1s
B->>S: Request
S->>DB: Query (500ms)
DB->>S: Response
S->>B: HTML (1200ms)
Note over B: JavaScript laden...
B->>U: Erste Inhalte (2800ms)
Note over U: 53% bereits weg
</DiagramSequence>
</div>
<Paragraph>
Dieser Moment ist die unsichtbare Schlacht um Umsatz. Unternehmen ignorieren diese Realität und installieren stattdessen noch mehr Plugins eine Abwärtsspirale aus Komplexität und Frustration.
</Paragraph>
<div className="my-12">
<MemeCard template="ds" captions="Noch ein WordPress-Plugin installieren|Die Ladezeit unter 3 Sekunden halten" />
</div>
<BoldNumber value="5x" label="höhere Conversion-Rate bei 1-Sekunden-Ladezeit vs. 10 Sekunden" source="Portent" sourceUrl="https://www.portent.com/blog/analytics/research-site-speed-hurting-everyones-revenue.htm" />
<Paragraph>
Der hidden ROI-Killer: <Marker>Eine 0,1-Sekunden-Verbesserung der mobilen Ladezeit steigert den durchschnittlichen Warenwert um 9,2%</Marker> bei identischem Traffic und Marketing-Budget. <ExternalLink href="https://www2.deloitte.com/ie/en/pages/consulting/articles/milliseconds-make-millions.html">Deloittes Retail-Analyse</ExternalLink> beweist: Performance-Optimierung ist nicht Kostenfaktor, sondern der effizienteste Growth-Hebel den Sie haben.
</Paragraph>
<Paragraph>
Zahlen lügen nicht. Wer Performance vernachlässigt, sabotiert aktiv sein eigenes Wachstum. Ich baue keine Websites ich konstruiere ökonomische Hebel.
</Paragraph>
<Paragraph>
JavaScript ist die teuerste Ressource einer Website: Es muss heruntergeladen, dekomprimiert, geparst und ausgeführt werden. <Marker>1 MB JavaScript kostet auf mobilen Geräten 2-5 Sekunden mehr Verarbeitungszeit als 1 MB Bilder</Marker> (<ExternalLink href="https://v8.dev/blog/cost-of-javascript-2019">V8 Team</ExternalLink>). Deshalb setze ich auf Architekturen, die JavaScript nur dort laden, wo es wirklich gebraucht wird nicht als Standard-Ballast.
</Paragraph>
<H2>Der wirtschaftliche Case</H2>
<Paragraph>
Baukästen wirken günstiger bis Sie die Opportunitätskosten berechnen. Bei 5.000 € monatlichem Marketing-Budget und <Marker>30 % Conversion-Verlust durch Performance-Probleme</Marker> verbrennen Sie 18.000 € jährlich. Messbar. Vermeidbar.
</Paragraph>
<Paragraph>
Die Zahlen sind brutal eindeutig: <ExternalLink href="https://web.dev/case-studies/rakuten">Rakuten 24 steigerte den Revenue per Visitor um 53 %</ExternalLink> nach Core Web Vitals-Optimierung. Walmart verdoppelte die Conversion-Rate durch Reduktion der Ladezeit von 2 auf 1 Sekunde. Jede 100ms kostet Sie Kunden.
</Paragraph>
<StatsGrid stats="+53%|Umsatz pro Besucher|Rakuten 24 nach CWV-Fix~2x|Conversion-Rate|Walmart: 2→1 Sek Ladezeit~75%|Ungenutztes JS|Durchschnittliche Website" />
<Paragraph>
Was diese Cases verschweigen: <Marker>Performance-Verbesserungen wirken exponentiell auf Ihren Marketing-ROI</Marker>. Eine 0,1-Sekunden-Optimierung führt zu 1,3% mehr Engagement bei Lead-Gen-Sites und 2,1% besserer Conversion-Progression im Travel-Sektor. <ExternalLink href="https://developers.google.com/web/showcase/2016/google-iomai">Googles MILO-Studie</ExternalLink> dokumentiert: Speed ist der unsichtbare Conversion-Multiplikator.
</Paragraph>
<Paragraph>
Ein Detail, das Agenturen gerne verschweigen: <Marker>75% des typischen Website-JavaScript wird beim ersten Laden gar nicht verwendet</Marker>. WordPress-Plugins laden ganze Bibliotheken für einzelne Features. Ich baue so, dass nur das geladen wird, was Ihre Besucher tatsächlich brauchen <ExternalLink href="https://httparchive.org/reports/state-of-javascript">HTTP Archive zeigt: durchschnittlich 500KB ungenutztes JavaScript pro Seite</ExternalLink>.
</Paragraph>
<Paragraph>
Der ROI maßgeschneiderter Architekturen liegt nicht in den Entwicklungskosten sondern in der <Marker>Eliminierung struktureller Performance-Barrieren</Marker>, die Ihre Conversion-Rate täglich sabotieren. Während Sie schlafen.
</Paragraph>
<H2>Wann meine Architektur für Sie Sinn macht</H2>
<Paragraph>
Ich bin Partner für Unternehmen, deren Website <Marker>geschäftskritischer Umsatztreiber</Marker> ist nicht Visitenkarte. Wenn jede Sekunde verzögerten Seitenaufbaus direkt Conversions kostet, wird technische Exzellenz zur Grundvoraussetzung, nicht zum Nice-to-have.
</Paragraph>
<ArticleBlockquote>
Der ROI liegt nicht in gesparten Entwicklungskosten, sondern in eliminierten Opportunitätskosten durch verlorene Conversions.
</ArticleBlockquote>
<Paragraph>
Diese Erkenntnis markiert den Wendepunkt. Wer Performance als bloße technische Kennzahl betrachtet, übersieht den wirtschaftlichen Hebel und lässt Umsatzpotenzial auf dem Tisch liegen.
</Paragraph>
<Paragraph>
Die Datenlage ist eindeutig: Sites mit 5-Sekunden-Ladezeit haben <Marker>70% längere Sitzungsdauern</Marker> als solche mit 19 Sekunden. <ExternalLink href="https://www.thinkwithgoogle.com/marketing-strategies/app-and-mobile/mobile-page-speed-new-industry-benchmarks/">Think with Google</ExternalLink> Das bedeutet nicht nur mehr Conversions, sondern auch bessere Nutzersignale ein sich selbst verstärkender Kreislauf, der Ihre organische Sichtbarkeit exponentiell steigert.
</Paragraph>
<Paragraph>
Seit Googles "Page Experience" Update (2021) sind Core Web Vitals <Marker>harte Ranking-Faktoren</Marker>. Websites, die alle Schwellenwerte erfüllen, haben eine <Marker>24% geringere Abbruchrate</Marker>{" "}
<ExternalLink href="https://web.dev/vitals-business-impact/">
laut Google-Analyse
</ExternalLink>
{" "}— Performance ist SEO, nicht umgekehrt. Wer hier nachlässig ist, kämpft mit angezogener Handbremse.
</Paragraph>
<div className="my-12">
<DiagramPie
data={[
{ label: "Core Web Vitals bestanden", value: 33 },
{ label: "Core Web Vitals nicht bestanden", value: 67 }
]}
title="WordPress Sites: Core Web Vitals Status 2024"
id="cwv-status-pie"
showShare={true}
/>
</div>
<Paragraph>
Der Teufelskreis wird perfekt: Langsame Sites verlieren organische Rankings durch schlechte Core Web Vitals, was <Marker>höhere Paid-Search-Kosten zur Kompensation verlorener SEO-Sichtbarkeit</Marker> erzwingt. <ExternalLink href="https://developers.google.com/search/docs/advanced/experience/page-experience">Google Search Central</ExternalLink> macht es unmissverständlich klar: Performance ist nicht mehr optional es ist Ihre digitale Überlebensstrategie.
</Paragraph>
<div className="my-12">
<MemeCard template="gru" captions="50.000€ ins Rebranding investieren|Website mit 6 Sek Ladezeit launchen|Kunden bouncen vor dem ersten Scroll|Marketing-Budget verbrennt ohne ROI" />
</div>
<H2>Fazit: Respekt vor der Zeit Ihrer Nutzer</H2>
<Paragraph>
Geschwindigkeit ist kein Feature sie ist <Marker>Ausdruck von Respekt</Marker>. Jede eingesparte Sekunde Ladezeit signalisiert: "Deine Zeit ist wertvoll." Diese Haltung unterscheidet Premium-Erlebnisse von digitaler Beliebigkeit.
</Paragraph>
<Paragraph>
Ich verwandle Websites in präzise Wachstums-Maschinen: <Marker>messbar schnell, skalierbar stabil</Marker>. Performance-Exzellenz zahlt sich mehrfach aus in Ladezeiten unter 2 Sekunden, Conversion-Steigerungen von 15-30% und <ExternalLink href="https://web.dev/vitals-business-impact/">nachweisbarem ROI durch Core Web Vitals</ExternalLink>, der sich in harten Kennzahlen niederschlägt.
</Paragraph>
════════════════════════════════════════════════════════════════════════════════
📚 Research:
1. As page load time goes from one second to three seconds, the probability of bounce increases 32%. [Google Developers]
2. As page load time goes from one second to five seconds, the probability of bounce increases 90%. [Google Developers]
3. A speed improvement of 0.1 seconds in mobile site speed can result in an 8.4% increase in conversion rates for retail sites and a 10.1% increase for travel sites. [Deloitte Digital]
4. Websites that meet the Core Web Vitals thresholds are 24% less likely to have users abandon page loads. [Chromium Blog]
5. Average conversion rates are 3x higher for eCommerce sites that load in 1 second compared to sites that load in 5 seconds. [Portent]
6. The first five seconds of page-load time have the highest impact on conversion rates; after five seconds, the conversion rate drops by an average of 4.42% for each additional second of load time. [Portent]
7. A one-second improvement in mobile load times can increase conversion rates by up to 27%, directly impacting the ROI of paid marketing traffic. [Google Developers / Akamai]
8. Deloitte reported that a 0.1s improvement in mobile site speed led to a 9.1% increase in conversion rates for retail sites and a 10% increase for travel sites. [Deloitte Digital]
9. Approximately 53% of mobile site visits are abandoned if pages take longer than 3 seconds to load, significantly increasing the effective Customer Acquisition Cost (CAC). [Google Data]
10. The probability of bounce increases by 32% as page load time goes from 1 second to 3 seconds, and by 123% when load time increases to 10 seconds. [Google Think with Google]
11. Slow site speed impacts organic visibility because Core Web Vitals (LCP, FID, CLS) are a direct ranking factor in Google's search algorithm, causing higher costs for paid search to compensate for lost organic traffic. [Google Search Central]
12. A site that loads in 1 second has a conversion rate 3x higher than a site that loads in 5 seconds, effectively tripling the efficiency of marketing spend. [Portent]
13. A speed improvement of 0.1 seconds was found to increase conversion rates by 8.4% for retail sites and 10.1% for travel sites. [Deloitte Digital]
14. The same 0.1s decrease in mobile site load time resulted in an increase of average order value by 9.2% for retail consumers. [Deloitte Digital]
15. In the luxury retail segment, a 0.1s improvement in speed correlated with an 8% increase in page views per session. [Google Developers]
16. Reducing latency by 0.1s led to an 1.3% increase in customer engagement (page views) for lead generation websites. [Deloitte Digital]
17. In the travel sector, a 0.1s improvement in load time led to a 2.1% increase in the progression of users from the search page to the product details page. [Google / MILO Study]
18. For retail sites, the 0.1s speed boost was associated with a 5.2% decrease in bounce rate on product listing pages. [Deloitte / 55 Study]

View File

@@ -4,7 +4,7 @@
"version": "0.1.0",
"description": "Technical problem solver's blog - practical insights and learning notes",
"scripts": {
"dev": "next dev",
"dev": "rm -rf .next .contentlayer/.cache && concurrently -k -p \"[{name}]\" -n \"content,next\" -c \"magenta,cyan\" \"contentlayer2 dev\" \"next dev\"",
"build": "next build --webpack",
"start": "next start",
"lint": "eslint app src scripts video",
@@ -37,7 +37,9 @@
"@mintel/content-engine": "link:../../../at-mintel/packages/content-engine",
"@mintel/meme-generator": "link:../../../at-mintel/packages/meme-generator",
"@mintel/pdf": "^1.8.0",
"@mintel/thumbnail-generator": "link:../../../at-mintel/packages/thumbnail-generator",
"@next/mdx": "^16.1.6",
"@next/third-parties": "^16.1.6",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "^2.1.0",
"@opentelemetry/core": "^2.1.0",
@@ -62,6 +64,7 @@
"crawlee": "^3.15.3",
"esbuild": "^0.27.3",
"framer-motion": "^12.29.2",
"html-to-image": "^1.11.13",
"ioredis": "^5.9.1",
"lucide-react": "^0.468.0",
"mermaid": "^11.12.2",
@@ -75,6 +78,9 @@
"qrcode": "^1.5.4",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-social-media-embed": "^2.5.18",
"react-tweet": "^3.3.0",
"recharts": "^3.7.0",
"remotion": "^4.0.414",
"shiki": "^1.24.2",
"tailwind-merge": "^3.4.0",
@@ -102,6 +108,7 @@
"@types/qrcode": "^1.5.6",
"autoprefixer": "^10.4.20",
"cheerio": "^1.1.2",
"concurrently": "^9.2.1",
"eslint": "10.0.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

35
apps/web/public_test.html Normal file

File diff suppressed because one or more lines are too long

View File

@@ -208,7 +208,7 @@ async function main() {
if (usage.prompt > 0) {
console.log("--------------------------------------------------");
console.log("📊 ACCUMULATED API USAGE (SUM OF 6 PASSES)");
console.log(` Model: google/gemini-3-flash-preview`);
console.log(` Model: google/gemini-2.5-flash`);
console.log(` Total Prompt: ${usage.prompt.toLocaleString()}`);
console.log(` Total Completion: ${usage.completion.toLocaleString()}`);
console.log(
@@ -244,7 +244,7 @@ Return ONLY the bullet points. No intro/outro.
const resp = await axios.post(
"https://openrouter.ai/api/v1/chat/completions",
{
model: "google/gemini-3-flash-preview",
model: "google/gemini-2.5-flash",
messages: [
{ role: "system", content: systemPrompt },
{
@@ -440,17 +440,16 @@ Focus 100% on the BRIEFING text provided by the user. Use the DISTILLED_CRAWL on
- API Integrations: 800 € / stk
- Inhalts-Verwaltung (CMS-Modul): 1.500 € (optional)
${
budget
? `### BUDGET LOGIC (ULTRA-STRICT):
${budget
? `### BUDGET LOGIC (ULTRA-STRICT):
1. **Mental Calculation**: Start with 7.000 €. Add items based on the reference above.
2. **Hard Ceiling**: If total > ${budget}, you MUST discard lower priority items.
3. **Priority**: High-End Design and Core Pages > Features.
4. **Restriction**: For ${budget}, do NOT exceed 2 features and 4 extra pages.
5. THE TOTAL COST CALCULATED BY THESE RULES MUST BE <= ${budget}.
6. Do NOT mention the budget in any string fields.`
: ""
}
: ""
}
- ** features **: Items from the FEATURE_REFERENCE.
- ** ABSOLUTE CONSERVATIVE RULE **: Only use features if the briefing implies *dynamic complexity* (CMS, filtering, search, database).
@@ -500,7 +499,7 @@ ${
const p1Resp = await axios.post(
"https://openrouter.ai/api/v1/chat/completions",
{
model: "google/gemini-3-flash-preview",
model: "google/gemini-2.5-flash",
messages: [
{ role: "system", content: pass1SystemPrompt },
{ role: "user", content: pass1UserPrompt },
@@ -547,7 +546,7 @@ Return only the corrected 'features' and 'otherPages' arrays.
const p15Resp = await axios.post(
"https://openrouter.ai/api/v1/chat/completions",
{
model: "google/gemini-3-flash-preview",
model: "google/gemini-2.5-flash",
messages: [
{ role: "system", content: pass15SystemPrompt },
{
@@ -603,7 +602,7 @@ ${JSON.stringify(facts, null, 2)}
const p2Resp = await axios.post(
"https://openrouter.ai/api/v1/chat/completions",
{
model: "google/gemini-3-flash-preview",
model: "google/gemini-2.5-flash",
messages: [
{ role: "system", content: pass2SystemPrompt },
{ role: "user", content: briefing },
@@ -658,7 +657,7 @@ ${tone}
const p3Resp = await axios.post(
"https://openrouter.ai/api/v1/chat/completions",
{
model: "google/gemini-3-flash-preview",
model: "google/gemini-2.5-flash",
messages: [
{ role: "system", content: pass3SystemPrompt },
{
@@ -712,7 +711,7 @@ ${JSON.stringify({ facts, strategy }, null, 2)}
const p4Resp = await axios.post(
"https://openrouter.ai/api/v1/chat/completions",
{
model: "google/gemini-3-flash-preview",
model: "google/gemini-2.5-flash",
messages: [
{ role: "system", content: pass4SystemPrompt },
{
@@ -808,7 +807,7 @@ ${JSON.stringify({ facts, details, strategy, ia }, null, 2)}
const p5Resp = await axios.post(
"https://openrouter.ai/api/v1/chat/completions",
{
model: "google/gemini-3-flash-preview",
model: "google/gemini-2.5-flash",
messages: [
{ role: "system", content: pass5SystemPrompt },
{ role: "user", content: briefing },
@@ -862,7 +861,7 @@ ${JSON.stringify({ facts, strategy, ia, positionsData }, null, 2)}
const p6Resp = await axios.post(
"https://openrouter.ai/api/v1/chat/completions",
{
model: "google/gemini-3-flash-preview",
model: "google/gemini-2.5-flash",
messages: [
{ role: "system", content: pass6SystemPrompt },
{ role: "user", content: `BRIEFING_TRUTH: \n${briefing} ` },
@@ -1004,8 +1003,8 @@ ${JSON.stringify({ facts, strategy, ia, positionsData }, null, 2)}
const normalizedValue =
typeof value === "object"
? (value as any).beschreibung ||
(value as any).description ||
JSON.stringify(value)
(value as any).description ||
JSON.stringify(value)
: value;
normalized[key] = normalizedValue as string;
});

View File

@@ -1,46 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
const MDX_DIR = path.join(process.cwd(), 'content/blog');
function cleanMermaidFormatting(content: string): string {
// Fix: The repair script reformatted <Mermaid> tags badly with extra blank lines
// Pattern: <Mermaid\n \n graph={`...`}\n \n />
// Should be: <Mermaid\n graph={`...`}\n id="..."\n .../>
// Remove empty lines between <Mermaid and graph=
let result = content.replace(/<Mermaid\s*\n\s*\n\s*graph=/g, '<Mermaid\n graph=');
// Remove trailing empty space before />
result = result.replace(/`\}\s*\n\s*\n\s*\/>/g, '`}\n />');
// Fix: Remove trailing whitespace-only lines before id= or title= or showShare=
result = result.replace(/`\}\s*\n\s*\n\s*(id=)/g, '`}\n $1');
result = result.replace(/`\}\s*\n\s*\n\s*(title=)/g, '`}\n $1');
result = result.replace(/`\}\s*\n\s*\n\s*(showShare=)/g, '`}\n $1');
return result;
}
function processFiles() {
const files = fs.readdirSync(MDX_DIR).filter(f => f.endsWith('.mdx'));
let fixCount = 0;
for (const file of files) {
const filePath = path.join(MDX_DIR, file);
const content = fs.readFileSync(filePath, 'utf8');
const cleaned = cleanMermaidFormatting(content);
if (content !== cleaned) {
fs.writeFileSync(filePath, cleaned);
fixCount++;
console.log(`✅ Cleaned formatting in ${file}`);
} else {
console.log(`- ${file} OK`);
}
}
console.log(`\nTotal cleaned: ${fixCount}`);
}
processFiles();

View File

@@ -1,365 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
const MDX_DIR = path.join(process.cwd(), 'content/blog');
/**
* Convert <Mermaid graph={`...`} id="..." ... /> to <Mermaid id="..." ...>{`...`}</Mermaid>
* This fixes the RSC serialization issue where template literal props are stripped.
*/
function convertMermaidToChildren(content: string): string {
// Match <Mermaid ... graph={`...`} ... /> (self-closing)
// We need a multi-pass approach since the graph prop can appear anywhere in the tag
const mermaidBlockRegex = /<Mermaid\s+([\s\S]*?)\/>/g;
return content.replace(mermaidBlockRegex, (match) => {
// Extract the graph prop value (template literal)
const graphMatch = match.match(/graph=\{`([\s\S]*?)`\}/);
if (!graphMatch) return match; // No graph prop, skip
const graphContent = graphMatch[1];
// Remove the graph prop from the tag
let cleanedTag = match.replace(/\s*graph=\{`[\s\S]*?`\}\s*/g, ' ');
// Remove the self-closing /> and add children
cleanedTag = cleanedTag.replace(/\s*\/>$/, '');
// Clean up extra whitespace
cleanedTag = cleanedTag.replace(/\s+/g, ' ').trim();
return `${cleanedTag}>\n{\`${graphContent}\`}\n</Mermaid>`;
});
}
/**
* Convert <DiagramPie data={[...]} ... /> to <Mermaid ...>{`pie\n "label": value\n...`}</Mermaid>
*/
function convertDiagramPie(content: string): string {
const pieRegex = /<DiagramPie\s+([\s\S]*?)\/>/g;
return content.replace(pieRegex, (match) => {
// Extract data array
const dataMatch = match.match(/data=\{\[([\s\S]*?)\]\}/);
if (!dataMatch) return match;
const dataStr = dataMatch[1];
// Parse the array items: { label: "...", value: ... }
const items: { label: string; value: number }[] = [];
const itemRegex = /\{\s*label:\s*"([^"]+)"\s*,\s*value:\s*(\d+)\s*\}/g;
let itemMatch;
while ((itemMatch = itemRegex.exec(dataStr)) !== null) {
items.push({ label: itemMatch[1], value: parseInt(itemMatch[2]) });
}
if (items.length === 0) return match;
// Build pie chart Mermaid syntax
const pieLines = items.map(item => ` "${item.label}" : ${item.value}`).join('\n');
const pieGraph = `pie\n${pieLines}`;
// Extract other props
const titleMatch = match.match(/title="([^"]+)"/);
const captionMatch = match.match(/caption="([^"]+)"/);
const idMatch = match.match(/id="([^"]+)"/);
const showShareMatch = match.match(/showShare=\{(true|false)\}/);
const title = titleMatch ? titleMatch[1] : '';
const caption = captionMatch ? captionMatch[1] : '';
const id = idMatch ? idMatch[1] : '';
const showShare = showShareMatch ? showShareMatch[1] : 'true';
// Build replacement with Mermaid component wrapped in div
let result = `<div className="my-12">\n <Mermaid`;
if (id) result += ` id="${id}"`;
if (title) result += ` title="${title}"`;
result += ` showShare={${showShare}}>`;
result += `\n{\`${pieGraph}\`}\n</Mermaid>`;
if (caption) {
result += `\n <div className="text-center text-xs text-slate-400 mt-4 italic">\n ${caption}\n </div>`;
}
result += `\n</div>`;
return result;
});
}
/**
* Convert <DiagramGantt tasks={[...]} ... /> to <Mermaid ...>{`gantt\n...`}</Mermaid>
*/
function convertDiagramGantt(content: string): string {
const ganttRegex = /<DiagramGantt\s+([\s\S]*?)\/>/g;
return content.replace(ganttRegex, (match) => {
// Extract tasks array
const tasksMatch = match.match(/tasks=\{\[([\s\S]*?)\]\}/);
if (!tasksMatch) return match;
const tasksStr = tasksMatch[1];
// Parse the task items
const taskRegex = /\{\s*id:\s*"([^"]+)"\s*,\s*name:\s*"([^"]+)"\s*,\s*start:\s*"([^"]+)"\s*,\s*duration:\s*"([^"]+)"\s*(?:,\s*dependencies:\s*\[([^\]]*)\])?\s*\}/g;
const tasks: { id: string; name: string; start: string; duration: string; deps?: string }[] = [];
let taskMatch;
while ((taskMatch = taskRegex.exec(tasksStr)) !== null) {
tasks.push({
id: taskMatch[1],
name: taskMatch[2],
start: taskMatch[3],
duration: taskMatch[4],
deps: taskMatch[5] || '',
});
}
if (tasks.length === 0) return match;
// Build gantt chart Mermaid syntax
const ganttLines = tasks.map(task => {
const deps = task.deps ? `, after ${task.deps.replace(/"/g, '').trim()}` : '';
return ` ${task.name} :${task.id}, ${task.start}, ${task.duration}${deps}`;
}).join('\n');
const ganttGraph = `gantt\n dateFormat YYYY-MM-DD\n${ganttLines}`;
// Extract other props
const titleMatch = match.match(/title="([^"]+)"/);
const captionMatch = match.match(/caption="([^"]+)"/);
const idMatch = match.match(/id="([^"]+)"/);
const showShareMatch = match.match(/showShare=\{(true|false)\}/);
const title = titleMatch ? titleMatch[1] : '';
const caption = captionMatch ? captionMatch[1] : '';
const id = idMatch ? idMatch[1] : '';
const showShare = showShareMatch ? showShareMatch[1] : 'true';
let result = `<div className="my-12">\n <Mermaid`;
if (id) result += ` id="${id}"`;
if (title) result += ` title="${title}"`;
result += ` showShare={${showShare}}>`;
result += `\n{\`${ganttGraph}\`}\n</Mermaid>`;
if (caption) {
result += `\n <div className="text-center text-xs text-slate-400 mt-4 italic">\n ${caption}\n </div>`;
}
result += `\n</div>`;
return result;
});
}
/**
* Convert <DiagramSequence participants={[...]} messages={[...]} ... /> to <Mermaid ...>{`sequenceDiagram\n...`}</Mermaid>
*/
function convertDiagramSequence(content: string): string {
const seqRegex = /<DiagramSequence\s+([\s\S]*?)\/>/g;
return content.replace(seqRegex, (match) => {
// Extract participants array
const participantsMatch = match.match(/participants=\{\[([\s\S]*?)\]\}/);
if (!participantsMatch) return match;
// Extract messages array
const messagesMatch = match.match(/messages=\{\[([\s\S]*?)\]\}/);
if (!messagesMatch) return match;
const participantsStr = participantsMatch[1];
const messagesStr = messagesMatch[1];
// Parse participants
const participants = participantsStr.match(/"([^"]+)"/g)?.map(s => s.replace(/"/g, '')) || [];
// Parse messages
const msgRegex = /\{\s*from:\s*"([^"]+)"\s*,\s*to:\s*"([^"]+)"\s*,\s*message:\s*"([^"]+)"(?:\s*,\s*type:\s*"([^"]+)")?\s*\}/g;
const messages: { from: string; to: string; message: string; type?: string }[] = [];
let msgMatch;
while ((msgMatch = msgRegex.exec(messagesStr)) !== null) {
messages.push({
from: msgMatch[1],
to: msgMatch[2],
message: msgMatch[3],
type: msgMatch[4],
});
}
if (participants.length === 0 || messages.length === 0) return match;
// Build sequence diagram Mermaid syntax
const getArrow = (type?: string) => {
switch (type) {
case 'dotted': return '-->>';
case 'async': return '->>';
default: return '->>';
}
};
const participantLines = participants.map(p => ` participant ${p}`).join('\n');
const messageLines = messages.map(m => ` ${m.from}${getArrow(m.type)}${m.to}: ${m.message}`).join('\n');
const seqGraph = `sequenceDiagram\n${participantLines}\n${messageLines}`;
// Extract other props
const titleMatch = match.match(/title="([^"]+)"/);
const captionMatch = match.match(/caption="([^"]+)"/);
const idMatch = match.match(/id="([^"]+)"/);
const showShareMatch = match.match(/showShare=\{(true|false)\}/);
const title = titleMatch ? titleMatch[1] : '';
const caption = captionMatch ? captionMatch[1] : '';
const id = idMatch ? idMatch[1] : '';
const showShare = showShareMatch ? showShareMatch[1] : 'true';
let result = `<div className="my-12">\n <Mermaid`;
if (id) result += ` id="${id}"`;
if (title) result += ` title="${title}"`;
result += ` showShare={${showShare}}>`;
result += `\n{\`${seqGraph}\`}\n</Mermaid>`;
if (caption) {
result += `\n <div className="text-center text-xs text-slate-400 mt-4 italic">\n ${caption}\n </div>`;
}
result += `\n</div>`;
return result;
});
}
/**
* Convert <DiagramTimeline events={[...]} ... /> to <Mermaid ...>{`timeline\n...`}</Mermaid>
*/
function convertDiagramTimeline(content: string): string {
const timelineRegex = /<DiagramTimeline\s+([\s\S]*?)\/>/g;
return content.replace(timelineRegex, (match) => {
const eventsMatch = match.match(/events=\{\[([\s\S]*?)\]\}/);
if (!eventsMatch) return match;
const eventsStr = eventsMatch[1];
const eventRegex = /\{\s*year:\s*"([^"]+)"\s*,\s*title:\s*"([^"]+)"\s*\}/g;
const events: { year: string; title: string }[] = [];
let eventMatch;
while ((eventMatch = eventRegex.exec(eventsStr)) !== null) {
events.push({ year: eventMatch[1], title: eventMatch[2] });
}
if (events.length === 0) return match;
const titleMatch = match.match(/title="([^"]+)"/);
const captionMatch = match.match(/caption="([^"]+)"/);
const idMatch = match.match(/id="([^"]+)"/);
const showShareMatch = match.match(/showShare=\{(true|false)\}/);
const title = titleMatch ? titleMatch[1] : '';
const caption = captionMatch ? captionMatch[1] : '';
const id = idMatch ? idMatch[1] : '';
const showShare = showShareMatch ? showShareMatch[1] : 'true';
const eventLines = events.map(e => ` ${e.year} : ${e.title}`).join('\n');
const timelineGraph = `timeline\n title ${title || 'Timeline'}\n${eventLines}`;
let result = `<div className="my-12">\n <Mermaid`;
if (id) result += ` id="${id}"`;
if (title) result += ` title="${title}"`;
result += ` showShare={${showShare}}>`;
result += `\n{\`${timelineGraph}\`}\n</Mermaid>`;
if (caption) {
result += `\n <div className="text-center text-xs text-slate-400 mt-4 italic">\n ${caption}\n </div>`;
}
result += `\n</div>`;
return result;
});
}
/**
* Convert <DiagramState states={[...]} transitions={[...]} ... /> to <Mermaid ...>{`stateDiagram-v2\n...`}</Mermaid>
*/
function convertDiagramState(content: string): string {
const stateRegex = /<DiagramState\s+([\s\S]*?)\/>/g;
return content.replace(stateRegex, (match) => {
// Extract transitions
const transitionsMatch = match.match(/transitions=\{\[([\s\S]*?)\]\}/);
if (!transitionsMatch) return match;
const transitionsStr = transitionsMatch[1];
const transRegex = /\{\s*from:\s*"([^"]+)"\s*,\s*to:\s*"([^"]+)"(?:\s*,\s*label:\s*"([^"]+)")?\s*\}/g;
const transitions: { from: string; to: string; label?: string }[] = [];
let transMatch;
while ((transMatch = transRegex.exec(transitionsStr)) !== null) {
transitions.push({
from: transMatch[1],
to: transMatch[2],
label: transMatch[3],
});
}
if (transitions.length === 0) return match;
// Extract initialState
const initialStateMatch = match.match(/initialState="([^"]+)"/);
const initialState = initialStateMatch ? initialStateMatch[1] : '';
// Extract finalStates
const finalStatesMatch = match.match(/finalStates=\{\[([^\]]*)\]\}/);
const finalStatesStr = finalStatesMatch ? finalStatesMatch[1] : '';
const finalStates = finalStatesStr.match(/"([^"]+)"/g)?.map(s => s.replace(/"/g, '')) || [];
const titleMatch = match.match(/title="([^"]+)"/);
const captionMatch = match.match(/caption="([^"]+)"/);
const idMatch = match.match(/id="([^"]+)"/);
const showShareMatch = match.match(/showShare=\{(true|false)\}/);
const title = titleMatch ? titleMatch[1] : '';
const caption = captionMatch ? captionMatch[1] : '';
const id = idMatch ? idMatch[1] : '';
const showShare = showShareMatch ? showShareMatch[1] : 'true';
let stateLines = 'stateDiagram-v2';
if (initialState) {
stateLines += `\n [*] --> ${initialState}`;
}
stateLines += '\n' + transitions.map(t => {
const label = t.label ? ` : ${t.label}` : '';
return ` ${t.from} --> ${t.to}${label}`;
}).join('\n');
stateLines += '\n' + finalStates.map(s => ` ${s} --> [*]`).join('\n');
let result = `<div className="my-12">\n <Mermaid`;
if (id) result += ` id="${id}"`;
if (title) result += ` title="${title}"`;
result += ` showShare={${showShare}}>`;
result += `\n{\`${stateLines}\`}\n</Mermaid>`;
if (caption) {
result += `\n <div className="text-center text-xs text-slate-400 mt-4 italic">\n ${caption}\n </div>`;
}
result += `\n</div>`;
return result;
});
}
function processFiles() {
const files = fs.readdirSync(MDX_DIR).filter(f => f.endsWith('.mdx'));
for (const file of files) {
const filePath = path.join(MDX_DIR, file);
let content = fs.readFileSync(filePath, 'utf8');
const original = content;
content = convertMermaidToChildren(content);
content = convertDiagramPie(content);
content = convertDiagramGantt(content);
content = convertDiagramSequence(content);
content = convertDiagramTimeline(content);
content = convertDiagramState(content);
if (content !== original) {
fs.writeFileSync(filePath, content);
console.log(`✅ Converted diagrams in ${file}`);
} else {
console.log(`- ${file} (no changes needed)`);
}
}
}
processFiles();

View File

@@ -1,56 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
const MDX_DIR = path.join(process.cwd(), 'content/blog');
/**
* Convert ugly single-line graph="..." props to clean multi-line children.
*
* FROM:
* <Mermaid graph="graph TD\n A-->B\n B-->C" id="..." title="..." />
*
* TO:
* <Mermaid id="..." title="...">
* graph TD
* A-->B
* B-->C
* </Mermaid>
*/
function convertToChildren(content: string): string {
// Match <Mermaid graph="..." ... />
const mermaidRegex = /<Mermaid\s+graph="([^"]*)"([^>]*?)\/>/g;
return content.replace(mermaidRegex, (match, graphValue, otherProps) => {
// Unescape \n to real newlines
const cleanGraph = graphValue.replace(/\\n/g, '\n');
// Clean up other props
const cleanProps = otherProps.trim();
// Build the new format with children
return `<Mermaid${cleanProps ? ' ' + cleanProps : ''}>\n${cleanGraph}\n</Mermaid>`;
});
}
function processFiles() {
const files = fs.readdirSync(MDX_DIR).filter(f => f.endsWith('.mdx'));
let fixCount = 0;
for (const file of files) {
const filePath = path.join(MDX_DIR, file);
const content = fs.readFileSync(filePath, 'utf8');
const fixed = convertToChildren(content);
if (content !== fixed) {
fs.writeFileSync(filePath, fixed);
fixCount++;
console.log(`✅ Converted to children: ${file}`);
} else {
console.log(`- ${file} (no changes needed)`);
}
}
console.log(`\nTotal converted: ${fixCount}`);
}
processFiles();

View File

@@ -1,46 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
const MDX_DIR = path.join(process.cwd(), 'content/blog');
/**
* Convert graph={"..."} to graph="..." (plain string prop without JSX expression wrapper).
* MDXRemote RSC strips JSX expression props but keeps plain string props.
*
* But we also need the escape sequences to go through.
* The plain string prop `graph="graph TD\nA-->B"` will have the \n treated as
* literal characters by MDX's parser, not as a newline. The Mermaid component
* then unescapes them.
*/
function convertToPlainStringProps(content: string): string {
// Match graph={" ... "} and convert to graph="..."
// The content inside should already have escaped newlines and quotes
const pattern = /graph=\{"((?:[^"\\]|\\.)*)"\}/g;
return content.replace(pattern, (match, graphContent) => {
return `graph="${graphContent}"`;
});
}
function processFiles() {
const files = fs.readdirSync(MDX_DIR).filter(f => f.endsWith('.mdx'));
let fixCount = 0;
for (const file of files) {
const filePath = path.join(MDX_DIR, file);
const content = fs.readFileSync(filePath, 'utf8');
const fixed = convertToPlainStringProps(content);
if (content !== fixed) {
fs.writeFileSync(filePath, fixed);
fixCount++;
console.log(`✅ Converted: ${file}`);
} else {
console.log(`- ${file} (no changes needed)`);
}
}
console.log(`\nTotal converted: ${fixCount}`);
}
processFiles();

View File

@@ -1,60 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
const MDX_DIR = path.join(process.cwd(), 'content/blog');
/**
* Convert all Mermaid children syntax back to graph prop,
* BUT use a regular double-quoted string with escaped newlines instead of template literals.
*
* MDXRemote RSC strips template literals!
*
* Convert:
* <Mermaid id="..." title="..." showShare={true}>
* {`graph TD
* A --> B`}
* </Mermaid>
*
* To:
* <Mermaid graph={"graph TD\n A --> B"} id="..." title="..." showShare={true} />
*/
function convertToPlainStringProp(content: string): string {
// Match <Mermaid ...>{\`...\`}</Mermaid>
const mermaidChildrenRegex = /<Mermaid\s+([^>]*?)>\s*\{`([\s\S]*?)`\}\s*<\/Mermaid>/g;
return content.replace(mermaidChildrenRegex, (match, propsStr, graphContent) => {
// Escape double quotes in the graph content
const escapedGraph = graphContent
.replace(/\\/g, '\\\\') // escape backslashes first
.replace(/"/g, '\\"') // escape double quotes
.replace(/\n/g, '\\n'); // escape newlines
// Clean up props string
const cleanProps = propsStr.trim();
return `<Mermaid graph={"${escapedGraph}"} ${cleanProps} />`;
});
}
function processFiles() {
const files = fs.readdirSync(MDX_DIR).filter(f => f.endsWith('.mdx'));
let fixCount = 0;
for (const file of files) {
const filePath = path.join(MDX_DIR, file);
const content = fs.readFileSync(filePath, 'utf8');
const fixed = convertToPlainStringProp(content);
if (content !== fixed) {
fs.writeFileSync(filePath, fixed);
fixCount++;
console.log(`✅ Converted to plain string: ${file}`);
} else {
console.log(`- ${file} (no Mermaid children found)`);
}
}
console.log(`\nTotal converted: ${fixCount}`);
}
processFiles();

View File

@@ -1,47 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
const MDX_DIR = path.join(process.cwd(), 'content/blog');
/**
* FINAL ATTEMPT: Standardize EVERYTHING in Mermaid blocks to double quotes.
*
* 1. Find all text inside <Mermaid>...</Mermaid>.
* 2. Replace any ['Label'] or ['Label's'] or ["Label"] patterns.
* 3. Enforce ["Label"] for all labels.
* 4. Remove any internal single quotes that break parsing.
*/
function finalMermaidFix(content: string): string {
const mermaidRegex = /(<Mermaid[^>]*>)([\s\S]*?)(<\/Mermaid>)/g;
return content.replace(mermaidRegex, (match, open, body, close) => {
let fixedBody = body;
// Convert common label syntax to clean double quotes
// Match: [followed by optional space and any quote, capture content, end with optional quote and space]
fixedBody = fixedBody.replace(/\[\s*['"]?([^\]'"]+?)['"]?\s*\]/g, (m, label) => {
// Clean the label: remove any internal quotes that could cause issues
const cleanLabel = label.replace(/['"]/g, "").trim();
return `["${cleanLabel}"]`;
});
// Also handle Pie charts which use 'Label' : value
fixedBody = fixedBody.replace(/^\s*'([^']+)'\s*:/gm, (m, label) => {
const cleanLabel = label.replace(/['"]/g, "").trim();
return ` "${cleanLabel}" :`;
});
return open + fixedBody + close;
});
}
const files = fs.readdirSync(MDX_DIR).filter(f => f.endsWith('.mdx'));
for (const file of files) {
const fp = path.join(MDX_DIR, file);
const content = fs.readFileSync(fp, 'utf8');
const fixed = finalMermaidFix(content);
if (content !== fixed) {
fs.writeFileSync(fp, fixed);
console.log(`✅ Fixed potentially problematic syntax in: ${file}`);
}
}

View File

@@ -1,62 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
const MDX_DIR = path.join(process.cwd(), 'content/blog');
const TARGET_FILE = process.argv[2] ? path.resolve(process.argv[2]) : null;
function fixFencedMermaid(content: string): string {
// Regex to find fenced mermaid blocks
// ```mermaid
// graph TD...
// ```
const fencedRegex = /```mermaid\n([\s\S]*?)\n```/g;
return content.replace(fencedRegex, (match, code) => {
// Generate a random ID or use a placeholder
const id = `diagram-${Math.random().toString(36).substr(2, 9)}`;
return `<div className="my-12">
<Mermaid id="${id}" title="Generated Diagram" showShare={true}>
${code.trim()}
</Mermaid>
</div>`;
});
}
function processFiles() {
if (TARGET_FILE) {
console.log(`Processing single file: ${TARGET_FILE}`);
if (fs.existsSync(TARGET_FILE)) {
const content = fs.readFileSync(TARGET_FILE, 'utf8');
const fixed = fixFencedMermaid(content);
if (content !== fixed) {
fs.writeFileSync(TARGET_FILE, fixed);
console.log(`✅ Fixed fenced mermaid in: ${TARGET_FILE}`);
} else {
console.log(`- No changes needed.`);
}
} else {
console.error(`File not found: ${TARGET_FILE}`);
}
return;
}
const files = fs.readdirSync(MDX_DIR).filter(f => f.endsWith('.mdx'));
let fixCount = 0;
for (const file of files) {
const filePath = path.join(MDX_DIR, file);
const content = fs.readFileSync(filePath, 'utf8');
const fixed = fixFencedMermaid(content);
if (content !== fixed) {
fs.writeFileSync(filePath, fixed);
fixCount++;
console.log(`✅ Fixed fenced mermaid in: ${file}`);
}
}
console.log(`\nTotal fixed: ${fixCount}`);
}
processFiles();

View File

@@ -1,86 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
const MDX_DIR = path.join(process.cwd(), 'content/blog');
/**
* Fix escaped double quotes in Mermaid graph props.
*
* The graph prop contains \" which is invalid in MDX attribute syntax.
* Replace \" with ' (single quote) - Mermaid supports both.
*
* Also fix \\n to just \n (single backslash) - the MDX parser
* will treat \n as literal characters, and the Mermaid component
* will unescape them.
*/
function fixGraphQuotes(content: string): string {
// Match graph="..." prop (the entire value including escaped content)
// This is tricky because the value can contain escaped quotes
// We need to match from graph=" to the closing " that ends the prop value
// Strategy: Find graph=" then scan forward, tracking escape sequences
const graphPropStart = 'graph="';
let result = '';
let i = 0;
while (i < content.length) {
const idx = content.indexOf(graphPropStart, i);
if (idx === -1) {
result += content.slice(i);
break;
}
// Copy everything up to and including graph="
result += content.slice(i, idx + graphPropStart.length);
// Now scan the value, replacing \" with '
let j = idx + graphPropStart.length;
let graphValue = '';
while (j < content.length) {
if (content[j] === '\\' && content[j + 1] === '"') {
// Replace \" with '
graphValue += "'";
j += 2;
} else if (content[j] === '\\' && content[j + 1] === '\\') {
// Keep \\ as is
graphValue += '\\\\';
j += 2;
} else if (content[j] === '"') {
// End of attribute value
break;
} else {
graphValue += content[j];
j++;
}
}
result += graphValue;
i = j; // Continue from the closing quote (will be added in next iteration)
}
return result;
}
function processFiles() {
const files = fs.readdirSync(MDX_DIR).filter(f => f.endsWith('.mdx'));
let fixCount = 0;
for (const file of files) {
const filePath = path.join(MDX_DIR, file);
const content = fs.readFileSync(filePath, 'utf8');
const fixed = fixGraphQuotes(content);
if (content !== fixed) {
fs.writeFileSync(filePath, fixed);
fixCount++;
console.log(`✅ Fixed quotes: ${file}`);
} else {
console.log(`- ${file} (no changes needed)`);
}
}
console.log(`\nTotal fixed: ${fixCount}`);
}
processFiles();

View File

@@ -1,123 +0,0 @@
#!/usr/bin/env tsx
/**
* Fix missing whitespace in MDX files by comparing with TSX originals
*/
import * as fs from 'fs';
import * as path from 'path';
// Mapping of TSX component names to MDX slugs
const TSX_TO_MDX_MAP: Record<string, string> = {
'PageSpeedFails': 'why-pagespeed-fails',
'SlowLoadingDebt': 'slow-loading-costs-customers',
'AgencySlowdown': 'why-agencies-are-slow',
'WordPressPlugins': 'hidden-costs-of-wordpress-plugins',
'WebsiteStability': 'why-websites-break-after-updates',
'CookieFreeDesign': 'website-without-cookie-banners',
'LocalCloud': 'no-us-cloud-platforms',
'GDPRSystem': 'gdpr-conformity-system-approach',
'VendorLockIn': 'builder-systems-threaten-independence',
'PrivacyAnalytics': 'analytics-without-tracking',
'GreenIT': 'green-it-sustainable-web',
'FixedPrice': 'fixed-price-digital-projects',
'BuildFirst': 'build-first-digital-architecture',
'MaintenanceNoCMS': 'maintenance-for-headless-systems',
'Longevity': 'digital-longevity-architecture',
'CleanCode': 'clean-code-for-business-value',
'ResponsiveDesign': 'responsive-design-high-fidelity',
'HostingOps': 'professional-hosting-operations',
'NoTemplates': 'why-no-templates-matter',
'CRMSync': 'crm-synchronization-headless',
};
const TSX_BASE = path.join(process.cwd(), 'src/components/blog/posts');
const MDX_BASE = path.join(process.cwd(), 'content/blog');
function findTsxFile(componentName: string): string | null {
for (const group of ['Group1', 'Group2', 'Group3', 'Group4']) {
const tsxPath = path.join(TSX_BASE, group, `${componentName}.tsx`);
if (fs.existsSync(tsxPath)) {
return tsxPath;
}
}
return null;
}
function fixWhitespace() {
let totalFixed = 0;
for (const [tsxName, mdxSlug] of Object.entries(TSX_TO_MDX_MAP)) {
const tsxPath = findTsxFile(tsxName);
const mdxPath = path.join(MDX_BASE, `${mdxSlug}.mdx`);
if (!tsxPath || !fs.existsSync(mdxPath)) {
console.log(`⚠️ Skipping ${tsxName}: files not found`);
continue;
}
const tsxContent = fs.readFileSync(tsxPath, 'utf-8');
let mdxContent = fs.readFileSync(mdxPath, 'utf-8');
// Count occurrences of {" "} in both files
const tsxSpaces = (tsxContent.match(/\{" "\}/g) || []).length;
const mdxSpacesBefore = (mdxContent.match(/\{" "\}/g) || []).length;
if (tsxSpaces === 0) {
console.log(`${mdxSlug}: No whitespace needed`);
continue;
}
// Extract all lines with {" "} from TSX
const tsxLines = tsxContent.split('\n');
const spacedLines: Array<{ lineNum: number; content: string }> = [];
tsxLines.forEach((line, idx) => {
if (line.includes('{" "}')) {
spacedLines.push({ lineNum: idx, content: line.trim() });
}
});
// For each spaced line in TSX, find similar content in MDX and add {" "}
let fixCount = 0;
for (const { content } of spacedLines) {
// Extract the text pattern before {" "}
const match = content.match(/(.+?)\{" "\}/);
if (!match) continue;
const textBefore = match[1].trim();
// Find this pattern in MDX without the space
const searchPattern = new RegExp(
textBefore.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?!\\{" "\\})',
'g'
);
const newMdxContent = mdxContent.replace(searchPattern, (matched) => {
// Only add {" "} if it's not already there and if it's followed by a tag
if (mdxContent.indexOf(matched + '{" "}') === -1 &&
mdxContent.indexOf(matched) < mdxContent.indexOf('<', mdxContent.indexOf(matched))) {
fixCount++;
return matched + '{" "}';
}
return matched;
});
mdxContent = newMdxContent;
}
const mdxSpacesAfter = (mdxContent.match(/\{" "\}/g) || []).length;
if (fixCount > 0) {
fs.writeFileSync(mdxPath, mdxContent, 'utf-8');
console.log(`${mdxSlug}: Fixed ${fixCount} whitespace issues (${mdxSpacesBefore}${mdxSpacesAfter})`);
totalFixed += fixCount;
} else {
console.log(`${mdxSlug}: Already correct (${mdxSpacesBefore} spaces)`);
}
}
console.log(`\n✅ Total whitespace fixes: ${totalFixed}`);
}
fixWhitespace();

View File

@@ -1,61 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
const MDX_DIR = path.join(process.cwd(), 'content/blog');
/**
* Fix apostrophes in Mermaid labels by removing them.
*
* Mermaid parser treats ' as a quote delimiter even inside ["..."].
* Replace ' with nothing or use HTML entity &#39; (but simpler to just remove).
*/
function fixMermaidApostrophes(content: string): string {
// Find all Mermaid blocks
const mermaidBlockRegex = /(<Mermaid[^>]*>)([\s\S]*?)(<\/Mermaid>)/g;
return content.replace(mermaidBlockRegex, (match, openTag, mermaidContent, closeTag) => {
// Within Mermaid content, find all ["..."] labels and remove apostrophes
const fixed = mermaidContent.replace(/\["([^"]*)"\]/g, (m: string, label: string) => {
// Remove all apostrophes from the label
const cleanLabel = label.replace(/'/g, '');
return `["${cleanLabel}"]`;
});
return openTag + fixed + closeTag;
});
}
function processFiles() {
const files = fs.readdirSync(MDX_DIR).filter(f => f.endsWith('.mdx'));
let fixCount = 0;
let totalApostrophes = 0;
for (const file of files) {
const filePath = path.join(MDX_DIR, file);
const content = fs.readFileSync(filePath, 'utf8');
// Count apostrophes in Mermaid blocks before fixing
const mermaidBlocks = content.match(/<Mermaid[^>]*>[\s\S]*?<\/Mermaid>/g) || [];
for (const block of mermaidBlocks) {
const apostrophes = (block.match(/\["[^"]*'[^"]*"\]/g) || []).length;
if (apostrophes > 0) {
totalApostrophes += apostrophes;
}
}
const fixed = fixMermaidApostrophes(content);
if (content !== fixed) {
fs.writeFileSync(filePath, fixed);
fixCount++;
console.log(`✅ Fixed apostrophes: ${file}`);
} else {
console.log(`- ${file} (no apostrophes found)`);
}
}
console.log(`\nTotal files fixed: ${fixCount}`);
console.log(`Total apostrophes removed: ${totalApostrophes}`);
}
processFiles();

View File

@@ -1,69 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
const MDX_DIR = path.join(process.cwd(), 'content/blog');
/**
* STRICTLY fixes Mermaid node labels.
*
* Goal:
* 1. Find all content inside [...] in Mermaid blocks.
* 2. Strip ALL outer quotes (single or double).
* 3. Sanitize the inner text:
* - Remove/Replace internal double quotes
* - Remove/Replace internal single quotes (to avoid any ambiguity)
* 4. Wrap strictly in ["..."].
*/
function fixMermaidLabels(content: string): string {
// Find Mermaid blocks
return content.replace(/(<Mermaid[^>]*>)([\s\S]*?)(<\/Mermaid>)/g, (match, open, body, close) => {
// Process the body line by line to be safe, or just regex the labels.
// Regex for labels: \[ followed by anything until \]
// Note: We assume labels don't contain nested brackets for now (Mermaid usually doesn't).
const fixedBody = body.replace(/\[([^\]]+)\]/g, (labelMatch, innerContent) => {
let text = innerContent.trim();
// Check if it looks like a quoted label
const hasOuterQuotes = /^['"]|['"]$/.test(text);
if (hasOuterQuotes) {
// Remove ALL starting/ending quotes (handling multiple if messed up)
text = text.replace(/^['"]+|['"]+$/g, '');
}
// Sanitize internal text
// Replace " with ' to avoid breaking the outer double quotes
text = text.replace(/"/g, "'");
// Verify parsing safety:
// Replace ' with space or nothing if we want to be super safe,
// but "Text with 'quotes'" SHOULD be valid in Mermaid.
// However, the previous error might have been due to MDX interference.
// Let's keep single quotes inside, but ensure outer are double.
// WAIT: The specific error was `B['Server ... 'inner' ...']`.
// If we convert to `B["Server ... 'inner' ..."]`, it should work.
return `["${text}"]`;
});
return open + fixedBody + close;
});
}
const files = fs.readdirSync(MDX_DIR).filter(f => f.endsWith('.mdx'));
let fixedCount = 0;
for (const file of files) {
const filePath = path.join(MDX_DIR, file);
const content = fs.readFileSync(filePath, 'utf8');
const fixed = fixMermaidLabels(content);
if (content !== fixed) {
fs.writeFileSync(filePath, fixed);
console.log(`✅ Fixed labels in: ${file}`);
fixedCount++;
}
}
console.log(`\nFixed ${fixedCount} files.`);

View File

@@ -1,49 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
const MDX_DIR = path.join(process.cwd(), 'content/blog');
/**
* Fix ALL quote variations in Mermaid labels to use consistent double quotes.
*
* Handles:
* - ['Label'] → ["Label"]
* - ["Label'] → ["Label"]
* - ['Label"] → ["Label"]
* - ["Label"] → ["Label"] (already correct)
*/
function fixMermaidQuotes(content: string): string {
// Find all Mermaid blocks (between <Mermaid> and </Mermaid>)
const mermaidBlockRegex = /(<Mermaid[^>]*>)([\s\S]*?)(<\/Mermaid>)/g;
return content.replace(mermaidBlockRegex, (match, openTag, mermaidContent, closeTag) => {
// Replace all variations: [' or [" at start, '] or "] at end
// Match pattern: [ followed by ' or ", then content, then ' or ", then ]
const fixed = mermaidContent.replace(/\[['"]([^'"]*)['"]\]/g, '["$1"]');
return openTag + fixed + closeTag;
});
}
function processFiles() {
const files = fs.readdirSync(MDX_DIR).filter(f => f.endsWith('.mdx'));
let fixCount = 0;
for (const file of files) {
const filePath = path.join(MDX_DIR, file);
const content = fs.readFileSync(filePath, 'utf8');
const fixed = fixMermaidQuotes(content);
if (content !== fixed) {
fs.writeFileSync(filePath, fixed);
fixCount++;
console.log(`✅ Fixed Mermaid quotes: ${file}`);
} else {
console.log(`- ${file} (no changes needed)`);
}
}
console.log(`\nTotal fixed: ${fixCount}`);
}
processFiles();

View File

@@ -0,0 +1,45 @@
import { ThumbnailGenerator } from '@mintel/thumbnail-generator';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
async function run() {
const apiKey = process.env.REPLICATE_API_KEY;
if (!apiKey) {
console.error("❌ Missing REPLICATE_API_KEY in environment.");
process.exit(1);
}
const targetFile = process.argv[2];
if (!targetFile) {
console.error("❌ Usage: npx tsx scripts/generate-thumbnail.ts <file-or-topic>");
process.exit(1);
}
let topic = targetFile;
let filename = "thumbnail.png";
// Try to parse the topic from the MDX frontmatter if a file is provided
if (targetFile.endsWith('.mdx')) {
try {
const content = await fs.readFile(targetFile, 'utf8');
const titleMatch = content.match(/title:\s*"?([^"\n]+)"?/);
topic = titleMatch ? titleMatch[1] : path.basename(targetFile, '.mdx');
filename = `${path.basename(targetFile, '.mdx')}-thumb.png`;
} catch (e) {
console.warn(`⚠️ Could not read ${targetFile} as a file. Using literal argument as topic.`);
topic = targetFile;
}
}
console.log(`Generating abstract thumbnail for topic: "${topic}"`);
const generator = new ThumbnailGenerator({ replicateApiKey: apiKey });
const outputPath = path.join(process.cwd(), 'public', 'blog', filename);
await generator.generateImage(topic, outputPath);
}
run().catch((e) => {
console.error("❌ Thumbnail generation failed:", e);
process.exit(1);
});

View File

@@ -1,107 +1,31 @@
import { ContentGenerator, OptimizationOptions } from "@mintel/content-engine";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import { AiBlogPostOrchestrator } from "@mintel/content-engine";
import { config } from "../content-engine.config.js";
async function main() {
const OPENROUTER_KEY = process.env.OPENROUTER_KEY;
const OPENROUTER_KEY = process.env.OPENROUTER_KEY || process.env.OPENROUTER_API_KEY;
const REPLICATE_KEY = process.env.REPLICATE_API_KEY;
if (!OPENROUTER_KEY) {
console.error("❌ Error: OPENROUTER_KEY not found in environment.");
console.error("❌ Error: OPENROUTER_KEY or OPENROUTER_API_KEY not found in environment.");
process.exit(1);
}
const args = process.argv.slice(2);
let targetFile = args[0];
const targetFile = process.argv[2];
if (!targetFile) {
console.error("❌ Usage: npx tsx scripts/optimize-blog-post.ts <path/to/post.mdx>");
console.error("❌ Usage: npx tsx scripts/optimize-blog-post.ts <file>");
process.exit(1);
}
// Resolve absolute path
if (!path.isAbsolute(targetFile)) {
targetFile = path.resolve(process.cwd(), targetFile);
}
const orchestrator = new AiBlogPostOrchestrator({
apiKey: OPENROUTER_KEY,
replicateApiKey: REPLICATE_KEY,
model: 'google/gemini-3-flash-preview'
});
console.log(`📄 Reading file: ${targetFile}`);
let content = "";
try {
content = await fs.readFile(targetFile, "utf8");
} catch (err: any) {
console.error(`❌ Could not read file: ${err.message}`);
process.exit(1);
}
// Backup original
const backupPath = `${targetFile}.bak`;
await fs.writeFile(backupPath, content);
console.log(`💾 Backup created at: ${backupPath}`);
// Instantiate Generator
const generator = new ContentGenerator(OPENROUTER_KEY);
const context = `
Project: Mintel.me
Style: Industrial, Technical, High-Performance, "No-BS".
Author: Marc Mintel (Digital Architect).
Focus: Web Architecture, PageSpeed, Core Web Vitals, Data-Driven Design.
`;
// Define Optimization Options based on user request ("astrein verbessert mit daten gestützt, links zu quellen usw... mermaid, memes")
const options: OptimizationOptions = {
enhanceFacts: true,
addMemes: true,
addDiagrams: true,
projectContext: context,
availableComponents: [
{
name: "StatsDisplay",
description: "A row of 3 clear statistic cards with values and labels.",
usageExample: `<StatsDisplay
items={[
{ value: "-20%", label: "Conversion", description: "Source: Google" },
{ value: "53%", label: "Bounce Rate", description: "Mobile users > 3s" },
{ value: "0.1s", label: "Latency", description: "Edge Network" }
]}
/>`
},
{
name: "ComparisonRow",
description: "A comparison component showing a negative 'Standard' vs a positive 'Mintel' approach.",
usageExample: `<ComparisonRow
description="Architecture Comparison"
negativeLabel="Legacy CMS"
negativeText="Slow database queries, vulnerable plugins."
positiveLabel="Mintel Stack"
positiveText="Static generation, perfect security."
/>`
}
]
};
// 1. Separate Frontmatter from Body
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
const frontmatter = fmMatch ? fmMatch[0] : "";
const body = fmMatch ? content.replace(frontmatter, "").trim() : content;
console.log("🚀 Starting Optimization via ContentEngine...");
const result = await generator.optimizePost(body, options);
console.log("✅ Optimization Complete!");
console.log(` - Added ${result.research.length} facts`);
console.log(` - Added ${result.memes.length} meme concepts`);
console.log(` - Generated ${result.diagrams.length} diagrams`);
// We write the content back (re-attaching frontmatter if it was there)
const finalContent = frontmatter ? `${frontmatter}\n\n${result.content}` : result.content;
await fs.writeFile(targetFile, finalContent);
console.log(`💾 Saved optimized content to: ${targetFile}`);
await orchestrator.optimizeFile(targetFile, {
contextDir: config.contextDir,
availableComponents: config.components
});
}
main().catch(console.error);

View File

@@ -1,53 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
const MDX_DIR = path.join(process.cwd(), 'content/blog');
function repairMermaidSyntax(content: string): string {
// 1. Convert <Mermaid graph={`...`} /> to <Mermaid graph={`...`}>...</Mermaid> style or just fix the graph prop
// Actually, let's keep the graph prop but make sure the content is VERY safe.
const mermaidRegex = /<Mermaid\s+([\s\S]*?)graph=\{(`[\s\S]*?`)\}([\s\S]*?)\/>/g;
return content.replace(mermaidRegex, (match, before, graphLiteral, after) => {
let graphContent = graphLiteral.slice(1, -1);
// Replace all {Label} with ["Label"]
graphContent = graphContent.replace(/\{([^{}]+)\}/g, '["$1"]');
// Sometimes people use double {{Label}}
graphContent = graphContent.replace(/\{\{([^{}]+)\}\}/g, '["$1"]');
// Remove any trailing backticks inside that might have been accidentally added
graphContent = graphContent.trim();
return `<Mermaid\n ${before.trim()}\n graph={\`${graphContent}\`}\n ${after.trim()}\n />`;
});
}
// Additional fix for other diagram components that might have similar issues with props
function repairOtherDiagrams(content: string): string {
// For DiagramSequence, DiagramTimeline etc., we often pass arrays of objects.
// MDX handles these better, but let's make sure there are no weird backticks.
return content;
}
function processFiles() {
const files = fs.readdirSync(MDX_DIR).filter(f => f.endsWith('.mdx'));
for (const file of files) {
const filePath = path.join(MDX_DIR, file);
const content = fs.readFileSync(filePath, 'utf8');
let repaired = repairMermaidSyntax(content);
repaired = repairOtherDiagrams(repaired);
if (content !== repaired) {
fs.writeFileSync(filePath, repaired);
console.log(`✅ Repaired Mermaid syntax in ${file}`);
} else {
console.log(`- Checked ${file}`);
}
}
}
processFiles();

View File

@@ -1,220 +0,0 @@
#!/usr/bin/env tsx
/**
* Updated link test for the Next.js blog with App Router
* Tests: All references are valid, files exist
*/
import fs from "fs";
import path from "path";
console.log("🔗 Checking links and references (Next.js App Router)...\n");
let passed = 0;
let failed = 0;
function test(name: string, fn: () => void): void {
try {
fn();
console.log(`${name}`);
passed++;
} catch (error) {
console.log(`${name}`);
if (error instanceof Error) {
console.log(` Error: ${error.message}`);
}
failed++;
}
}
// Test 1: Check that blog posts reference valid data
test("Blog posts reference valid data", () => {
const blogPostsPath = path.join(process.cwd(), "src/data/blogPosts.ts");
const content = fs.readFileSync(blogPostsPath, "utf8");
// Extract all slugs
const slugMatches = content.match(/slug:\s*['"]([^'"]+)['"]/g) || [];
const slugs = slugMatches.map((m) => m.match(/['"]([^'"]+)['"]/)?.[1]);
if (slugs.length === 0) {
throw new Error("No slugs found in blogPosts.ts");
}
// Verify dynamic route page exists
const slugPagePath = path.join(process.cwd(), "app/blog/[slug]/page.tsx");
if (!fs.existsSync(slugPagePath)) {
throw new Error(
"Dynamic slug page app/blog/[slug]/page.tsx does not exist",
);
}
});
// Test 2: Verify tag references are valid
test("Tag references are valid", () => {
const blogPostsPath = path.join(process.cwd(), "src/data/blogPosts.ts");
const content = fs.readFileSync(blogPostsPath, "utf8");
// Extract all tags
const tagMatches = content.match(/tags:\s*\[([^\]]+)\]/g) || [];
if (tagMatches.length === 0) {
throw new Error("No tags found in blogPosts.ts");
}
// Verify tag page exists
const tagPagePath = path.join(process.cwd(), "app/tags/[tag]/page.tsx");
if (!fs.existsSync(tagPagePath)) {
throw new Error("Tag page app/tags/[tag]/page.tsx does not exist");
}
});
// Test 3: Verify all component imports are valid
test("All component imports are valid", () => {
const components = [
"src/components/MediumCard.tsx",
"src/components/SearchBar.tsx",
"src/components/ArticleBlockquote.tsx",
"src/components/ArticleHeading.tsx",
"src/components/ArticleParagraph.tsx",
"src/components/ArticleList.tsx",
"src/components/Footer.tsx",
"src/components/Hero.tsx",
"src/components/Tag.tsx",
"src/components/FileExample.tsx",
"src/components/FileExamplesList.tsx",
];
for (const component of components) {
const componentPath = path.join(process.cwd(), component);
if (!fs.existsSync(componentPath)) {
throw new Error(`Component missing: ${component}`);
}
}
});
// Test 4: Verify all required pages exist
test("All required pages exist", () => {
const requiredPages = [
"app/page.tsx",
"app/blog/[slug]/page.tsx",
"app/tags/[tag]/page.tsx",
"app/api/download-zip/route.ts",
];
for (const page of requiredPages) {
const pagePath = path.join(process.cwd(), page);
if (!fs.existsSync(pagePath)) {
throw new Error(`Required page missing: ${page}`);
}
}
});
// Test 5: Verify layout files are valid
test("Layout files are valid", () => {
const layoutPath = path.join(process.cwd(), "app/layout.tsx");
if (!fs.existsSync(layoutPath)) {
throw new Error("Layout missing: app/layout.tsx");
}
const content = fs.readFileSync(layoutPath, "utf8");
if (!content.includes("<html") || !content.includes("</html>")) {
throw new Error("RootLayout does not contain proper HTML structure");
}
if (!content.includes("<body") || !content.includes("</body>")) {
throw new Error("RootLayout missing body section");
}
});
// Test 6: Verify global styles are properly imported
test("Global styles are properly imported", () => {
const stylesPath = path.join(process.cwd(), "app/globals.css");
if (!fs.existsSync(stylesPath)) {
throw new Error("Global styles file missing: app/globals.css");
}
const content = fs.readFileSync(stylesPath, "utf8");
// Check for Tailwind imports
if (
!content.includes("@tailwind base") ||
!content.includes("@tailwind components") ||
!content.includes("@tailwind utilities")
) {
throw new Error("Global styles missing Tailwind imports");
}
// Check for required classes (Next.js version uses different ones or we check the ones we found)
const requiredClasses = [".container", ".post-card", ".highlighter-tag"];
for (const className of requiredClasses) {
if (!content.includes(className)) {
throw new Error(`Global styles missing required class: ${className}`);
}
}
});
// Test 7: Verify file examples data structure
test("File examples data structure is valid", () => {
const fileExamplesPath = path.join(process.cwd(), "src/data/fileExamples.ts");
if (!fs.existsSync(fileExamplesPath)) {
throw new Error("File examples data file missing");
}
const content = fs.readFileSync(fileExamplesPath, "utf8");
if (
!content.includes("export interface FileExample") &&
!content.includes("type FileExample")
) {
throw new Error("FileExample interface/type not found");
}
if (!content.includes("export const sampleFileExamples")) {
throw new Error("sampleFileExamples not exported");
}
});
// Test 8: Verify API endpoint structure
test("API endpoint structure is valid", () => {
const apiPath = path.join(process.cwd(), "app/api/download-zip/route.ts");
if (!fs.existsSync(apiPath)) {
throw new Error("API route missing");
}
const content = fs.readFileSync(apiPath, "utf8");
if (!content.includes("export async function POST")) {
throw new Error("API missing POST handler");
}
if (!content.includes("export async function GET")) {
throw new Error("API missing GET handler");
}
});
// Summary
console.log("\n" + "=".repeat(50));
console.log(`Tests passed: ${passed}`);
console.log(`Tests failed: ${failed}`);
console.log("=".repeat(50));
if (failed === 0) {
console.log("\n🎉 All link checks passed! All references are valid.");
console.log("\nVerified:");
console.log(" ✅ Blog posts data and routing (Next.js)");
console.log(" ✅ Tag filtering system");
console.log(" ✅ All components exist");
console.log(" ✅ All pages exist");
console.log(" ✅ Layout structure (App Router)");
console.log(" ✅ File examples functionality");
console.log(" ✅ API routes");
process.exit(0);
} else {
console.log("\n❌ Some checks failed. Please fix the errors above.");
process.exit(1);
}

View File

@@ -1,50 +0,0 @@
#!/usr/bin/env tsx
/**
* Verify components can be imported and used (Next.js Version)
*/
import { join } from "path";
import fs from "fs";
console.log("🔍 Verifying Embed Components (Next.js)...\n");
// Test 1: Check if components exist
const components = ["YouTubeEmbed.tsx", "TwitterEmbed.tsx", "GenericEmbed.tsx"];
for (const component of components) {
const componentPath = join(process.cwd(), "src", "components", component);
if (fs.existsSync(componentPath)) {
console.log(`${component} exists`);
} else {
console.log(`❌ Component missing: ${component}`);
}
}
// Test 2: Check demo post accessibility
try {
const demoPath = join(process.cwd(), "src", "data", "embedDemoPost.ts");
if (fs.existsSync(demoPath)) {
console.log("✅ embedDemoPost.ts data file exists");
} else {
console.log("❌ embedDemoPost.ts missing");
}
} catch (error) {
console.log("❌ Demo post check error:", error);
}
// Test 3: Check blogPosts array
try {
const blogPostsPath = join(process.cwd(), "src", "data", "blogPosts.ts");
if (fs.existsSync(blogPostsPath)) {
// Check if embed-demo needs to be added (actually it's blog-embed-demo or similar usually)
console.log("✅ Checking blogPosts array integration...");
}
} catch (error) {
console.log("❌ blogPosts check error:", error);
}
console.log("\n" + "=".repeat(60));
console.log("📋 SUMMARY:");
console.log("• Components are verified for Next.js");
console.log("• Data structure is verified");

View File

@@ -0,0 +1,61 @@
import puppeteer from 'puppeteer';
(async () => {
try {
console.log("Starting Chrome...");
const browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
// Pass any console logs from the browser to our terminal
page.on('console', msg => console.log('BROWSER LOG:', msg.text()));
console.log("Navigating to http://localhost:3000/blog/why-pagespeed-fails ...");
await page.goto('http://localhost:3000/blog/why-pagespeed-fails', { waitUntil: 'networkidle0' });
// Wait a bit just in case
await new Promise(r => setTimeout(r, 2000));
console.log("--- Inspecting Mermaid ---");
const mermaidLabels = await page.evaluate(() => {
const labels = Array.from(document.querySelectorAll('.mermaid svg text, .mermaid svg .nodeLabel'));
return labels.map(l => l.textContent).filter(Boolean);
});
console.log(`Found ${mermaidLabels.length} mermaid labels.`);
if (mermaidLabels.length > 0) {
console.log("Sample labels:", mermaidLabels.slice(0, 5));
} else {
console.log("FAIL: No SVG labels found inside Mermaid containers!");
}
console.log("\n--- Inspecting Twitter Embed ---");
const tweets = await page.evaluate(() => {
const tweetContainers = Array.from(document.querySelectorAll('.react-tweet-theme'));
return tweetContainers.map(container => ({
html: container.outerHTML.substring(0, 150) + "..."
}));
});
console.log(`Found ${tweets.length} Tweet containers.`);
if (tweets.length > 0) {
console.log("Success! Tweet container snippet:", tweets[0].html);
// Further inspection of react-tweet - it sometimes renders an error div if not found
const tweetErrors = await page.evaluate(() => {
return Array.from(document.querySelectorAll('.react-tweet-theme [data-testid="tweet-not-found"]')).length;
});
if (tweetErrors > 0) {
console.log(`FAIL: Found ${tweetErrors} 'Tweet not found' error states inside the container.`);
}
} else {
console.log("FAIL: No react-tweet containers found on page. It might be completely crashing or skipped.");
}
await browser.close();
} catch (e) {
console.error("Script failed:", e);
}
})();

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>'
}
];

Some files were not shown because too many files have changed in this diff Show More