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
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Failing after 14s
Build & Deploy / 🧪 QA (push) Failing after 1m48s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
This commit is contained in:
@@ -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>
|
||||
</div>
|
||||
<blockquote className="md:col-span-11 relative flex items-center">
|
||||
<div className="text-2xl md:text-3xl font-serif text-slate-900 italic leading-[1.4] md:leading-snug tracking-tight m-0">
|
||||
{children}
|
||||
</div>
|
||||
</blockquote>
|
||||
</div>
|
||||
);
|
||||
export const ArticleBlockquote: React.FC<BlockquoteProps> = ({ children, className = '' }) => {
|
||||
// Generate a quick stable hash based on content length/chars for ID
|
||||
const shareId = `blockquote-${Math.random().toString(36).substring(7).toUpperCase()}`;
|
||||
|
||||
return (
|
||||
<Reveal direction="up" delay={0.1}>
|
||||
<figure
|
||||
id={shareId}
|
||||
className={`not-prose my-16 group relative transition-all duration-500 ease-out z-10 ${className}`}
|
||||
>
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-emerald-100/50 to-emerald-50/50 rounded-[2rem] blur opacity-20 group-hover:opacity-40 transition duration-1000 -z-10" />
|
||||
|
||||
<div className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl shadow-sm group-hover:shadow-md group-hover:border-slate-200 transition-all duration-500 overflow-hidden relative p-8 md:p-12">
|
||||
|
||||
{/* Subtle top shine */}
|
||||
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white to-transparent opacity-80" />
|
||||
|
||||
{/* Share Button top right */}
|
||||
<div className="absolute top-4 right-4 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500">
|
||||
<ComponentShareButton targetId={shareId} title="Zitat" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-6 md:gap-8 items-start relative z-10">
|
||||
<div className="shrink-0">
|
||||
<div className="w-12 h-12 md:w-16 md:h-16 bg-slate-50 border border-slate-100 rounded-xl flex items-center justify-center group-hover:scale-110 group-hover:bg-white group-hover:border-emerald-200 transition-all duration-500 shadow-sm relative overflow-hidden">
|
||||
{/* Small emerald glow effect inside the icon box on hover */}
|
||||
<div className="absolute inset-0 bg-emerald-500/0 group-hover:bg-emerald-500/5 transition-colors duration-500" />
|
||||
<svg className="w-6 h-6 md:w-8 md:h-8 text-slate-300 group-hover:text-emerald-500 transition-colors duration-500 relative z-10" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<blockquote className="flex-1 relative">
|
||||
<div className="text-xl md:text-2xl lg:text-3xl font-serif text-slate-800 italic leading-relaxed md:leading-[1.4] tracking-tight m-0">
|
||||
{children}
|
||||
</div>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
</figure>
|
||||
</Reveal>
|
||||
);
|
||||
};
|
||||
|
||||
interface CodeBlockProps {
|
||||
code?: string;
|
||||
|
||||
@@ -3,26 +3,52 @@ import React from "react";
|
||||
interface HeadingProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const H1: React.FC<HeadingProps> = ({ children, className = "" }) => (
|
||||
const getTextContent = (node: React.ReactNode): string => {
|
||||
if (typeof node === "string") return node;
|
||||
if (typeof node === "number") return String(node);
|
||||
if (React.isValidElement(node)) return getTextContent((node.props as any).children);
|
||||
if (Array.isArray(node)) return node.map(getTextContent).join("");
|
||||
return "";
|
||||
};
|
||||
|
||||
const charMap: Record<string, string> = { "ä": "ae", "ö": "oe", "ü": "ue" };
|
||||
|
||||
const generateId = (children: React.ReactNode): string => {
|
||||
const text = getTextContent(children);
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[äöü]/g, (c) => charMap[c] ?? c)
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
};
|
||||
|
||||
export const H1: React.FC<HeadingProps> = ({ children, className = "", id }) => (
|
||||
<h1
|
||||
id={id || generateId(children)}
|
||||
className={`not-prose text-3xl md:text-5xl font-bold text-slate-900 mb-8 mt-12 leading-[1.1] tracking-tight font-sans ${className}`}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
|
||||
export const H2: React.FC<HeadingProps> = ({ children, className = "" }) => (
|
||||
<h2
|
||||
className={`not-prose text-2xl md:text-4xl font-bold text-slate-900 mb-6 mt-10 leading-tight tracking-tight font-sans ${className}`}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
export const H2: React.FC<HeadingProps> = ({ children, className = "", id }) => {
|
||||
const generatedId = id || generateId(children);
|
||||
return (
|
||||
<h2
|
||||
id={generatedId}
|
||||
className={`not-prose text-2xl md:text-4xl font-bold text-slate-900 mb-6 mt-10 leading-tight tracking-tight font-sans ${className}`}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
};
|
||||
|
||||
export const H3: React.FC<HeadingProps> = ({ children, className = "" }) => (
|
||||
export const H3: React.FC<HeadingProps> = ({ children, className = "", id }) => (
|
||||
<h3
|
||||
id={id || generateId(children)}
|
||||
className={`not-prose text-xl md:text-2xl font-bold text-slate-900 mb-4 mt-8 leading-snug tracking-tight font-sans ${className}`}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,56 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { ComponentShareButton } from './ComponentShareButton';
|
||||
import { Reveal } from './Reveal';
|
||||
|
||||
interface ArticleMemeProps {
|
||||
template: string;
|
||||
captions: string[];
|
||||
/** Pipe-delimited captions, e.g. "top text|bottom text" */
|
||||
captions: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ArticleMeme: React.FC<ArticleMemeProps> = ({ template, captions, className = '' }) => {
|
||||
/**
|
||||
* Encode a caption string for the memegen.link URL format.
|
||||
*/
|
||||
function encodeMemeCaption(text: string): string {
|
||||
if (!text || text.trim() === '') return '_';
|
||||
|
||||
return text
|
||||
.trim()
|
||||
.replace(/_/g, "__")
|
||||
.replace(/-/g, "--")
|
||||
.replace(/\n/g, "~q")
|
||||
.replace(/\?/g, "~q")
|
||||
.replace(/&/g, "~a")
|
||||
.replace(/%/g, "~p")
|
||||
.replace(/#/g, "~h")
|
||||
.replace(/\//g, "~s")
|
||||
.replace(/\\/g, "~b")
|
||||
.replace(/<(?:\/?[a-z0-9]+)*>/gi, "")
|
||||
.replace(/\s+/g, "_")
|
||||
.replace(/€/g, "Euro") // Replace Euro sign
|
||||
.replace(/['"„“‚‘]/g, "") // Remove quotes
|
||||
.replace(/[^a-zA-Z0-9_\-~äöüÄÖÜß.,!]/g, ""); // Remove other special chars
|
||||
}
|
||||
|
||||
|
||||
const TEMPLATE_MAP: Record<string, string> = {
|
||||
'drake': 'drake',
|
||||
'drake hotline bling': 'drake',
|
||||
'distracted boyfriend': 'db',
|
||||
'distracted': 'db',
|
||||
'expanding brain': 'gb',
|
||||
'expanding': 'gb',
|
||||
'gb': 'gb',
|
||||
'this is fine': 'fine',
|
||||
'fine': 'fine',
|
||||
'clown': 'gb',
|
||||
'clown applying makeup': 'gb',
|
||||
'two buttons': 'ds',
|
||||
'daily struggle': 'ds',
|
||||
'ds': 'ds',
|
||||
'gru': 'gru',
|
||||
'change my mind': 'cmm',
|
||||
'always has been': 'ahb',
|
||||
'uno reverse': 'uno',
|
||||
'disaster girl': 'disastergirl',
|
||||
'is this a pigeon': 'pigeon',
|
||||
'roll safe': 'rollsafe',
|
||||
'rollsafe': 'rollsafe',
|
||||
'surprised pikachu': 'pikachu',
|
||||
'batman slapping robin': 'slap',
|
||||
'left exit 12': 'exit',
|
||||
'one does not simply': 'mordor',
|
||||
'panik kalm panik': 'panik-kalm-panik',
|
||||
};
|
||||
|
||||
function slugifyTemplate(template: string): string {
|
||||
if (!template) return 'drake';
|
||||
|
||||
const normalized = template.toLowerCase().trim();
|
||||
const validIds = new Set(Object.values(TEMPLATE_MAP));
|
||||
|
||||
// Check if it's already a valid known ID
|
||||
if (validIds.has(normalized)) return normalized;
|
||||
|
||||
// Check case-insensitive map
|
||||
for (const key of Object.keys(TEMPLATE_MAP)) {
|
||||
if (key === normalized) return TEMPLATE_MAP[key];
|
||||
}
|
||||
|
||||
// Fallback: don't randomly slugify since memegen might 404. Force known good.
|
||||
return 'drake';
|
||||
}
|
||||
|
||||
function buildMemeUrl(template: string, captions: string[]): string {
|
||||
const slug = slugifyTemplate(template);
|
||||
const encoded = captions.map(encodeMemeCaption);
|
||||
return `https://api.memegen.link/images/${slug}/${encoded.join('/')}.png`;
|
||||
}
|
||||
|
||||
export const ArticleMeme: React.FC<ArticleMemeProps & { image?: string }> = ({ template, captions, image, className = '' }) => {
|
||||
const captionList = (captions || '').split('|').map(s => s.trim()).filter(Boolean);
|
||||
const imageUrl = image || buildMemeUrl(template, captionList);
|
||||
const shareId = `artmeme-${React.useId().replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<div className={`not-prose relative overflow-hidden group rounded-2xl border border-slate-200 bg-white shadow-xl max-w-2xl mx-auto my-12 ${className}`}>
|
||||
{/* Meme "Image" Placeholder with Industrial Styling */}
|
||||
<div className="aspect-video bg-slate-900 flex flex-col items-center justify-between p-8 text-center relative overflow-hidden">
|
||||
{/* Decorative Grid */}
|
||||
<div className="absolute inset-0 opacity-10 pointer-events-none"
|
||||
style={{ backgroundImage: 'radial-gradient(#ffffff 1px, transparent 1px)', backgroundSize: '20px 20px' }} />
|
||||
<Reveal direction="up" delay={0.1}>
|
||||
<div id={shareId} className={`not-prose max-w-xl mx-auto my-16 group relative transition-all duration-500 ease-out z-10 ${className}`}>
|
||||
{/* Ambient Glow */}
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-red-100/30 to-slate-100/30 rounded-[2rem] blur opacity-10 group-hover:opacity-30 transition duration-1000 -z-10" />
|
||||
|
||||
{/* Top Caption */}
|
||||
<div className="relative z-10 w-full">
|
||||
<h4 className="text-white text-3xl md:text-4xl font-black uppercase tracking-tighter leading-none [text-shadow:_2px_2px_0_rgb(0_0_0_/_80%)]">
|
||||
{captions[0]}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl overflow-hidden shadow-sm shadow-slate-200 group-hover:shadow-md transition-all duration-500 relative">
|
||||
{/* Share Button */}
|
||||
<div className="absolute top-4 right-4 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-50">
|
||||
<ComponentShareButton targetId={shareId} title={`Meme: ${template}`} />
|
||||
</div>
|
||||
|
||||
{/* Meme Figure/Avatar Placeholder */}
|
||||
<div className="relative z-10 w-32 h-32 md:w-40 md:h-40 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-full flex items-center justify-center border-4 border-white transform group-hover:scale-105 transition-transform duration-500 shadow-2xl">
|
||||
<span className="text-5xl">🤖</span>
|
||||
</div>
|
||||
|
||||
{/* Bottom Caption */}
|
||||
<div className="relative z-10 w-full">
|
||||
<h4 className="text-white text-2xl md:text-3xl font-bold uppercase tracking-tight leading-none [text-shadow:_1px_1px_0_rgb(0_0_0_/_80%)]">
|
||||
{captions[1] || ''}
|
||||
</h4>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={captionList.length > 0 ? captionList.join(' / ') : template}
|
||||
className="w-full h-auto block"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meme Footer / Metadata */}
|
||||
<div className="p-4 bg-slate-50 flex items-center justify-between border-t border-slate-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded bg-slate-200 flex items-center justify-center text-[10px] font-mono text-slate-500 uppercase tracking-widest font-bold">
|
||||
AI
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest leading-none">Meme Template</p>
|
||||
<p className="text-xs font-bold text-slate-900 mt-1 uppercase">{template}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-slate-300">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M15 3h6v6" /><path d="M10 14 21 3" /><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Quote } from 'lucide-react';
|
||||
import { ComponentShareButton } from './ComponentShareButton';
|
||||
import { Reveal } from './Reveal';
|
||||
|
||||
interface ArticleQuoteProps {
|
||||
quote: string;
|
||||
@@ -6,9 +9,7 @@ interface ArticleQuoteProps {
|
||||
role?: string;
|
||||
source?: string;
|
||||
sourceUrl?: string;
|
||||
/** If true, shows a "Translated" badge */
|
||||
translated?: boolean;
|
||||
/** If true, treats the author as a company/brand (shows entity icon instead of initials) */
|
||||
isCompany?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
@@ -23,41 +24,65 @@ export const ArticleQuote: React.FC<ArticleQuoteProps> = ({
|
||||
isCompany,
|
||||
className = '',
|
||||
}) => {
|
||||
// Generate a stable ID based on author for sharing
|
||||
const shareId = `quote-${author.toLowerCase().replace(/[^a-z0-9]/g, '-')}`;
|
||||
|
||||
return (
|
||||
<figure className={`not-prose my-20 ${className}`}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-0 md:gap-12 border-t-2 border-slate-900 pt-8 mt-12 mb-12">
|
||||
{/* Meta column (left side on desktop) */}
|
||||
<div className="md:col-span-4 lg:col-span-3 pb-8 md:pb-0 border-b md:border-b-0 border-slate-200 mb-8 md:mb-0 md:pr-8 md:border-r">
|
||||
<div className="flex flex-row md:flex-col items-center md:items-start gap-4 md:gap-6">
|
||||
{isCompany ? (
|
||||
<div className="w-16 h-16 bg-slate-900 flex items-center justify-center shrink-0">
|
||||
<svg className="w-8 h-8 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-16 h-16 bg-slate-100 flex items-center justify-center shrink-0 text-lg font-bold text-slate-900 font-serif border border-slate-200">
|
||||
{(author || '').split(' ').map(w => w[0]).join('').slice(0, 2)}
|
||||
</div>
|
||||
)}
|
||||
<figcaption className="flex flex-col gap-1 w-full md:mt-1">
|
||||
<span className="font-sans font-black text-lg text-slate-900 tracking-tight leading-none uppercase">{author}</span>
|
||||
{role && <span className="font-mono text-[10px] text-slate-500 uppercase tracking-widest mt-1">{role}</span>}
|
||||
{translated && <span className="inline-block px-1.5 py-0.5 border border-slate-300 text-[9px] font-mono text-slate-600 uppercase tracking-widest w-fit mt-2">Translated</span>}
|
||||
{sourceUrl && (
|
||||
<a href={sourceUrl} target="_blank" rel="noreferrer" className="mt-4 md:mt-6 inline-block font-sans text-[11px] font-black uppercase tracking-[0.2em] text-slate-900 hover:text-emerald-600 decoration-2 underline-offset-4 decoration-emerald-200 hover:decoration-emerald-500 underline transition-all">
|
||||
{source || 'Source'} →
|
||||
</a>
|
||||
<Reveal direction="up" delay={0.1}>
|
||||
<figure
|
||||
id={shareId}
|
||||
className={`not-prose my-16 group relative transition-all duration-500 ease-out z-10 ${className}`}
|
||||
>
|
||||
{/* Ambient Background Glow matching the homepage feel */}
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-slate-200 to-slate-100 rounded-[2rem] blur opacity-20 group-hover:opacity-40 transition duration-1000 -z-10" />
|
||||
|
||||
<div className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl shadow-sm group-hover:shadow-md group-hover:border-slate-200 transition-all duration-500 overflow-hidden relative">
|
||||
|
||||
{/* Subtle top shine */}
|
||||
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white to-transparent opacity-80" />
|
||||
|
||||
<div className="p-8 md:p-12 relative z-10 flex flex-col md:flex-row gap-8 md:gap-12 items-start">
|
||||
|
||||
{/* Share Button top right */}
|
||||
<div className="absolute top-4 right-4 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500">
|
||||
<ComponentShareButton targetId={shareId} title={`Zitat von ${author}`} />
|
||||
</div>
|
||||
|
||||
{/* Author Meta (Left Rail) */}
|
||||
<div className="md:w-1/3 flex flex-row md:flex-col items-center md:items-start gap-4 md:gap-6 shrink-0 pt-2">
|
||||
{isCompany ? (
|
||||
<div className="w-12 h-12 md:w-16 md:h-16 bg-slate-50 border border-slate-100 rounded-xl flex items-center justify-center shrink-0 group-hover:scale-110 group-hover:bg-white transition-transform duration-500">
|
||||
<svg className="w-6 h-6 md:w-8 md:h-8 text-slate-700" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 md:w-16 md:h-16 bg-slate-50 border border-slate-100 rounded-xl flex items-center justify-center shrink-0 group-hover:scale-110 group-hover:bg-white transition-transform duration-500">
|
||||
<Quote className="w-5 h-5 md:w-6 md:h-6 text-slate-400" />
|
||||
</div>
|
||||
)}
|
||||
</figcaption>
|
||||
<div className="flex flex-col gap-2 mt-2 w-full">
|
||||
{translated && (
|
||||
<div className="inline-flex w-fit px-2 py-0.5 border border-slate-200 rounded-md text-[9px] font-mono text-slate-500 uppercase tracking-widest bg-slate-50">Übersetzt</div>
|
||||
)}
|
||||
{sourceUrl ? (
|
||||
<a href={sourceUrl} target="_blank" rel="noreferrer" className="inline-flex w-fit items-center gap-1 text-[10px] font-mono font-bold uppercase tracking-widest text-blue-600 hover:text-blue-800 transition-colors group/link mt-1">
|
||||
{source || 'Source'} <span className="group-hover/link:translate-x-0.5 transition-transform">→</span>
|
||||
</a>
|
||||
) : (
|
||||
source && <span className="text-[10px] font-mono uppercase tracking-widest text-slate-400 mt-1">{source}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quote Content (Right Rail) */}
|
||||
<blockquote className="md:w-3/4 border-l-[3px] border-emerald-500/20 pl-6 md:pl-10 py-1 relative mt-6 md:mt-0">
|
||||
<Quote className="absolute -top-4 -left-4 w-12 h-12 text-slate-100 -z-10 rotate-180 opacity-50 pointer-events-none" />
|
||||
<div className="text-xl md:text-3xl font-serif text-slate-800 leading-[1.4] m-0 italic [&>p]:inline [&>ul]:m-0 [&>ul]:list-none [&>ul>li]:m-0 [&>ul>li]:p-0">
|
||||
{quote}
|
||||
</div>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quote column (right side) */}
|
||||
<blockquote className="md:col-span-8 lg:col-span-9 flex items-center relative">
|
||||
<p className="text-2xl md:text-3xl lg:text-4xl font-serif text-slate-900 italic leading-[1.3] tracking-tight m-0 before:content-['\201C'] after:content-['\201D']">
|
||||
{quote}
|
||||
</p>
|
||||
</blockquote>
|
||||
</div>
|
||||
</figure>
|
||||
</figure>
|
||||
</Reveal>
|
||||
);
|
||||
};
|
||||
|
||||
137
apps/web/src/components/BoldNumber.tsx
Normal file
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
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
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
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
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
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,11 +49,10 @@ export const Header: React.FC = () => {
|
||||
<header className="sticky top-0 z-[100] w-full">
|
||||
{/* Decoupled Background Layer - Prevents backdrop-filter parent context bugs */}
|
||||
<div
|
||||
className={`absolute inset-0 transition-all duration-500 -z-10 ${
|
||||
isScrolled
|
||||
className={`absolute inset-0 transition-all duration-500 -z-10 ${isScrolled
|
||||
? "bg-white/70 backdrop-blur-xl border-b border-slate-100 shadow-sm shadow-slate-100/50"
|
||||
: "bg-white/80 backdrop-blur-md border-b border-slate-50"
|
||||
}`}
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Animated tech border at bottom */}
|
||||
@@ -95,11 +94,10 @@ export const Header: React.FC = () => {
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`text-xs font-bold uppercase tracking-widest transition-colors duration-300 relative ${
|
||||
active
|
||||
className={`text-xs font-bold uppercase tracking-widest transition-colors duration-300 relative ${active
|
||||
? "text-slate-900"
|
||||
: "text-slate-400 hover:text-slate-900"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{active && (
|
||||
<span className="absolute -bottom-1 left-0 right-0 flex justify-center">
|
||||
@@ -247,11 +245,10 @@ export const Header: React.FC = () => {
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className={`relative flex flex-col justify-center p-6 h-[110px] rounded-2xl border transition-all duration-200 ${
|
||||
active
|
||||
className={`relative flex flex-col justify-center p-6 h-[110px] rounded-2xl border transition-all duration-200 ${active
|
||||
? "bg-slate-50 border-slate-200 ring-1 ring-slate-200"
|
||||
: "bg-white border-slate-100 active:bg-slate-50"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<span className="text-[15px] font-black tracking-tight text-slate-900 block leading-tight mb-1">
|
||||
|
||||
315
apps/web/src/components/Header.tsx.bak
Normal file
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
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
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
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">
|
||||
<BlogThumbnailSVG
|
||||
slug={slug}
|
||||
variant="square"
|
||||
className="w-full h-full"
|
||||
/>
|
||||
<div className="flex-shrink-0 w-[56px] h-[56px] md:w-[80px] md:h-[80px] rounded-lg overflow-hidden border border-slate-100 group-hover:border-slate-200 transition-colors bg-slate-50 relative">
|
||||
{post.thumbnail ? (
|
||||
<img
|
||||
src={post.thumbnail}
|
||||
alt={title}
|
||||
className="w-full h-full object-cover grayscale-[0.5] group-hover:grayscale-0 group-hover:scale-110 transition-all duration-700"
|
||||
/>
|
||||
) : (
|
||||
<BlogThumbnailSVG
|
||||
slug={slug}
|
||||
variant="square"
|
||||
className="w-full h-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-3 md:space-y-4 min-w-0">
|
||||
|
||||
256
apps/web/src/components/MemeCard.tsx
Normal file
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,70 +278,68 @@ const MermaidInternal: React.FC<MermaidProps> = ({
|
||||
if (!id) return null;
|
||||
|
||||
return (
|
||||
<figure ref={wrapperRef} className="mermaid-wrapper not-prose my-20 w-full max-w-full border-y-2 border-slate-900 py-12 relative overflow-visible">
|
||||
<Reveal direction="up" delay={0.1}>
|
||||
<figure ref={wrapperRef} className="mermaid-wrapper not-prose my-16 w-full max-w-full group relative transition-all duration-500 ease-out z-10">
|
||||
|
||||
{/* Blueprint Grid Background Pattern */}
|
||||
<div className="absolute inset-0 z-0 pointer-events-none opacity-[0.03]" style={{ backgroundImage: 'linear-gradient(to right, #0f172a 1px, transparent 1px), linear-gradient(to bottom, #0f172a 1px, transparent 1px)', backgroundSize: '40px 40px' }} />
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-slate-200 to-slate-100/50 rounded-[2rem] blur opacity-20 group-hover:opacity-40 transition duration-1000 -z-10" />
|
||||
|
||||
<div className="w-full flex flex-col items-center justify-center relative z-10 px-4 md:px-8">
|
||||
{title && (
|
||||
<div className="w-full flex justify-between items-baseline mb-12 border-b border-slate-200 pb-4">
|
||||
<h4 className="text-left text-lg md:text-xl font-bold text-slate-900 tracking-tight m-0">
|
||||
{title}
|
||||
</h4>
|
||||
<span className="text-[10px] font-mono text-slate-400 uppercase tracking-[0.3em] font-bold">System_Architecture</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-center w-full overflow-x-auto">
|
||||
<div
|
||||
className={`mermaid
|
||||
w-full flex justify-center
|
||||
|
||||
/* Safely scale the SVG container wide without corrupting internal label calculations */
|
||||
[&>div]:!w-full [&>div]:!flex [&>div]:!justify-center
|
||||
[&_svg]:!max-w-full [&_svg]:max-w-4xl [&_svg]:!h-auto [&_svg]:!max-h-[60vh] md:[&_svg]:!max-h-[600px]
|
||||
|
||||
/* Premium Industrial Styling */
|
||||
[&_.node_rect]:!rx-[0px] [&_.node_rect]:!ry-[0px] /* Sharp corners for notebook look */
|
||||
[&_.node_rect]:!fill-white
|
||||
[&_.node_rect]:!stroke-slate-900 [&_.node_rect]:!stroke-[2px]
|
||||
[&_.node_rect]:!filter-none
|
||||
|
||||
[&_.edgePath_path]:!stroke-slate-900 [&_.edgePath_path]:!stroke-[2px]
|
||||
[&_.marker]:!fill-slate-900 [&_.marker]:!stroke-slate-900
|
||||
|
||||
/* Labels */
|
||||
[&_.nodeLabel]:!font-mono [&_.nodeLabel]:!font-bold [&_.nodeLabel]:!text-slate-900
|
||||
`}
|
||||
id={id}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
overflow: "visible"
|
||||
}}
|
||||
>
|
||||
{error ? (
|
||||
<div className="text-red-500 p-4 border border-red-200 bg-red-50 text-sm font-mono uppercase tracking-widest text-center w-full h-32 flex items-center justify-center">
|
||||
{error}
|
||||
<div className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl shadow-sm group-hover:shadow-md group-hover:border-slate-200 transition-all duration-500 overflow-hidden relative">
|
||||
|
||||
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white to-transparent opacity-80" />
|
||||
|
||||
<div className="w-full flex flex-col items-center justify-center p-6 md:p-8 lg:p-10 relative z-10">
|
||||
|
||||
{showShare && (
|
||||
<div className="absolute top-6 right-6 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-50">
|
||||
<ComponentShareButton targetId={id} title={title || 'System Architecture'} />
|
||||
</div>
|
||||
) : svgContent ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: svgContent }} />
|
||||
) : (
|
||||
// Hide raw graph until rendered
|
||||
<div style={{ display: 'none' }}>{sanitizedGraph}</div>
|
||||
)}
|
||||
|
||||
{title && (
|
||||
<header className="w-full mb-8 flex flex-col md:flex-row md:justify-between md:items-end gap-2 border-b border-slate-100 pb-4">
|
||||
<div>
|
||||
<h4 className="text-xl md:text-2xl font-bold text-slate-900 tracking-tight m-0 flex items-center gap-3">
|
||||
<span className="w-2 h-2 rounded-full bg-slate-400 shadow-[0_0_8px_rgba(148,163,184,0.6)] hidden md:block" />
|
||||
{title}
|
||||
</h4>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center w-full overflow-x-auto relative z-20">
|
||||
<div
|
||||
className={`mermaid
|
||||
w-full flex justify-center
|
||||
[&>div]:!w-full [&>div]:!flex [&>div]:!justify-center
|
||||
[&_svg]:!max-w-full [&_svg]:max-w-4xl [&_svg]:!h-auto [&_svg]:!max-h-[60vh] md:[&_svg]:!max-h-[600px]
|
||||
[&_.node_rect]:!rx-[8px] [&_.node_rect]:!ry-[8px]
|
||||
[&_.node_rect]:!fill-white
|
||||
[&_.node_rect]:!stroke-slate-200 [&_.node_rect]:!stroke-[2px]
|
||||
[&_.edgePath_path]:!stroke-slate-400 [&_.edgePath_path]:!stroke-[1.5px]
|
||||
[&_.marker]:!fill-slate-400 [&_.marker]:!stroke-slate-400
|
||||
[&_.nodeLabel]:!font-sans [&_.nodeLabel]:!font-bold [&_.nodeLabel]:!text-slate-700
|
||||
`}
|
||||
id={id}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
overflow: "visible"
|
||||
}}
|
||||
>
|
||||
{error ? (
|
||||
<div className="text-red-500 p-4 border border-red-200 bg-red-50 text-xs font-mono uppercase tracking-widest text-center w-full h-32 flex items-center justify-center rounded-xl">
|
||||
{error}
|
||||
</div>
|
||||
) : svgContent ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: svgContent }} />
|
||||
) : (
|
||||
<div style={{ display: 'none' }}>{sanitizedGraph}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showShare && id && isRendered && (
|
||||
<div className="flex justify-end w-full mt-10">
|
||||
<DiagramShareButton
|
||||
diagramId={id}
|
||||
title={title}
|
||||
svgContent={svgContent}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</figure>
|
||||
</figure>
|
||||
</Reveal>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
69
apps/web/src/components/MetricBar.tsx
Normal file
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
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">
|
||||
<div>
|
||||
<h3 className="text-xl md:text-2xl font-bold text-slate-900 tracking-tight m-0 uppercase flex items-center gap-3">
|
||||
<span className="w-4 h-4 bg-slate-900 shrink-0" />
|
||||
{title}
|
||||
</h3>
|
||||
{subtitle && <p className="font-mono text-xs text-slate-500 uppercase tracking-[0.2em] mt-2 leading-none m-0">{subtitle}</p>}
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-slate-400 uppercase tracking-[0.3em] flex gap-2">
|
||||
<span>DATA_SYNC</span>
|
||||
<span>/</span>
|
||||
<span>V2</span>
|
||||
</div>
|
||||
</header>
|
||||
<Reveal direction="up" delay={0.1}>
|
||||
<figure
|
||||
id={shareId}
|
||||
className={`not-prose my-16 group relative transition-all duration-500 ease-out z-10 ${className}`}
|
||||
>
|
||||
{/* Ambient Background Glow matching the homepage feel */}
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-slate-200 to-slate-100/50 rounded-[2rem] blur opacity-20 group-hover:opacity-40 transition duration-1000 -z-10" />
|
||||
|
||||
<div className="space-y-0 border-y border-slate-300">
|
||||
{(items || []).map((item, index) => {
|
||||
return (
|
||||
<div key={index} className="grid grid-cols-1 md:grid-cols-12 gap-4 md:gap-8 items-center py-6 border-b border-slate-100 last:border-0 relative group">
|
||||
<div className="md:col-span-4 flex flex-col">
|
||||
<span className="font-bold text-slate-900 text-sm md:text-base tracking-tight">{item.label}</span>
|
||||
{item.description && (
|
||||
<span className="text-[11px] font-mono text-slate-500 mt-1 max-w-[200px] leading-tight">{item.description}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl shadow-sm group-hover:shadow-md group-hover:border-slate-200 transition-all duration-500 overflow-hidden relative">
|
||||
|
||||
<div className="md:col-span-6 flex items-center pr-8 py-4 md:py-0">
|
||||
<div className="h-[2px] w-full bg-slate-200 relative">
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 h-[4px]"
|
||||
style={{
|
||||
width: `${Math.min(100, (item.value / item.max) * 100)}%`,
|
||||
backgroundColor: ({
|
||||
red: '#ef4444',
|
||||
green: '#10b981',
|
||||
blue: '#3b82f6',
|
||||
orange: '#f59e0b',
|
||||
slate: '#0f172a',
|
||||
} as Record<string, string>)[item.color || 'slate'] || '#0f172a'
|
||||
}}
|
||||
>
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 w-4 h-4 bg-white border-[3px] rounded-full shadow-sm" style={{ borderColor: 'inherit' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Subtle top shine */}
|
||||
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white to-transparent opacity-80" />
|
||||
|
||||
<div className="md:col-span-2 flex justify-start md:justify-end">
|
||||
<span className={`text-3xl md:text-4xl font-black tabular-nums tracking-tighter leading-none ${item.color === 'green' ? 'text-emerald-600' : 'text-slate-900'}`}>
|
||||
{item.value}
|
||||
<span className="text-sm font-bold text-slate-400 ml-1">{item.unit || ''}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-6 md:p-8 lg:p-10 relative z-10">
|
||||
{/* Share Button top right */}
|
||||
<div className="absolute top-6 right-6 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-50">
|
||||
<ComponentShareButton targetId={shareId} title={title} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
<header className="mb-8 flex flex-col md:flex-row md:justify-between md:items-end gap-2 border-b border-slate-100 pb-4">
|
||||
<div>
|
||||
<h3 className="text-xl md:text-2xl font-bold text-slate-900 tracking-tight m-0 flex items-center gap-3">
|
||||
{/* Small pulsing indicator matching homepage style */}
|
||||
<span className="w-2 h-2 rounded-full bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.6)] animate-pulse hidden md:block" />
|
||||
{title}
|
||||
</h3>
|
||||
{subtitle && <p className="text-sm text-slate-500 mt-1 leading-snug m-0">{subtitle}</p>}
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-slate-400 uppercase tracking-[0.2em] bg-slate-50 px-2 py-1 rounded-full border border-slate-100 inline-flex items-center">
|
||||
DATA_SYNC / V2
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(items || []).map((item, index) => {
|
||||
const percentage = Math.min(100, (item.value / item.max) * 100);
|
||||
const colorMap: Record<string, string> = {
|
||||
red: 'bg-red-500',
|
||||
green: 'bg-emerald-500',
|
||||
blue: 'bg-blue-500',
|
||||
orange: 'bg-amber-500',
|
||||
slate: 'bg-slate-800',
|
||||
};
|
||||
const barColor = colorMap[item.color || 'slate'] || colorMap.slate;
|
||||
const isHighlight = item.color === 'green' || item.color === 'blue';
|
||||
|
||||
return (
|
||||
<div key={index} className="grid grid-cols-1 md:grid-cols-12 gap-2 md:gap-6 items-center p-4 hover:bg-slate-50/50 rounded-xl transition-all duration-300 group/row border border-transparent hover:border-slate-100">
|
||||
<div className="md:col-span-4 flex flex-col">
|
||||
<span className={`font-bold text-sm md:text-base tracking-tight transition-colors duration-300 ${isHighlight ? 'text-slate-900' : 'text-slate-700 group-hover/row:text-slate-900'}`}>{item.label}</span>
|
||||
{item.description && (
|
||||
<span className="text-[11px] md:text-xs text-slate-500 mt-0.5 leading-tight">{item.description}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-6 flex items-center py-2 md:py-0 w-full relative">
|
||||
<div className="h-2 w-full bg-slate-100 rounded-full relative overflow-hidden">
|
||||
<div
|
||||
className={`absolute top-0 left-0 bottom-0 rounded-full transition-all duration-1000 ease-out group-hover/row:brightness-110 ${barColor}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex justify-start md:justify-end items-baseline gap-1">
|
||||
<span className={`text-2xl md:text-3xl font-black tabular-nums tracking-tighter leading-none transition-colors duration-300 ${isHighlight ? 'text-slate-900' : 'text-slate-700 group-hover/row:text-slate-900'}`}>
|
||||
{item.value}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-slate-400">{item.unit || ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</figure>
|
||||
</Reveal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -29,7 +29,7 @@ const Reveal: React.FC<RevealProps> = ({
|
||||
viewport = { once: true, margin: "-10%" },
|
||||
}) => {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, {
|
||||
useInView(ref, {
|
||||
once: viewport.once ?? true,
|
||||
margin: (viewport.margin as any) ?? "-10%",
|
||||
amount: (viewport.amount as any) ?? 0.1,
|
||||
@@ -37,10 +37,9 @@ const Reveal: React.FC<RevealProps> = ({
|
||||
const mainControls = useAnimation();
|
||||
|
||||
useEffect(() => {
|
||||
if (isInView) {
|
||||
mainControls.start("visible");
|
||||
}
|
||||
}, [isInView, mainControls]);
|
||||
// Force visibility immediately to prevent white screen
|
||||
mainControls.start("visible");
|
||||
}, [mainControls]);
|
||||
|
||||
const variants: Variants = {
|
||||
hidden: {
|
||||
|
||||
188
apps/web/src/components/RevenueLossCalculator.tsx
Normal file
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);
|
||||
if (isDataUrl) {
|
||||
// If it's already a Data URL (e.g. from html-to-image), we can display it immediately
|
||||
setImagePreview(diagramImage);
|
||||
} else {
|
||||
// It's probably an SVG string, convert to Data URL
|
||||
const svgBlob = new Blob([diagramImage], { type: "image/svg+xml;charset=utf-8" });
|
||||
const svgUrl = URL.createObjectURL(svgBlob);
|
||||
setImagePreview(svgUrl);
|
||||
}
|
||||
|
||||
const logoImg = new Image();
|
||||
logoImg.src = IconBlack.src || IconBlack;
|
||||
// Optional: If we want to strictly apply the watermark via Canvas, we would do it here.
|
||||
// But for the sake of getting the preview to work reliably first, just setting the imagePreview
|
||||
// directly to the data URL or SVG blob URL is the safest approach. The watermark logic was
|
||||
// likely failing because `IconBlack` wasn't resolving correctly or `img.onload` wasn't firing
|
||||
// properly in all environments.
|
||||
|
||||
img.onload = () => {
|
||||
const scale = 3; // 3x scaling for sharpness
|
||||
canvas.width = img.width * scale;
|
||||
canvas.height = img.height * scale;
|
||||
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw image with scaling
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Add Watermark
|
||||
const drawWatermark = () => {
|
||||
const logoSize = Math.min(canvas.width, canvas.height) * 0.1; // 10% of smallest dimension
|
||||
const padding = logoSize * 0.4;
|
||||
const x = canvas.width - logoSize - padding;
|
||||
const y = canvas.height - logoSize - padding;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.1; // Subtle watermark
|
||||
ctx.drawImage(logoImg, x, y, logoSize, logoSize);
|
||||
ctx.restore();
|
||||
|
||||
setImagePreview(canvas.toDataURL("image/png"));
|
||||
URL.revokeObjectURL(svgUrl);
|
||||
};
|
||||
|
||||
if (logoImg.complete) {
|
||||
drawWatermark();
|
||||
} else {
|
||||
logoImg.onload = drawWatermark;
|
||||
}
|
||||
};
|
||||
|
||||
img.src = svgUrl;
|
||||
}
|
||||
}, [diagramImage, isOpen]);
|
||||
|
||||
@@ -162,14 +131,11 @@ export function ShareModal({
|
||||
title={modalTitle}
|
||||
maxWidth={diagramImage ? "max-w-3xl" : "max-w-lg"}
|
||||
>
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
{imagePreview ? (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
{/* Social Post Preview Section */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-slate-400 ml-1">
|
||||
Social Preview
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<div className="bg-slate-50 rounded-2xl border border-slate-100 overflow-hidden shadow-inner">
|
||||
<div className="p-4 flex items-center gap-3 border-b border-white/50">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-200 animate-pulse" />
|
||||
@@ -194,11 +160,11 @@ export function ShareModal({
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative p-6 flex flex-col items-center">
|
||||
<div className="relative p-2 md:p-4 flex flex-col items-center w-full">
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt={title || "Diagram"}
|
||||
className="max-w-full max-h-[30vh] object-contain transition-transform duration-700"
|
||||
className="w-full max-h-[45vh] object-contain transition-transform duration-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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
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
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>
|
||||
)}
|
||||
<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">
|
||||
<h3 className="text-xl md:text-2xl font-bold text-slate-900 tracking-tight m-0">{title}</h3>
|
||||
<div className="font-mono text-sm text-slate-500">{maxTime}ms</div>
|
||||
</header>
|
||||
<Reveal direction="up" delay={0.1}>
|
||||
<figure id={shareId} className="my-16 not-prose font-sans group relative transition-all duration-500 ease-out z-10">
|
||||
{/* Ambient Background Glow matching the homepage feel */}
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-blue-100/30 to-slate-100/30 rounded-[2rem] blur opacity-20 group-hover:opacity-40 transition duration-1000 -z-10" />
|
||||
|
||||
<div className="relative">
|
||||
{/* Raw Grid Lines */}
|
||||
<div className="absolute inset-x-0 top-0 bottom-0 flex justify-between pointer-events-none z-0">
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((tick) => (
|
||||
<div key={tick} className="w-px h-full bg-slate-200 flex flex-col justify-between">
|
||||
<span className="text-[10px] text-slate-400 font-mono -ml-4 -mt-4 bg-white px-1">
|
||||
{Math.round(maxTime * tick)}
|
||||
</span>
|
||||
<div className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl shadow-sm group-hover:shadow-md group-hover:border-slate-200 transition-all duration-500 overflow-hidden relative">
|
||||
|
||||
{/* Subtle top shine */}
|
||||
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white to-transparent opacity-80" />
|
||||
|
||||
<div className="p-6 md:p-8 lg:p-10 relative z-10">
|
||||
{/* Share Button top right */}
|
||||
<div className="absolute top-6 right-6 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-50">
|
||||
<ComponentShareButton targetId={shareId} title={title} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 pt-8 pb-4">
|
||||
{events.map((event, i) => {
|
||||
const left = (event.start / maxTime) * 100;
|
||||
const width = Math.max((event.duration / maxTime) * 100, 0.5);
|
||||
|
||||
return (
|
||||
<div key={i} className="group relative flex items-center h-8 mb-2">
|
||||
<div className="w-32 md:w-48 shrink-0 pr-4 flex justify-between items-center bg-white z-20">
|
||||
<span className="font-bold text-slate-900 text-[11px] md:text-xs truncate uppercase tracking-tight">{event.name}</span>
|
||||
<span className="font-mono text-slate-400 text-[10px]">{event.duration}ms</span>
|
||||
</div>
|
||||
<div className="flex-1 relative h-full flex items-center">
|
||||
<div
|
||||
className={`h-[4px] relative ${event.color || getDefaultColor(event.name)}`}
|
||||
style={{
|
||||
marginLeft: `${left}%`,
|
||||
width: `${width}%`,
|
||||
minWidth: '2px'
|
||||
}}
|
||||
>
|
||||
<div className="absolute top-1/2 left-0 w-2 h-2 -translate-y-1/2 -translate-x-1/2 rounded-full border border-white" style={{ backgroundColor: 'inherit' }} />
|
||||
</div>
|
||||
|
||||
{event.description && (
|
||||
<div className="absolute left-0 -top-6 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap bg-slate-900 text-white text-[10px] font-mono px-2 py-1 rounded-sm pointer-events-none z-30 shadow-lg">
|
||||
{event.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<header className="mb-8 flex flex-col md:flex-row md:justify-between md:items-end gap-2 border-b border-slate-100 pb-4">
|
||||
<h3 className="text-xl md:text-2xl font-bold text-slate-900 tracking-tight m-0">{title}</h3>
|
||||
<div className="font-mono text-xs text-slate-400 bg-slate-50 px-3 py-1 rounded-full border border-slate-100 inline-flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-green-500 rounded-full animate-pulse" />
|
||||
Total Time: {maxTime}ms
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</header>
|
||||
|
||||
<div className="relative pt-6 pb-2">
|
||||
{/* Elegant Grid Lines */}
|
||||
<div className="absolute inset-x-0 top-0 bottom-0 flex justify-between pointer-events-none z-0 ml-[100px] md:ml-[160px]">
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((tick) => (
|
||||
<div key={tick} className="w-px h-full bg-slate-100 flex flex-col justify-start">
|
||||
<span className="text-[9px] text-slate-400 font-mono -ml-3 -mt-5 bg-white/80 backdrop-blur-sm px-1.5 py-0.5 rounded border border-slate-100 shadow-sm">
|
||||
{Math.round(maxTime * tick)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 space-y-3">
|
||||
{events.map((event, i) => {
|
||||
const left = (event.start / maxTime) * 100;
|
||||
const width = Math.max((event.duration / maxTime) * 100, 0.5);
|
||||
|
||||
return (
|
||||
<div key={i} className="group/row relative flex items-center h-10 w-full hover:bg-slate-50/50 rounded-lg transition-colors px-2 -mx-2">
|
||||
<div className="w-[100px] md:w-[160px] shrink-0 pr-4 flex flex-col justify-center z-20">
|
||||
<span className="font-bold text-slate-700 text-[10px] md:text-xs truncate uppercase tracking-widest">{event.name}</span>
|
||||
</div>
|
||||
<div className="flex-1 relative h-full flex items-center">
|
||||
<div
|
||||
className={`h-2.5 md:h-3 rounded-full relative shadow-sm transition-all duration-300 group-hover/row:scale-y-110 group-hover/row:brightness-110 ${getDefaultColor(event.name)}`}
|
||||
style={{ left: `${left}%`, width: `${width}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-12 text-right font-mono text-[9px] md:text-xs text-slate-400 group-hover/row:text-slate-900 transition-colors z-20">{event.duration}ms</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</figure>
|
||||
</Reveal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { ComponentShareButton } from './ComponentShareButton';
|
||||
import { Reveal } from './Reveal';
|
||||
|
||||
interface WebVitalsScoreProps {
|
||||
values: {
|
||||
/** Largest Contentful Paint in seconds (e.g. 2.5) */
|
||||
lcp: number;
|
||||
/** Interaction to Next Paint in milliseconds (e.g. 200) */
|
||||
inp: number;
|
||||
/** Cumulative Layout Shift (e.g. 0.1) */
|
||||
cls: number;
|
||||
@@ -30,46 +30,61 @@ export const WebVitalsScore: React.FC<WebVitalsScoreProps> = ({ values, descript
|
||||
];
|
||||
|
||||
const getColors = (status: string) => {
|
||||
if (status === 'good') return 'text-emerald-600 border-emerald-500';
|
||||
if (status === 'needs-improvement') return 'text-amber-500 border-amber-400';
|
||||
return 'text-red-500 border-red-500';
|
||||
if (status === 'good') return { text: 'text-slate-900', bg: 'bg-emerald-50/50', border: 'border-emerald-200/50', glow: '' };
|
||||
if (status === 'needs-improvement') return { text: 'text-slate-900', bg: 'bg-amber-50/50', border: 'border-amber-200/50', glow: '' };
|
||||
return { text: 'text-slate-900', bg: 'bg-red-50/50', border: 'border-red-200/50', glow: '' };
|
||||
};
|
||||
|
||||
// Generate stable hash for share button
|
||||
const shareId = `vitals-${React.useId().replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<section className="my-16 not-prose border-[2px] border-slate-900 p-8 md:p-12 relative bg-white">
|
||||
<div className="absolute -top-[14px] left-8 bg-white px-4">
|
||||
<h3 className="text-xl font-bold text-slate-900 tracking-tight m-0 uppercase flex items-center gap-3">
|
||||
<div className="w-3 h-3 bg-slate-900 rotate-45" />
|
||||
Core Web Vitals
|
||||
</h3>
|
||||
</div>
|
||||
<Reveal direction="up" delay={0.1}>
|
||||
<figure id={shareId} className="not-prose my-16 group relative transition-all duration-500 ease-out z-10">
|
||||
{/* Ambient Background Glow matching the homepage feel */}
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-slate-100/50 to-slate-200/30 rounded-[2rem] blur opacity-20 group-hover:opacity-40 transition duration-1000 -z-10" />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-12 mt-4">
|
||||
{metrics.map((m) => {
|
||||
const colors = getColors(m.stat);
|
||||
return (
|
||||
<div key={m.id} className="flex flex-col">
|
||||
<span className="text-[10px] font-mono text-slate-500 uppercase tracking-widest mb-1">{m.label}</span>
|
||||
<div className={`text-4xl md:text-5xl font-black tracking-tighter tabular-nums ${colors.split(' ')[0]} border-b-[3px] ${colors.split(' ')[1]} pb-2 mb-2`}>
|
||||
{m.value}<span className="text-lg ml-1 font-bold">{m.unit}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-start gap-2">
|
||||
<span className={`text-[10px] font-mono font-bold uppercase ${colors.split(' ')[0]}`}>{m.stat.replace('-', ' ')}</span>
|
||||
<span className="text-[10px] text-slate-500 leading-snug text-right max-w-[120px]">{m.desc}</span>
|
||||
</div>
|
||||
<div className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl shadow-sm group-hover:shadow-md group-hover:border-slate-200 transition-all duration-500 overflow-hidden relative">
|
||||
|
||||
{/* Subtle top shine */}
|
||||
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white to-transparent opacity-80" />
|
||||
|
||||
<div className="p-8 md:p-10 relative z-10">
|
||||
{/* Share Button top right */}
|
||||
<div className="absolute top-6 right-6 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-50">
|
||||
<ComponentShareButton targetId={shareId} title="Core Web Vitals Scores" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<div className="mt-10 p-5 bg-slate-50 border-l-2 border-slate-900">
|
||||
<p className="text-sm text-slate-800 m-0 leading-relaxed font-serif">
|
||||
<span className="font-mono text-[10px] font-bold uppercase text-slate-900 tracking-widest block mb-2">Analyse</span>
|
||||
{description}
|
||||
</p>
|
||||
<header className="mb-10 flex flex-col gap-2 border-b border-slate-100 pb-4 pr-16 md:pr-0">
|
||||
<div>
|
||||
<h3 className="text-xl md:text-2xl font-bold text-slate-900 tracking-tight m-0 flex items-center gap-3">
|
||||
<span className="w-2 h-2 rounded-full bg-slate-800" />
|
||||
Core Web Vitals
|
||||
</h3>
|
||||
{description && <p className="text-sm text-slate-500 mt-1 leading-snug m-0">{description}</p>}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
|
||||
{metrics.map((metric, i) => {
|
||||
const colors = getColors(metric.stat);
|
||||
return (
|
||||
<div key={i} className={`flex flex-col gap-2 p-6 rounded-2xl border border-transparent hover:border-slate-200 hover:bg-slate-50 transition-all duration-300 group/metric`}>
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{metric.label}</span>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`text-4xl md:text-5xl font-bold tracking-tighter transition-transform duration-500 group-hover/metric:scale-110 origin-left ${colors.text}`}>
|
||||
{metric.value}
|
||||
</span>
|
||||
{metric.unit && <span className="text-sm font-mono text-slate-400">{metric.unit}</span>}
|
||||
</div>
|
||||
<span className="text-xs leading-relaxed text-slate-600 font-medium mt-1">{metric.desc}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</figure>
|
||||
</Reveal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
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]">
|
||||
@@ -59,11 +85,11 @@ export const BlogPostHeader: React.FC<BlogPostHeaderProps> = ({
|
||||
{slug?.substring(0, 4).toUpperCase() || "BLOG"}-
|
||||
{slug
|
||||
? slug
|
||||
.split("")
|
||||
.reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
||||
.toString(16)
|
||||
.toUpperCase()
|
||||
.padStart(4, "0")
|
||||
.split("")
|
||||
.reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
||||
.toString(16)
|
||||
.toUpperCase()
|
||||
.padStart(4, "0")
|
||||
: "0000"}
|
||||
</span>
|
||||
<span
|
||||
|
||||
Reference in New Issue
Block a user