"use client"; import React, { useEffect, useState, useCallback, useRef, useMemo, } from "react"; import { useUIStream } from "@json-render/react"; import type { Spec } from "@json-render/core"; import { collectUsedComponents, serializeProps } from "@json-render/codegen"; import { toast } from "sonner"; import { CodeBlock } from "./code-block"; import { CopyButton } from "./copy-button"; import { Toaster } from "./ui/sonner"; import { PlaygroundRenderer } from "@/lib/render/renderer"; import { playgroundCatalog } from "@/lib/render/catalog"; const SIMULATION_PROMPT = "Create a contact form with name, email, and message"; interface SimulationStage { tree: Spec; stream: string; } const SIMULATION_STAGES: SimulationStage[] = [ { tree: { root: "card", elements: { card: { type: "Card", props: { title: "Contact Us", maxWidth: "md" }, children: [], }, }, }, stream: '{"op":"add","path":"/root","value":"card"}', }, { tree: { root: "card", elements: { card: { type: "Card", props: { title: "Contact Us", maxWidth: "md" }, children: ["name"], }, name: { type: "Input", props: { label: "Name", name: "name" }, }, }, }, stream: '{"op":"add","path":"/elements/card","value":{"type":"Card","props":{"title":"Contact Us","maxWidth":"md"},"children":["name"]}}', }, { tree: { root: "card", elements: { card: { type: "Card", props: { title: "Contact Us", maxWidth: "md" }, children: ["name", "email"], }, name: { type: "Input", props: { label: "Name", name: "name" }, }, email: { type: "Input", props: { label: "Email", name: "email" }, }, }, }, stream: '{"op":"add","path":"/elements/email","value":{"type":"Input","props":{"label":"Email","name":"email"}}}', }, { tree: { root: "card", elements: { card: { type: "Card", props: { title: "Contact Us", maxWidth: "md" }, children: ["name", "email", "message"], }, name: { type: "Input", props: { label: "Name", name: "name" }, }, email: { type: "Input", props: { label: "Email", name: "email" }, }, message: { type: "Textarea", props: { label: "Message", name: "message" }, }, }, }, stream: '{"op":"add","path":"/elements/message","value":{"type":"Textarea","props":{"label":"Message","name":"message"}}}', }, { tree: { root: "card", elements: { card: { type: "Card", props: { title: "Contact Us", maxWidth: "md" }, children: ["name", "email", "message", "submit"], }, name: { type: "Input", props: { label: "Name", name: "name" }, }, email: { type: "Input", props: { label: "Email", name: "email" }, }, message: { type: "Textarea", props: { label: "Message", name: "message" }, }, submit: { type: "Button", props: { label: "Send Message", variant: "primary" }, }, }, }, stream: '{"op":"add","path":"/elements/submit","value":{"type":"Button","props":{"label":"Send Message","variant":"primary"}}}', }, ]; type Mode = "simulation" | "interactive"; type Phase = "typing" | "streaming" | "complete"; type Tab = "stream" | "json" | "nested" | "catalog"; type RenderView = "dynamic" | "static"; interface DemoProps { fullscreen?: boolean; skipSimulation?: boolean; } /** * Convert a flat Spec into a nested tree structure that is easier for humans * to read. Children keys are resolved recursively into inline objects. */ function specToNested(spec: Spec): Record { function resolve(key: string): Record { const el = spec.elements[key]; if (!el) return { _key: key, _missing: true }; const node: Record = { type: el.type }; if (el.props && Object.keys(el.props).length > 0) { node.props = el.props; } if (el.visible !== undefined) { node.visible = el.visible; } if (el.on && Object.keys(el.on).length > 0) { node.on = el.on; } if (el.repeat) { node.repeat = el.repeat; } if (el.children && el.children.length > 0) { node.children = el.children.map(resolve); } return node; } const result: Record = {}; if (spec.state && Object.keys(spec.state).length > 0) { result.state = spec.state; } result.elements = resolve(spec.root); return result; } const EXAMPLE_PROMPTS = [ "Create a login form with email and password", "Build a feedback form with rating stars", "Design a contact card with avatar", "Make a settings panel with toggles", ]; export function Demo({ fullscreen = false, skipSimulation = false, }: DemoProps) { const [mode, setMode] = useState( skipSimulation ? "interactive" : "simulation", ); const [phase, setPhase] = useState( skipSimulation ? "complete" : "typing", ); const [typedPrompt, setTypedPrompt] = useState(""); const [userPrompt, setUserPrompt] = useState(""); const [stageIndex, setStageIndex] = useState(-1); const [streamLines, setStreamLines] = useState([]); const [activeTab, setActiveTab] = useState("json"); const [renderView, setRenderView] = useState("dynamic"); const [simulationTree, setSimulationTree] = useState(null); const [isFullscreen, setIsFullscreen] = useState(false); const [showExportModal, setShowExportModal] = useState(false); const [selectedExportFile, setSelectedExportFile] = useState( null, ); const [showMobileFileTree, setShowMobileFileTree] = useState(false); const [collapsedFolders, setCollapsedFolders] = useState>( new Set(), ); const inputRef = useRef(null); const [catalogSection, setCatalogSection] = useState< "components" | "actions" >("components"); // Catalog data for the catalog tab const catalogData = useMemo(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const raw = playgroundCatalog.data as any; function extractFields(zodObj: unknown): { name: string; type: string }[] { if (!zodObj) return []; try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const obj = zodObj as any; const shape = typeof obj.shape === "object" ? obj.shape : typeof obj._def?.shape === "function" ? obj._def.shape() : typeof obj._def?.shape === "object" ? obj._def.shape : null; if (!shape) return []; return Object.entries(shape).map(([name, schema]) => { let type = "unknown"; try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const s = schema as any; const typeName: string = s?._zod?.def?.type ?? s?._def?.typeName ?? ""; if (typeName.includes("string")) type = "string"; else if (typeName.includes("number")) type = "number"; else if (typeName.includes("boolean")) type = "boolean"; else if (typeName.includes("array")) type = "array"; else if (typeName.includes("enum")) { const values = s?._zod?.def?.values ?? s?._def?.values; type = Array.isArray(values) ? values.join(" | ") : "enum"; } else if (typeName.includes("union")) type = "union"; else if (typeName.includes("nullable")) { const inner = s?._zod?.def?.innerType ?? s?._def?.innerType; const innerName: string = inner?._zod?.def?.type ?? inner?._def?.typeName ?? ""; if (innerName.includes("string")) type = "string?"; else if (innerName.includes("number")) type = "number?"; else if (innerName.includes("boolean")) type = "boolean?"; else if (innerName.includes("array")) type = "array?"; else if (innerName.includes("enum")) { const values = inner?._zod?.def?.values ?? inner?._def?.values; type = Array.isArray(values) ? `(${values.join(" | ")})?` : "enum?"; } else type = "optional"; } } catch { // ignore } return { name, type }; }); } catch { return []; } } const components = Object.entries(raw.components ?? {}) // eslint-disable-next-line @typescript-eslint/no-explicit-any .map(([name, def]: [string, any]) => ({ name, description: (def.description as string) ?? "", props: extractFields(def.props), slots: (def.slots as string[]) ?? [], events: (def.events as string[]) ?? [], })) .sort((a, b) => a.name.localeCompare(b.name)); const actions = Object.entries(raw.actions ?? {}) // eslint-disable-next-line @typescript-eslint/no-explicit-any .map(([name, def]: [string, any]) => ({ name, description: (def.description as string) ?? "", params: extractFields(def.params), })) .sort((a, b) => a.name.localeCompare(b.name)); return { components, actions }; }, []); // Disable body scroll when any modal is open useEffect(() => { if (isFullscreen || showExportModal) { document.body.style.overflow = "hidden"; } else { document.body.style.overflow = ""; } return () => { document.body.style.overflow = ""; }; }, [isFullscreen, showExportModal]); // Use the library's useUIStream hook for real API calls const { spec: apiSpec, isStreaming, send, clear, rawLines: apiRawLines, } = useUIStream({ api: "/api/generate", onError: (err: Error) => { console.error("Generation error:", err); toast.error(err.message || "Generation failed. Please try again."); }, } as Parameters[0]); const currentSimulationStage = stageIndex >= 0 ? SIMULATION_STAGES[stageIndex] : null; // Determine which tree to display - keep simulation tree until new API response const currentTree = mode === "simulation" ? currentSimulationStage?.tree || simulationTree : apiSpec || simulationTree; const stopGeneration = useCallback(() => { if (mode === "simulation") { setMode("interactive"); setPhase("complete"); setTypedPrompt(SIMULATION_PROMPT); setUserPrompt(""); } clear(); }, [mode, clear]); // Typing effect for simulation useEffect(() => { if (mode !== "simulation" || phase !== "typing") return; let i = 0; const interval = setInterval(() => { if (i < SIMULATION_PROMPT.length) { setTypedPrompt(SIMULATION_PROMPT.slice(0, i + 1)); i++; } else { clearInterval(interval); setTimeout(() => setPhase("streaming"), 500); } }, 20); return () => clearInterval(interval); }, [mode, phase]); // Streaming effect for simulation useEffect(() => { if (mode !== "simulation" || phase !== "streaming") return; let i = 0; const interval = setInterval(() => { if (i < SIMULATION_STAGES.length) { const stage = SIMULATION_STAGES[i]; if (stage) { setStageIndex(i); setStreamLines((prev) => [...prev, stage.stream]); setSimulationTree(stage.tree); } i++; } else { clearInterval(interval); setTimeout(() => { setPhase("complete"); setMode("interactive"); setUserPrompt(""); }, 500); } }, 600); return () => clearInterval(interval); }, [mode, phase]); // Track stream lines from real API (use raw JSONL patch lines) useEffect(() => { if (mode === "interactive" && apiRawLines.length > 0) { setStreamLines(apiRawLines); } }, [mode, apiRawLines]); const handleSubmit = useCallback(async () => { if (!userPrompt.trim() || isStreaming) return; setStreamLines([]); await send(userPrompt); }, [userPrompt, isStreaming, send]); const jsonCode = currentTree ? JSON.stringify(currentTree, null, 2) : "// waiting..."; const nestedCode = useMemo(() => { if (!currentTree || !currentTree.root) return "// waiting..."; return JSON.stringify(specToNested(currentTree), null, 2); }, [currentTree]); // Generate all export files for Next.js project const exportedFiles = useMemo(() => { if (!currentTree || !currentTree.root) { return []; } const tree = currentTree; const components = collectUsedComponents(tree); const files: { path: string; content: string }[] = []; // Helper to generate JSX function generateJSX(key: string, indent: number): string { const element = tree.elements[key]; if (!element) return ""; const spaces = " ".repeat(indent); const componentName = element.type; const propsObj: Record = {}; for (const [k, v] of Object.entries(element.props)) { if (v !== null && v !== undefined) { propsObj[k] = v; } } const propsStr = serializeProps(propsObj); const hasChildren = element.children && element.children.length > 0; if (!hasChildren) { return propsStr ? `${spaces}<${componentName} ${propsStr} />` : `${spaces}<${componentName} />`; } const lines: string[] = []; lines.push( propsStr ? `${spaces}<${componentName} ${propsStr}>` : `${spaces}<${componentName}>`, ); for (const childKey of element.children!) { lines.push(generateJSX(childKey, indent + 1)); } lines.push(`${spaces}`); return lines.join("\n"); } // 1. package.json files.push({ path: "package.json", content: JSON.stringify( { name: "generated-app", version: "0.1.0", private: true, scripts: { dev: "next dev", build: "next build", start: "next start", }, dependencies: { next: "^16.1.3", react: "^19.2.3", "react-dom": "^19.2.3", }, devDependencies: { "@types/node": "^25.0.9", "@types/react": "^19.2.8", typescript: "^5.9.3", }, }, null, 2, ), }); // 2. tsconfig.json files.push({ path: "tsconfig.json", content: JSON.stringify( { compilerOptions: { target: "ES2017", lib: ["dom", "dom.iterable", "esnext"], allowJs: true, skipLibCheck: true, strict: true, noEmit: true, esModuleInterop: true, module: "esnext", moduleResolution: "bundler", resolveJsonModule: true, isolatedModules: true, jsx: "preserve", incremental: true, plugins: [{ name: "next" }], paths: { "@/*": ["./*"] }, }, include: ["next-env.d.ts", "**/*.ts", "**/*.tsx"], exclude: ["node_modules"], }, null, 2, ), }); // 3. next.config.js files.push({ path: "next.config.js", content: `/** @type {import('next').NextConfig} */ module.exports = { reactStrictMode: true, }; `, }); // 4. app/globals.css files.push({ path: "app/globals.css", content: `@tailwind base; @tailwind components; @tailwind utilities; :root { --background: #ffffff; --foreground: #171717; --border: #e5e5e5; --muted-foreground: #737373; } @media (prefers-color-scheme: dark) { :root { --background: #0a0a0a; --foreground: #ededed; --border: #262626; --muted-foreground: #a3a3a3; } } body { background: var(--background); color: var(--foreground); font-family: system-ui, sans-serif; } `, }); // 5. tailwind.config.js files.push({ path: "tailwind.config.js", content: `/** @type {import('tailwindcss').Config} */ module.exports = { content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"], theme: { extend: { colors: { background: "var(--background)", foreground: "var(--foreground)", border: "var(--border)", "muted-foreground": "var(--muted-foreground)", }, }, }, plugins: [], }; `, }); // 6. app/layout.tsx files.push({ path: "app/layout.tsx", content: `import type { Metadata } from "next"; import "./globals.css"; export const metadata: Metadata = { title: "Generated App", }; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( {children} ); } `, }); // 7. Component files const componentTemplates: Record = { Card: `"use client"; import { ReactNode } from "react"; interface CardProps { title?: string; description?: string; maxWidth?: "sm" | "md" | "lg"; children?: ReactNode; } export function Card({ title, description, maxWidth, children }: CardProps) { const widthClass = maxWidth === "sm" ? "max-w-xs" : maxWidth === "md" ? "max-w-sm" : maxWidth === "lg" ? "max-w-md" : "w-full"; return (
{title &&
{title}
} {description &&
{description}
}
{children}
); } `, Input: `"use client"; interface InputProps { label?: string; name?: string; type?: string; placeholder?: string; } export function Input({ label, name, type = "text", placeholder }: InputProps) { return (
{label && }
); } `, Textarea: `"use client"; interface TextareaProps { label?: string; name?: string; placeholder?: string; rows?: number; } export function Textarea({ label, name, placeholder, rows = 3 }: TextareaProps) { return (
{label && }