Skip to content

Commit 608a2d3

Browse files
committed
refactor: replace action buttons with three-dot dropdown menu
- Single three-dot (...) trigger button in top-right corner - Dropdown menu with: Copy to clipboard, Download file, Save as artifact - Overlay only visible on hover (hides when cursor leaves component) - Menu stays open while interacting, closes on outside click
1 parent 6b1efa5 commit 608a2d3

1 file changed

Lines changed: 100 additions & 95 deletions

File tree

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

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

3-
import { useState, useCallback, useMemo, useRef, type ReactNode } from "react";
3+
import { useState, useCallback, useMemo, useRef, useEffect, type ReactNode } from "react";
44
import { useAgent } from "@copilotkit/react-core/v2";
55
import { SEED_TEMPLATES } from "@/components/template-library/seed-templates";
66
import {
@@ -41,6 +41,21 @@ export function SaveTemplateOverlay({
4141
const [saveState, setSaveState] = useState<SaveState>("idle");
4242
const [templateName, setTemplateName] = useState("");
4343
const [copyState, setCopyState] = useState<"idle" | "copied">("idle");
44+
const [menuOpen, setMenuOpen] = useState(false);
45+
const [hovered, setHovered] = useState(false);
46+
const menuRef = useRef<HTMLDivElement>(null);
47+
48+
// Close menu on outside click
49+
useEffect(() => {
50+
if (!menuOpen) return;
51+
const handleClick = (e: MouseEvent) => {
52+
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
53+
setMenuOpen(false);
54+
}
55+
};
56+
document.addEventListener("mousedown", handleClick);
57+
return () => document.removeEventListener("mousedown", handleClick);
58+
}, [menuOpen]);
4459

4560
// Capture pending_template at mount time — it may be cleared by the agent later.
4661
// Uses ref (not state) to avoid an async re-render that would shift sibling positions
@@ -79,6 +94,7 @@ export function SaveTemplateOverlay({
7994
const handleSave = useCallback(() => {
8095
const name = templateName.trim() || title || "Untitled Template";
8196
setSaveState("saving");
97+
setMenuOpen(false);
8298

8399
const templates = agent.state?.templates || [];
84100
const newTemplate = {
@@ -119,25 +135,34 @@ export function SaveTemplateOverlay({
119135
if (!exportHtml) return;
120136
const filename = `${slugify(title) || "visualization"}.html`;
121137
triggerDownload(exportHtml, filename);
138+
setMenuOpen(false);
122139
}, [exportHtml, title]);
123140

124141
const handleCopy = useCallback(() => {
125142
const textToCopy = componentType === "widgetRenderer" ? html : exportHtml;
126143
if (!textToCopy) return;
127144
navigator.clipboard.writeText(textToCopy).then(() => {
128145
setCopyState("copied");
146+
setMenuOpen(false);
129147
setTimeout(() => setCopyState("idle"), 1800);
130148
});
131149
}, [componentType, html, exportHtml]);
132150

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");
153+
133154
return (
134-
<div className="relative">
135-
{/* Save as Template button — hidden until content is ready */}
155+
<div
156+
className="relative"
157+
onMouseEnter={() => setHovered(true)}
158+
onMouseLeave={() => setHovered(false)}
159+
>
160+
{/* Action overlay — hidden until hover */}
136161
<div
137-
className="absolute top-2 right-2 z-10 transition-opacity duration-500"
162+
className="absolute top-2 right-2 z-10 transition-opacity duration-200"
138163
style={{
139-
opacity: ready ? 1 : 0,
140-
pointerEvents: ready ? "auto" : "none",
164+
opacity: showTrigger ? 1 : 0,
165+
pointerEvents: showTrigger ? "auto" : "none",
141166
}}
142167
>
143168
{/* Saved confirmation */}
@@ -182,7 +207,7 @@ export function SaveTemplateOverlay({
182207
</div>
183208
)}
184209

185-
{/* Name input */}
210+
{/* Name input for save-as-template */}
186211
{saveState === "input" && (
187212
<div
188213
className="flex items-center gap-2 rounded-lg px-3 py-2 shadow-lg"
@@ -232,108 +257,88 @@ export function SaveTemplateOverlay({
232257
</div>
233258
)}
234259

235-
{/* Idle with matched template: show download/copy only */}
236-
{saveState === "idle" && matchedTemplate && exportHtml && (
237-
<div className="flex items-center gap-1.5">
238-
<button
239-
onClick={handleCopy}
240-
className="flex items-center justify-center rounded-lg p-1.5 shadow-md transition-all duration-150 hover:scale-105"
241-
style={{
242-
background: "var(--surface-primary, #fff)",
243-
border: "1px solid var(--color-border-glass, rgba(0,0,0,0.1))",
244-
color: copyState === "copied" ? "var(--color-text-success, #3B6D11)" : "var(--text-secondary, #666)",
245-
}}
246-
title={copyState === "copied" ? "Copied!" : "Copy code"}
247-
>
248-
{copyState === "copied" ? (
249-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
250-
<path d="M20 6 9 17l-5-5" />
251-
</svg>
252-
) : (
253-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
254-
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
255-
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
256-
</svg>
257-
)}
258-
</button>
260+
{/* Idle: three-dot trigger + dropdown menu */}
261+
{saveState === "idle" && (
262+
<div ref={menuRef} className="relative">
259263
<button
260-
onClick={handleDownload}
264+
onClick={() => setMenuOpen((v) => !v)}
261265
className="flex items-center justify-center rounded-lg p-1.5 shadow-md transition-all duration-150 hover:scale-105"
262266
style={{
263267
background: "var(--surface-primary, #fff)",
264268
border: "1px solid var(--color-border-glass, rgba(0,0,0,0.1))",
265269
color: "var(--text-secondary, #666)",
266270
}}
267-
title="Download as HTML"
271+
title="Options"
268272
>
269-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
270-
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
271-
<polyline points="7 10 12 15 17 10" />
272-
<line x1="12" y1="15" x2="12" y2="3" />
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" />
273277
</svg>
274278
</button>
275-
</div>
276-
)}
277279

278-
{/* Idle without matched template: show all action buttons */}
279-
{saveState === "idle" && !matchedTemplate && (
280-
<div className="flex items-center gap-1.5">
281-
{exportHtml && (
282-
<>
283-
<button
284-
onClick={handleCopy}
285-
className="flex items-center justify-center rounded-lg p-1.5 shadow-md transition-all duration-150 hover:scale-105"
286-
style={{
287-
background: "var(--surface-primary, #fff)",
288-
border: "1px solid var(--color-border-glass, rgba(0,0,0,0.1))",
289-
color: copyState === "copied" ? "var(--color-text-success, #3B6D11)" : "var(--text-secondary, #666)",
290-
}}
291-
title={copyState === "copied" ? "Copied!" : "Copy code"}
292-
>
293-
{copyState === "copied" ? (
294-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
295-
<path d="M20 6 9 17l-5-5" />
296-
</svg>
297-
) : (
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+
}}
288+
>
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+
>
298334
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
299-
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
300-
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
335+
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" />
301336
</svg>
302-
)}
303-
</button>
304-
<button
305-
onClick={handleDownload}
306-
className="flex items-center justify-center rounded-lg p-1.5 shadow-md transition-all duration-150 hover:scale-105"
307-
style={{
308-
background: "var(--surface-primary, #fff)",
309-
border: "1px solid var(--color-border-glass, rgba(0,0,0,0.1))",
310-
color: "var(--text-secondary, #666)",
311-
}}
312-
title="Download as HTML"
313-
>
314-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
315-
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
316-
<polyline points="7 10 12 15 17 10" />
317-
<line x1="12" y1="15" x2="12" y2="3" />
318-
</svg>
319-
</button>
320-
</>
337+
Save as artifact
338+
</button>
339+
)}
340+
</div>
321341
)}
322-
<button
323-
onClick={() => setSaveState("input")}
324-
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"
325-
style={{
326-
background: "var(--surface-primary, #fff)",
327-
border: "1px solid var(--color-border-glass, rgba(0,0,0,0.1))",
328-
color: "var(--text-secondary, #666)",
329-
}}
330-
title="Save as Template"
331-
>
332-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
333-
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" />
334-
</svg>
335-
Save as Template
336-
</button>
337342
</div>
338343
)}
339344
</div>

0 commit comments

Comments
 (0)