demo.tsx 17 KB

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