demo.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. "use client";
  2. import { useEffect, useState, useCallback, useRef } from "react";
  3. import { CodeBlock } from "./code-block";
  4. const SIMULATION_PROMPT = "Create a contact form with name, email, and message";
  5. interface UIElement {
  6. key: string;
  7. type: string;
  8. props: Record<string, unknown>;
  9. children?: string[];
  10. }
  11. interface UITree {
  12. root: string;
  13. elements: Record<string, UIElement>;
  14. }
  15. interface SimulationStage {
  16. tree: UITree;
  17. stream: string;
  18. }
  19. const SIMULATION_STAGES: SimulationStage[] = [
  20. {
  21. tree: { root: "form", elements: { form: { key: "form", type: "Form", props: { title: "Contact Us" }, children: [] } } },
  22. stream: '{"op":"set","path":"/root","value":"form"}',
  23. },
  24. {
  25. tree: { root: "form", elements: { form: { key: "form", type: "Form", props: { title: "Contact Us" }, children: ["name"] }, name: { key: "name", type: "Input", props: { label: "Name", name: "name" } } } },
  26. stream: '{"op":"add","path":"/elements/form","value":{"key":"form","type":"Form","props":{"title":"Contact Us"},"children":["name"]}}',
  27. },
  28. {
  29. tree: { root: "form", elements: { form: { key: "form", type: "Form", props: { title: "Contact Us" }, children: ["name", "email"] }, name: { key: "name", type: "Input", props: { label: "Name", name: "name" } }, email: { key: "email", type: "Input", props: { label: "Email", name: "email" } } } },
  30. stream: '{"op":"add","path":"/elements/email","value":{"key":"email","type":"Input","props":{"label":"Email","name":"email"}}}',
  31. },
  32. {
  33. tree: { root: "form", elements: { form: { key: "form", type: "Form", props: { title: "Contact Us" }, 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" } } } },
  34. stream: '{"op":"add","path":"/elements/message","value":{"key":"message","type":"Textarea","props":{"label":"Message","name":"message"}}}',
  35. },
  36. {
  37. tree: { root: "form", elements: { form: { key: "form", type: "Form", props: { title: "Contact Us" }, 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", action: "submit" } } } },
  38. stream: '{"op":"add","path":"/elements/submit","value":{"key":"submit","type":"Button","props":{"label":"Send Message","action":"submit"}}}',
  39. },
  40. ];
  41. const CODE_EXAMPLE = `import { createCatalog } from '@json-render/core';
  42. import { z } from 'zod';
  43. export const catalog = createCatalog({
  44. components: {
  45. Form: {
  46. props: z.object({
  47. title: z.string(),
  48. }),
  49. hasChildren: true,
  50. },
  51. Input: {
  52. props: z.object({
  53. label: z.string(),
  54. name: z.string(),
  55. }),
  56. },
  57. Textarea: {
  58. props: z.object({
  59. label: z.string(),
  60. name: z.string(),
  61. }),
  62. },
  63. Button: {
  64. props: z.object({
  65. label: z.string(),
  66. action: z.string(),
  67. }),
  68. },
  69. },
  70. });`;
  71. type Mode = "simulation" | "interactive";
  72. type Phase = "typing" | "streaming" | "complete";
  73. type Tab = "stream" | "json" | "code";
  74. function parsePatch(line: string): { op: string; path: string; value: unknown } | null {
  75. try {
  76. const trimmed = line.trim();
  77. if (!trimmed || trimmed.startsWith("//")) return null;
  78. return JSON.parse(trimmed);
  79. } catch {
  80. return null;
  81. }
  82. }
  83. function applyPatch(tree: UITree, patch: { op: string; path: string; value: unknown }): UITree {
  84. const newTree = { ...tree, elements: { ...tree.elements } };
  85. if (patch.path === "/root") {
  86. newTree.root = patch.value as string;
  87. return newTree;
  88. }
  89. if (patch.path.startsWith("/elements/")) {
  90. const key = patch.path.slice("/elements/".length).split("/")[0];
  91. if (key && (patch.op === "set" || patch.op === "add")) {
  92. newTree.elements[key] = patch.value as UIElement;
  93. }
  94. }
  95. return newTree;
  96. }
  97. export function Demo() {
  98. const [mode, setMode] = useState<Mode>("simulation");
  99. const [phase, setPhase] = useState<Phase>("typing");
  100. const [typedPrompt, setTypedPrompt] = useState("");
  101. const [userPrompt, setUserPrompt] = useState("");
  102. const [stageIndex, setStageIndex] = useState(-1);
  103. const [streamLines, setStreamLines] = useState<string[]>([]);
  104. const [activeTab, setActiveTab] = useState<Tab>("json");
  105. const [actionFired, setActionFired] = useState(false);
  106. const [tree, setTree] = useState<UITree | null>(null);
  107. const [isLoading, setIsLoading] = useState(false);
  108. const abortRef = useRef<AbortController | null>(null);
  109. const currentSimulationStage = stageIndex >= 0 ? SIMULATION_STAGES[stageIndex] : null;
  110. const stopGeneration = useCallback(() => {
  111. abortRef.current?.abort();
  112. if (mode === "simulation") {
  113. // Skip to interactive mode
  114. setMode("interactive");
  115. setPhase("complete");
  116. setTypedPrompt(SIMULATION_PROMPT);
  117. setUserPrompt("");
  118. }
  119. setIsLoading(false);
  120. }, [mode]);
  121. // Typing effect for simulation
  122. useEffect(() => {
  123. if (mode !== "simulation" || phase !== "typing") return;
  124. let i = 0;
  125. const interval = setInterval(() => {
  126. if (i < SIMULATION_PROMPT.length) {
  127. setTypedPrompt(SIMULATION_PROMPT.slice(0, i + 1));
  128. i++;
  129. } else {
  130. clearInterval(interval);
  131. setTimeout(() => setPhase("streaming"), 500);
  132. }
  133. }, 20);
  134. return () => clearInterval(interval);
  135. }, [mode, phase]);
  136. // Streaming effect for simulation
  137. useEffect(() => {
  138. if (mode !== "simulation" || phase !== "streaming") return;
  139. let i = 0;
  140. const interval = setInterval(() => {
  141. if (i < SIMULATION_STAGES.length) {
  142. const stage = SIMULATION_STAGES[i];
  143. if (stage) {
  144. setStageIndex(i);
  145. setStreamLines((prev) => [...prev, stage.stream]);
  146. setTree(stage.tree);
  147. }
  148. i++;
  149. } else {
  150. clearInterval(interval);
  151. setTimeout(() => {
  152. setPhase("complete");
  153. setMode("interactive");
  154. setUserPrompt("");
  155. }, 500);
  156. }
  157. }, 600);
  158. return () => clearInterval(interval);
  159. }, [mode, phase]);
  160. const handleSubmit = useCallback(async () => {
  161. if (!userPrompt.trim() || isLoading) return;
  162. abortRef.current?.abort();
  163. abortRef.current = new AbortController();
  164. setIsLoading(true);
  165. setStreamLines([]);
  166. setTree({ root: "", elements: {} });
  167. try {
  168. const response = await fetch("/api/generate", {
  169. method: "POST",
  170. headers: { "Content-Type": "application/json" },
  171. body: JSON.stringify({ prompt: userPrompt }),
  172. signal: abortRef.current.signal,
  173. });
  174. if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
  175. const reader = response.body?.getReader();
  176. if (!reader) throw new Error("No response body");
  177. const decoder = new TextDecoder();
  178. let buffer = "";
  179. let currentTree: UITree = { root: "", elements: {} };
  180. while (true) {
  181. const { done, value } = await reader.read();
  182. if (done) break;
  183. buffer += decoder.decode(value, { stream: true });
  184. const lines = buffer.split("\n");
  185. buffer = lines.pop() ?? "";
  186. for (const line of lines) {
  187. const patch = parsePatch(line);
  188. if (patch) {
  189. currentTree = applyPatch(currentTree, patch);
  190. setTree({ ...currentTree });
  191. setStreamLines((prev) => [...prev, line.trim()]);
  192. }
  193. }
  194. }
  195. if (buffer.trim()) {
  196. const patch = parsePatch(buffer);
  197. if (patch) {
  198. currentTree = applyPatch(currentTree, patch);
  199. setTree({ ...currentTree });
  200. setStreamLines((prev) => [...prev, buffer.trim()]);
  201. }
  202. }
  203. } catch (err) {
  204. if ((err as Error).name !== "AbortError") {
  205. console.error("Generation error:", err);
  206. }
  207. } finally {
  208. setIsLoading(false);
  209. }
  210. }, [userPrompt, isLoading]);
  211. const handleAction = () => {
  212. setActionFired(true);
  213. setTimeout(() => setActionFired(false), 2000);
  214. };
  215. // Render preview from tree
  216. const renderPreview = () => {
  217. const currentTree = mode === "simulation" ? currentSimulationStage?.tree : tree;
  218. if (!currentTree || !currentTree.root || !currentTree.elements[currentTree.root]) {
  219. return <div className="text-muted-foreground/50 text-sm">{isLoading ? "generating..." : "waiting..."}</div>;
  220. }
  221. const root = currentTree.elements[currentTree.root];
  222. if (!root) return null;
  223. const title = root.props.title as string | undefined;
  224. const children = (root.children ?? []).map((key) => currentTree.elements[key]).filter(Boolean) as UIElement[];
  225. return (
  226. <div className="text-center animate-in fade-in duration-200">
  227. <div className="border border-border rounded-lg p-4 bg-background inline-block text-left w-56">
  228. {title && <h3 className="font-semibold mb-3 text-sm">{title}</h3>}
  229. <div className="space-y-2">
  230. {children.map((child) => {
  231. if (child.type === "Input") {
  232. return (
  233. <div key={child.key} className="animate-in fade-in slide-in-from-bottom-1 duration-200">
  234. <label className="text-[10px] text-muted-foreground block mb-0.5">
  235. {child.props.label as string}
  236. </label>
  237. <div className="h-7 w-full bg-card border border-border rounded px-2 text-xs" />
  238. </div>
  239. );
  240. }
  241. if (child.type === "Textarea") {
  242. return (
  243. <div key={child.key} className="animate-in fade-in slide-in-from-bottom-1 duration-200">
  244. <label className="text-[10px] text-muted-foreground block mb-0.5">
  245. {child.props.label as string}
  246. </label>
  247. <div className="h-14 w-full bg-card border border-border rounded px-2 text-xs" />
  248. </div>
  249. );
  250. }
  251. if (child.type === "Button") {
  252. return (
  253. <button
  254. key={child.key}
  255. onClick={handleAction}
  256. className="w-full px-3 py-1.5 bg-foreground text-background rounded text-xs font-medium hover:opacity-90 transition-opacity animate-in fade-in slide-in-from-bottom-1 duration-200"
  257. >
  258. {child.props.label as string}
  259. </button>
  260. );
  261. }
  262. return null;
  263. })}
  264. </div>
  265. </div>
  266. {actionFired && (
  267. <div className="mt-3 text-xs font-mono text-muted-foreground animate-in fade-in slide-in-from-bottom-2">
  268. onAction(&quot;submit&quot;)
  269. </div>
  270. )}
  271. </div>
  272. );
  273. };
  274. const currentTree = mode === "simulation" ? currentSimulationStage?.tree : tree;
  275. const jsonCode = currentTree ? JSON.stringify(currentTree, null, 2) : "// waiting...";
  276. const isTypingSimulation = mode === "simulation" && phase === "typing";
  277. const isStreamingSimulation = mode === "simulation" && phase === "streaming";
  278. const showLoadingDots = isStreamingSimulation || isLoading;
  279. return (
  280. <div className="w-full max-w-4xl mx-auto">
  281. {/* Prompt input */}
  282. <div className="mb-6">
  283. <div className="border border-border rounded p-3 bg-card font-mono text-sm min-h-[44px] flex items-center justify-between">
  284. {mode === "simulation" ? (
  285. <div className="flex items-center flex-1">
  286. <span className="inline-flex items-center h-5">{typedPrompt}</span>
  287. {isTypingSimulation && (
  288. <span className="inline-block w-2 h-4 bg-foreground ml-0.5 animate-pulse" />
  289. )}
  290. </div>
  291. ) : (
  292. <form
  293. className="flex items-center flex-1"
  294. onSubmit={(e) => {
  295. e.preventDefault();
  296. handleSubmit();
  297. }}
  298. >
  299. <input
  300. type="text"
  301. value={userPrompt}
  302. onChange={(e) => setUserPrompt(e.target.value)}
  303. placeholder="Describe what you want to build..."
  304. className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground/50"
  305. disabled={isLoading}
  306. maxLength={140}
  307. autoFocus
  308. />
  309. </form>
  310. )}
  311. {(mode === "simulation" || isLoading) ? (
  312. <button
  313. onClick={stopGeneration}
  314. className="ml-2 p-1 text-muted-foreground hover:text-foreground transition-colors"
  315. aria-label="Stop"
  316. >
  317. <svg
  318. width="14"
  319. height="14"
  320. viewBox="0 0 24 24"
  321. fill="currentColor"
  322. stroke="none"
  323. >
  324. <rect x="6" y="6" width="12" height="12" rx="1" />
  325. </svg>
  326. </button>
  327. ) : (
  328. <button
  329. onClick={handleSubmit}
  330. disabled={!userPrompt.trim()}
  331. className="ml-2 p-1 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-30"
  332. aria-label="Submit"
  333. >
  334. <svg
  335. width="14"
  336. height="14"
  337. viewBox="0 0 24 24"
  338. fill="none"
  339. stroke="currentColor"
  340. strokeWidth="2"
  341. strokeLinecap="round"
  342. strokeLinejoin="round"
  343. >
  344. <path d="M12 5v14" />
  345. <path d="M19 12l-7 7-7-7" />
  346. </svg>
  347. </button>
  348. )}
  349. </div>
  350. <div className="mt-2 text-xs text-muted-foreground">
  351. Try: &quot;Create a login form&quot; or &quot;Build a feedback form with rating&quot;
  352. </div>
  353. </div>
  354. <div className="grid lg:grid-cols-2 gap-4">
  355. {/* Tabbed code/stream/json panel */}
  356. <div>
  357. <div className="flex gap-4 mb-2">
  358. {(["json", "stream", "code"] as const).map((tab) => (
  359. <button
  360. key={tab}
  361. onClick={() => setActiveTab(tab)}
  362. className={`text-xs font-mono transition-colors ${
  363. activeTab === tab ? "text-foreground" : "text-muted-foreground hover:text-foreground"
  364. }`}
  365. >
  366. {tab}
  367. </button>
  368. ))}
  369. </div>
  370. <div className="border border-border rounded p-3 bg-card font-mono text-xs h-96 overflow-auto text-left">
  371. {activeTab === "stream" && (
  372. <div className="space-y-1">
  373. {streamLines.map((line, i) => (
  374. <div
  375. key={i}
  376. className="text-muted-foreground truncate animate-in fade-in slide-in-from-bottom-1 duration-200"
  377. >
  378. {line}
  379. </div>
  380. ))}
  381. {showLoadingDots && (
  382. <div className="flex gap-1 mt-2">
  383. <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse" />
  384. <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse [animation-delay:75ms]" />
  385. <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse [animation-delay:150ms]" />
  386. </div>
  387. )}
  388. {streamLines.length === 0 && !showLoadingDots && (
  389. <div className="text-muted-foreground/50">waiting...</div>
  390. )}
  391. </div>
  392. )}
  393. <div className={activeTab === "json" ? "" : "hidden"}>
  394. <CodeBlock code={jsonCode} lang="json" />
  395. </div>
  396. <div className={activeTab === "code" ? "" : "hidden"}>
  397. <CodeBlock code={CODE_EXAMPLE} lang="tsx" />
  398. </div>
  399. </div>
  400. </div>
  401. {/* Rendered output */}
  402. <div>
  403. <div className="text-xs text-muted-foreground mb-2 font-mono">render</div>
  404. <div className="border border-border rounded p-3 bg-card h-96 flex items-center justify-center">
  405. {renderPreview()}
  406. </div>
  407. </div>
  408. </div>
  409. </div>
  410. );
  411. }