demo.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  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. const [isFullscreen, setIsFullscreen] = useState(false);
  63. // Use the library's useUIStream hook for real API calls
  64. const {
  65. tree: apiTree,
  66. isStreaming,
  67. send,
  68. clear,
  69. } = useUIStream({
  70. api: "/api/generate",
  71. onError: (err: Error) => console.error("Generation error:", err),
  72. } as Parameters<typeof useUIStream>[0]);
  73. // Initialize interactive state for Select components
  74. useInteractiveState();
  75. const currentSimulationStage = stageIndex >= 0 ? SIMULATION_STAGES[stageIndex] : null;
  76. // Determine which tree to display - keep simulation tree until new API response
  77. const currentTree = mode === "simulation"
  78. ? (currentSimulationStage?.tree || simulationTree)
  79. : (apiTree || simulationTree);
  80. const stopGeneration = useCallback(() => {
  81. if (mode === "simulation") {
  82. setMode("interactive");
  83. setPhase("complete");
  84. setTypedPrompt(SIMULATION_PROMPT);
  85. setUserPrompt("");
  86. }
  87. clear();
  88. }, [mode, clear]);
  89. // Typing effect for simulation
  90. useEffect(() => {
  91. if (mode !== "simulation" || phase !== "typing") return;
  92. let i = 0;
  93. const interval = setInterval(() => {
  94. if (i < SIMULATION_PROMPT.length) {
  95. setTypedPrompt(SIMULATION_PROMPT.slice(0, i + 1));
  96. i++;
  97. } else {
  98. clearInterval(interval);
  99. setTimeout(() => setPhase("streaming"), 500);
  100. }
  101. }, 20);
  102. return () => clearInterval(interval);
  103. }, [mode, phase]);
  104. // Streaming effect for simulation
  105. useEffect(() => {
  106. if (mode !== "simulation" || phase !== "streaming") return;
  107. let i = 0;
  108. const interval = setInterval(() => {
  109. if (i < SIMULATION_STAGES.length) {
  110. const stage = SIMULATION_STAGES[i];
  111. if (stage) {
  112. setStageIndex(i);
  113. setStreamLines((prev) => [...prev, stage.stream]);
  114. setSimulationTree(stage.tree);
  115. }
  116. i++;
  117. } else {
  118. clearInterval(interval);
  119. setTimeout(() => {
  120. setPhase("complete");
  121. setMode("interactive");
  122. setUserPrompt("");
  123. }, 500);
  124. }
  125. }, 600);
  126. return () => clearInterval(interval);
  127. }, [mode, phase]);
  128. // Track stream lines from real API
  129. useEffect(() => {
  130. if (mode === "interactive" && apiTree) {
  131. // Convert tree to stream line for display
  132. const streamLine = JSON.stringify({ tree: apiTree });
  133. if (!streamLines.includes(streamLine) && Object.keys(apiTree.elements).length > 0) {
  134. setStreamLines((prev) => {
  135. const lastLine = prev[prev.length - 1];
  136. if (lastLine !== streamLine) {
  137. return [...prev, streamLine];
  138. }
  139. return prev;
  140. });
  141. }
  142. }
  143. }, [mode, apiTree, streamLines]);
  144. const handleSubmit = useCallback(async () => {
  145. if (!userPrompt.trim() || isStreaming) return;
  146. setStreamLines([]);
  147. await send(userPrompt);
  148. }, [userPrompt, isStreaming, send]);
  149. // Expose action handler for registry components - shows toast with text
  150. useEffect(() => {
  151. (window as unknown as { __demoAction?: (text: string) => void }).__demoAction = (text: string) => {
  152. toast(text);
  153. };
  154. return () => {
  155. delete (window as unknown as { __demoAction?: (text: string) => void }).__demoAction;
  156. };
  157. }, []);
  158. const jsonCode = currentTree ? JSON.stringify(currentTree, null, 2) : "// waiting...";
  159. const isTypingSimulation = mode === "simulation" && phase === "typing";
  160. const isStreamingSimulation = mode === "simulation" && phase === "streaming";
  161. const showLoadingDots = isStreamingSimulation || isStreaming;
  162. return (
  163. <div className="w-full max-w-4xl mx-auto text-left">
  164. {/* Prompt input */}
  165. <div className="mb-6">
  166. <div className="border border-border rounded p-3 bg-background font-mono text-sm min-h-[44px] flex items-center justify-between">
  167. {mode === "simulation" ? (
  168. <div className="flex items-center flex-1">
  169. <span className="inline-flex items-center h-5">{typedPrompt}</span>
  170. {isTypingSimulation && (
  171. <span className="inline-block w-2 h-4 bg-foreground ml-0.5 animate-pulse" />
  172. )}
  173. </div>
  174. ) : (
  175. <form
  176. className="flex items-center flex-1"
  177. onSubmit={(e) => {
  178. e.preventDefault();
  179. handleSubmit();
  180. }}
  181. >
  182. <input
  183. type="text"
  184. value={userPrompt}
  185. onChange={(e) => setUserPrompt(e.target.value)}
  186. placeholder="Describe what you want to build..."
  187. className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground/50"
  188. disabled={isStreaming}
  189. maxLength={140}
  190. autoFocus
  191. />
  192. </form>
  193. )}
  194. {(mode === "simulation" || isStreaming) ? (
  195. <button
  196. onClick={stopGeneration}
  197. 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"
  198. aria-label="Stop"
  199. >
  200. <svg
  201. width="16"
  202. height="16"
  203. viewBox="0 0 24 24"
  204. fill="currentColor"
  205. stroke="none"
  206. >
  207. <rect x="6" y="6" width="12" height="12" />
  208. </svg>
  209. </button>
  210. ) : (
  211. <button
  212. onClick={handleSubmit}
  213. disabled={!userPrompt.trim()}
  214. 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"
  215. aria-label="Submit"
  216. >
  217. <svg
  218. width="16"
  219. height="16"
  220. viewBox="0 0 24 24"
  221. fill="none"
  222. stroke="currentColor"
  223. strokeWidth="2"
  224. strokeLinecap="round"
  225. strokeLinejoin="round"
  226. >
  227. <path d="M12 5v14" />
  228. <path d="M19 12l-7 7-7-7" />
  229. </svg>
  230. </button>
  231. )}
  232. </div>
  233. <div className="mt-2 text-xs text-muted-foreground text-center">
  234. Try: &quot;Create a login form&quot; or &quot;Build a feedback form with rating&quot;
  235. </div>
  236. </div>
  237. <div className="grid lg:grid-cols-2 gap-4">
  238. {/* Tabbed code/stream/json panel */}
  239. <div>
  240. <div className="flex items-center gap-4 mb-2 h-6">
  241. {(["json", "stream", "code"] as const).map((tab) => (
  242. <button
  243. key={tab}
  244. onClick={() => setActiveTab(tab)}
  245. className={`text-xs font-mono transition-colors ${
  246. activeTab === tab ? "text-foreground" : "text-muted-foreground hover:text-foreground"
  247. }`}
  248. >
  249. {tab}
  250. </button>
  251. ))}
  252. </div>
  253. <div className="border border-border rounded p-3 bg-background font-mono text-xs h-96 overflow-auto text-left">
  254. <div className={activeTab === "stream" ? "" : "hidden"}>
  255. {streamLines.length > 0 ? (
  256. <>
  257. <CodeBlock code={streamLines.join("\n")} lang="json" />
  258. {showLoadingDots && (
  259. <div className="flex gap-1 mt-2">
  260. <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse" />
  261. <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse [animation-delay:75ms]" />
  262. <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse [animation-delay:150ms]" />
  263. </div>
  264. )}
  265. </>
  266. ) : (
  267. <div className="text-muted-foreground/50">
  268. {showLoadingDots ? "streaming..." : "waiting..."}
  269. </div>
  270. )}
  271. </div>
  272. <div className={activeTab === "json" ? "" : "hidden"}>
  273. <CodeBlock code={jsonCode} lang="json" />
  274. </div>
  275. <div className={activeTab === "code" ? "" : "hidden"}>
  276. <CodeBlock code={CODE_EXAMPLE} lang="tsx" />
  277. </div>
  278. </div>
  279. </div>
  280. {/* Rendered output using json-render */}
  281. <div>
  282. <div className="flex items-center justify-between mb-2 h-6">
  283. <div className="text-xs text-muted-foreground font-mono">render</div>
  284. <button
  285. onClick={() => setIsFullscreen(true)}
  286. className="text-muted-foreground hover:text-foreground transition-colors"
  287. aria-label="Maximize"
  288. >
  289. <svg
  290. width="14"
  291. height="14"
  292. viewBox="0 0 24 24"
  293. fill="none"
  294. stroke="currentColor"
  295. strokeWidth="2"
  296. strokeLinecap="round"
  297. strokeLinejoin="round"
  298. >
  299. <path d="M8 3H5a2 2 0 0 0-2 2v3" />
  300. <path d="M21 8V5a2 2 0 0 0-2-2h-3" />
  301. <path d="M3 16v3a2 2 0 0 0 2 2h3" />
  302. <path d="M16 21h3a2 2 0 0 0 2-2v-3" />
  303. </svg>
  304. </button>
  305. </div>
  306. <div className="border border-border rounded p-3 bg-background h-96 overflow-auto">
  307. {currentTree && currentTree.root ? (
  308. <div className="animate-in fade-in duration-200 w-full min-h-full flex items-center justify-center py-4">
  309. <JSONUIProvider registry={demoRegistry as Parameters<typeof JSONUIProvider>[0]['registry']}>
  310. <Renderer
  311. tree={currentTree}
  312. registry={demoRegistry as Parameters<typeof Renderer>[0]['registry']}
  313. loading={isStreaming || isStreamingSimulation}
  314. fallback={fallbackComponent as Parameters<typeof Renderer>[0]['fallback']}
  315. />
  316. </JSONUIProvider>
  317. </div>
  318. ) : (
  319. <div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
  320. {isStreaming ? "generating..." : "waiting..."}
  321. </div>
  322. )}
  323. </div>
  324. <Toaster position="bottom-right" />
  325. </div>
  326. </div>
  327. {/* Fullscreen modal */}
  328. {isFullscreen && (
  329. <div className="fixed inset-0 z-50 bg-background flex flex-col">
  330. <div className="flex items-center justify-between px-6 h-14 border-b border-border">
  331. <div className="text-sm font-mono">render</div>
  332. <button
  333. onClick={() => setIsFullscreen(false)}
  334. className="text-muted-foreground hover:text-foreground transition-colors p-1"
  335. aria-label="Close"
  336. >
  337. <svg
  338. width="20"
  339. height="20"
  340. viewBox="0 0 24 24"
  341. fill="none"
  342. stroke="currentColor"
  343. strokeWidth="2"
  344. strokeLinecap="round"
  345. strokeLinejoin="round"
  346. >
  347. <path d="M18 6L6 18" />
  348. <path d="M6 6l12 12" />
  349. </svg>
  350. </button>
  351. </div>
  352. <div className="flex-1 overflow-auto p-6">
  353. {currentTree && currentTree.root ? (
  354. <div className="w-full min-h-full flex items-center justify-center">
  355. <JSONUIProvider registry={demoRegistry as Parameters<typeof JSONUIProvider>[0]['registry']}>
  356. <Renderer
  357. tree={currentTree}
  358. registry={demoRegistry as Parameters<typeof Renderer>[0]['registry']}
  359. loading={isStreaming || isStreamingSimulation}
  360. fallback={fallbackComponent as Parameters<typeof Renderer>[0]['fallback']}
  361. />
  362. </JSONUIProvider>
  363. </div>
  364. ) : (
  365. <div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
  366. {isStreaming ? "generating..." : "waiting..."}
  367. </div>
  368. )}
  369. </div>
  370. </div>
  371. )}
  372. </div>
  373. );
  374. }