feat(ui): enhance component share UX and add new interactive simulations
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🏗️ Build (push) Failing after 26s
Build & Deploy / 🧪 QA (push) Failing after 1m14s
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 6s
Build & Deploy / 🏗️ Build (push) Failing after 26s
Build & Deploy / 🧪 QA (push) Failing after 1m14s
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:
@@ -5,16 +5,19 @@ import { Share2 } from "lucide-react";
|
||||
import { ShareModal } from "./ShareModal";
|
||||
import { useAnalytics } from "./analytics/useAnalytics";
|
||||
import * as htmlToImage from "html-to-image";
|
||||
import QRCode from "qrcode";
|
||||
|
||||
interface ComponentShareButtonProps {
|
||||
targetId: string;
|
||||
title?: string;
|
||||
shareText?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ComponentShareButton: React.FC<ComponentShareButtonProps> = ({
|
||||
targetId,
|
||||
title = "Component",
|
||||
title = "Mintel Architektur Insights",
|
||||
shareText = "Schauen Sie sich diese spannende interaktive Simulation an:",
|
||||
className = ""
|
||||
}) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
@@ -27,31 +30,131 @@ export const ComponentShareButton: React.FC<ComponentShareButtonProps> = ({
|
||||
? `${window.location.origin}${window.location.pathname}#${targetId}`
|
||||
: "";
|
||||
|
||||
const applyWatermark = async (base64Img: string, qrCodeSrc: string): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
// html-to-image uses pixelRatio: 2, so img.width/img.height are 2x
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return resolve(base64Img);
|
||||
|
||||
// Draw main image
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// Watermark styling for 2x resolution
|
||||
const padding = 32;
|
||||
const wHeight = 110;
|
||||
const wWidth = 360;
|
||||
const x = canvas.width - wWidth - padding;
|
||||
const y = canvas.height - wHeight - padding;
|
||||
|
||||
// Draw pill shape background
|
||||
ctx.fillStyle = "rgba(255, 255, 255, 0.95)";
|
||||
ctx.shadowColor = "rgba(0, 0, 0, 0.05)";
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowOffsetY = 4;
|
||||
|
||||
if (ctx.roundRect) {
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, wWidth, wHeight, 24);
|
||||
ctx.fill();
|
||||
} else {
|
||||
ctx.fillRect(x, y, wWidth, wHeight);
|
||||
}
|
||||
|
||||
// Draw text
|
||||
ctx.shadowColor = "transparent";
|
||||
ctx.textAlign = "right";
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
// "mintel."
|
||||
ctx.font = "900 36px sans-serif";
|
||||
const textX = x + wWidth - wHeight - 12; // Right of text, left of QR code
|
||||
const mintelText = "mintel";
|
||||
const dotText = ".";
|
||||
const dotWidth = ctx.measureText(dotText).width;
|
||||
const mintelWidth = ctx.measureText(mintelText).width;
|
||||
|
||||
ctx.fillStyle = "#0ea5e9";
|
||||
ctx.fillText(dotText, textX, y + 40);
|
||||
ctx.fillStyle = "#0f172a";
|
||||
ctx.fillText(mintelText, textX - dotWidth, y + 40);
|
||||
|
||||
// "Artikel lesen"
|
||||
ctx.font = "600 20px sans-serif";
|
||||
ctx.fillStyle = "#64748b";
|
||||
ctx.fillText("Zum Artikel", textX, y + 76);
|
||||
|
||||
// Draw solid line separator
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(textX + 6, y + 20);
|
||||
ctx.lineTo(textX + 6, y + wHeight - 20);
|
||||
ctx.strokeStyle = "#e2e8f0";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// Draw QR Code
|
||||
const qrImg = new Image();
|
||||
qrImg.onload = () => {
|
||||
const qrSize = wHeight - 24; // padding 12*2 inside pill
|
||||
const qrX = x + wWidth - qrSize - 12;
|
||||
const qrY = y + 12;
|
||||
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
|
||||
resolve(canvas.toDataURL("image/png"));
|
||||
};
|
||||
qrImg.onerror = () => resolve(base64Img); // Error fallback
|
||||
qrImg.src = qrCodeSrc;
|
||||
};
|
||||
img.onerror = () => resolve(base64Img); // Error fallback
|
||||
img.src = base64Img;
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenModal = async () => {
|
||||
setIsCapturing(true);
|
||||
try {
|
||||
const element = document.getElementById(targetId);
|
||||
if (element) {
|
||||
const dataUrl = await htmlToImage.toPng(element, {
|
||||
// Force explicit dimensions to prevent html-to-image from clipping
|
||||
const w = element.offsetWidth;
|
||||
const h = element.offsetHeight;
|
||||
|
||||
const rawDataUrl = await htmlToImage.toPng(element, {
|
||||
quality: 1,
|
||||
type: 'image/png',
|
||||
pixelRatio: 2,
|
||||
backgroundColor: 'transparent',
|
||||
backgroundColor: 'white',
|
||||
skipFonts: true,
|
||||
// Filter out any buttons that are part of the UI but shouldn't be in the image
|
||||
width: w,
|
||||
height: h,
|
||||
style: {
|
||||
transform: 'none',
|
||||
margin: '0',
|
||||
width: `${w}px`,
|
||||
height: `${h}px`
|
||||
},
|
||||
filter: (node) => {
|
||||
if (node.tagName === 'BUTTON' || (node as HTMLElement).dataset?.shareButton === 'true') {
|
||||
const el = node as HTMLElement;
|
||||
if (el.tagName === 'BUTTON' || el.dataset?.shareButton === 'true' || el.dataset?.shareWrapper === 'true') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
style: {
|
||||
// Ensure we don't accidentally cut off by resetting transform/margins inside the clone
|
||||
transform: 'none',
|
||||
}
|
||||
});
|
||||
|
||||
setGeneratedImage(dataUrl);
|
||||
// Generate QR Code
|
||||
const qrDataUrl = await QRCode.toDataURL(currentUrl, {
|
||||
width: 120, // slightly larger for sharper scaling
|
||||
margin: 0,
|
||||
color: { dark: '#0f172a', light: '#ffffff' }
|
||||
});
|
||||
|
||||
// Add Watermark
|
||||
const watermarkedImage = await applyWatermark(rawDataUrl, qrDataUrl);
|
||||
setGeneratedImage(watermarkedImage);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to capture component:", err);
|
||||
@@ -60,40 +163,41 @@ export const ComponentShareButton: React.FC<ComponentShareButtonProps> = ({
|
||||
setIsModalOpen(true);
|
||||
trackEvent("component_share_opened", {
|
||||
component_id: targetId,
|
||||
component_title: title,
|
||||
component_title: title
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`relative ${className}`}>
|
||||
<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"
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-full font-medium text-xs transition-all shadow-sm
|
||||
${isCapturing
|
||||
? "bg-slate-100 text-slate-400 cursor-wait border border-slate-200"
|
||||
: "bg-white text-slate-600 hover:text-slate-900 border border-slate-200 hover:border-slate-300 hover:shadow-md"
|
||||
}`}
|
||||
title="Diesen Abschnitt als Bild teilen"
|
||||
>
|
||||
<Share2 strokeWidth={2.5} className={`w-3 h-3 ${isCapturing ? 'animate-spin' : ''}`} />
|
||||
<span>{isCapturing ? "Erstelle Bild..." : "Teilen"}</span>
|
||||
{isCapturing ? (
|
||||
<div className="w-3.5 h-3.5 border-2 border-slate-300 border-t-slate-500 rounded-full animate-spin" />
|
||||
) : (
|
||||
<Share2 size={14} />
|
||||
)}
|
||||
<span>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}
|
||||
description={shareText}
|
||||
diagramImage={generatedImage}
|
||||
qrCodeData={generatedImage} // Let's try sending it to both to be safe depending on ShareModal's internals
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user