demo.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  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 {
  9. demoRegistry,
  10. fallbackComponent,
  11. useInteractiveState,
  12. } from "./demo/index";
  13. const SIMULATION_PROMPT = "Create a contact form with name, email, and message";
  14. interface SimulationStage {
  15. tree: UITree;
  16. stream: string;
  17. }
  18. const SIMULATION_STAGES: SimulationStage[] = [
  19. {
  20. tree: {
  21. root: "card",
  22. elements: {
  23. card: {
  24. key: "card",
  25. type: "Card",
  26. props: { title: "Contact Us", maxWidth: "md" },
  27. children: [],
  28. },
  29. },
  30. },
  31. stream: '{"op":"set","path":"/root","value":"card"}',
  32. },
  33. {
  34. tree: {
  35. root: "card",
  36. elements: {
  37. card: {
  38. key: "card",
  39. type: "Card",
  40. props: { title: "Contact Us", maxWidth: "md" },
  41. children: ["name"],
  42. },
  43. name: {
  44. key: "name",
  45. type: "Input",
  46. props: { label: "Name", name: "name" },
  47. },
  48. },
  49. },
  50. stream:
  51. '{"op":"add","path":"/elements/card","value":{"key":"card","type":"Card","props":{"title":"Contact Us","maxWidth":"md"},"children":["name"]}}',
  52. },
  53. {
  54. tree: {
  55. root: "card",
  56. elements: {
  57. card: {
  58. key: "card",
  59. type: "Card",
  60. props: { title: "Contact Us", maxWidth: "md" },
  61. children: ["name", "email"],
  62. },
  63. name: {
  64. key: "name",
  65. type: "Input",
  66. props: { label: "Name", name: "name" },
  67. },
  68. email: {
  69. key: "email",
  70. type: "Input",
  71. props: { label: "Email", name: "email" },
  72. },
  73. },
  74. },
  75. stream:
  76. '{"op":"add","path":"/elements/email","value":{"key":"email","type":"Input","props":{"label":"Email","name":"email"}}}',
  77. },
  78. {
  79. tree: {
  80. root: "card",
  81. elements: {
  82. card: {
  83. key: "card",
  84. type: "Card",
  85. props: { title: "Contact Us", maxWidth: "md" },
  86. children: ["name", "email", "message"],
  87. },
  88. name: {
  89. key: "name",
  90. type: "Input",
  91. props: { label: "Name", name: "name" },
  92. },
  93. email: {
  94. key: "email",
  95. type: "Input",
  96. props: { label: "Email", name: "email" },
  97. },
  98. message: {
  99. key: "message",
  100. type: "Textarea",
  101. props: { label: "Message", name: "message" },
  102. },
  103. },
  104. },
  105. stream:
  106. '{"op":"add","path":"/elements/message","value":{"key":"message","type":"Textarea","props":{"label":"Message","name":"message"}}}',
  107. },
  108. {
  109. tree: {
  110. root: "card",
  111. elements: {
  112. card: {
  113. key: "card",
  114. type: "Card",
  115. props: { title: "Contact Us", maxWidth: "md" },
  116. children: ["name", "email", "message", "submit"],
  117. },
  118. name: {
  119. key: "name",
  120. type: "Input",
  121. props: { label: "Name", name: "name" },
  122. },
  123. email: {
  124. key: "email",
  125. type: "Input",
  126. props: { label: "Email", name: "email" },
  127. },
  128. message: {
  129. key: "message",
  130. type: "Textarea",
  131. props: { label: "Message", name: "message" },
  132. },
  133. submit: {
  134. key: "submit",
  135. type: "Button",
  136. props: { label: "Send Message", variant: "primary" },
  137. },
  138. },
  139. },
  140. stream:
  141. '{"op":"add","path":"/elements/submit","value":{"key":"submit","type":"Button","props":{"label":"Send Message","variant":"primary"}}}',
  142. },
  143. ];
  144. const CODE_EXAMPLE = `import { Renderer, useUIStream } from '@json-render/react';
  145. import { registry } from './registry';
  146. function App() {
  147. const { tree, isStreaming, send } = useUIStream({
  148. api: '/api/generate',
  149. });
  150. return (
  151. <Renderer
  152. tree={tree}
  153. registry={registry}
  154. loading={isStreaming}
  155. />
  156. );
  157. }`;
  158. type Mode = "simulation" | "interactive";
  159. type Phase = "typing" | "streaming" | "complete";
  160. type Tab = "stream" | "json" | "code";
  161. export function Demo() {
  162. const [mode, setMode] = useState<Mode>("simulation");
  163. const [phase, setPhase] = useState<Phase>("typing");
  164. const [typedPrompt, setTypedPrompt] = useState("");
  165. const [userPrompt, setUserPrompt] = useState("");
  166. const [stageIndex, setStageIndex] = useState(-1);
  167. const [streamLines, setStreamLines] = useState<string[]>([]);
  168. const [activeTab, setActiveTab] = useState<Tab>("json");
  169. const [simulationTree, setSimulationTree] = useState<UITree | null>(null);
  170. const [isFullscreen, setIsFullscreen] = useState(false);
  171. const inputRef = useRef<HTMLInputElement>(null);
  172. // Use the library's useUIStream hook for real API calls
  173. const {
  174. tree: apiTree,
  175. isStreaming,
  176. send,
  177. clear,
  178. } = useUIStream({
  179. api: "/api/generate",
  180. onError: (err: Error) => console.error("Generation error:", err),
  181. } as Parameters<typeof useUIStream>[0]);
  182. // Initialize interactive state for Select components
  183. useInteractiveState();
  184. const currentSimulationStage =
  185. stageIndex >= 0 ? SIMULATION_STAGES[stageIndex] : null;
  186. // Determine which tree to display - keep simulation tree until new API response
  187. const currentTree =
  188. mode === "simulation"
  189. ? currentSimulationStage?.tree || simulationTree
  190. : apiTree || simulationTree;
  191. const stopGeneration = useCallback(() => {
  192. if (mode === "simulation") {
  193. setMode("interactive");
  194. setPhase("complete");
  195. setTypedPrompt(SIMULATION_PROMPT);
  196. setUserPrompt("");
  197. }
  198. clear();
  199. }, [mode, clear]);
  200. // Typing effect for simulation
  201. useEffect(() => {
  202. if (mode !== "simulation" || phase !== "typing") return;
  203. let i = 0;
  204. const interval = setInterval(() => {
  205. if (i < SIMULATION_PROMPT.length) {
  206. setTypedPrompt(SIMULATION_PROMPT.slice(0, i + 1));
  207. i++;
  208. } else {
  209. clearInterval(interval);
  210. setTimeout(() => setPhase("streaming"), 500);
  211. }
  212. }, 20);
  213. return () => clearInterval(interval);
  214. }, [mode, phase]);
  215. // Streaming effect for simulation
  216. useEffect(() => {
  217. if (mode !== "simulation" || phase !== "streaming") return;
  218. let i = 0;
  219. const interval = setInterval(() => {
  220. if (i < SIMULATION_STAGES.length) {
  221. const stage = SIMULATION_STAGES[i];
  222. if (stage) {
  223. setStageIndex(i);
  224. setStreamLines((prev) => [...prev, stage.stream]);
  225. setSimulationTree(stage.tree);
  226. }
  227. i++;
  228. } else {
  229. clearInterval(interval);
  230. setTimeout(() => {
  231. setPhase("complete");
  232. setMode("interactive");
  233. setUserPrompt("");
  234. }, 500);
  235. }
  236. }, 600);
  237. return () => clearInterval(interval);
  238. }, [mode, phase]);
  239. // Track stream lines from real API
  240. useEffect(() => {
  241. if (mode === "interactive" && apiTree) {
  242. // Convert tree to stream line for display
  243. const streamLine = JSON.stringify({ tree: apiTree });
  244. if (
  245. !streamLines.includes(streamLine) &&
  246. Object.keys(apiTree.elements).length > 0
  247. ) {
  248. setStreamLines((prev) => {
  249. const lastLine = prev[prev.length - 1];
  250. if (lastLine !== streamLine) {
  251. return [...prev, streamLine];
  252. }
  253. return prev;
  254. });
  255. }
  256. }
  257. }, [mode, apiTree, streamLines]);
  258. const handleSubmit = useCallback(async () => {
  259. if (!userPrompt.trim() || isStreaming) return;
  260. setStreamLines([]);
  261. await send(userPrompt);
  262. }, [userPrompt, isStreaming, send]);
  263. // Expose action handler for registry components - shows toast with text
  264. useEffect(() => {
  265. (
  266. window as unknown as { __demoAction?: (text: string) => void }
  267. ).__demoAction = (text: string) => {
  268. toast(text);
  269. };
  270. return () => {
  271. delete (window as unknown as { __demoAction?: (text: string) => void })
  272. .__demoAction;
  273. };
  274. }, []);
  275. const jsonCode = currentTree
  276. ? JSON.stringify(currentTree, null, 2)
  277. : "// waiting...";
  278. const isTypingSimulation = mode === "simulation" && phase === "typing";
  279. const isStreamingSimulation = mode === "simulation" && phase === "streaming";
  280. const showLoadingDots = isStreamingSimulation || isStreaming;
  281. return (
  282. <div className="w-full max-w-4xl mx-auto text-left">
  283. {/* Prompt input */}
  284. <div className="mb-6">
  285. <div
  286. className="border border-border rounded p-3 bg-background font-mono text-sm min-h-[44px] flex items-center justify-between cursor-text"
  287. onClick={() => {
  288. if (mode === "simulation") {
  289. setMode("interactive");
  290. setPhase("complete");
  291. setUserPrompt("");
  292. setTimeout(() => inputRef.current?.focus(), 0);
  293. } else {
  294. inputRef.current?.focus();
  295. }
  296. }}
  297. >
  298. {mode === "simulation" ? (
  299. <div className="flex items-center flex-1">
  300. <span className="inline-flex items-center h-5">
  301. {typedPrompt}
  302. </span>
  303. {isTypingSimulation && (
  304. <span className="inline-block w-2 h-4 bg-foreground ml-0.5 animate-pulse" />
  305. )}
  306. </div>
  307. ) : (
  308. <form
  309. className="flex items-center flex-1"
  310. onSubmit={(e) => {
  311. e.preventDefault();
  312. handleSubmit();
  313. }}
  314. >
  315. <input
  316. ref={inputRef}
  317. type="text"
  318. value={userPrompt}
  319. onChange={(e) => setUserPrompt(e.target.value)}
  320. placeholder="Describe what you want to build..."
  321. className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground/50 text-base"
  322. disabled={isStreaming}
  323. maxLength={140}
  324. />
  325. </form>
  326. )}
  327. {mode === "simulation" || isStreaming ? (
  328. <button
  329. onClick={(e) => {
  330. e.stopPropagation();
  331. stopGeneration();
  332. }}
  333. 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"
  334. aria-label="Stop"
  335. >
  336. <svg
  337. width="16"
  338. height="16"
  339. viewBox="0 0 24 24"
  340. fill="currentColor"
  341. stroke="none"
  342. >
  343. <rect x="6" y="6" width="12" height="12" />
  344. </svg>
  345. </button>
  346. ) : (
  347. <button
  348. onClick={(e) => {
  349. e.stopPropagation();
  350. handleSubmit();
  351. }}
  352. disabled={!userPrompt.trim()}
  353. 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"
  354. aria-label="Submit"
  355. >
  356. <svg
  357. width="16"
  358. height="16"
  359. viewBox="0 0 24 24"
  360. fill="none"
  361. stroke="currentColor"
  362. strokeWidth="2"
  363. strokeLinecap="round"
  364. strokeLinejoin="round"
  365. >
  366. <path d="M12 5v14" />
  367. <path d="M19 12l-7 7-7-7" />
  368. </svg>
  369. </button>
  370. )}
  371. </div>
  372. <div className="mt-2 text-xs text-muted-foreground text-center">
  373. Try: &quot;Create a login form&quot; or &quot;Build a feedback form
  374. with rating&quot;
  375. </div>
  376. </div>
  377. <div className="grid lg:grid-cols-2 gap-4">
  378. {/* Tabbed code/stream/json panel */}
  379. <div className="min-w-0">
  380. <div className="flex items-center gap-4 mb-2 h-6">
  381. {(["json", "stream", "code"] as const).map((tab) => (
  382. <button
  383. key={tab}
  384. onClick={() => setActiveTab(tab)}
  385. className={`text-xs font-mono transition-colors ${
  386. activeTab === tab
  387. ? "text-foreground"
  388. : "text-muted-foreground hover:text-foreground"
  389. }`}
  390. >
  391. {tab}
  392. </button>
  393. ))}
  394. </div>
  395. <div className="border border-border rounded p-3 bg-background font-mono text-xs h-96 overflow-auto text-left">
  396. <div className={activeTab === "stream" ? "" : "hidden"}>
  397. {streamLines.length > 0 ? (
  398. <>
  399. <CodeBlock code={streamLines.join("\n")} lang="json" />
  400. {showLoadingDots && (
  401. <div className="flex gap-1 mt-2">
  402. <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse" />
  403. <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse [animation-delay:75ms]" />
  404. <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse [animation-delay:150ms]" />
  405. </div>
  406. )}
  407. </>
  408. ) : (
  409. <div className="text-muted-foreground/50">
  410. {showLoadingDots ? "streaming..." : "waiting..."}
  411. </div>
  412. )}
  413. </div>
  414. <div className={activeTab === "json" ? "" : "hidden"}>
  415. <CodeBlock code={jsonCode} lang="json" />
  416. </div>
  417. <div className={activeTab === "code" ? "" : "hidden"}>
  418. <CodeBlock code={CODE_EXAMPLE} lang="tsx" />
  419. </div>
  420. </div>
  421. </div>
  422. {/* Rendered output using json-render */}
  423. <div className="min-w-0">
  424. <div className="flex items-center justify-between mb-2 h-6">
  425. <div className="text-xs text-muted-foreground font-mono">
  426. render
  427. </div>
  428. <button
  429. onClick={() => setIsFullscreen(true)}
  430. className="text-muted-foreground hover:text-foreground transition-colors"
  431. aria-label="Maximize"
  432. >
  433. <svg
  434. width="14"
  435. height="14"
  436. viewBox="0 0 24 24"
  437. fill="none"
  438. stroke="currentColor"
  439. strokeWidth="2"
  440. strokeLinecap="round"
  441. strokeLinejoin="round"
  442. >
  443. <path d="M8 3H5a2 2 0 0 0-2 2v3" />
  444. <path d="M21 8V5a2 2 0 0 0-2-2h-3" />
  445. <path d="M3 16v3a2 2 0 0 0 2 2h3" />
  446. <path d="M16 21h3a2 2 0 0 0 2-2v-3" />
  447. </svg>
  448. </button>
  449. </div>
  450. <div className="border border-border rounded p-3 bg-background h-96 overflow-auto">
  451. {currentTree && currentTree.root ? (
  452. <div className="animate-in fade-in duration-200 w-full min-h-full flex items-center justify-center py-4">
  453. <JSONUIProvider
  454. registry={
  455. demoRegistry as Parameters<
  456. typeof JSONUIProvider
  457. >[0]["registry"]
  458. }
  459. >
  460. <Renderer
  461. tree={currentTree}
  462. registry={
  463. demoRegistry as Parameters<typeof Renderer>[0]["registry"]
  464. }
  465. loading={isStreaming || isStreamingSimulation}
  466. fallback={
  467. fallbackComponent as Parameters<
  468. typeof Renderer
  469. >[0]["fallback"]
  470. }
  471. />
  472. </JSONUIProvider>
  473. </div>
  474. ) : (
  475. <div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
  476. {isStreaming ? "generating..." : "waiting..."}
  477. </div>
  478. )}
  479. </div>
  480. <Toaster position="bottom-right" />
  481. </div>
  482. </div>
  483. {/* Fullscreen modal */}
  484. {isFullscreen && (
  485. <div className="fixed inset-0 z-50 bg-background flex flex-col">
  486. <div className="flex items-center justify-between px-6 h-14 border-b border-border">
  487. <div className="text-sm font-mono">render</div>
  488. <button
  489. onClick={() => setIsFullscreen(false)}
  490. className="text-muted-foreground hover:text-foreground transition-colors p-1"
  491. aria-label="Close"
  492. >
  493. <svg
  494. width="20"
  495. height="20"
  496. viewBox="0 0 24 24"
  497. fill="none"
  498. stroke="currentColor"
  499. strokeWidth="2"
  500. strokeLinecap="round"
  501. strokeLinejoin="round"
  502. >
  503. <path d="M18 6L6 18" />
  504. <path d="M6 6l12 12" />
  505. </svg>
  506. </button>
  507. </div>
  508. <div className="flex-1 overflow-auto p-6">
  509. {currentTree && currentTree.root ? (
  510. <div className="w-full min-h-full flex items-center justify-center">
  511. <JSONUIProvider
  512. registry={
  513. demoRegistry as Parameters<
  514. typeof JSONUIProvider
  515. >[0]["registry"]
  516. }
  517. >
  518. <Renderer
  519. tree={currentTree}
  520. registry={
  521. demoRegistry as Parameters<typeof Renderer>[0]["registry"]
  522. }
  523. loading={isStreaming || isStreamingSimulation}
  524. fallback={
  525. fallbackComponent as Parameters<
  526. typeof Renderer
  527. >[0]["fallback"]
  528. }
  529. />
  530. </JSONUIProvider>
  531. </div>
  532. ) : (
  533. <div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
  534. {isStreaming ? "generating..." : "waiting..."}
  535. </div>
  536. )}
  537. </div>
  538. </div>
  539. )}
  540. </div>
  541. );
  542. }