Skip to content

Commit 3e8114b

Browse files
breadonceejerelvelarde
authored andcommitted
refactor(generative-ui): extract save-template overlay into shared component
Move save-as-template UI from widget-renderer into a reusable SaveTemplateOverlay component. Wrap bar chart, pie chart, and widget renderer with it so all generated UIs can be saved as templates. Uses useAgent() directly instead of window.postMessage relay.
1 parent 1e9d11d commit 3e8114b

4 files changed

Lines changed: 290 additions & 218 deletions

File tree

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { BarChart as RechartsBarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
22
import { z } from 'zod';
33
import { CHART_COLORS, CHART_CONFIG } from './config';
4+
import { SaveTemplateOverlay } from '../save-template-overlay';
45

56
export const BarChartProps = z.object({
67
title: z.string().describe("Chart title"),
@@ -16,7 +17,9 @@ export const BarChartProps = z.object({
1617
type BarChartProps = z.infer<typeof BarChartProps>;
1718

1819
export function BarChart({ title, description, data }: BarChartProps) {
19-
if (!data || !Array.isArray(data) || data.length === 0) {
20+
const hasData = data && Array.isArray(data) && data.length > 0;
21+
22+
if (!hasData) {
2023
return (
2124
<div className="rounded-xl border dark:border-zinc-700 shadow-sm p-6 max-w-2xl mx-auto my-6 bg-[var(--background)]">
2225
<div className="mb-4">
@@ -35,27 +38,34 @@ export function BarChart({ title, description, data }: BarChartProps) {
3538
}));
3639

3740
return (
38-
<div className="rounded-xl border dark:border-zinc-700 shadow-sm p-6 max-w-2xl mx-auto my-6 bg-[var(--background)]">
39-
<div className="mb-4">
40-
<h3 className="text-xl font-bold dark:text-white">{title}</h3>
41-
<p className="text-sm text-gray-600 dark:text-zinc-400">{description}</p>
42-
</div>
41+
<SaveTemplateOverlay
42+
title={title}
43+
description={description}
44+
componentType="barChart"
45+
componentData={{ title, description, data }}
46+
>
47+
<div className="rounded-xl border dark:border-zinc-700 shadow-sm p-6 max-w-2xl mx-auto my-6 bg-[var(--background)]">
48+
<div className="mb-4">
49+
<h3 className="text-xl font-bold dark:text-white">{title}</h3>
50+
<p className="text-sm text-gray-600 dark:text-zinc-400">{description}</p>
51+
</div>
4352

44-
<ResponsiveContainer width="100%" height={300}>
45-
<RechartsBarChart data={coloredData} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
46-
<XAxis
47-
dataKey="label"
48-
tick={{ fontSize: 12 }}
49-
stroke="var(--chart-axis)"
50-
/>
51-
<YAxis
52-
tick={{ fontSize: 12 }}
53-
stroke="var(--chart-axis)"
54-
/>
55-
<Tooltip contentStyle={CHART_CONFIG.tooltipStyle} />
56-
<Bar isAnimationActive={false} dataKey="value" radius={[4, 4, 0, 0]} />
57-
</RechartsBarChart>
58-
</ResponsiveContainer>
59-
</div>
53+
<ResponsiveContainer width="100%" height={300}>
54+
<RechartsBarChart data={coloredData} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
55+
<XAxis
56+
dataKey="label"
57+
tick={{ fontSize: 12 }}
58+
stroke="var(--chart-axis)"
59+
/>
60+
<YAxis
61+
tick={{ fontSize: 12 }}
62+
stroke="var(--chart-axis)"
63+
/>
64+
<Tooltip contentStyle={CHART_CONFIG.tooltipStyle} />
65+
<Bar isAnimationActive={false} dataKey="value" radius={[4, 4, 0, 0]} />
66+
</RechartsBarChart>
67+
</ResponsiveContainer>
68+
</div>
69+
</SaveTemplateOverlay>
6070
);
6171
}

apps/app/src/components/generative-ui/charts/pie-chart.tsx

Lines changed: 46 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from "recharts";
77
import { z } from "zod";
88
import { CHART_COLORS, CHART_CONFIG } from "./config";
9+
import { SaveTemplateOverlay } from "../save-template-overlay";
910

1011
export const PieChartProps = z.object({
1112
title: z.string().describe("Chart title"),
@@ -21,7 +22,9 @@ export const PieChartProps = z.object({
2122
type PieChartProps = z.infer<typeof PieChartProps>;
2223

2324
export function PieChart({ title, description, data }: PieChartProps) {
24-
if (!data || !Array.isArray(data) || data.length === 0) {
25+
const hasData = data && Array.isArray(data) && data.length > 0;
26+
27+
if (!hasData) {
2528
return (
2629
<div className="rounded-xl border dark:border-zinc-700 shadow-sm p-6 max-w-lg mx-auto my-6 bg-[var(--background)]">
2730
<div className="mb-4">
@@ -44,43 +47,50 @@ export function PieChart({ title, description, data }: PieChartProps) {
4447
}));
4548

4649
return (
47-
<div className="rounded-xl border dark:border-zinc-700 shadow-sm p-6 max-w-lg mx-auto my-6 bg-[var(--background)]">
48-
<div className="mb-4">
49-
<h3 className="text-xl font-bold dark:text-white">{title}</h3>
50-
<p className="text-sm text-gray-600 dark:text-zinc-400">
51-
{description}
52-
</p>
53-
</div>
54-
55-
<ResponsiveContainer width="100%" height={300}>
56-
<RechartsPieChart>
57-
<Pie
58-
data={coloredData}
59-
dataKey="value"
60-
nameKey="label"
61-
cx="50%"
62-
cy="50%"
63-
outerRadius={100}
64-
isAnimationActive={false}
65-
/>
66-
<Tooltip contentStyle={CHART_CONFIG.tooltipStyle} />
67-
</RechartsPieChart>
68-
</ResponsiveContainer>
50+
<SaveTemplateOverlay
51+
title={title}
52+
description={description}
53+
componentType="pieChart"
54+
componentData={{ title, description, data }}
55+
>
56+
<div className="rounded-xl border dark:border-zinc-700 shadow-sm p-6 max-w-lg mx-auto my-6 bg-[var(--background)]">
57+
<div className="mb-4">
58+
<h3 className="text-xl font-bold dark:text-white">{title}</h3>
59+
<p className="text-sm text-gray-600 dark:text-zinc-400">
60+
{description}
61+
</p>
62+
</div>
6963

70-
{/* Legend */}
71-
<div className="mt-4 grid grid-cols-2 gap-2">
72-
{data.map((item, index) => (
73-
<div key={index} className="flex items-center gap-2">
74-
<div
75-
className="w-3 h-3 rounded-sm"
76-
style={{
77-
backgroundColor: CHART_COLORS[index % CHART_COLORS.length],
78-
}}
64+
<ResponsiveContainer width="100%" height={300}>
65+
<RechartsPieChart>
66+
<Pie
67+
data={coloredData}
68+
dataKey="value"
69+
nameKey="label"
70+
cx="50%"
71+
cy="50%"
72+
outerRadius={100}
73+
isAnimationActive={false}
7974
/>
80-
<span className="text-sm dark:text-zinc-300">{item.label}</span>
81-
</div>
82-
))}
75+
<Tooltip contentStyle={CHART_CONFIG.tooltipStyle} />
76+
</RechartsPieChart>
77+
</ResponsiveContainer>
78+
79+
{/* Legend */}
80+
<div className="mt-4 grid grid-cols-2 gap-2">
81+
{data.map((item, index) => (
82+
<div key={index} className="flex items-center gap-2">
83+
<div
84+
className="w-3 h-3 rounded-sm"
85+
style={{
86+
backgroundColor: CHART_COLORS[index % CHART_COLORS.length],
87+
}}
88+
/>
89+
<span className="text-sm dark:text-zinc-300">{item.label}</span>
90+
</div>
91+
))}
92+
</div>
8393
</div>
84-
</div>
94+
</SaveTemplateOverlay>
8595
);
8696
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
"use client";
2+
3+
import { useState, useCallback, type ReactNode } from "react";
4+
import { useAgent } from "@copilotkit/react-core/v2";
5+
6+
type SaveState = "idle" | "input" | "saving" | "saved";
7+
8+
interface SaveTemplateOverlayProps {
9+
/** Title used as default template name */
10+
title: string;
11+
/** Description stored with the template */
12+
description: string;
13+
/** Raw HTML to save (for widget renderer templates) */
14+
html?: string;
15+
/** Structured data to save (for chart templates) */
16+
componentData?: Record<string, unknown>;
17+
/** The component type that produced this (e.g. "barChart", "pieChart", "widgetRenderer") */
18+
componentType: string;
19+
/** Whether content has finished rendering — button hidden until true */
20+
ready?: boolean;
21+
children: ReactNode;
22+
}
23+
24+
export function SaveTemplateOverlay({
25+
title,
26+
description,
27+
html,
28+
componentData,
29+
componentType,
30+
ready = true,
31+
children,
32+
}: SaveTemplateOverlayProps) {
33+
const { agent } = useAgent();
34+
const [saveState, setSaveState] = useState<SaveState>("idle");
35+
const [templateName, setTemplateName] = useState("");
36+
37+
const handleSave = useCallback(() => {
38+
const name = templateName.trim() || title || "Untitled Template";
39+
setSaveState("saving");
40+
41+
const templates = agent.state?.templates || [];
42+
const newTemplate = {
43+
id: crypto.randomUUID(),
44+
name,
45+
description: description || title || "",
46+
html: html || "",
47+
component_type: componentType,
48+
component_data: componentData || null,
49+
data_description: "",
50+
created_at: new Date().toISOString(),
51+
version: 1,
52+
};
53+
agent.setState({ templates: [...templates, newTemplate] });
54+
55+
setTemplateName("");
56+
setTimeout(() => {
57+
setSaveState("saved");
58+
setTimeout(() => setSaveState("idle"), 1800);
59+
}, 400);
60+
}, [agent, templateName, title, description, html, componentData, componentType]);
61+
62+
return (
63+
<div className="relative">
64+
{/* Save as Template button — hidden until content is ready */}
65+
<div
66+
className="absolute top-2 right-2 z-10 transition-opacity duration-500"
67+
style={{
68+
opacity: ready ? 1 : 0,
69+
pointerEvents: ready ? "auto" : "none",
70+
}}
71+
>
72+
{/* Saved confirmation */}
73+
{saveState === "saved" && (
74+
<div
75+
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold shadow-md"
76+
style={{
77+
background: "var(--color-background-success, #EAF3DE)",
78+
color: "var(--color-text-success, #3B6D11)",
79+
border: "1px solid var(--color-border-success, #3B6D11)",
80+
animation: "tmpl-pop 0.35s cubic-bezier(.34,1.56,.64,1)",
81+
}}
82+
>
83+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
84+
<path d="M20 6 9 17l-5-5" style={{ strokeDasharray: 24, strokeDashoffset: 24, animation: "tmpl-check 0.4s ease-out 0.1s forwards" }} />
85+
</svg>
86+
Saved!
87+
</div>
88+
)}
89+
90+
{/* Saving spinner */}
91+
{saveState === "saving" && (
92+
<div
93+
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium shadow-md"
94+
style={{
95+
background: "var(--surface-primary, #fff)",
96+
border: "1px solid var(--color-border-glass, rgba(0,0,0,0.1))",
97+
color: "var(--text-secondary, #666)",
98+
}}
99+
>
100+
<div
101+
style={{
102+
width: 12,
103+
height: 12,
104+
borderRadius: "50%",
105+
border: "2px solid var(--color-border-tertiary, rgba(0,0,0,0.15))",
106+
borderTopColor: "var(--color-lilac-dark, #6366f1)",
107+
animation: "spin 0.6s linear infinite",
108+
}}
109+
/>
110+
Saving...
111+
</div>
112+
)}
113+
114+
{/* Name input */}
115+
{saveState === "input" && (
116+
<div
117+
className="flex items-center gap-2 rounded-lg px-3 py-2 shadow-lg"
118+
style={{
119+
background: "var(--surface-primary, #fff)",
120+
border: "1px solid var(--color-border-glass, rgba(0,0,0,0.1))",
121+
animation: "tmpl-slideIn 0.2s ease-out",
122+
}}
123+
>
124+
<input
125+
type="text"
126+
placeholder="Template name..."
127+
value={templateName}
128+
onChange={(e) => setTemplateName(e.target.value)}
129+
onKeyDown={(e) => {
130+
if (e.key === "Enter") handleSave();
131+
if (e.key === "Escape") {
132+
setSaveState("idle");
133+
setTemplateName("");
134+
}
135+
}}
136+
autoFocus
137+
className="text-xs px-2 py-1 rounded-md outline-none"
138+
style={{
139+
width: 140,
140+
background: "var(--color-background-secondary, #f5f5f5)",
141+
color: "var(--text-primary, #1a1a1a)",
142+
border: "1px solid var(--color-border-tertiary, rgba(0,0,0,0.1))",
143+
}}
144+
/>
145+
<button
146+
onClick={handleSave}
147+
className="text-xs px-2 py-1 rounded-md font-medium text-white"
148+
style={{
149+
background: "linear-gradient(135deg, var(--color-lilac-dark, #6366f1), var(--color-mint-dark, #10b981))",
150+
}}
151+
>
152+
Save
153+
</button>
154+
<button
155+
onClick={() => { setSaveState("idle"); setTemplateName(""); }}
156+
className="text-xs px-1.5 py-1 rounded-md"
157+
style={{ color: "var(--text-secondary, #666)" }}
158+
>
159+
Cancel
160+
</button>
161+
</div>
162+
)}
163+
164+
{/* Idle bookmark button */}
165+
{saveState === "idle" && (
166+
<button
167+
onClick={() => setSaveState("input")}
168+
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium shadow-md transition-all duration-150 hover:scale-105"
169+
style={{
170+
background: "var(--surface-primary, #fff)",
171+
border: "1px solid var(--color-border-glass, rgba(0,0,0,0.1))",
172+
color: "var(--text-secondary, #666)",
173+
}}
174+
title="Save as Template"
175+
>
176+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
177+
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" />
178+
</svg>
179+
Save as Template
180+
</button>
181+
)}
182+
</div>
183+
184+
{children}
185+
</div>
186+
);
187+
}

0 commit comments

Comments
 (0)