11"use client" ;
22
33import { 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" ;
64import {
75 assembleStandaloneHtml ,
86 chartToStandaloneHtml ,
97 triggerDownload ,
108 slugify ,
119} from "./export-utils" ;
1210
13- type SaveState = "idle" | "input" | "saving" | "saved" ;
14-
1511interface 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
3121export 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