feat(ui): complete structural rewrite of content components to strict engineering blueprint aesthetic
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Failing after 27s
Build & Deploy / 🧪 QA (push) Failing after 1m15s
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 27s
Build & Deploy / 🧪 QA (push) Failing after 1m15s
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:
@@ -20,9 +20,16 @@ interface BlockquoteProps {
|
||||
}
|
||||
|
||||
export const ArticleBlockquote: React.FC<BlockquoteProps> = ({ children, className = '' }) => (
|
||||
<blockquote className={`not-prose border-l-4 border-slate-900 pl-6 italic text-slate-700 my-10 text-xl md:text-2xl font-serif ${className}`}>
|
||||
{children}
|
||||
</blockquote>
|
||||
<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>
|
||||
);
|
||||
|
||||
interface CodeBlockProps {
|
||||
|
||||
63
apps/web/src/components/ArticleQuote.tsx
Normal file
63
apps/web/src/components/ArticleQuote.tsx
Normal file
@@ -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<ArticleQuoteProps> = ({
|
||||
quote,
|
||||
author,
|
||||
role,
|
||||
source,
|
||||
sourceUrl,
|
||||
translated,
|
||||
isCompany,
|
||||
className = '',
|
||||
}) => {
|
||||
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>
|
||||
)}
|
||||
</figcaption>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quote column (right side) */}
|
||||
<blockquote className="md:col-span-8 lg:col-span-9 flex items-center relative">
|
||||
<p className="text-2xl md:text-3xl lg:text-4xl font-serif text-slate-900 italic leading-[1.3] tracking-tight m-0 before:content-['\201C'] after:content-['\201D']">
|
||||
{quote}
|
||||
</p>
|
||||
</blockquote>
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
};
|
||||
@@ -21,13 +21,39 @@ interface MermaidProps {
|
||||
legendFontSize?: string;
|
||||
}
|
||||
|
||||
export const Mermaid: React.FC<MermaidProps> = ({
|
||||
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 (
|
||||
<div className="p-4 bg-red-50 border border-red-100 rounded text-red-600 text-sm font-mono text-center my-8">
|
||||
Diagram rendering crashed.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const MermaidInternal: React.FC<MermaidProps> = ({
|
||||
graph,
|
||||
children,
|
||||
id: providedId,
|
||||
title,
|
||||
showShare = false,
|
||||
fontSize = "16px",
|
||||
fontSize = "20px",
|
||||
nodeFontSize,
|
||||
labelFontSize,
|
||||
actorFontSize,
|
||||
@@ -38,8 +64,11 @@ export const Mermaid: React.FC<MermaidProps> = ({
|
||||
legendFontSize,
|
||||
}) => {
|
||||
const [id, setId] = useState<string | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [svgContent, setSvgContent] = useState<string>("");
|
||||
const [isVisible, setIsVisible] = useState(true); // Forced true specifically to bypass lazy loading bug
|
||||
const [isRendered, setIsRendered] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<MermaidProps> = ({
|
||||
.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 (
|
||||
<div className="not-prose my-8 p-6 bg-slate-50 border border-slate-200 rounded-xl text-center">
|
||||
<p className="text-sm text-slate-400 font-medium">Diagramm konnte nicht geladen werden</p>
|
||||
</div>
|
||||
);
|
||||
}, [providedId]);
|
||||
const [isRendered, setIsRendered] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<MermaidProps> = ({
|
||||
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<MermaidProps> = ({
|
||||
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 (
|
||||
<div className="mermaid-wrapper not-prose relative left-1/2 right-1/2 -ml-[50vw] -mr-[50vw] w-screen px-5 md:px-16 py-12 bg-slate-50/50 border-y border-slate-100">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<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">
|
||||
|
||||
{/* 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="w-full flex flex-col items-center justify-center relative z-10 px-4 md:px-8">
|
||||
{title && (
|
||||
<h4 className="text-center text-sm font-bold text-slate-700 mb-8 uppercase tracking-[0.2em]">
|
||||
{title}
|
||||
</h4>
|
||||
<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">
|
||||
<div className="flex justify-center w-full overflow-x-auto">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`mermaid transition-opacity duration-500 w-full flex justify-center ${isRendered ? "opacity-100" : "opacity-0"}`}
|
||||
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",
|
||||
overflow: "visible"
|
||||
}}
|
||||
>
|
||||
{error ? (
|
||||
<div className="text-red-500 p-4 border border-red-200 rounded bg-red-50 text-sm">
|
||||
<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>
|
||||
) : svgContent ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: svgContent }} />
|
||||
) : (
|
||||
sanitizedGraph
|
||||
// Hide raw graph until rendered
|
||||
<div style={{ display: 'none' }}>{sanitizedGraph}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showShare && id && (
|
||||
<div className="flex justify-center mt-8">
|
||||
{showShare && id && isRendered && (
|
||||
<div className="flex justify-end w-full mt-10">
|
||||
<DiagramShareButton
|
||||
diagramId={id}
|
||||
title={title}
|
||||
@@ -201,6 +384,12 @@ export const Mermaid: React.FC<MermaidProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
};
|
||||
|
||||
export const Mermaid = (props: MermaidProps) => (
|
||||
<MermaidErrorBoundary>
|
||||
<MermaidInternal {...props} />
|
||||
</MermaidErrorBoundary>
|
||||
);
|
||||
|
||||
87
apps/web/src/components/PremiumComparisonChart.tsx
Normal file
87
apps/web/src/components/PremiumComparisonChart.tsx
Normal file
@@ -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<PremiumComparisonChartProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
items,
|
||||
className = '',
|
||||
}) => {
|
||||
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>
|
||||
|
||||
<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="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>
|
||||
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
};
|
||||
91
apps/web/src/components/WaterfallChart.tsx
Normal file
91
apps/web/src/components/WaterfallChart.tsx
Normal file
@@ -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<WaterfallChartProps> = ({ 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 (
|
||||
<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>
|
||||
|
||||
<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>
|
||||
))}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
75
apps/web/src/components/WebVitalsScore.tsx
Normal file
75
apps/web/src/components/WebVitalsScore.tsx
Normal file
@@ -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<WebVitalsScoreProps> = ({ 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 (
|
||||
<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>
|
||||
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<div className="mt-10 p-5 bg-slate-50 border-l-2 border-slate-900">
|
||||
<p className="text-sm text-slate-800 m-0 leading-relaxed font-serif">
|
||||
<span className="font-mono text-[10px] font-bold uppercase text-slate-900 tracking-widest block mb-2">Analyse</span>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user