|
1 | 1 | "use client"; |
2 | 2 |
|
3 | | -import { useState, useCallback, useMemo, useRef, type ReactNode } from "react"; |
| 3 | +import { useState, useCallback, useMemo, useRef, useEffect, type ReactNode } from "react"; |
4 | 4 | import { useAgent } from "@copilotkit/react-core/v2"; |
5 | 5 | import { SEED_TEMPLATES } from "@/components/template-library/seed-templates"; |
6 | 6 | import { |
@@ -41,6 +41,21 @@ export function SaveTemplateOverlay({ |
41 | 41 | const [saveState, setSaveState] = useState<SaveState>("idle"); |
42 | 42 | const [templateName, setTemplateName] = useState(""); |
43 | 43 | 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]); |
44 | 59 |
|
45 | 60 | // Capture pending_template at mount time — it may be cleared by the agent later. |
46 | 61 | // Uses ref (not state) to avoid an async re-render that would shift sibling positions |
@@ -79,6 +94,7 @@ export function SaveTemplateOverlay({ |
79 | 94 | const handleSave = useCallback(() => { |
80 | 95 | const name = templateName.trim() || title || "Untitled Template"; |
81 | 96 | setSaveState("saving"); |
| 97 | + setMenuOpen(false); |
82 | 98 |
|
83 | 99 | const templates = agent.state?.templates || []; |
84 | 100 | const newTemplate = { |
@@ -119,25 +135,34 @@ export function SaveTemplateOverlay({ |
119 | 135 | if (!exportHtml) return; |
120 | 136 | const filename = `${slugify(title) || "visualization"}.html`; |
121 | 137 | triggerDownload(exportHtml, filename); |
| 138 | + setMenuOpen(false); |
122 | 139 | }, [exportHtml, title]); |
123 | 140 |
|
124 | 141 | const handleCopy = useCallback(() => { |
125 | 142 | const textToCopy = componentType === "widgetRenderer" ? html : exportHtml; |
126 | 143 | if (!textToCopy) return; |
127 | 144 | navigator.clipboard.writeText(textToCopy).then(() => { |
128 | 145 | setCopyState("copied"); |
| 146 | + setMenuOpen(false); |
129 | 147 | setTimeout(() => setCopyState("idle"), 1800); |
130 | 148 | }); |
131 | 149 | }, [componentType, html, exportHtml]); |
132 | 150 |
|
| 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 | + |
133 | 154 | 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 */} |
136 | 161 | <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" |
138 | 163 | style={{ |
139 | | - opacity: ready ? 1 : 0, |
140 | | - pointerEvents: ready ? "auto" : "none", |
| 164 | + opacity: showTrigger ? 1 : 0, |
| 165 | + pointerEvents: showTrigger ? "auto" : "none", |
141 | 166 | }} |
142 | 167 | > |
143 | 168 | {/* Saved confirmation */} |
@@ -182,7 +207,7 @@ export function SaveTemplateOverlay({ |
182 | 207 | </div> |
183 | 208 | )} |
184 | 209 |
|
185 | | - {/* Name input */} |
| 210 | + {/* Name input for save-as-template */} |
186 | 211 | {saveState === "input" && ( |
187 | 212 | <div |
188 | 213 | className="flex items-center gap-2 rounded-lg px-3 py-2 shadow-lg" |
@@ -232,108 +257,88 @@ export function SaveTemplateOverlay({ |
232 | 257 | </div> |
233 | 258 | )} |
234 | 259 |
|
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"> |
259 | 263 | <button |
260 | | - onClick={handleDownload} |
| 264 | + onClick={() => setMenuOpen((v) => !v)} |
261 | 265 | className="flex items-center justify-center rounded-lg p-1.5 shadow-md transition-all duration-150 hover:scale-105" |
262 | 266 | style={{ |
263 | 267 | background: "var(--surface-primary, #fff)", |
264 | 268 | border: "1px solid var(--color-border-glass, rgba(0,0,0,0.1))", |
265 | 269 | color: "var(--text-secondary, #666)", |
266 | 270 | }} |
267 | | - title="Download as HTML" |
| 271 | + title="Options" |
268 | 272 | > |
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" /> |
273 | 277 | </svg> |
274 | 278 | </button> |
275 | | - </div> |
276 | | - )} |
277 | 279 |
|
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 | + > |
298 | 334 | <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" /> |
301 | 336 | </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> |
321 | 341 | )} |
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> |
337 | 342 | </div> |
338 | 343 | )} |
339 | 344 | </div> |
|
0 commit comments