demo.tsx 9.0 KB

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