demo.tsx 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645
  1. "use client";
  2. import React, { 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: "card", elements: { card: { key: "card", type: "Card", props: { title: "Contact Us", maxWidth: "md" }, children: [] } } },
  22. stream: '{"op":"set","path":"/root","value":"card"}',
  23. },
  24. {
  25. 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" } } } },
  26. stream: '{"op":"add","path":"/elements/card","value":{"key":"card","type":"Card","props":{"title":"Contact Us","maxWidth":"md"},"children":["name"]}}',
  27. },
  28. {
  29. 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" } } } },
  30. stream: '{"op":"add","path":"/elements/email","value":{"key":"email","type":"Input","props":{"label":"Email","name":"email"}}}',
  31. },
  32. {
  33. 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" } } } },
  34. stream: '{"op":"add","path":"/elements/message","value":{"key":"message","type":"Textarea","props":{"label":"Message","name":"message"}}}',
  35. },
  36. {
  37. 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" } } } },
  38. stream: '{"op":"add","path":"/elements/submit","value":{"key":"submit","type":"Button","props":{"label":"Send Message","variant":"primary"}}}',
  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 [openSelect, setOpenSelect] = useState<string | null>(null);
  109. const [selectValues, setSelectValues] = useState<Record<string, string>>({});
  110. const abortRef = useRef<AbortController | null>(null);
  111. const currentSimulationStage = stageIndex >= 0 ? SIMULATION_STAGES[stageIndex] : null;
  112. const stopGeneration = useCallback(() => {
  113. abortRef.current?.abort();
  114. if (mode === "simulation") {
  115. // Skip to interactive mode
  116. setMode("interactive");
  117. setPhase("complete");
  118. setTypedPrompt(SIMULATION_PROMPT);
  119. setUserPrompt("");
  120. }
  121. setIsLoading(false);
  122. }, [mode]);
  123. // Typing effect for simulation
  124. useEffect(() => {
  125. if (mode !== "simulation" || phase !== "typing") return;
  126. let i = 0;
  127. const interval = setInterval(() => {
  128. if (i < SIMULATION_PROMPT.length) {
  129. setTypedPrompt(SIMULATION_PROMPT.slice(0, i + 1));
  130. i++;
  131. } else {
  132. clearInterval(interval);
  133. setTimeout(() => setPhase("streaming"), 500);
  134. }
  135. }, 20);
  136. return () => clearInterval(interval);
  137. }, [mode, phase]);
  138. // Streaming effect for simulation
  139. useEffect(() => {
  140. if (mode !== "simulation" || phase !== "streaming") return;
  141. let i = 0;
  142. const interval = setInterval(() => {
  143. if (i < SIMULATION_STAGES.length) {
  144. const stage = SIMULATION_STAGES[i];
  145. if (stage) {
  146. setStageIndex(i);
  147. setStreamLines((prev) => [...prev, stage.stream]);
  148. setTree(stage.tree);
  149. }
  150. i++;
  151. } else {
  152. clearInterval(interval);
  153. setTimeout(() => {
  154. setPhase("complete");
  155. setMode("interactive");
  156. setUserPrompt("");
  157. }, 500);
  158. }
  159. }, 600);
  160. return () => clearInterval(interval);
  161. }, [mode, phase]);
  162. const handleSubmit = useCallback(async () => {
  163. if (!userPrompt.trim() || isLoading) return;
  164. abortRef.current?.abort();
  165. abortRef.current = new AbortController();
  166. setIsLoading(true);
  167. setStreamLines([]);
  168. setTree({ root: "", elements: {} });
  169. try {
  170. const response = await fetch("/api/generate", {
  171. method: "POST",
  172. headers: { "Content-Type": "application/json" },
  173. body: JSON.stringify({ prompt: userPrompt }),
  174. signal: abortRef.current.signal,
  175. });
  176. if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
  177. const reader = response.body?.getReader();
  178. if (!reader) throw new Error("No response body");
  179. const decoder = new TextDecoder();
  180. let buffer = "";
  181. let currentTree: UITree = { root: "", elements: {} };
  182. while (true) {
  183. const { done, value } = await reader.read();
  184. if (done) break;
  185. buffer += decoder.decode(value, { stream: true });
  186. const lines = buffer.split("\n");
  187. buffer = lines.pop() ?? "";
  188. for (const line of lines) {
  189. const patch = parsePatch(line);
  190. if (patch) {
  191. currentTree = applyPatch(currentTree, patch);
  192. setTree({ ...currentTree });
  193. setStreamLines((prev) => [...prev, line.trim()]);
  194. }
  195. }
  196. }
  197. if (buffer.trim()) {
  198. const patch = parsePatch(buffer);
  199. if (patch) {
  200. currentTree = applyPatch(currentTree, patch);
  201. setTree({ ...currentTree });
  202. setStreamLines((prev) => [...prev, buffer.trim()]);
  203. }
  204. }
  205. } catch (err) {
  206. if ((err as Error).name !== "AbortError") {
  207. console.error("Generation error:", err);
  208. }
  209. } finally {
  210. setIsLoading(false);
  211. }
  212. }, [userPrompt, isLoading]);
  213. const handleAction = () => {
  214. setActionFired(true);
  215. setTimeout(() => setActionFired(false), 2000);
  216. };
  217. // Render a single element
  218. const renderElement = (element: UIElement, elements: Record<string, UIElement>): React.ReactNode => {
  219. const { type, props, children: childKeys = [] } = element;
  220. const renderChildren = () => childKeys.map((key) => {
  221. const child = elements[key];
  222. return child ? renderElement(child, elements) : null;
  223. });
  224. const customClass = Array.isArray(props.className) ? (props.className as string[]).join(" ") : "";
  225. const baseClass = "animate-in fade-in slide-in-from-bottom-1 duration-200";
  226. switch (type) {
  227. // Layout
  228. case "Card":
  229. const maxWidthClass = props.maxWidth === "sm" ? "max-w-xs sm:min-w-[280px]" : props.maxWidth === "md" ? "max-w-sm sm:min-w-[320px]" : props.maxWidth === "lg" ? "max-w-md sm:min-w-[360px]" : "w-full";
  230. const centeredClass = props.centered ? "mx-auto" : "";
  231. return (
  232. <div key={element.key} className={`border border-border rounded-lg p-3 bg-background overflow-hidden ${maxWidthClass} ${centeredClass} ${baseClass} ${customClass}`}>
  233. {props.title ? <div className="font-semibold text-sm mb-1 text-left">{props.title as string}</div> : null}
  234. {props.description ? <div className="text-[10px] text-muted-foreground mb-2 text-left">{props.description as string}</div> : null}
  235. <div className="space-y-2">{renderChildren()}</div>
  236. </div>
  237. );
  238. case "Stack":
  239. const isHorizontal = props.direction === "horizontal";
  240. const stackGap = props.gap === "lg" ? "gap-3" : props.gap === "sm" ? "gap-1" : "gap-2";
  241. return (
  242. <div key={element.key} className={`flex ${isHorizontal ? "flex-row flex-wrap items-center" : "flex-col"} ${stackGap} ${baseClass} ${customClass}`}>
  243. {renderChildren()}
  244. </div>
  245. );
  246. case "Grid":
  247. const hasCustomCols = customClass.includes("grid-cols-");
  248. const cols = hasCustomCols ? "" : (props.columns === 4 ? "grid-cols-4" : props.columns === 3 ? "grid-cols-3" : props.columns === 2 ? "grid-cols-2" : "grid-cols-1");
  249. const gridGap = props.gap === "lg" ? "gap-3" : props.gap === "sm" ? "gap-1" : "gap-2";
  250. return (
  251. <div key={element.key} className={`grid ${cols} ${gridGap} ${baseClass} ${customClass}`}>
  252. {renderChildren()}
  253. </div>
  254. );
  255. case "Divider":
  256. return <hr key={element.key} className={`border-border my-2 ${baseClass} ${customClass}`} />;
  257. // Form Inputs
  258. case "Input":
  259. return (
  260. <div key={element.key} className={`${baseClass} ${customClass}`}>
  261. {props.label ? <label className="text-[10px] text-muted-foreground block mb-0.5 text-left">{props.label as string}</label> : null}
  262. <input
  263. type={(props.type as string) || "text"}
  264. placeholder={props.placeholder as string || ""}
  265. className="h-7 w-full bg-card border border-border rounded px-2 text-xs focus:outline-none focus:ring-1 focus:ring-foreground/20"
  266. />
  267. </div>
  268. );
  269. case "Textarea":
  270. const rows = (props.rows as number) || 3;
  271. return (
  272. <div key={element.key} className={`${baseClass} ${customClass}`}>
  273. {props.label ? <label className="text-[10px] text-muted-foreground block mb-0.5 text-left">{props.label as string}</label> : null}
  274. <textarea
  275. placeholder={props.placeholder as string || ""}
  276. rows={rows}
  277. className="w-full bg-card border border-border rounded px-2 py-1 text-xs resize-none focus:outline-none focus:ring-1 focus:ring-foreground/20"
  278. />
  279. </div>
  280. );
  281. case "Select":
  282. const selectOptions = (props.options as string[]) || [];
  283. const selectedValue = selectValues[element.key];
  284. const isOpen = openSelect === element.key;
  285. return (
  286. <div key={element.key} className={`relative ${baseClass} ${customClass}`}>
  287. {props.label ? <label className="text-[10px] text-muted-foreground block mb-0.5 text-left">{props.label as string}</label> : null}
  288. <div
  289. onClick={() => setOpenSelect(isOpen ? null : element.key)}
  290. className="h-7 w-full bg-card border border-border rounded px-2 text-xs flex items-center justify-between cursor-pointer hover:border-foreground/30 transition-colors"
  291. >
  292. <span className={selectedValue ? "text-foreground" : "text-muted-foreground/50"}>
  293. {selectedValue || props.placeholder as string || "Select..."}
  294. </span>
  295. <svg className={`w-3 h-3 transition-transform ${isOpen ? "rotate-180" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
  296. </div>
  297. {isOpen && selectOptions.length > 0 && (
  298. <div className="absolute z-10 top-full left-0 right-0 mt-1 bg-card border border-border rounded shadow-lg overflow-hidden">
  299. {selectOptions.map((opt, i) => (
  300. <div
  301. key={i}
  302. onClick={() => {
  303. setSelectValues((prev) => ({ ...prev, [element.key]: opt }));
  304. setOpenSelect(null);
  305. }}
  306. className={`px-2 py-1.5 text-xs text-left cursor-pointer hover:bg-muted transition-colors ${selectedValue === opt ? "bg-muted" : ""}`}
  307. >
  308. {opt}
  309. </div>
  310. ))}
  311. </div>
  312. )}
  313. </div>
  314. );
  315. case "Checkbox":
  316. return (
  317. <label key={element.key} className={`flex items-center gap-2 text-xs ${baseClass} ${customClass}`}>
  318. <div className={`w-3.5 h-3.5 border border-border rounded-sm ${props.checked ? "bg-foreground" : "bg-card"}`} />
  319. {props.label as string}
  320. </label>
  321. );
  322. case "Radio":
  323. const options = (props.options as string[]) || [];
  324. return (
  325. <div key={element.key} className={`space-y-1 ${baseClass} ${customClass}`}>
  326. {props.label ? <div className="text-[10px] text-muted-foreground mb-1 text-left">{props.label as string}</div> : null}
  327. {options.map((opt, i) => (
  328. <label key={i} className="flex items-center gap-2 text-xs">
  329. <div className={`w-3.5 h-3.5 border border-border rounded-full ${i === 0 ? "bg-foreground" : "bg-card"}`} />
  330. {opt}
  331. </label>
  332. ))}
  333. </div>
  334. );
  335. case "Switch":
  336. return (
  337. <label key={element.key} className={`flex items-center justify-between gap-2 text-xs ${baseClass} ${customClass}`}>
  338. <span>{props.label as string}</span>
  339. <div className={`w-8 h-4 rounded-full relative ${props.checked ? "bg-foreground" : "bg-border"}`}>
  340. <div className={`absolute w-3 h-3 rounded-full bg-background top-0.5 transition-all ${props.checked ? "right-0.5" : "left-0.5"}`} />
  341. </div>
  342. </label>
  343. );
  344. // Actions
  345. case "Button":
  346. const variant = props.variant as string;
  347. const btnClass = variant === "danger" ? "bg-red-500 text-white" : variant === "secondary" ? "bg-card border border-border text-foreground" : "bg-foreground text-background";
  348. return (
  349. <button key={element.key} onClick={handleAction} className={`self-start px-3 py-1.5 rounded text-xs font-medium hover:opacity-90 transition-opacity ${btnClass} ${baseClass} ${customClass}`}>
  350. {props.label as string}
  351. </button>
  352. );
  353. case "Link":
  354. return (
  355. <span key={element.key} className={`text-xs text-blue-500 underline cursor-pointer ${baseClass} ${customClass}`}>
  356. {props.label as string}
  357. </span>
  358. );
  359. // Typography
  360. case "Heading":
  361. const level = (props.level as number) || 2;
  362. const headingClass = level === 1 ? "text-lg font-bold" : level === 3 ? "text-xs font-semibold" : level === 4 ? "text-[10px] font-semibold" : "text-sm font-semibold";
  363. return <div key={element.key} className={`${headingClass} text-left ${baseClass} ${customClass}`}>{props.text as string}</div>;
  364. case "Text":
  365. const textVariant = props.variant as string;
  366. const textClass = textVariant === "caption" ? "text-[10px]" : textVariant === "muted" ? "text-xs text-muted-foreground" : "text-xs";
  367. return <p key={element.key} className={`${textClass} text-left ${baseClass} ${customClass}`}>{props.content as string}</p>;
  368. // Data Display
  369. case "Image":
  370. const hasCustomSize = customClass.includes("w-") || customClass.includes("h-");
  371. const imgStyle = hasCustomSize ? {} : { width: (props.width as number) || 80, height: (props.height as number) || 60 };
  372. return (
  373. <div key={element.key} className={`bg-card border border-border rounded flex items-center justify-center text-[10px] text-muted-foreground aspect-video ${baseClass} ${customClass}`} style={imgStyle}>
  374. {props.alt as string || "img"}
  375. </div>
  376. );
  377. case "Avatar":
  378. const name = props.name as string || "?";
  379. const initials = name.split(" ").map(n => n[0]).join("").slice(0, 2).toUpperCase();
  380. const avatarSize = props.size === "lg" ? "w-10 h-10 text-sm" : props.size === "sm" ? "w-6 h-6 text-[8px]" : "w-8 h-8 text-[10px]";
  381. return (
  382. <div key={element.key} className={`${avatarSize} rounded-full bg-muted flex items-center justify-center font-medium ${baseClass} ${customClass}`}>
  383. {initials}
  384. </div>
  385. );
  386. case "Badge":
  387. const badgeVariant = props.variant as string;
  388. const badgeClass = badgeVariant === "success" ? "bg-green-100 text-green-800" : badgeVariant === "warning" ? "bg-yellow-100 text-yellow-800" : badgeVariant === "danger" ? "bg-red-100 text-red-800" : "bg-muted text-foreground";
  389. return <span key={element.key} className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${badgeClass} ${baseClass} ${customClass}`}>{props.text as string}</span>;
  390. case "Alert":
  391. const alertType = props.type as string;
  392. const alertClass = alertType === "success" ? "bg-green-50 border-green-200" : alertType === "warning" ? "bg-yellow-50 border-yellow-200" : alertType === "error" ? "bg-red-50 border-red-200" : "bg-blue-50 border-blue-200";
  393. return (
  394. <div key={element.key} className={`p-2 rounded border ${alertClass} ${baseClass} ${customClass}`}>
  395. <div className="text-xs font-medium">{props.title as string}</div>
  396. {props.message ? <div className="text-[10px] mt-0.5">{props.message as string}</div> : null}
  397. </div>
  398. );
  399. case "Progress":
  400. const value = Math.min(100, Math.max(0, (props.value as number) || 0));
  401. return (
  402. <div key={element.key} className={`${baseClass} ${customClass}`}>
  403. {props.label ? <div className="text-[10px] text-muted-foreground mb-1 text-left">{props.label as string}</div> : null}
  404. <div className="h-2 bg-muted rounded-full overflow-hidden">
  405. <div className="h-full bg-foreground rounded-full transition-all" style={{ width: `${value}%` }} />
  406. </div>
  407. </div>
  408. );
  409. case "Rating":
  410. const ratingValue = (props.value as number) || 0;
  411. const maxRating = (props.max as number) || 5;
  412. return (
  413. <div key={element.key} className={`${baseClass} ${customClass}`}>
  414. {props.label ? <div className="text-[10px] text-muted-foreground mb-1 text-left">{props.label as string}</div> : null}
  415. <div className="flex gap-0.5">
  416. {Array.from({ length: maxRating }).map((_, i) => (
  417. <span key={i} className={`text-sm ${i < ratingValue ? "text-yellow-400" : "text-muted"}`}>*</span>
  418. ))}
  419. </div>
  420. </div>
  421. );
  422. // Fallback for Form type (legacy)
  423. case "Form":
  424. return (
  425. <div key={element.key} className={`border border-border rounded-lg p-3 bg-background ${baseClass} ${customClass}`}>
  426. {props.title ? <div className="font-semibold text-sm mb-2 text-left">{props.title as string}</div> : null}
  427. <div className="space-y-2">{renderChildren()}</div>
  428. </div>
  429. );
  430. default:
  431. return <div key={element.key} className={`text-[10px] text-muted-foreground ${baseClass} ${customClass}`}>[{type}]</div>;
  432. }
  433. };
  434. // Render preview from tree
  435. const renderPreview = () => {
  436. const currentTree = mode === "simulation" ? currentSimulationStage?.tree : tree;
  437. if (!currentTree || !currentTree.root || !currentTree.elements[currentTree.root]) {
  438. return <div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">{isLoading ? "generating..." : "waiting..."}</div>;
  439. }
  440. const root = currentTree.elements[currentTree.root];
  441. if (!root) return null;
  442. return (
  443. <div className="animate-in fade-in duration-200 w-full flex flex-col items-center py-4">
  444. <div className="my-auto">
  445. {renderElement(root, currentTree.elements)}
  446. {actionFired && (
  447. <div className="mt-3 text-xs font-mono text-muted-foreground text-center animate-in fade-in slide-in-from-bottom-2">
  448. onAction()
  449. </div>
  450. )}
  451. </div>
  452. </div>
  453. );
  454. };
  455. const currentTree = mode === "simulation" ? currentSimulationStage?.tree : tree;
  456. const jsonCode = currentTree ? JSON.stringify(currentTree, null, 2) : "// waiting...";
  457. const isTypingSimulation = mode === "simulation" && phase === "typing";
  458. const isStreamingSimulation = mode === "simulation" && phase === "streaming";
  459. const showLoadingDots = isStreamingSimulation || isLoading;
  460. return (
  461. <div className="w-full max-w-4xl mx-auto">
  462. {/* Prompt input */}
  463. <div className="mb-6">
  464. <div className="border border-border rounded p-3 bg-card font-mono text-sm min-h-[44px] flex items-center justify-between">
  465. {mode === "simulation" ? (
  466. <div className="flex items-center flex-1">
  467. <span className="inline-flex items-center h-5">{typedPrompt}</span>
  468. {isTypingSimulation && (
  469. <span className="inline-block w-2 h-4 bg-foreground ml-0.5 animate-pulse" />
  470. )}
  471. </div>
  472. ) : (
  473. <form
  474. className="flex items-center flex-1"
  475. onSubmit={(e) => {
  476. e.preventDefault();
  477. handleSubmit();
  478. }}
  479. >
  480. <input
  481. type="text"
  482. value={userPrompt}
  483. onChange={(e) => setUserPrompt(e.target.value)}
  484. placeholder="Describe what you want to build..."
  485. className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground/50"
  486. disabled={isLoading}
  487. maxLength={140}
  488. autoFocus
  489. />
  490. </form>
  491. )}
  492. {(mode === "simulation" || isLoading) ? (
  493. <button
  494. onClick={stopGeneration}
  495. className="ml-2 p-1 text-muted-foreground hover:text-foreground transition-colors"
  496. aria-label="Stop"
  497. >
  498. <svg
  499. width="14"
  500. height="14"
  501. viewBox="0 0 24 24"
  502. fill="currentColor"
  503. stroke="none"
  504. >
  505. <rect x="6" y="6" width="12" height="12" rx="1" />
  506. </svg>
  507. </button>
  508. ) : (
  509. <button
  510. onClick={handleSubmit}
  511. disabled={!userPrompt.trim()}
  512. className="ml-2 p-1 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-30"
  513. aria-label="Submit"
  514. >
  515. <svg
  516. width="14"
  517. height="14"
  518. viewBox="0 0 24 24"
  519. fill="none"
  520. stroke="currentColor"
  521. strokeWidth="2"
  522. strokeLinecap="round"
  523. strokeLinejoin="round"
  524. >
  525. <path d="M12 5v14" />
  526. <path d="M19 12l-7 7-7-7" />
  527. </svg>
  528. </button>
  529. )}
  530. </div>
  531. <div className="mt-2 text-xs text-muted-foreground">
  532. Try: &quot;Create a login form&quot; or &quot;Build a feedback form with rating&quot;
  533. </div>
  534. </div>
  535. <div className="grid lg:grid-cols-2 gap-4">
  536. {/* Tabbed code/stream/json panel */}
  537. <div>
  538. <div className="flex gap-4 mb-2">
  539. {(["json", "stream", "code"] as const).map((tab) => (
  540. <button
  541. key={tab}
  542. onClick={() => setActiveTab(tab)}
  543. className={`text-xs font-mono transition-colors ${
  544. activeTab === tab ? "text-foreground" : "text-muted-foreground hover:text-foreground"
  545. }`}
  546. >
  547. {tab}
  548. </button>
  549. ))}
  550. </div>
  551. <div className="border border-border rounded p-3 bg-card font-mono text-xs h-96 overflow-auto text-left">
  552. {activeTab === "stream" && (
  553. <div className="space-y-1">
  554. {streamLines.map((line, i) => (
  555. <div
  556. key={i}
  557. className="text-muted-foreground truncate animate-in fade-in slide-in-from-bottom-1 duration-200"
  558. >
  559. {line}
  560. </div>
  561. ))}
  562. {showLoadingDots && (
  563. <div className="flex gap-1 mt-2">
  564. <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse" />
  565. <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse [animation-delay:75ms]" />
  566. <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse [animation-delay:150ms]" />
  567. </div>
  568. )}
  569. {streamLines.length === 0 && !showLoadingDots && (
  570. <div className="text-muted-foreground/50">waiting...</div>
  571. )}
  572. </div>
  573. )}
  574. <div className={activeTab === "json" ? "" : "hidden"}>
  575. <CodeBlock code={jsonCode} lang="json" />
  576. </div>
  577. <div className={activeTab === "code" ? "" : "hidden"}>
  578. <CodeBlock code={CODE_EXAMPLE} lang="tsx" />
  579. </div>
  580. </div>
  581. </div>
  582. {/* Rendered output */}
  583. <div>
  584. <div className="text-xs text-muted-foreground mb-2 font-mono">render</div>
  585. <div className="border border-border rounded p-3 bg-card h-96 overflow-auto flex flex-col">
  586. {renderPreview()}
  587. </div>
  588. </div>
  589. </div>
  590. </div>
  591. );
  592. }