"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"; import { buildCatalogDisplayData } from "@/lib/render/catalog-display"; const SIMULATION_PROMPT = "Show a team performance dashboard"; interface SimulationStage { tree: Spec; stream: string; } const DASH_STATE = { chartData: [ { label: "Mon", value: 12 }, { label: "Tue", value: 28 }, { label: "Wed", value: 19 }, { label: "Thu", value: 34 }, { label: "Fri", value: 45 }, { label: "Sat", value: 38 }, { label: "Sun", value: 52 }, ], }; const METRIC_REVENUE = { type: "Metric", props: { label: "Weekly Revenue", value: "12,400", prefix: "$", change: "+18%", changeType: "positive", }, } as const; const CHART = { type: "LineGraph", props: { data: { $state: "/chartData" } }, } as const; const SEP = { type: "Separator", props: {} } as const; const PROGRESS_DEALS = { type: "Progress", props: { value: 72, label: "Deals Closed -- 72%" }, } as const; const PROGRESS_RETENTION = { type: "Progress", props: { value: 91, label: "Retention -- 91%" }, } as const; const SIMULATION_STAGES: SimulationStage[] = [ { tree: { root: "card", state: DASH_STATE, elements: { card: { type: "Card", props: { title: "Team Performance", maxWidth: "sm", centered: true }, children: [], }, }, }, stream: '{"op":"add","path":"/root","value":"card"}', }, { tree: { root: "card", state: DASH_STATE, elements: { card: { type: "Card", props: { title: "Team Performance", maxWidth: "sm", centered: true }, children: ["m1"], }, m1: METRIC_REVENUE, }, }, stream: '{"op":"add","path":"/elements/m1","value":{"type":"Metric","props":{"label":"Weekly Revenue","value":"12,400","prefix":"$","change":"+18%","changeType":"positive"}}}', }, { tree: { root: "card", state: DASH_STATE, elements: { card: { type: "Card", props: { title: "Team Performance", maxWidth: "sm", centered: true }, children: ["m1", "chart"], }, m1: METRIC_REVENUE, chart: CHART, }, }, stream: '{"op":"add","path":"/elements/chart","value":{"type":"LineGraph","props":{"data":{"$state":"/chartData"}}}}', }, { tree: { root: "card", state: DASH_STATE, elements: { card: { type: "Card", props: { title: "Team Performance", maxWidth: "sm", centered: true }, children: ["m1", "chart", "sep", "p1"], }, m1: METRIC_REVENUE, chart: CHART, sep: SEP, p1: PROGRESS_DEALS, }, }, stream: '{"op":"add","path":"/elements/p1","value":{"type":"Progress","props":{"value":72,"label":"Deals Closed -- 72%"}}}', }, { tree: { root: "card", state: DASH_STATE, elements: { card: { type: "Card", props: { title: "Team Performance", maxWidth: "sm", centered: true }, children: ["m1", "chart", "sep", "p1", "p2"], }, m1: METRIC_REVENUE, chart: CHART, sep: SEP, p1: PROGRESS_DEALS, p2: PROGRESS_RETENTION, }, }, stream: '{"op":"add","path":"/elements/p2","value":{"type":"Progress","props":{"value":91,"label":"Retention -- 91%"}}}', }, ]; 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 = [ "Recipe card with rating and ingredients", "Order receipt with item list and total", "Team member profile card", "Notification inbox with alerts", ]; 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( () => buildCatalogDisplayData(playgroundCatalog.data), [], ); // 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 && }