demo.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. "use client";
  2. import React, { useEffect, useState, useCallback } from "react";
  3. import { Renderer, useUIStream, JSONUIProvider } from "@json-render/react";
  4. import type { UITree } from "@json-render/core";
  5. import { toast } from "sonner";
  6. import { CodeBlock } from "./code-block";
  7. import { Toaster } from "./ui/sonner";
  8. import { demoRegistry, fallbackComponent, useInteractiveState } from "./demo/index";
  9. const SIMULATION_PROMPT = "Create a contact form with name, email, and message";
  10. interface SimulationStage {
  11. tree: UITree;
  12. stream: string;
  13. }
  14. const SIMULATION_STAGES: SimulationStage[] = [
  15. {
  16. tree: { root: "card", elements: { card: { key: "card", type: "Card", props: { title: "Contact Us", maxWidth: "md" }, children: [] } } },
  17. stream: '{"op":"set","path":"/root","value":"card"}',
  18. },
  19. {
  20. tree: { root: "card", elements: { card: { key: "card", type: "Card", props: { title: "Contact Us", maxWidth: "md" }, children: ["name"] }, name: { key: "name", type: "Input", props: { label: "Name", name: "name" } } } },
  21. stream: '{"op":"add","path":"/elements/card","value":{"key":"card","type":"Card","props":{"title":"Contact Us","maxWidth":"md"},"children":["name"]}}',
  22. },
  23. {
  24. tree: { root: "card", elements: { card: { key: "card", type: "Card", props: { title: "Contact Us", maxWidth: "md" }, children: ["name", "email"] }, name: { key: "name", type: "Input", props: { label: "Name", name: "name" } }, email: { key: "email", type: "Input", props: { label: "Email", name: "email" } } } },
  25. stream: '{"op":"add","path":"/elements/email","value":{"key":"email","type":"Input","props":{"label":"Email","name":"email"}}}',
  26. },
  27. {
  28. tree: { root: "card", elements: { card: { key: "card", type: "Card", props: { title: "Contact Us", maxWidth: "md" }, children: ["name", "email", "message"] }, name: { key: "name", type: "Input", props: { label: "Name", name: "name" } }, email: { key: "email", type: "Input", props: { label: "Email", name: "email" } }, message: { key: "message", type: "Textarea", props: { label: "Message", name: "message" } } } },
  29. stream: '{"op":"add","path":"/elements/message","value":{"key":"message","type":"Textarea","props":{"label":"Message","name":"message"}}}',
  30. },
  31. {
  32. tree: { root: "card", elements: { card: { key: "card", type: "Card", props: { title: "Contact Us", maxWidth: "md" }, children: ["name", "email", "message", "submit"] }, name: { key: "name", type: "Input", props: { label: "Name", name: "name" } }, email: { key: "email", type: "Input", props: { label: "Email", name: "email" } }, message: { key: "message", type: "Textarea", props: { label: "Message", name: "message" } }, submit: { key: "submit", type: "Button", props: { label: "Send Message", variant: "primary" } } } },
  33. stream: '{"op":"add","path":"/elements/submit","value":{"key":"submit","type":"Button","props":{"label":"Send Message","variant":"primary"}}}',
  34. },
  35. ];
  36. const CODE_EXAMPLE = `import { Renderer, useUIStream } from '@json-render/react';
  37. import { registry } from './registry';
  38. function App() {
  39. const { tree, isStreaming, send } = useUIStream({
  40. api: '/api/generate',
  41. });
  42. return (
  43. <Renderer
  44. tree={tree}
  45. registry={registry}
  46. loading={isStreaming}
  47. />
  48. );
  49. }`;
  50. type Mode = "simulation" | "interactive";
  51. type Phase = "typing" | "streaming" | "complete";
  52. type Tab = "stream" | "json" | "code";
  53. export function Demo() {
  54. const [mode, setMode] = useState<Mode>("simulation");
  55. const [phase, setPhase] = useState<Phase>("typing");
  56. const [typedPrompt, setTypedPrompt] = useState("");
  57. const [userPrompt, setUserPrompt] = useState("");
  58. const [stageIndex, setStageIndex] = useState(-1);
  59. const [streamLines, setStreamLines] = useState<string[]>([]);
  60. const [activeTab, setActiveTab] = useState<Tab>("json");
  61. const [simulationTree, setSimulationTree] = useState<UITree | null>(null);
  62. // Use the library's useUIStream hook for real API calls
  63. const {
  64. tree: apiTree,
  65. isStreaming,
  66. send,
  67. clear,
  68. } = useUIStream({
  69. api: "/api/generate",
  70. onError: (err: Error) => console.error("Generation error:", err),
  71. } as Parameters<typeof useUIStream>[0]);
  72. // Initialize interactive state for Select components
  73. useInteractiveState();
  74. const currentSimulationStage = stageIndex >= 0 ? SIMULATION_STAGES[stageIndex] : null;
  75. // Determine which tree to display - keep simulation tree until new API response
  76. const currentTree = mode === "simulation"
  77. ? (currentSimulationStage?.tree || simulationTree)
  78. : (apiTree || simulationTree);
  79. const stopGeneration = useCallback(() => {
  80. if (mode === "simulation") {
  81. setMode("interactive");
  82. setPhase("complete");
  83. setTypedPrompt(SIMULATION_PROMPT);
  84. setUserPrompt("");
  85. }
  86. clear();
  87. }, [mode, clear]);
  88. // Typing effect for simulation
  89. useEffect(() => {
  90. if (mode !== "simulation" || phase !== "typing") return;
  91. let i = 0;
  92. const interval = setInterval(() => {
  93. if (i < SIMULATION_PROMPT.length) {
  94. setTypedPrompt(SIMULATION_PROMPT.slice(0, i + 1));
  95. i++;
  96. } else {
  97. clearInterval(interval);
  98. setTimeout(() => setPhase("streaming"), 500);
  99. }
  100. }, 20);
  101. return () => clearInterval(interval);
  102. }, [mode, phase]);
  103. // Streaming effect for simulation
  104. useEffect(() => {
  105. if (mode !== "simulation" || phase !== "streaming") return;
  106. let i = 0;
  107. const interval = setInterval(() => {
  108. if (i < SIMULATION_STAGES.length) {
  109. const stage = SIMULATION_STAGES[i];
  110. if (stage) {
  111. setStageIndex(i);
  112. setStreamLines((prev) => [...prev, stage.stream]);
  113. setSimulationTree(stage.tree);
  114. }
  115. i++;
  116. } else {
  117. clearInterval(interval);
  118. setTimeout(() => {
  119. setPhase("complete");
  120. setMode("interactive");
  121. setUserPrompt("");
  122. }, 500);
  123. }
  124. }, 600);
  125. return () => clearInterval(interval);
  126. }, [mode, phase]);
  127. // Track stream lines from real API
  128. useEffect(() => {
  129. if (mode === "interactive" && apiTree) {
  130. // Convert tree to stream line for display
  131. const streamLine = JSON.stringify({ tree: apiTree });
  132. if (!streamLines.includes(streamLine) && Object.keys(apiTree.elements).length > 0) {
  133. setStreamLines((prev) => {
  134. const lastLine = prev[prev.length - 1];
  135. if (lastLine !== streamLine) {
  136. return [...prev, streamLine];
  137. }
  138. return prev;
  139. });
  140. }
  141. }
  142. }, [mode, apiTree, streamLines]);
  143. const handleSubmit = useCallback(async () => {
  144. if (!userPrompt.trim() || isStreaming) return;
  145. setStreamLines([]);
  146. await send(userPrompt);
  147. }, [userPrompt, isStreaming, send]);
  148. // Expose action handler for registry components - shows toast with text
  149. useEffect(() => {
  150. (window as unknown as { __demoAction?: (text: string) => void }).__demoAction = (text: string) => {
  151. toast(text);
  152. };
  153. return () => {
  154. delete (window as unknown as { __demoAction?: (text: string) => void }).__demoAction;
  155. };
  156. }, []);
  157. const jsonCode = currentTree ? JSON.stringify(currentTree, null, 2) : "// waiting...";
  158. const isTypingSimulation = mode === "simulation" && phase === "typing";
  159. const isStreamingSimulation = mode === "simulation" && phase === "streaming";
  160. const showLoadingDots = isStreamingSimulation || isStreaming;
  161. return (
  162. <div className="w-full max-w-4xl mx-auto text-left">
  163. {/* Prompt input */}
  164. <div className="mb-6">
  165. <div className="border border-border rounded p-3 bg-background font-mono text-sm min-h-[44px] flex items-center justify-between">
  166. {mode === "simulation" ? (
  167. <div className="flex items-center flex-1">
  168. <span className="inline-flex items-center h-5">{typedPrompt}</span>
  169. {isTypingSimulation && (
  170. <span className="inline-block w-2 h-4 bg-foreground ml-0.5 animate-pulse" />
  171. )}
  172. </div>
  173. ) : (
  174. <form
  175. className="flex items-center flex-1"
  176. onSubmit={(e) => {
  177. e.preventDefault();
  178. handleSubmit();
  179. }}
  180. >
  181. <input
  182. type="text"
  183. value={userPrompt}
  184. onChange={(e) => setUserPrompt(e.target.value)}
  185. placeholder="Describe what you want to build..."
  186. className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground/50"
  187. disabled={isStreaming}
  188. maxLength={140}
  189. autoFocus
  190. />
  191. </form>
  192. )}
  193. {(mode === "simulation" || isStreaming) ? (
  194. <button
  195. onClick={stopGeneration}
  196. className="ml-2 w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
  197. aria-label="Stop"
  198. >
  199. <svg
  200. width="16"
  201. height="16"
  202. viewBox="0 0 24 24"
  203. fill="currentColor"
  204. stroke="none"
  205. >
  206. <rect x="6" y="6" width="12" height="12" />
  207. </svg>
  208. </button>
  209. ) : (
  210. <button
  211. onClick={handleSubmit}
  212. disabled={!userPrompt.trim()}
  213. className="ml-2 w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors disabled:opacity-30"
  214. aria-label="Submit"
  215. >
  216. <svg
  217. width="16"
  218. height="16"
  219. viewBox="0 0 24 24"
  220. fill="none"
  221. stroke="currentColor"
  222. strokeWidth="2"
  223. strokeLinecap="round"
  224. strokeLinejoin="round"
  225. >
  226. <path d="M12 5v14" />
  227. <path d="M19 12l-7 7-7-7" />
  228. </svg>
  229. </button>
  230. )}
  231. </div>
  232. <div className="mt-2 text-xs text-muted-foreground text-center">
  233. Try: &quot;Create a login form&quot; or &quot;Build a feedback form with rating&quot;
  234. </div>
  235. </div>
  236. <div className="grid lg:grid-cols-2 gap-4">
  237. {/* Tabbed code/stream/json panel */}
  238. <div>
  239. <div className="flex gap-4 mb-2">
  240. {(["json", "stream", "code"] as const).map((tab) => (
  241. <button
  242. key={tab}
  243. onClick={() => setActiveTab(tab)}
  244. className={`text-xs font-mono transition-colors ${
  245. activeTab === tab ? "text-foreground" : "text-muted-foreground hover:text-foreground"
  246. }`}
  247. >
  248. {tab}
  249. </button>
  250. ))}
  251. </div>
  252. <div className="border border-border rounded p-3 bg-background font-mono text-xs h-96 overflow-auto text-left">
  253. <div className={activeTab === "stream" ? "" : "hidden"}>
  254. {streamLines.length > 0 ? (
  255. <>
  256. <CodeBlock code={streamLines.join("\n")} lang="json" />
  257. {showLoadingDots && (
  258. <div className="flex gap-1 mt-2">
  259. <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse" />
  260. <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse [animation-delay:75ms]" />
  261. <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse [animation-delay:150ms]" />
  262. </div>
  263. )}
  264. </>
  265. ) : (
  266. <div className="text-muted-foreground/50">
  267. {showLoadingDots ? "streaming..." : "waiting..."}
  268. </div>
  269. )}
  270. </div>
  271. <div className={activeTab === "json" ? "" : "hidden"}>
  272. <CodeBlock code={jsonCode} lang="json" />
  273. </div>
  274. <div className={activeTab === "code" ? "" : "hidden"}>
  275. <CodeBlock code={CODE_EXAMPLE} lang="tsx" />
  276. </div>
  277. </div>
  278. </div>
  279. {/* Rendered output using json-render */}
  280. <div>
  281. <div className="text-xs text-muted-foreground mb-2 font-mono">render</div>
  282. <div className="border border-border rounded p-3 bg-background h-96 overflow-auto">
  283. {currentTree && currentTree.root ? (
  284. <div className="animate-in fade-in duration-200 w-full min-h-full flex items-center justify-center py-4">
  285. <JSONUIProvider registry={demoRegistry as Parameters<typeof JSONUIProvider>[0]['registry']}>
  286. <Renderer
  287. tree={currentTree}
  288. registry={demoRegistry as Parameters<typeof Renderer>[0]['registry']}
  289. loading={isStreaming || isStreamingSimulation}
  290. fallback={fallbackComponent as Parameters<typeof Renderer>[0]['fallback']}
  291. />
  292. </JSONUIProvider>
  293. </div>
  294. ) : (
  295. <div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
  296. {isStreaming ? "generating..." : "waiting..."}
  297. </div>
  298. )}
  299. </div>
  300. <Toaster position="bottom-right" />
  301. </div>
  302. </div>
  303. </div>
  304. );
  305. }