diff --git a/apps/web/src/components/ArticleBlockquote.tsx b/apps/web/src/components/ArticleBlockquote.tsx index b655b6e..f4e86d0 100644 --- a/apps/web/src/components/ArticleBlockquote.tsx +++ b/apps/web/src/components/ArticleBlockquote.tsx @@ -20,9 +20,16 @@ interface BlockquoteProps { } export const ArticleBlockquote: React.FC = ({ children, className = '' }) => ( -
- {children} -
+
+
+ +
+
+
+ {children} +
+
+
); interface CodeBlockProps { diff --git a/apps/web/src/components/ArticleQuote.tsx b/apps/web/src/components/ArticleQuote.tsx new file mode 100644 index 0000000..70c4301 --- /dev/null +++ b/apps/web/src/components/ArticleQuote.tsx @@ -0,0 +1,63 @@ +import React from 'react'; + +interface ArticleQuoteProps { + quote: string; + author: string; + 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; +} + +export const ArticleQuote: React.FC = ({ + quote, + author, + role, + source, + sourceUrl, + translated, + isCompany, + className = '', +}) => { + return ( +
+
+ {/* Meta column (left side on desktop) */} +
+
+ {isCompany ? ( +
+ +
+ ) : ( +
+ {(author || '').split(' ').map(w => w[0]).join('').slice(0, 2)} +
+ )} +
+ {author} + {role && {role}} + {translated && Translated} + {sourceUrl && ( + + {source || 'Source'} → + + )} +
+
+
+ + {/* Quote column (right side) */} +
+

+ {quote} +

+
+
+
+ ); +}; diff --git a/apps/web/src/components/Mermaid.tsx b/apps/web/src/components/Mermaid.tsx index 6ad0a20..9ee65b6 100644 --- a/apps/web/src/components/Mermaid.tsx +++ b/apps/web/src/components/Mermaid.tsx @@ -21,13 +21,39 @@ interface MermaidProps { legendFontSize?: string; } -export const Mermaid: React.FC = ({ +class MermaidErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean }> { + constructor(props: { children: React.ReactNode }) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(_error: any) { + return { hasError: true }; + } + + componentDidCatch(error: any, errorInfo: any) { + console.error("MermaidErrorBoundary caught an error:", error, errorInfo); + } + + render() { + if (this.state.hasError) { + return ( +
+ Diagram rendering crashed. +
+ ); + } + return this.props.children; + } +} + +const MermaidInternal: React.FC = ({ graph, children, id: providedId, title, showShare = false, - fontSize = "16px", + fontSize = "20px", nodeFontSize, labelFontSize, actorFontSize, @@ -38,8 +64,11 @@ export const Mermaid: React.FC = ({ legendFontSize, }) => { const [id, setId] = useState(null); - const containerRef = useRef(null); + const wrapperRef = useRef(null); const [svgContent, setSvgContent] = useState(""); + const [isVisible, setIsVisible] = useState(true); // Forced true specifically to bypass lazy loading bug + const [isRendered, setIsRendered] = useState(false); + const [error, setError] = useState(null); // Extract text from React children nodes (MDX parses multi-line content as React nodes) const extractTextFromChildren = (node: React.ReactNode): string => { @@ -66,29 +95,86 @@ export const Mermaid: React.FC = ({ .replace(/\\"/g, '"') .replace(/\\'/g, "'"); - useEffect(() => { - setId( - providedId || `mermaid-${Math.random().toString(36).substring(2, 11)}`, + // Early return for empty graph — show fallback instead of blank space + if (!sanitizedGraph || sanitizedGraph.length < 5) { + return ( +
+

Diagramm konnte nicht geladen werden

+
); - }, [providedId]); - const [isRendered, setIsRendered] = useState(false); - const [error, setError] = useState(null); + } 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 + useEffect(() => { + if (!wrapperRef.current) return; + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setIsVisible(true); + observer.disconnect(); // Render once per diagram + } + }); + }, + { rootMargin: "200px" } // Start rendering 200px before it scrolls into view + ); + + observer.observe(wrapperRef.current); + return () => observer.disconnect(); + }, [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 + flowchart: { + useMaxWidth: true, + htmlLabels: false, + curve: 'basis', + nodeSpacing: 50, + rankSpacing: 60, + padding: 15 + }, + sequence: { + useMaxWidth: true, + showSequenceNumbers: false, + height: 40, + actorMargin: 30, + messageMargin: 30 + }, + gantt: { useMaxWidth: true }, + pie: { useMaxWidth: true }, + state: { useMaxWidth: true }, themeVariables: { - // Base colors - industrial slate/white palette - primaryColor: "#ffffff", - primaryTextColor: "#1e293b", - primaryBorderColor: "#cbd5e1", - lineColor: "#94a3b8", - secondaryColor: "#f8fafc", - tertiaryColor: "#f1f5f9", - - // Background colors + primaryColor: "#f8fafc", // slate-50 + primaryTextColor: "#334155", // slate-700 + primaryBorderColor: "#cbd5e1", // slate-300 + lineColor: "#64748b", // slate-500 + secondaryColor: "#f1f5f9", // slate-100 + tertiaryColor: "#e2e8f0", // slate-200 // Background colors background: "#ffffff", mainBkg: "#ffffff", secondBkg: "#f8fafc", @@ -107,16 +193,16 @@ export const Mermaid: React.FC = ({ edgeLabelBackground: "#ffffff", // Font - fontFamily: "Inter, system-ui, sans-serif", - fontSize: fontSize, - nodeFontSize: nodeFontSize || fontSize, - labelFontSize: labelFontSize || fontSize, - actorFontSize: actorFontSize || fontSize, - messageFontSize: messageFontSize || fontSize, - noteFontSize: noteFontSize || fontSize, - titleFontSize: titleFontSize || "20px", - sectionFontSize: sectionFontSize || fontSize, - legendFontSize: legendFontSize || fontSize, + fontFamily: "var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace", + fontSize: fontSize || "18px", + nodeFontSize: nodeFontSize || fontSize || "18px", + labelFontSize: labelFontSize || fontSize || "16px", + actorFontSize: actorFontSize || fontSize || "18px", + messageFontSize: messageFontSize || fontSize || "16px", + noteFontSize: noteFontSize || fontSize || "16px", + titleFontSize: titleFontSize || "24px", + sectionFontSize: sectionFontSize || fontSize || "18px", + legendFontSize: legendFontSize || fontSize || "18px", // Pie Chart Colors - High Contrast Industrial Palette pie1: "#0f172a", // Deep Navy @@ -135,64 +221,161 @@ export const Mermaid: React.FC = ({ securityLevel: "loose", }); - const render = async () => { - if (containerRef.current && id) { - if (!sanitizedGraph || sanitizedGraph.trim() === "") { - console.warn("Mermaid: Empty or invalid graph provided, skipping render."); - return; - } + 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; + } + + const maxRetries = 3; + let attempt = 0; + let success = false; + + while (attempt < maxRetries && !success) { + attempt++; try { - const { svg } = await mermaid.render(`${id}-svg`, sanitizedGraph); - containerRef.current.innerHTML = svg; - setSvgContent(svg); + if (!sanitizedGraph) { + console.warn("Mermaid: Empty graph definition received, skipping 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'; + tempDiv.style.left = '-9999px'; + tempDiv.style.visibility = 'hidden'; + + 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); + } + } + + let scaledSvg = rawSvg; + const svgStartEnd = rawSvg.indexOf('>'); + if (svgStartEnd > -1) { + const svgTag = rawSvg.substring(0, svgStartEnd + 1); + const rest = rawSvg.substring(svgStartEnd + 1); + const newSvgTag = svgTag.replace(/width="[^"]*"/, 'width="100%"').replace(/height="[^"]*"/, 'height="auto"'); + scaledSvg = newSvgTag + rest; + } + + // Store SVG in React state — React renders it via dangerouslySetInnerHTML + setSvgContent(scaledSvg); setIsRendered(true); + setError(null); + success = true; } catch (err) { - console.error("Mermaid rendering failed:", err); - console.error("Graph that failed:", sanitizedGraph); - const errorMessage = err instanceof Error ? err.message : String(err); - setError(`Failed to render diagram: ${errorMessage}`); - setIsRendered(true); + 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)); + } } } }; - if (id) { - render(); + // 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(); + } + } + }); + + resizeObserver.observe(wrapperRef.current); + + // Fallback: Try immediately if we already have size + if (wrapperRef.current && wrapperRef.current.clientWidth > 0) { + renderGraph(); + resizeObserver.disconnect(); } - }, [sanitizedGraph, id]); + + return () => resizeObserver.disconnect(); + }, [isVisible, id, sanitizedGraph, fontSize, nodeFontSize, labelFontSize, actorFontSize, messageFontSize, noteFontSize, titleFontSize, sectionFontSize, legendFontSize]); if (!id) return null; return ( -
-
+
+ + {/* Blueprint Grid Background Pattern */} +
+ +
{title && ( -

- {title} -

+
+

+ {title} +

+ System_Architecture +
)} -
+
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", + overflow: "visible" }} > {error ? ( -
+
{error}
+ ) : svgContent ? ( +
) : ( - sanitizedGraph + // Hide raw graph until rendered +
{sanitizedGraph}
)}
- {showShare && id && ( -
+ {showShare && id && isRendered && ( +
= ({
)}
-
+
); }; + +export const Mermaid = (props: MermaidProps) => ( + + + +); diff --git a/apps/web/src/components/PremiumComparisonChart.tsx b/apps/web/src/components/PremiumComparisonChart.tsx new file mode 100644 index 0000000..25e114c --- /dev/null +++ b/apps/web/src/components/PremiumComparisonChart.tsx @@ -0,0 +1,87 @@ +'use client'; + +import React from 'react'; + +interface ChartItem { + label: string; + value: number; + max: number; + unit?: string; + color?: 'blue' | 'green' | 'red' | 'slate' | 'orange'; + description?: string; +} + +interface PremiumComparisonChartProps { + title: string; + subtitle?: string; + items: ChartItem[]; + className?: string; +} + +export const PremiumComparisonChart: React.FC = ({ + title, + subtitle, + items, + className = '', +}) => { + return ( +
+
+
+

+ + {title} +

+ {subtitle &&

{subtitle}

} +
+
+ DATA_SYNC + / + V2 +
+
+ +
+ {(items || []).map((item, index) => { + return ( +
+
+ {item.label} + {item.description && ( + {item.description} + )} +
+ +
+
+
)[item.color || 'slate'] || '#0f172a' + }} + > +
+
+
+
+ +
+ + {item.value} + {item.unit || ''} + +
+
+ ); + })} +
+
+ ); +}; diff --git a/apps/web/src/components/WaterfallChart.tsx b/apps/web/src/components/WaterfallChart.tsx new file mode 100644 index 0000000..3ece52d --- /dev/null +++ b/apps/web/src/components/WaterfallChart.tsx @@ -0,0 +1,91 @@ +'use client'; + +import React from 'react'; + +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 { + title?: string; + events: WaterfallEvent[]; + totalDuration?: number; + showShare?: boolean; +} + +export const WaterfallChart: React.FC = ({ title = 'Resource Waterfall', events, totalDuration }) => { + const maxTime = totalDuration || Math.max(...events.map(e => e.start + e.duration)); + + const getDefaultColor = (name: string) => { + const n = name.toLowerCase(); + if (n.includes('html') || n.includes('document')) return 'bg-slate-900'; + 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'; + if (n.includes('font')) return 'bg-pink-400'; + return 'bg-slate-300'; + }; + + return ( +
+
+

{title}

+
{maxTime}ms
+
+ +
+ {/* Raw Grid Lines */} +
+ {[0, 0.25, 0.5, 0.75, 1].map((tick) => ( +
+ + {Math.round(maxTime * tick)} + +
+ ))} +
+ +
+ {events.map((event, i) => { + const left = (event.start / maxTime) * 100; + const width = Math.max((event.duration / maxTime) * 100, 0.5); + + return ( +
+
+ {event.name} + {event.duration}ms +
+
+
+
+
+ + {event.description && ( +
+ {event.description} +
+ )} +
+
+ ); + })} +
+
+
+ ); +}; diff --git a/apps/web/src/components/WebVitalsScore.tsx b/apps/web/src/components/WebVitalsScore.tsx new file mode 100644 index 0000000..ddd66cd --- /dev/null +++ b/apps/web/src/components/WebVitalsScore.tsx @@ -0,0 +1,75 @@ +'use client'; + +import React from 'react'; + +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; + }; + description?: string; + showShare?: boolean; +} + +export const WebVitalsScore: React.FC = ({ values, description }) => { + const getStatus = (metric: 'lcp' | 'inp' | 'cls', value: number): 'good' | 'needs-improvement' | 'poor' => { + if (metric === 'lcp') return value <= 2.5 ? 'good' : value <= 4.0 ? 'needs-improvement' : 'poor'; + if (metric === 'inp') return value <= 200 ? 'good' : value <= 500 ? 'needs-improvement' : 'poor'; + if (metric === 'cls') return value <= 0.1 ? 'good' : value <= 0.25 ? 'needs-improvement' : 'poor'; + return 'poor'; + }; + + const metrics = [ + { id: 'lcp', label: 'Largest Contentful Paint', value: values.lcp, unit: 's', stat: getStatus('lcp', values.lcp), desc: 'Wann der Hauptinhalt geladen ist' }, + { id: 'inp', label: 'Interaction to Next Paint', value: values.inp, unit: 'ms', stat: getStatus('inp', values.inp), desc: 'Reaktionszeit auf Klicks' }, + { id: 'cls', label: 'Cumulative Layout Shift', value: values.cls, unit: '', stat: getStatus('cls', values.cls), desc: 'Visuelle Stabilität beim Laden' } + ]; + + 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'; + }; + + return ( +
+
+

+
+ Core Web Vitals +

+
+ +
+ {metrics.map((m) => { + const colors = getColors(m.stat); + return ( +
+ {m.label} +
+ {m.value}{m.unit} +
+
+ {m.stat.replace('-', ' ')} + {m.desc} +
+
+ ); + })} +
+ + {description && ( +
+

+ Analyse + {description} +

+
+ )} +
+ ); +};