Skip to content

Commit a28b069

Browse files
committed
refactor: horizontal dots menu, remove save-as-artifact option
- Change three-dot trigger from vertical to horizontal orientation - Remove "Save as artifact" menu item and all save-related state/UI - Menu now shows only: Copy to clipboard, Download file - Clean up unused imports (useAgent, SEED_TEMPLATES)
1 parent 608a2d3 commit a28b069

1 file changed

Lines changed: 50 additions & 258 deletions

File tree

apps/app/src/components/generative-ui/save-template-overlay.tsx

Lines changed: 50 additions & 258 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,31 @@
11
"use client";
22

33
import { useState, useCallback, useMemo, useRef, useEffect, type ReactNode } from "react";
4-
import { useAgent } from "@copilotkit/react-core/v2";
5-
import { SEED_TEMPLATES } from "@/components/template-library/seed-templates";
64
import {
75
assembleStandaloneHtml,
86
chartToStandaloneHtml,
97
triggerDownload,
108
slugify,
119
} from "./export-utils";
1210

13-
type SaveState = "idle" | "input" | "saving" | "saved";
14-
1511
interface SaveTemplateOverlayProps {
16-
/** Title used as default template name */
1712
title: string;
18-
/** Description stored with the template */
1913
description: string;
20-
/** Raw HTML to save (for widget renderer templates) */
2114
html?: string;
22-
/** Structured data to save (for chart templates) */
2315
componentData?: Record<string, unknown>;
24-
/** The component type that produced this (e.g. "barChart", "pieChart", "widgetRenderer") */
2516
componentType: string;
26-
/** Whether content has finished rendering — button hidden until true */
2717
ready?: boolean;
2818
children: ReactNode;
2919
}
3020

