demo.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. "use client";
  2. import { useEffect, useState, useCallback } from "react";
  3. import { CodeBlock } from "./code-block";
  4. const PROMPT = "Create a contact form with name, email, and message";
  5. interface StageJson {
  6. key: string;
  7. type: string;
  8. props: Record<string, unknown>;
  9. children?: StageJson[];
  10. }
  11. interface Stage {
  12. json: StageJson;
  13. stream: string;
  14. }
  15. const STAGES: Stage[] = [
  16. { json: { key: "form", type: "Form", props: { title: "Contact Us" }, children: [] }, stream: '{"op":"set","path":"/form","value":{"type":"Form","props":{"title":"Contact Us"}}}' },
  17. { json: { key: "form", type: "Form", props: { title: "Contact Us" }, children: [{ key: "name", type: "Input", props: { label: "Name", name: "name" } }] }, stream: '{"op":"add","path":"/form/children/0","value":{"type":"Input","props":{"label":"Name"}}}' },
  18. { json: { key: "form", type: "Form", props: { title: "Contact Us" }, children: [{ key: "name", type: "Input", props: { label: "Name", name: "name" } }, { key: "email", type: "Input", props: { label: "Email", name: "email" } }] }, stream: '{"op":"add","path":"/form/children/1","value":{"type":"Input","props":{"label":"Email"}}}' },
  19. { json: { key: "form", type: "Form", props: { title: "Contact Us" }, children: [{ key: "name", type: "Input", props: { label: "Name", name: "name" } }, { key: "email", type: "Input", props: { label: "Email", name: "email" } }, { key: "message", type: "Textarea", props: { label: "Message", name: "message" } }] }, stream: '{"op":"add","path":"/form/children/2","value":{"type":"Textarea","props":{"label":"Message"}}}' },
  20. { json: { key: "form", type: "Form", props: { title: "Contact Us" }, children: [{ key: "name", type: "Input", props: { label: "Name", name: "name" } }, { key: "email", type: "Input", props: { label: "Email", name: "email" } }, { key: "message", type: "Textarea", props: { label: "Message", name: "message" } }, { key: "submit", type: "Button", props: { label: "Send Message", action: "submit" } }] }, stream: '{"op":"add","path":"/form/children/3","value":{"type":"Button","props":{"label":"Send Message"}}}' },
  21. ];
  22. const CODE_EXAMPLE = `import { createCatalog } from '@json-render/core';
  23. import { z } from 'zod';
  24. export const catalog = createCatalog({
  25. components: {
  26. Form: {
  27. props: z.object({
  28. title: z.string(),
  29. }),
  30. hasChildren: true,
  31. },
  32. Input: {
  33. props: z.object({
  34. label: z.string(),
  35. name: z.string(),
  36. }),
  37. },
  38. Textarea: {
  39. props: z.object({
  40. label: z.string(),
  41. name: z.string(),
  42. }),
  43. },
  44. Button: {
  45. props: z.object({
  46. label: z.string(),
  47. action: z.string(),
  48. }),
  49. },
  50. },
  51. });`;
  52. type Phase = "typing" | "streaming" | "complete";
  53. type Tab = "stream" | "json" | "code";
  54. export function Demo() {
  55. const [phase, setPhase] = useState<Phase>("typing");
  56. const [typedPrompt, setTypedPrompt] = useState("");
  57. const [stageIndex, setStageIndex] = useState(-1);
  58. const [streamLines, setStreamLines] = useState<string[]>([]);
  59. const [activeTab, setActiveTab] = useState<Tab>("stream");
  60. const [actionFired, setActionFired] = useState(false);
  61. const currentStage = stageIndex >= 0 ? STAGES[stageIndex] : null;
  62. const reset = useCallback(() => {
  63. setPhase("typing");
  64. setTypedPrompt("");
  65. setStageIndex(-1);
  66. setStreamLines([]);
  67. setActiveTab("stream");
  68. setActionFired(false);
  69. }, []);
  70. // Typing effect
  71. useEffect(() => {
  72. if (phase !== "typing") return;
  73. let i = 0;
  74. const interval = setInterval(() => {
  75. if (i < PROMPT.length) {
  76. setTypedPrompt(PROMPT.slice(0, i + 1));
  77. i++;
  78. } else {
  79. clearInterval(interval);
  80. setTimeout(() => setPhase("streaming"), 500);
  81. }
  82. }, 20);
  83. return () => clearInterval(interval);
  84. }, [phase]);
  85. // Streaming effect
  86. useEffect(() => {
  87. if (phase !== "streaming") return;
  88. let i = 0;
  89. const interval = setInterval(() => {
  90. if (i < STAGES.length) {
  91. const currentIndex = i;
  92. const stage = STAGES[currentIndex];
  93. if (stage) {
  94. setStageIndex(currentIndex);
  95. setStreamLines((prev) => [...prev, stage.stream]);
  96. }
  97. i++;
  98. } else {
  99. clearInterval(interval);
  100. setTimeout(() => {
  101. setPhase("complete");
  102. setActiveTab("json");
  103. }, 500);
  104. }
  105. }, 600);
  106. return () => clearInterval(interval);
  107. }, [phase]);
  108. const handleAction = () => {
  109. setActionFired(true);
  110. setTimeout(() => setActionFired(false), 2000);
  111. };
  112. // Progressive render based on current stage
  113. const renderPreview = () => {
  114. if (!currentStage) {
  115. return <div className="text-muted-foreground/50 text-sm">waiting...</div>;
  116. }
  117. const { props, children = [] } = currentStage.json;
  118. const title = props.title as string | undefined;
  119. const nameField = children.find(c => c.key === "name");
  120. const emailField = children.find(c => c.key === "email");
  121. const messageField = children.find(c => c.key === "message");
  122. const submitBtn = children.find(c => c.key === "submit");
  123. return (
  124. <div className="text-center animate-in fade-in duration-200">
  125. <div className="border border-border rounded-lg p-4 bg-background inline-block text-left w-56">
  126. {title && (
  127. <h3 className="font-semibold mb-3 text-sm">{title}</h3>
  128. )}
  129. <div className="space-y-2">
  130. {nameField && (
  131. <div className="animate-in fade-in slide-in-from-bottom-1 duration-200">
  132. <label className="text-[10px] text-muted-foreground block mb-0.5">{nameField.props.label as string}</label>
  133. <div className="h-7 w-full bg-card border border-border rounded px-2 text-xs" />
  134. </div>
  135. )}
  136. {emailField && (
  137. <div className="animate-in fade-in slide-in-from-bottom-1 duration-200">
  138. <label className="text-[10px] text-muted-foreground block mb-0.5">{emailField.props.label as string}</label>
  139. <div className="h-7 w-full bg-card border border-border rounded px-2 text-xs" />
  140. </div>
  141. )}
  142. {messageField && (
  143. <div className="animate-in fade-in slide-in-from-bottom-1 duration-200">
  144. <label className="text-[10px] text-muted-foreground block mb-0.5">{messageField.props.label as string}</label>
  145. <div className="h-14 w-full bg-card border border-border rounded px-2 text-xs" />
  146. </div>
  147. )}
  148. {submitBtn && (
  149. <button
  150. onClick={handleAction}
  151. 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"
  152. >
  153. {submitBtn.props.label as string}
  154. </button>
  155. )}
  156. </div>
  157. </div>
  158. {actionFired && (
  159. <div className="mt-3 text-xs font-mono text-muted-foreground animate-in fade-in slide-in-from-bottom-2">
  160. onAction(&quot;submit&quot;)
  161. </div>
  162. )}
  163. </div>
  164. );
  165. };
  166. const jsonCode = currentStage
  167. ? JSON.stringify(currentStage.json, null, 2)
  168. : "// waiting...";
  169. return (
  170. <div className="w-full max-w-4xl mx-auto">
  171. {/* Prompt input */}
  172. <div className="mb-6">
  173. <div className="border border-border rounded p-3 bg-card font-mono text-sm min-h-[44px] flex items-center justify-between">
  174. <div className="flex items-center">
  175. <span className="inline-flex items-center h-5">{typedPrompt}</span>
  176. {phase === "typing" && (
  177. <span className="inline-block w-2 h-4 bg-foreground ml-0.5 animate-pulse" />
  178. )}
  179. </div>
  180. <button
  181. onClick={reset}
  182. className="ml-2 p-1 text-muted-foreground hover:text-foreground transition-colors"
  183. aria-label="Replay demo"
  184. >
  185. <svg
  186. width="14"
  187. height="14"
  188. viewBox="0 0 24 24"
  189. fill="none"
  190. stroke="currentColor"
  191. strokeWidth="2"
  192. strokeLinecap="round"
  193. strokeLinejoin="round"
  194. >
  195. <path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
  196. <path d="M21 3v5h-5" />
  197. </svg>
  198. </button>
  199. </div>
  200. </div>
  201. <div className="grid lg:grid-cols-2 gap-4">
  202. {/* Tabbed code/stream/json panel */}
  203. <div>
  204. <div className="flex gap-4 mb-2">
  205. {(["stream", "json", "code"] as const).map((tab) => (
  206. <button
  207. key={tab}
  208. onClick={() => setActiveTab(tab)}
  209. className={`text-xs font-mono transition-colors ${
  210. activeTab === tab ? "text-foreground" : "text-muted-foreground hover:text-foreground"
  211. }`}
  212. >
  213. {tab}
  214. </button>
  215. ))}
  216. </div>
  217. <div className="border border-border rounded p-3 bg-card font-mono text-xs h-96 overflow-auto text-left">
  218. {activeTab === "stream" && (
  219. <div className="space-y-1">
  220. {streamLines.map((line, i) => (
  221. <div
  222. key={i}
  223. className="text-muted-foreground truncate animate-in fade-in slide-in-from-bottom-1 duration-200"
  224. >
  225. {line}
  226. </div>
  227. ))}
  228. {phase === "streaming" && streamLines.length < STAGES.length && (
  229. <div className="flex gap-1 mt-2">
  230. <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse" />
  231. <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse [animation-delay:75ms]" />
  232. <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse [animation-delay:150ms]" />
  233. </div>
  234. )}
  235. {streamLines.length === 0 && phase !== "streaming" && (
  236. <div className="text-muted-foreground/50">waiting...</div>
  237. )}
  238. </div>
  239. )}
  240. <div className={activeTab === "json" ? "" : "hidden"}>
  241. <CodeBlock code={jsonCode} lang="json" />
  242. </div>
  243. <div className={activeTab === "code" ? "" : "hidden"}>
  244. <CodeBlock code={CODE_EXAMPLE} lang="tsx" />
  245. </div>
  246. </div>
  247. </div>
  248. {/* Rendered output */}
  249. <div>
  250. <div className="text-xs text-muted-foreground mb-2 font-mono">render</div>
  251. <div className="border border-border rounded p-3 bg-card h-96 flex items-center justify-center">
  252. {renderPreview()}
  253. </div>
  254. </div>
  255. </div>
  256. </div>
  257. );
  258. }