demo.tsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641
  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 baseClass = "animate-in fade-in slide-in-from-bottom-1 duration-200";
  225. switch (type) {
  226. // Layout
  227. case "Card":
  228. const maxWidthClass = props.maxWidth === "sm" ? "max-w-xs min-w-[280px]" : props.maxWidth === "md" ? "max-w-sm min-w-[320px]" : props.maxWidth === "lg" ? "max-w-md min-w-[360px]" : "";
  229. const centeredClass = props.centered ? "mx-auto" : "";
  230. return (
  231. <div key={element.key} className={`border border-border rounded-lg p-3 bg-background ${maxWidthClass} ${centeredClass} ${baseClass}`}>
  232. {props.title ? <div className="font-semibold text-sm mb-1 text-left">{props.title as string}</div> : null}
  233. {props.description ? <div className="text-[10px] text-muted-foreground mb-2 text-left">{props.description as string}</div> : null}
  234. <div className="space-y-2">{renderChildren()}</div>
  235. </div>
  236. );
  237. case "Stack":
  238. const isHorizontal = props.direction === "horizontal";
  239. const stackGap = props.gap === "lg" ? "gap-3" : props.gap === "sm" ? "gap-1" : "gap-2";
  240. return (
  241. <div key={element.key} className={`flex ${isHorizontal ? "flex-row items-center" : "flex-col"} ${stackGap} ${baseClass}`}>
  242. {renderChildren()}
  243. </div>
  244. );
  245. case "Grid":
  246. const cols = props.columns === 4 ? "grid-cols-4" : props.columns === 3 ? "grid-cols-3" : "grid-cols-2";
  247. const gridGap = props.gap === "lg" ? "gap-3" : props.gap === "sm" ? "gap-1" : "gap-2";
  248. return (
  249. <div key={element.key} className={`grid ${cols} ${gridGap} ${baseClass}`}>
  250. {renderChildren()}
  251. </div>
  252. );
  253. case "Divider":
  254. return <hr key={element.key} className={`border-border my-2 ${baseClass}`} />;
  255. // Form Inputs
  256. case "Input":
  257. return (
  258. <div key={element.key} className={baseClass}>
  259. {props.label ? <label className="text-[10px] text-muted-foreground block mb-0.5 text-left">{props.label as string}</label> : null}
  260. <input
  261. type={(props.type as string) || "text"}
  262. placeholder={props.placeholder as string || ""}
  263. 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"
  264. />
  265. </div>
  266. );
  267. case "Textarea":
  268. const rows = (props.rows as number) || 3;
  269. return (
  270. <div key={element.key} className={baseClass}>
  271. {props.label ? <label className="text-[10px] text-muted-foreground block mb-0.5 text-left">{props.label as string}</label> : null}
  272. <textarea
  273. placeholder={props.placeholder as string || ""}
  274. rows={rows}
  275. 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"
  276. />
  277. </div>
  278. );
  279. case "Select":
  280. const selectOptions = (props.options as string[]) || [];
  281. const selectedValue = selectValues[element.key];
  282. const isOpen = openSelect === element.key;
  283. return (
  284. <div key={element.key} className={`relative ${baseClass}`}>
  285. {props.label ? <label className="text-[10px] text-muted-foreground block mb-0.5 text-left">{props.label as string}</label> : null}
  286. <div
  287. onClick={() => setOpenSelect(isOpen ? null : element.key)}
  288. 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"
  289. >
  290. <span className={selectedValue ? "text-foreground" : "text-muted-foreground/50"}>
  291. {selectedValue || props.placeholder as string || "Select..."}
  292. </span>
  293. <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>
  294. </div>
  295. {isOpen && selectOptions.length > 0 && (
  296. <div className="absolute z-10 top-full left-0 right-0 mt-1 bg-card border border-border rounded shadow-lg overflow-hidden">
  297. {selectOptions.map((opt, i) => (
  298. <div
  299. key={i}
  300. onClick={() => {
  301. setSelectValues((prev) => ({ ...prev, [element.key]: opt }));
  302. setOpenSelect(null);
  303. }}
  304. className={`px-2 py-1.5 text-xs text-left cursor-pointer hover:bg-muted transition-colors ${selectedValue === opt ? "bg-muted" : ""}`}
  305. >
  306. {opt}
  307. </div>
  308. ))}
  309. </div>
  310. )}
  311. </div>
  312. );
  313. case "Checkbox":
  314. return (
  315. <label key={element.key} className={`flex items-center gap-2 text-xs ${baseClass}`}>
  316. <div className={`w-3.5 h-3.5 border border-border rounded-sm ${props.checked ? "bg-foreground" : "bg-card"}`} />
  317. {props.label as string}
  318. </label>
  319. );
  320. case "Radio":
  321. const options = (props.options as string[]) || [];
  322. return (
  323. <div key={element.key} className={`space-y-1 ${baseClass}`}>
  324. {props.label ? <div className="text-[10px] text-muted-foreground mb-1 text-left">{props.label as string}</div> : null}
  325. {options.map((opt, i) => (
  326. <label key={i} className="flex items-center gap-2 text-xs">
  327. <div className={`w-3.5 h-3.5 border border-border rounded-full ${i === 0 ? "bg-foreground" : "bg-card"}`} />
  328. {opt}
  329. </label>
  330. ))}
  331. </div>
  332. );
  333. case "Switch":
  334. return (
  335. <label key={element.key} className={`flex items-center justify-between gap-2 text-xs ${baseClass}`}>
  336. <span>{props.label as string}</span>
  337. <div className={`w-8 h-4 rounded-full relative ${props.checked ? "bg-foreground" : "bg-border"}`}>
  338. <div className={`absolute w-3 h-3 rounded-full bg-background top-0.5 transition-all ${props.checked ? "right-0.5" : "left-0.5"}`} />
  339. </div>
  340. </label>
  341. );
  342. // Actions
  343. case "Button":
  344. const variant = props.variant as string;
  345. const btnClass = variant === "danger" ? "bg-red-500 text-white" : variant === "secondary" ? "bg-card border border-border text-foreground" : "bg-foreground text-background";
  346. return (
  347. <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}`}>
  348. {props.label as string}
  349. </button>
  350. );
  351. case "Link":
  352. return (
  353. <span key={element.key} className={`text-xs text-blue-500 underline cursor-pointer ${baseClass}`}>
  354. {props.label as string}
  355. </span>
  356. );
  357. // Typography
  358. case "Heading":
  359. const level = (props.level as number) || 2;
  360. const headingClass = level === 1 ? "text-lg font-bold" : level === 3 ? "text-xs font-semibold" : level === 4 ? "text-[10px] font-semibold" : "text-sm font-semibold";
  361. return <div key={element.key} className={`${headingClass} text-left ${baseClass}`}>{props.text as string}</div>;
  362. case "Text":
  363. const textVariant = props.variant as string;
  364. const textClass = textVariant === "caption" ? "text-[10px]" : textVariant === "muted" ? "text-xs text-muted-foreground" : "text-xs";
  365. return <p key={element.key} className={`${textClass} text-left ${baseClass}`}>{props.content as string}</p>;
  366. // Data Display
  367. case "Image":
  368. return (
  369. <div key={element.key} className={`bg-card border border-border rounded flex items-center justify-center text-[10px] text-muted-foreground ${baseClass}`} style={{ width: (props.width as number) || 80, height: (props.height as number) || 60 }}>
  370. {props.alt as string || "img"}
  371. </div>
  372. );
  373. case "Avatar":
  374. const name = props.name as string || "?";
  375. const initials = name.split(" ").map(n => n[0]).join("").slice(0, 2).toUpperCase();
  376. 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]";
  377. return (
  378. <div key={element.key} className={`${avatarSize} rounded-full bg-muted flex items-center justify-center font-medium ${baseClass}`}>
  379. {initials}
  380. </div>
  381. );
  382. case "Badge":
  383. const badgeVariant = props.variant as string;
  384. 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";
  385. return <span key={element.key} className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${badgeClass} ${baseClass}`}>{props.text as string}</span>;
  386. case "Alert":
  387. const alertType = props.type as string;
  388. 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";
  389. return (
  390. <div key={element.key} className={`p-2 rounded border ${alertClass} ${baseClass}`}>
  391. <div className="text-xs font-medium">{props.title as string}</div>
  392. {props.message ? <div className="text-[10px] mt-0.5">{props.message as string}</div> : null}
  393. </div>
  394. );
  395. case "Progress":
  396. const value = Math.min(100, Math.max(0, (props.value as number) || 0));
  397. return (
  398. <div key={element.key} className={baseClass}>
  399. {props.label ? <div className="text-[10px] text-muted-foreground mb-1 text-left">{props.label as string}</div> : null}
  400. <div className="h-2 bg-muted rounded-full overflow-hidden">
  401. <div className="h-full bg-foreground rounded-full transition-all" style={{ width: `${value}%` }} />
  402. </div>
  403. </div>
  404. );
  405. case "Rating":
  406. const ratingValue = (props.value as number) || 0;
  407. const maxRating = (props.max as number) || 5;
  408. return (
  409. <div key={element.key} className={baseClass}>
  410. {props.label ? <div className="text-[10px] text-muted-foreground mb-1 text-left">{props.label as string}</div> : null}
  411. <div className="flex gap-0.5">
  412. {Array.from({ length: maxRating }).map((_, i) => (
  413. <span key={i} className={`text-sm ${i < ratingValue ? "text-yellow-400" : "text-muted"}`}>*</span>
  414. ))}
  415. </div>
  416. </div>
  417. );
  418. // Fallback for Form type (legacy)
  419. case "Form":
  420. return (
  421. <div key={element.key} className={`border border-border rounded-lg p-3 bg-background ${baseClass}`}>
  422. {props.title ? <div className="font-semibold text-sm mb-2 text-left">{props.title as string}</div> : null}
  423. <div className="space-y-2">{renderChildren()}</div>
  424. </div>
  425. );
  426. default:
  427. return <div key={element.key} className={`text-[10px] text-muted-foreground ${baseClass}`}>[{type}]</div>;
  428. }
  429. };
  430. // Render preview from tree
  431. const renderPreview = () => {
  432. const currentTree = mode === "simulation" ? currentSimulationStage?.tree : tree;
  433. if (!currentTree || !currentTree.root || !currentTree.elements[currentTree.root]) {
  434. return <div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">{isLoading ? "generating..." : "waiting..."}</div>;
  435. }
  436. const root = currentTree.elements[currentTree.root];
  437. if (!root) return null;
  438. return (
  439. <div className="animate-in fade-in duration-200 w-full flex flex-col items-center py-4">
  440. <div className="my-auto">
  441. {renderElement(root, currentTree.elements)}
  442. {actionFired && (
  443. <div className="mt-3 text-xs font-mono text-muted-foreground text-center animate-in fade-in slide-in-from-bottom-2">
  444. onAction()
  445. </div>
  446. )}
  447. </div>
  448. </div>
  449. );
  450. };
  451. const currentTree = mode === "simulation" ? currentSimulationStage?.tree : tree;
  452. const jsonCode = currentTree ? JSON.stringify(currentTree, null, 2) : "// waiting...";
  453. const isTypingSimulation = mode === "simulation" && phase === "typing";
  454. const isStreamingSimulation = mode === "simulation" && phase === "streaming";
  455. const showLoadingDots = isStreamingSimulation || isLoading;
  456. return (
  457. <div className="w-full max-w-4xl mx-auto">
  458. {/* Prompt input */}
  459. <div className="mb-6">
  460. <div className="border border-border rounded p-3 bg-card font-mono text-sm min-h-[44px] flex items-center justify-between">
  461. {mode === "simulation" ? (
  462. <div className="flex items-center flex-1">
  463. <span className="inline-flex items-center h-5">{typedPrompt}</span>
  464. {isTypingSimulation && (
  465. <span className="inline-block w-2 h-4 bg-foreground ml-0.5 animate-pulse" />
  466. )}
  467. </div>
  468. ) : (
  469. <form
  470. className="flex items-center flex-1"
  471. onSubmit={(e) => {
  472. e.preventDefault();
  473. handleSubmit();
  474. }}
  475. >
  476. <input
  477. type="text"
  478. value={userPrompt}
  479. onChange={(e) => setUserPrompt(e.target.value)}
  480. placeholder="Describe what you want to build..."
  481. className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground/50"
  482. disabled={isLoading}
  483. maxLength={140}
  484. autoFocus
  485. />
  486. </form>
  487. )}
  488. {(mode === "simulation" || isLoading) ? (
  489. <button
  490. onClick={stopGeneration}
  491. className="ml-2 p-1 text-muted-foreground hover:text-foreground transition-colors"
  492. aria-label="Stop"
  493. >
  494. <svg
  495. width="14"
  496. height="14"
  497. viewBox="0 0 24 24"
  498. fill="currentColor"
  499. stroke="none"
  500. >
  501. <rect x="6" y="6" width="12" height="12" rx="1" />
  502. </svg>
  503. </button>
  504. ) : (
  505. <button
  506. onClick={handleSubmit}
  507. disabled={!userPrompt.trim()}
  508. className="ml-2 p-1 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-30"
  509. aria-label="Submit"
  510. >
  511. <svg
  512. width="14"
  513. height="14"
  514. viewBox="0 0 24 24"
  515. fill="none"
  516. stroke="currentColor"
  517. strokeWidth="2"
  518. strokeLinecap="round"
  519. strokeLinejoin="round"
  520. >
  521. <path d="M12 5v14" />
  522. <path d="M19 12l-7 7-7-7" />
  523. </svg>
  524. </button>
  525. )}
  526. </div>
  527. <div className="mt-2 text-xs text-muted-foreground">
  528. Try: &quot;Create a login form&quot; or &quot;Build a feedback form with rating&quot;
  529. </div>
  530. </div>
  531. <div className="grid lg:grid-cols-2 gap-4">
  532. {/* Tabbed code/stream/json panel */}
  533. <div>
  534. <div className="flex gap-4 mb-2">
  535. {(["json", "stream", "code"] as const).map((tab) => (
  536. <button
  537. key={tab}
  538. onClick={() => setActiveTab(tab)}
  539. className={`text-xs font-mono transition-colors ${
  540. activeTab === tab ? "text-foreground" : "text-muted-foreground hover:text-foreground"
  541. }`}
  542. >
  543. {tab}
  544. </button>
  545. ))}
  546. </div>
  547. <div className="border border-border rounded p-3 bg-card font-mono text-xs h-96 overflow-auto text-left">
  548. {activeTab === "stream" && (
  549. <div className="space-y-1">
  550. {streamLines.map((line, i) => (
  551. <div
  552. key={i}
  553. className="text-muted-foreground truncate animate-in fade-in slide-in-from-bottom-1 duration-200"
  554. >
  555. {line}
  556. </div>
  557. ))}
  558. {showLoadingDots && (
  559. <div className="flex gap-1 mt-2">
  560. <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse" />
  561. <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse [animation-delay:75ms]" />
  562. <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse [animation-delay:150ms]" />
  563. </div>
  564. )}
  565. {streamLines.length === 0 && !showLoadingDots && (
  566. <div className="text-muted-foreground/50">waiting...</div>
  567. )}
  568. </div>
  569. )}
  570. <div className={activeTab === "json" ? "" : "hidden"}>
  571. <CodeBlock code={jsonCode} lang="json" />
  572. </div>
  573. <div className={activeTab === "code" ? "" : "hidden"}>
  574. <CodeBlock code={CODE_EXAMPLE} lang="tsx" />
  575. </div>
  576. </div>
  577. </div>
  578. {/* Rendered output */}
  579. <div>
  580. <div className="text-xs text-muted-foreground mb-2 font-mono">render</div>
  581. <div className="border border-border rounded p-3 bg-card h-96 overflow-auto flex flex-col">
  582. {renderPreview()}
  583. </div>
  584. </div>
  585. </div>
  586. </div>
  587. );
  588. }