3121
export function SaveTemplateOverlay({
3222
title,
33-
description,
3423
html,
3524
componentData,
3625
componentType,
3726
ready = true,
3827
children,
3928
}: SaveTemplateOverlayProps) {
40-
const { agent } = useAgent();
41-
const [saveState, setSaveState] = useState<SaveState>("idle");
42-
const [templateName, setTemplateName] = useState("");
4329
const [copyState, setCopyState] = useState<"idle" | "copied">("idle");
4430
const [menuOpen, setMenuOpen] = useState(false);
4531
const [hovered, setHovered] = useState(false);
@@ -57,66 +43,6 @@ export function SaveTemplateOverlay({
5743
return () => document.removeEventListener("mousedown", handleClick);
5844
}, [menuOpen]);
5945

60-
// Capture pending_template at mount time — it may be cleared by the agent later.
61-
// Uses ref (not state) to avoid an async re-render that would shift sibling positions
62-
// and cause React to remount the iframe, losing rendered 3D/canvas content.
63-
const pending = agent.state?.pending_template as { id: string; name: string } | null | undefined;
64-
const sourceRef = useRef<{ id: string; name: string } | null>(null);
65-
// eslint-disable-next-line react-hooks/refs -- one-time ref init during render (React-endorsed pattern)
66-
if (pending?.id && !sourceRef.current) {
67-
sourceRef.current = pending; // eslint-disable-line react-hooks/refs
68-
}
69-
70-
// Check if this content matches an existing template:
71-
// 1. Exact HTML match (seed templates rendered as-is)
72-
// 2. Source template captured from pending_template (applied templates with modified data)
73-
const matchedTemplate = useMemo(() => {
74-
// First check source template from apply flow
75-
if (sourceRef.current) { // eslint-disable-line react-hooks/refs
76-
const allTemplates = [
77-
...SEED_TEMPLATES,
78-
...((agent.state?.templates as { id: string; name: string }[]) || []),
79-
];
80-
const source = allTemplates.find((t) => t.id === sourceRef.current!.id); // eslint-disable-line react-hooks/refs
81-
if (source) return source;
82-
}
83-
// Then check exact HTML match
84-
if (!html) return null;
85-
const normalise = (s: string) => s.replace(/\s+/g, " ").trim();
86-
const norm = normalise(html);
87-
const allTemplates = [
88-
...SEED_TEMPLATES,
89-
...((agent.state?.templates as { id: string; name: string; html: string }[]) || []),
90-
];
91-
return allTemplates.find((t) => t.html && normalise(t.html) === norm) ?? null;
92-
}, [html, agent.state?.templates]);
93-
94-
const handleSave = useCallback(() => {
95-
const name = templateName.trim() || title || "Untitled Template";
96-
setSaveState("saving");
97-
setMenuOpen(false);
98-
99-
const templates = agent.state?.templates || [];
100-
const newTemplate = {
101-
id: crypto.randomUUID(),
102-
name,
103-
description: description || title || "",
104-
html: html || "",
105-
component_type: componentType,
106-
component_data: componentData || null,
107-
data_description: "",
108-
created_at: new Date().toISOString(),
109-
version: 1,
110-
};
111-
agent.setState({ ...agent.state, templates: [...templates, newTemplate] });
112-
113-
setTemplateName("");
114-
setTimeout(() => {
115-
setSaveState("saved");
116-
setTimeout(() => setSaveState("idle"), 1800);
117-
}, 400);
118-
}, [agent, templateName, title, description, html, componentData, componentType]);
119-
12046
const exportHtml = useMemo(() => {
12147
if (componentType === "widgetRenderer" && html) {
12248
return assembleStandaloneHtml(html, title);
@@ -148,219 +74,85 @@ export function SaveTemplateOverlay({
14874
});
14975
}, [componentType, html, exportHtml]);
15076

151-
// Show the trigger button only when hovered or menu/save-input is active
152-
const showTrigger = ready && (hovered || menuOpen || saveState === "input" || saveState === "saving" || saveState === "saved");
77+
const showTrigger = ready && exportHtml && (hovered || menuOpen);
15378

15479
return (
15580
<div
15681
className="relative"
15782
onMouseEnter={() => setHovered(true)}
15883
onMouseLeave={() => setHovered(false)}
15984
>
160-
{/* Action overlay — hidden until hover */}
16185
<div
16286
className="absolute top-2 right-2 z-10 transition-opacity duration-200"
16387
style={{
16488
opacity: showTrigger ? 1 : 0,
16589
pointerEvents: showTrigger ? "auto" : "none",
16690
}}
16791
>
168-
{/* Saved confirmation */}
169-
{saveState === "saved" && (
170-
<div
171-
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold shadow-md"
172-
style={{
173-
background: "var(--color-background-success, #EAF3DE)",
174-
color: "var(--color-text-success, #3B6D11)",
175-
border: "1px solid var(--color-border-success, #3B6D11)",
176-
animation: "tmpl-pop 0.35s cubic-bezier(.34,1.56,.64,1)",
177-
}}
178-
>
179-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
180-
<path d="M20 6 9 17l-5-5" style={{ strokeDasharray: 24, strokeDashoffset: 24, animation: "tmpl-check 0.4s ease-out 0.1s forwards" }} />
181-
</svg>
182-
Saved!
183-
</div>
184-
)}
185-
186-
{/* Saving spinner */}
187-
{saveState === "saving" && (
188-
<div
189-
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium shadow-md"
92+
<div ref={menuRef} className="relative">
93+
<button
94+
onClick={() => setMenuOpen((v) => !v)}
95+
className="flex items-center justify-center rounded-lg p-1.5 shadow-md transition-all duration-150 hover:scale-105"
19096
style={{
19197
background: "var(--surface-primary, #fff)",
19298
border: "1px solid var(--color-border-glass, rgba(0,0,0,0.1))",
19399
color: "var(--text-secondary, #666)",
194100
}}
101+
title="Options"
195102
>
196-
<div
197-
style={{
198-
width: 12,
199-
height: 12,
200-
borderRadius: "50%",
201-
border: "2px solid var(--color-border-tertiary, rgba(0,0,0,0.15))",
202-
borderTopColor: "var(--color-lilac-dark, #6366f1)",
203-
animation: "spin 0.6s linear infinite",
204-
}}
205-
/>
206-
Saving...
207-
</div>
208-
)}
209-
210-
{/* Name input for save-as-template */}
211-
{saveState === "input" && (
212-
<div
213-
className="flex items-center gap-2 rounded-lg px-3 py-2 shadow-lg"
214-
style={{
215-
background: "var(--surface-primary, #fff)",
216-
border: "1px solid var(--color-border-glass, rgba(0,0,0,0.1))",
217-
animation: "tmpl-slideIn 0.2s ease-out",
218-
}}
219-
>
220-
<input
221-
type="text"
222-
placeholder="Template name..."
223-
value={templateName}
224-
onChange={(e) => setTemplateName(e.target.value)}
225-
onKeyDown={(e) => {
226-
if (e.key === "Enter") handleSave();
227-
if (e.key === "Escape") {
228-
setSaveState("idle");
229-
setTemplateName("");
230-
}
231-
}}
232-
autoFocus
233-
className="text-xs px-2 py-1 rounded-md outline-none"
234-
style={{
235-
width: 140,
236-
background: "var(--color-background-secondary, #f5f5f5)",
237-
color: "var(--text-primary, #1a1a1a)",
238-
border: "1px solid var(--color-border-tertiary, rgba(0,0,0,0.1))",
239-
}}
240-
/>
241-
<button
242-
onClick={handleSave}
243-
className="text-xs px-2 py-1 rounded-md font-medium text-white"
244-
style={{
245-
background: "linear-gradient(135deg, var(--color-lilac-dark, #6366f1), var(--color-mint-dark, #10b981))",
246-
}}
247-
>
248-
Save
249-
</button>
250-
<button
251-
onClick={() => { setSaveState("idle"); setTemplateName(""); }}
252-
className="text-xs px-1.5 py-1 rounded-md"
253-
style={{ color: "var(--text-secondary, #666)" }}
254-
>
255-
Cancel
256-
</button>
257-
</div>
258-
)}
103+
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
104+
<circle cx="5" cy="12" r="1.5" />
105+
<circle cx="12" cy="12" r="1.5" />
106+
<circle cx="19" cy="12" r="1.5" />
107+
</svg>
108+
</button>
259109

260-
{/* Idle: three-dot trigger + dropdown menu */}
261-
{saveState === "idle" && (
262-
<div ref={menuRef} className="relative">
263-
<button
264-
onClick={() => setMenuOpen((v) => !v)}
265-
className="flex items-center justify-center rounded-lg p-1.5 shadow-md transition-all duration-150 hover:scale-105"
110+
{menuOpen && (
111+
<div
112+
className="absolute top-full right-0 mt-1 rounded-lg py-1 shadow-lg min-w-[180px]"
266113
style={{
267114
background: "var(--surface-primary, #fff)",
268115
border: "1px solid var(--color-border-glass, rgba(0,0,0,0.1))",
269-
color: "var(--text-secondary, #666)",
116+
animation: "tmpl-slideIn 0.15s ease-out",
270117
}}
271-
title="Options"
272118
>
273-
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
274-
<circle cx="12" cy="5" r="1.5" />
275-
<circle cx="12" cy="12" r="1.5" />
276-
<circle cx="12" cy="19" r="1.5" />
277-
</svg>
278-
</button>
279-
280-
{menuOpen && (
281-
<div
282-
className="absolute top-full right-0 mt-1 rounded-lg py-1 shadow-lg min-w-[180px]"
283-
style={{
284-
background: "var(--surface-primary, #fff)",
285-
border: "1px solid var(--color-border-glass, rgba(0,0,0,0.1))",
286-
animation: "tmpl-slideIn 0.15s ease-out",
287-
}}
119+
<button
120+
onClick={handleCopy}
121+
className="flex items-center gap-2.5 w-full px-3 py-2 text-xs text-left transition-colors duration-100"
122+
style={{ color: copyState === "copied" ? "var(--color-text-success, #3B6D11)" : "var(--text-primary, #1a1a1a)" }}
123+
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--color-background-secondary, #f5f5f5)")}
124+
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
288125
>
289-
{exportHtml && (
290-
<>
291-
<button
292-
onClick={handleCopy}
293-
className="flex items-center gap-2.5 w-full px-3 py-2 text-xs text-left transition-colors duration-100"
294-
style={{ color: copyState === "copied" ? "var(--color-text-success, #3B6D11)" : "var(--text-primary, #1a1a1a)" }}
295-
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--color-background-secondary, #f5f5f5)")}
296-
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
297-
>
298-
{copyState === "copied" ? (
299-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
300-
<path d="M20 6 9 17l-5-5" />
301-
</svg>
302-
) : (
303-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
304-
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
305-
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
306-
</svg>
307-
)}
308-
{copyState === "copied" ? "Copied!" : "Copy to clipboard"}
309-
</button>
310-
<button
311-
onClick={handleDownload}
312-
className="flex items-center gap-2.5 w-full px-3 py-2 text-xs text-left transition-colors duration-100"
313-
style={{ color: "var(--text-primary, #1a1a1a)" }}
314-
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--color-background-secondary, #f5f5f5)")}
315-
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
316-
>
317-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
318-
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
319-
<polyline points="7 10 12 15 17 10" />
320-
<line x1="12" y1="15" x2="12" y2="3" />
321-
</svg>
322-
Download file
323-
</button>
324-
</>
325-
)}
326-
{!matchedTemplate && (
327-
<button
328-
onClick={() => { setMenuOpen(false); setSaveState("input"); }}
329-
className="flex items-center gap-2.5 w-full px-3 py-2 text-xs text-left transition-colors duration-100"
330-
style={{ color: "var(--text-primary, #1a1a1a)" }}
331-
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--color-background-secondary, #f5f5f5)")}
332-
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
333-
>
334-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
335-
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" />
336-
</svg>
337-
Save as artifact
338-
</button>
126+
{copyState === "copied" ? (
127+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
128+
<path d="M20 6 9 17l-5-5" />
129+
</svg>
130+
) : (
131+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
132+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
133+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
134+
</svg>
339135
)}
340-
</div>
341-
)}
342-
</div>
343-
)}
344-
</div>
345-
346-
{/* Template name badge — shown above widget when matched */}
347-
{saveState === "idle" && matchedTemplate && ready && (
348-
<div className="flex justify-end mb-1">
349-
<div
350-
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium"
351-
style={{
352-
background: "linear-gradient(135deg, rgba(99,102,241,0.10), rgba(16,185,129,0.10))",
353-
border: "1px solid rgba(99,102,241,0.20)",
354-
color: "var(--text-primary, #1a1a1a)",
355-
}}
356-
>
357-
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.55 }}>
358-
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" />
359-
</svg>
360-
{matchedTemplate.name}
361-
</div>
136+
{copyState === "copied" ? "Copied!" : "Copy to clipboard"}
137+
</button>
138+
<button
139+
onClick={handleDownload}
140+
className="flex items-center gap-2.5 w-full px-3 py-2 text-xs text-left transition-colors duration-100"
141+
style={{ color: "var(--text-primary, #1a1a1a)" }}
142+
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--color-background-secondary, #f5f5f5)")}
143+
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
144+
>
145+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
146+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
147+
<polyline points="7 10 12 15 17 10" />
148+
<line x1="12" y1="15" x2="12" y2="3" />
149+
</svg>
150+
Download file
151+
</button>
152+
</div>
153+
)}
362154
</div>
363-
)}
155+
</div>
364156

365157
{children}
366158
</div>

0 commit comments

Comments
 (0)