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
22
apps/web/app/api/tweet/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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 (10–250 Mitarbeiter)
|
||||
- **Online-Shop-Betreiber** mit 20k–500k€ 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
|
||||
63
apps/web/docs/CONTENT_RULES.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Content-Regeln für Blog-Post-Generierung
|
||||
|
||||
## 1. Visuelle Balance
|
||||
|
||||
- **Max 1 visuelle Komponente pro 3–4 Textabsätze**
|
||||
- Visualisierungen dürfen **niemals direkt hintereinander** stehen
|
||||
- Zwischen zwei visuellen Elementen müssen mindestens 2 Textabsätze liegen
|
||||
- Nicht mehr als 5–6 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 2–4 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 3–4 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 2–3 Memes pro Blog-Post
|
||||
|
||||
## 6. Textstruktur
|
||||
|
||||
- Jeder Abschnitt startet mit einer klaren H2/H3
|
||||
- `LeadParagraph` nur am Anfang (1–2 Stück)
|
||||
- Normaler Text in `Paragraph`-Komponenten
|
||||
- `Marker` sparsam einsetzen — max 2–3 pro Abschnitt
|
||||
- `IconList` für Aufzählungen mit Pro/Contra
|
||||
63
apps/web/docs/KEYWORDS.md
Normal 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"
|
||||
@@ -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
|
||||
|
||||
@@ -40,3 +40,14 @@ Es gibt keine versteckten Prioritäten, Sonderregeln oder impliziten Erwartungsh
|
||||
|
||||
Die Kommunikation ist auf nachhaltige Zusammenarbeit ausgelegt, nicht auf kurzfristige Zustimmung.
|
||||
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"
|
||||
408
apps/web/optimized_output.md
Normal 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]
|
||||
@@ -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",
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
35
apps/web/public_test.html
Normal 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,8 +440,7 @@ 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
|
||||
? `### 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.
|
||||
@@ -450,7 +449,7 @@ ${
|
||||
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} ` },
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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 ' (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();
|
||||
@@ -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.`);
|
||||
@@ -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();
|
||||
45
apps/web/scripts/generate-thumbnail.ts
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
61
apps/web/scripts/verify-embeds.ts
Normal 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);
|
||||
}
|
||||
})();
|
||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
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>
|
||||
<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">
|
||||
|
||||
<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;
|
||||
|
||||
@@ -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 = "" }) => (
|
||||
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}
|
||||
|
||||
@@ -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 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>
|
||||
</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>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={captionList.length > 0 ? captionList.join(' / ') : template}
|
||||
className="w-full h-auto block"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
<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-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 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-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 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 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'} →
|
||||
</a>
|
||||
<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">→</span>
|
||||
</a>
|
||||
) : (
|
||||
source && <span className="text-[10px] font-mono uppercase tracking-widest text-slate-400 mt-1">{source}</span>
|
||||
)}
|
||||
</figcaption>
|
||||
</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 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}
|
||||
</p>
|
||||
</div>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
</figure>
|
||||
</Reveal>
|
||||
);
|
||||
};
|
||||
|
||||
137
apps/web/src/components/BoldNumber.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
100
apps/web/src/components/Carousel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
95
apps/web/src/components/ComponentShareButton.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
|
||||
79
apps/web/src/components/DiagramFlow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
45
apps/web/src/components/ExternalLink.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
29
apps/web/src/components/FAQSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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,8 +49,7 @@ 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"
|
||||
}`}
|
||||
@@ -95,8 +94,7 @@ 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"
|
||||
}`}
|
||||
@@ -247,8 +245,7 @@ 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"
|
||||
}`}
|
||||
|
||||
315
apps/web/src/components/Header.tsx.bak
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
42
apps/web/src/components/ImageText.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
88
apps/web/src/components/LinkedInEmbed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
apps/web/src/components/MDXContent.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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">
|
||||
<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">
|
||||
|
||||
256
apps/web/src/components/MemeCard.tsx
Normal 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">
|
||||
“{captions[1] || 'Alles im grünen Bereich.'}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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,40 +278,46 @@ 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 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>
|
||||
)}
|
||||
<div className="flex justify-center w-full overflow-x-auto">
|
||||
|
||||
{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
|
||||
|
||||
/* 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]:!rx-[8px] [&_.node_rect]:!ry-[8px]
|
||||
[&_.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
|
||||
[&_.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={{
|
||||
@@ -363,28 +326,20 @@ const MermaidInternal: React.FC<MermaidProps> = ({
|
||||
}}
|
||||
>
|
||||
{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">
|
||||
<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 }} />
|
||||
) : (
|
||||
// Hide raw graph until rendered
|
||||
<div style={{ display: 'none' }}>{sanitizedGraph}</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>
|
||||
</Reveal>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
69
apps/web/src/components/MetricBar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
108
apps/web/src/components/PerformanceChart.tsx
Normal 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 > 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
<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="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>
|
||||
|
||||
<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 uppercase flex items-center gap-3">
|
||||
<span className="w-4 h-4 bg-slate-900 shrink-0" />
|
||||
<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="font-mono text-xs text-slate-500 uppercase tracking-[0.2em] mt-2 leading-none m-0">{subtitle}</p>}
|
||||
{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.3em] flex gap-2">
|
||||
<span>DATA_SYNC</span>
|
||||
<span>/</span>
|
||||
<span>V2</span>
|
||||
<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-0 border-y border-slate-300">
|
||||
<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-4 md:gap-8 items-center py-6 border-b border-slate-100 last:border-0 relative group">
|
||||
<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-slate-900 text-sm md:text-base tracking-tight">{item.label}</span>
|
||||
<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] font-mono text-slate-500 mt-1 max-w-[200px] leading-tight">{item.description}</span>
|
||||
<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 pr-8 py-4 md:py-0">
|
||||
<div className="h-[2px] w-full bg-slate-200 relative">
|
||||
<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-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>
|
||||
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">
|
||||
<span className={`text-3xl md:text-4xl font-black tabular-nums tracking-tighter leading-none ${item.color === 'green' ? 'text-emerald-600' : 'text-slate-900'}`}>
|
||||
<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 className="text-sm font-bold text-slate-400 ml-1">{item.unit || ''}</span>
|
||||
</span>
|
||||
<span className="text-xs font-bold text-slate-400">{item.unit || ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</figure>
|
||||
</Reveal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
// Force visibility immediately to prevent white screen
|
||||
mainControls.start("visible");
|
||||
}
|
||||
}, [isInView, mainControls]);
|
||||
}, [mainControls]);
|
||||
|
||||
const variants: Variants = {
|
||||
hidden: {
|
||||
|
||||
188
apps/web/src/components/RevenueLossCalculator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
const logoImg = new Image();
|
||||
logoImg.src = IconBlack.src || IconBlack;
|
||||
|
||||
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();
|
||||
if (isDataUrl) {
|
||||
// If it's already a Data URL (e.g. from html-to-image), we can display it immediately
|
||||
setImagePreview(diagramImage);
|
||||
} else {
|
||||
logoImg.onload = drawWatermark;
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
img.src = svgUrl;
|
||||
// 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.
|
||||
|
||||
}
|
||||
}, [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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
148
apps/web/src/components/StatsGrid.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
136
apps/web/src/components/TableOfContents.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<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="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>
|
||||
|
||||
<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-sm text-slate-500">{maxTime}ms</div>
|
||||
<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">
|
||||
{/* Raw Grid Lines */}
|
||||
<div className="absolute inset-x-0 top-0 bottom-0 flex justify-between pointer-events-none z-0">
|
||||
<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-200 flex flex-col justify-between">
|
||||
<span className="text-[10px] text-slate-400 font-mono -ml-4 -mt-4 bg-white px-1">
|
||||
<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 pt-8 pb-4">
|
||||
<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 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 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-[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>
|
||||
)}
|
||||
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>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</figure>
|
||||
</Reveal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
<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="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 className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-12 mt-4">
|
||||
{metrics.map((m) => {
|
||||
const colors = getColors(m.stat);
|
||||
<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={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 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>
|
||||
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</figure>
|
||||
</Reveal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
24
apps/web/src/components/analytics/useSafePathname.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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]">
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 2–4 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>'
|
||||
}
|
||||
];
|
||||
|
||||