"use client"; import React, { useState, useCallback, useRef, useEffect } from "react"; import { toast } from "sonner"; import { Check, Code, Copy, GripVertical, Pencil, Trash2, X, } from "lucide-react"; import { useUIStream, type Spec } from "@json-render/react"; import { DashboardRenderer } from "@/lib/render/renderer"; import { executeAction } from "@/lib/render/registry"; import { CodeHighlight } from "@/components/code-highlight"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card, CardContent } from "@/components/ui/card"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { AnimatedBorder } from "@/components/ui/animated-border"; const suggestions = [ "Customer list with delete", "Create customer form", "Invoice summary", "Revenue metrics", ]; interface WidgetProps { id?: string; initialPrompt?: string; initialSpec?: Spec; onGenerated?: () => void; onCleared?: () => void; onDeleted?: () => void; onSaved?: (id: string) => void; dragHandleProps?: React.HTMLAttributes; } export function Widget({ id: initialId, initialPrompt, initialSpec, onGenerated, onCleared, onDeleted, onSaved, dragHandleProps, }: WidgetProps): React.ReactElement { const [prompt, setPrompt] = useState(initialPrompt || ""); const [data, setData] = useState>({}); const [widgetId, setWidgetId] = useState(initialId); const [isEditing, setIsEditing] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showCode, setShowCode] = useState(false); const [copied, setCopied] = useState(false); const [editPrompt, setEditPrompt] = useState(""); const promptRef = useRef(prompt); const dataRef = useRef(data); const editInputRef = useRef(null); const promptInputRef = useRef(null); // Keep dataRef in sync useEffect(() => { dataRef.current = data; }, [data]); // Auto-focus prompt input for new widgets useEffect(() => { if (!initialSpec && promptInputRef.current) { promptInputRef.current.focus(); } }, [initialSpec]); const { spec, isStreaming, error, send, clear } = useUIStream({ api: "/api/generate", onError: (err) => console.error("Widget generation error:", err), onComplete: async (completedSpec) => { // Save to database when generation completes const currentPrompt = promptRef.current; if (completedSpec && currentPrompt) { try { if (widgetId) { // Update existing widget await fetch(`/api/v1/widgets/${widgetId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt: currentPrompt, spec: completedSpec, }), }); } else { // Create new widget const res = await fetch("/api/v1/widgets", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt: currentPrompt, spec: completedSpec, }), }); const saved = await res.json(); setWidgetId(saved.id); onSaved?.(saved.id); } } catch (err) { console.error("Failed to save widget:", err); } } }, }); // Keep promptRef in sync useEffect(() => { promptRef.current = prompt; }, [prompt]); const handleGenerate = useCallback( async (text?: string) => { const p = text || prompt; if (!p.trim()) return; if (text) setPrompt(text); await send(p, { data }); onGenerated?.(); }, [prompt, send, data, onGenerated], ); // eslint-disable-next-line @typescript-eslint/no-unused-vars const handleClear = useCallback(() => { clear(); setPrompt(""); setData({}); onCleared?.(); }, [clear, onCleared]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleGenerate(); } }, [handleGenerate], ); const handleEdit = useCallback(() => { setEditPrompt(""); setIsEditing(true); setTimeout(() => editInputRef.current?.focus(), 0); }, []); const handleEditSubmit = useCallback(async () => { if (editPrompt.trim()) { setIsEditing(false); // Pass the current spec so AI can modify it instead of replacing const existingSpec = spec || initialSpec; await send(editPrompt, { data, previousSpec: existingSpec }); onGenerated?.(); } }, [editPrompt, send, data, spec, initialSpec, onGenerated]); const handleEditKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleEditSubmit(); } else if (e.key === "Escape") { setIsEditing(false); } }, [handleEditSubmit], ); const handleDelete = useCallback(async () => { if (widgetId) { try { await fetch(`/api/v1/widgets/${widgetId}`, { method: "DELETE" }); toast.success("Widget deleted"); } catch (err) { console.error("Failed to delete widget:", err); toast.error("Failed to delete widget"); } } setShowDeleteConfirm(false); onDeleted?.(); }, [widgetId, onDeleted]); const handleDataChange = useCallback((path: string, value: unknown) => { setData((prev) => { const next = { ...prev }; // Convert path like "customerForm/name" to nested object const parts = path.split("/"); let current: Record = next; for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]!; if (!(part in current) || typeof current[part] !== "object") { current[part] = {}; } current = current[part] as Record; } const lastPart = parts[parts.length - 1]!; current[lastPart] = value; return next; }); }, []); // Use spec from stream, or initial spec for saved widgets const currentSpec = spec || initialSpec; // Auto-run initial actions when spec loads (for saved widgets) useEffect(() => { if (!currentSpec || isStreaming) return; // Check for initialActions in spec metadata const specWithMeta = currentSpec as Spec & { initialActions?: Array<{ action: string; params?: Record; }>; }; if (specWithMeta.initialActions) { specWithMeta.initialActions.forEach(({ action, params }) => { executeAction(action, params, setData); }); return; } // Auto-detect: find Tables/Charts and run their data-fetching actions const elements = currentSpec.elements || {}; const dataComponents = ["Table", "BarChart", "LineChart"]; const actionsRun = new Set(); for (const el of Object.values(elements)) { const element = el as { type: string; props?: Record }; if (dataComponents.includes(element.type) && element.props?.dataPath) { const dataPath = element.props.dataPath as string; // Extract resource name (e.g., "customers" from "customers.data") const resource = dataPath.split(".")[0]; if (!resource || actionsRun.has(resource)) continue; // Find a button that loads this data for (const btnEl of Object.values(elements)) { const btn = btnEl as { type: string; props?: Record; }; if (btn.type === "Button" && btn.props?.action) { const action = btn.props.action as string; if ( action.toLowerCase().includes(resource) && (action.startsWith("view") || action.startsWith("refresh") || action.startsWith("load")) ) { // Run this action with its params executeAction( action, btn.props.actionParams as Record, setData, ); actionsRun.add(resource); break; } } } } } }, [currentSpec, isStreaming]); const hasContent = currentSpec && Object.keys(currentSpec.elements).length > 0; // Title derived from prompt const title = prompt.trim() || "New Widget"; const handleCopyCode = async () => { if (currentSpec) { await navigator.clipboard.writeText(JSON.stringify(currentSpec, null, 2)); setCopied(true); setTimeout(() => setCopied(false), 2000); } }; return ( {isStreaming && } {/* Title bar */}
{dragHandleProps && ( )} {title}
{/* Hover actions */} {hasContent && (
)}
{/* Content area */} {error ? (
{error.message}
) : !hasContent && !isStreaming ? (

Try one of these:

{suggestions.map((s) => ( ))}
) : currentSpec ? ( showCode ? (
) : ( ) ) : null} {/* Edit overlay */} {isEditing && (
setEditPrompt(e.target.value)} onKeyDown={handleEditKeyDown} placeholder="Update widget prompt..." className="flex-1 text-sm" autoFocus />
)}
{/* Prompt input at bottom - only show for new widgets */} {!hasContent && (
setPrompt(e.target.value)} onKeyDown={handleKeyDown} placeholder="Describe this widget..." disabled={isStreaming} className="flex-1 text-sm" />
)} {/* Delete confirmation dialog */} Delete widget Are you sure you want to delete this widget? This action cannot be undone.
); }