|
| 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