Skip to content

Commit fd91f3d

Browse files
breadonceejerelvelarde
authored andcommitted
feat(template-library): add mini chart previews to template cards
Render inline SVG bar/pie chart previews in template cards using the saved component data. Widget renderer templates keep the iframe preview. Templates without HTML show a placeholder icon.
1 parent 3e8114b commit fd91f3d

1 file changed

Lines changed: 128 additions & 25 deletions

File tree

apps/app/src/components/template-library/template-card.tsx

Lines changed: 128 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,100 @@
11
"use client";
22

33
import { useRef, useEffect, useState } from "react";
4+
import { THEME_CSS } from "@/components/generative-ui/widget-renderer";
5+
6+
const CHART_COLORS = [
7+
"#3b82f6", "#8b5cf6", "#ec4899", "#f59e0b",
8+
"#10b981", "#06b6d4", "#f97316",
9+
];
410

511
interface TemplateCardProps {
612
id: string;
713
name: string;
814
description: string;
915
html: string;
16+
componentType?: string;
17+
componentData?: Record<string, unknown>;
1018
dataDescription: string;
1119
version: number;
1220
onApply: (id: string) => void;
1321
onDelete: (id: string) => void;
1422
}
1523

24+
/** Mini bar chart preview rendered as inline SVG */
25+
function BarChartPreview({ data }: { data: { label: string; value: number }[] }) {
26+
if (!data?.length) return null;
27+
const max = Math.max(...data.map((d) => d.value));
28+
const barWidth = Math.min(40, Math.floor(280 / data.length) - 8);
29+
const chartWidth = data.length * (barWidth + 8);
30+
const chartHeight = 100;
31+
32+
return (
33+
<svg
34+
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
35+
width="100%"
36+
height="100%"
37+
preserveAspectRatio="xMidYMid meet"
38+
style={{ padding: 16 }}
39+
>
40+
{data.map((d, i) => {
41+
const h = max > 0 ? (d.value / max) * (chartHeight - 20) : 0;
42+
return (
43+
<rect
44+
key={i}
45+
x={i * (barWidth + 8)}
46+
y={chartHeight - h - 10}
47+
width={barWidth}
48+
height={h}
49+
rx={3}
50+
fill={CHART_COLORS[i % CHART_COLORS.length]}
51+
/>
52+
);
53+
})}
54+
</svg>
55+
);
56+
}
57+
58+
/** Mini pie chart preview rendered as inline SVG */
59+
function PieChartPreview({ data }: { data: { label: string; value: number }[] }) {
60+
if (!data?.length) return null;
61+
const total = data.reduce((sum, d) => sum + d.value, 0);
62+
if (total === 0) return null;
63+
64+
const cx = 60, cy = 60, r = 50;
65+
let cumulative = 0;
66+
const slices = data.map((d, i) => {
67+
const startAngle = (cumulative / total) * 2 * Math.PI - Math.PI / 2;
68+
cumulative += d.value;
69+
const endAngle = (cumulative / total) * 2 * Math.PI - Math.PI / 2;
70+
const largeArc = d.value / total > 0.5 ? 1 : 0;
71+
const x1 = cx + r * Math.cos(startAngle);
72+
const y1 = cy + r * Math.sin(startAngle);
73+
const x2 = cx + r * Math.cos(endAngle);
74+
const y2 = cy + r * Math.sin(endAngle);
75+
return (
76+
<path
77+
key={i}
78+
d={`M${cx},${cy} L${x1},${y1} A${r},${r} 0 ${largeArc},1 ${x2},${y2} Z`}
79+
fill={CHART_COLORS[i % CHART_COLORS.length]}
80+
/>
81+
);
82+
});
83+
84+
return (
85+
<svg viewBox="0 0 120 120" width="100%" height="100%" preserveAspectRatio="xMidYMid meet">
86+
{slices}
87+
</svg>
88+
);
89+
}
90+
1691
export function TemplateCard({
1792
id,
1893
name,
1994
description,
2095
html,
96+
componentType,
97+
componentData,
2198
dataDescription,
2299
version,
23100
onApply,
@@ -26,19 +103,31 @@ export function TemplateCard({
26103
const iframeRef = useRef<HTMLIFrameElement>(null);
27104
const [previewReady, setPreviewReady] = useState(false);
28105

29-
useEffect(() => {
30-
if (!iframeRef.current || !html) return;
31-
const doc = `<!DOCTYPE html>
106+
const previewHtml = html ? `<!DOCTYPE html>
32107
<html><head><meta charset="utf-8">
33108
<style>
109+
${THEME_CSS}
34110
* { box-sizing: border-box; margin: 0; }
35-
body { font-family: system-ui, sans-serif; font-size: 16px; color: #1a1a1a; background: #fff; transform-origin: top left; overflow: hidden; }
36-
@media (prefers-color-scheme: dark) { body { color: #e8e6de; background: #1a1a18; } }
37-
</style></head><body><div id="content">${html}</div></body></html>`;
38-
iframeRef.current.srcdoc = doc;
39-
const timer = setTimeout(() => setPreviewReady(true), 500);
40-
return () => clearTimeout(timer);
41-
}, [html]);
111+
body {
112+
font-family: system-ui, -apple-system, sans-serif;
113+
font-size: 16px;
114+
line-height: 1.7;
115+
color: var(--color-text-primary);
116+
background: var(--color-background-primary);
117+
overflow: hidden;
118+
}
119+
</style></head><body><div id="content">${html}</div></body></html>` : "";
120+
121+
useEffect(() => {
122+
if (!iframeRef.current || !previewHtml) return;
123+
iframeRef.current.srcdoc = previewHtml;
124+
}, [previewHtml]);
125+
126+
// Determine chart data for mini preview
127+
const chartData = componentData?.data as { label: string; value: number }[] | undefined;
128+
const isBarChart = componentType === "barChart";
129+
const isPieChart = componentType === "pieChart";
130+
const isChart = isBarChart || isPieChart;
42131

43132
return (
44133
<div
@@ -53,18 +142,34 @@ body { font-family: system-ui, sans-serif; font-size: 16px; color: #1a1a1a; back
53142
className="relative overflow-hidden"
54143
style={{ height: 140, background: "var(--color-background-secondary, #f7f6f3)" }}
55144
>
56-
<iframe
57-
ref={iframeRef}
58-
sandbox="allow-same-origin"
59-
className="border-0 w-[300%] h-[300%] origin-top-left"
60-
style={{
61-
transform: "scale(0.333)",
62-
pointerEvents: "none",
63-
opacity: previewReady ? 1 : 0,
64-
transition: "opacity 300ms",
65-
}}
66-
title={`Preview: ${name}`}
67-
/>
145+
{isChart && chartData ? (
146+
<div className="flex items-center justify-center h-full">
147+
{isBarChart && <BarChartPreview data={chartData} />}
148+
{isPieChart && <PieChartPreview data={chartData} />}
149+
</div>
150+
) : html ? (
151+
<iframe
152+
ref={iframeRef}
153+
sandbox="allow-same-origin allow-scripts"
154+
onLoad={() => setPreviewReady(true)}
155+
className="border-0 w-[300%] h-[300%] origin-top-left"
156+
style={{
157+
transform: "scale(0.333)",
158+
pointerEvents: "none",
159+
opacity: previewReady ? 1 : 0,
160+
transition: "opacity 300ms",
161+
}}
162+
title={`Preview: ${name}`}
163+
/>
164+
) : (
165+
<div className="flex items-center justify-center h-full">
166+
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ color: "var(--text-tertiary, #999)", opacity: 0.5 }}>
167+
<rect x="3" y="3" width="18" height="18" rx="2" />
168+
<path d="M3 9h18" />
169+
<path d="M9 21V9" />
170+
</svg>
171+
</div>
172+
)}
68173
{/* Version badge */}
69174
<span
70175
className="absolute top-2 right-2 text-[10px] font-semibold px-1.5 py-0.5 rounded-full"
@@ -102,9 +207,7 @@ body { font-family: system-ui, sans-serif; font-size: 16px; color: #1a1a1a; back
102207
</div>
103208

104209
{/* Actions */}
105-
<div
106-
className="flex gap-2 p-3 pt-0"
107-
>
210+
<div className="flex gap-2 p-3 pt-0">
108211
<button
109212
onClick={() => onApply(id)}
110213
className="flex-1 text-xs font-medium py-1.5 rounded-lg transition-all duration-150 hover:scale-[1.02] text-white"

0 commit comments

Comments
 (0)