demo.tsx 59 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771
  1. "use client";
  2. import React, {
  3. useEffect,
  4. useState,
  5. useCallback,
  6. useRef,
  7. useMemo,
  8. } from "react";
  9. import { useUIStream } from "@json-render/react";
  10. import type { Spec } from "@json-render/core";
  11. import { collectUsedComponents, serializeProps } from "@json-render/codegen";
  12. import { toast } from "sonner";
  13. import { CodeBlock } from "./code-block";
  14. import { CopyButton } from "./copy-button";
  15. import { Toaster } from "./ui/sonner";
  16. import { PlaygroundRenderer } from "@/lib/render/renderer";
  17. import { playgroundCatalog } from "@/lib/render/catalog";
  18. import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
  19. const SIMULATION_PROMPT = "Show a team performance dashboard";
  20. interface SimulationStage {
  21. tree: Spec;
  22. stream: string;
  23. }
  24. const DASH_STATE = {
  25. chartData: [
  26. { label: "Mon", value: 12 },
  27. { label: "Tue", value: 28 },
  28. { label: "Wed", value: 19 },
  29. { label: "Thu", value: 34 },
  30. { label: "Fri", value: 45 },
  31. { label: "Sat", value: 38 },
  32. { label: "Sun", value: 52 },
  33. ],
  34. };
  35. const METRIC_REVENUE = {
  36. type: "Metric",
  37. props: {
  38. label: "Weekly Revenue",
  39. value: "12,400",
  40. prefix: "$",
  41. change: "+18%",
  42. changeType: "positive",
  43. },
  44. } as const;
  45. const CHART = {
  46. type: "LineGraph",
  47. props: { data: { $state: "/chartData" } },
  48. } as const;
  49. const SEP = { type: "Separator", props: {} } as const;
  50. const PROGRESS_DEALS = {
  51. type: "Progress",
  52. props: { value: 72, label: "Deals Closed -- 72%" },
  53. } as const;
  54. const PROGRESS_RETENTION = {
  55. type: "Progress",
  56. props: { value: 91, label: "Retention -- 91%" },
  57. } as const;
  58. const SIMULATION_STAGES: SimulationStage[] = [
  59. {
  60. tree: {
  61. root: "card",
  62. state: DASH_STATE,
  63. elements: {
  64. card: {
  65. type: "Card",
  66. props: { title: "Team Performance", maxWidth: "sm", centered: true },
  67. children: [],
  68. },
  69. },
  70. },
  71. stream: '{"op":"add","path":"/root","value":"card"}',
  72. },
  73. {
  74. tree: {
  75. root: "card",
  76. state: DASH_STATE,
  77. elements: {
  78. card: {
  79. type: "Card",
  80. props: { title: "Team Performance", maxWidth: "sm", centered: true },
  81. children: ["m1"],
  82. },
  83. m1: METRIC_REVENUE,
  84. },
  85. },
  86. stream:
  87. '{"op":"add","path":"/elements/m1","value":{"type":"Metric","props":{"label":"Weekly Revenue","value":"12,400","prefix":"$","change":"+18%","changeType":"positive"}}}',
  88. },
  89. {
  90. tree: {
  91. root: "card",
  92. state: DASH_STATE,
  93. elements: {
  94. card: {
  95. type: "Card",
  96. props: { title: "Team Performance", maxWidth: "sm", centered: true },
  97. children: ["m1", "chart"],
  98. },
  99. m1: METRIC_REVENUE,
  100. chart: CHART,
  101. },
  102. },
  103. stream:
  104. '{"op":"add","path":"/elements/chart","value":{"type":"LineGraph","props":{"data":{"$state":"/chartData"}}}}',
  105. },
  106. {
  107. tree: {
  108. root: "card",
  109. state: DASH_STATE,
  110. elements: {
  111. card: {
  112. type: "Card",
  113. props: { title: "Team Performance", maxWidth: "sm", centered: true },
  114. children: ["m1", "chart", "sep", "p1"],
  115. },
  116. m1: METRIC_REVENUE,
  117. chart: CHART,
  118. sep: SEP,
  119. p1: PROGRESS_DEALS,
  120. },
  121. },
  122. stream:
  123. '{"op":"add","path":"/elements/p1","value":{"type":"Progress","props":{"value":72,"label":"Deals Closed -- 72%"}}}',
  124. },
  125. {
  126. tree: {
  127. root: "card",
  128. state: DASH_STATE,
  129. elements: {
  130. card: {
  131. type: "Card",
  132. props: { title: "Team Performance", maxWidth: "sm", centered: true },
  133. children: ["m1", "chart", "sep", "p1", "p2"],
  134. },
  135. m1: METRIC_REVENUE,
  136. chart: CHART,
  137. sep: SEP,
  138. p1: PROGRESS_DEALS,
  139. p2: PROGRESS_RETENTION,
  140. },
  141. },
  142. stream:
  143. '{"op":"add","path":"/elements/p2","value":{"type":"Progress","props":{"value":91,"label":"Retention -- 91%"}}}',
  144. },
  145. ];
  146. type Mode = "simulation" | "interactive";
  147. type Phase = "typing" | "streaming" | "complete";
  148. type Tab = "stream" | "json" | "nested" | "catalog";
  149. type RenderView = "dynamic" | "static";
  150. interface DemoProps {
  151. fullscreen?: boolean;
  152. skipSimulation?: boolean;
  153. }
  154. /**
  155. * Convert a flat Spec into a nested tree structure that is easier for humans
  156. * to read. Children keys are resolved recursively into inline objects.
  157. */
  158. function specToNested(spec: Spec): Record<string, unknown> {
  159. function resolve(key: string): Record<string, unknown> {
  160. const el = spec.elements[key];
  161. if (!el) return { _key: key, _missing: true };
  162. const node: Record<string, unknown> = { type: el.type };
  163. if (el.props && Object.keys(el.props).length > 0) {
  164. node.props = el.props;
  165. }
  166. if (el.visible !== undefined) {
  167. node.visible = el.visible;
  168. }
  169. if (el.on && Object.keys(el.on).length > 0) {
  170. node.on = el.on;
  171. }
  172. if (el.repeat) {
  173. node.repeat = el.repeat;
  174. }
  175. if (el.children && el.children.length > 0) {
  176. node.children = el.children.map(resolve);
  177. }
  178. return node;
  179. }
  180. const result: Record<string, unknown> = {};
  181. if (spec.state && Object.keys(spec.state).length > 0) {
  182. result.state = spec.state;
  183. }
  184. result.elements = resolve(spec.root);
  185. return result;
  186. }
  187. const EXAMPLE_PROMPTS = [
  188. "Recipe card with rating and ingredients",
  189. "Order receipt with item list and total",
  190. "Team member profile card",
  191. "Notification inbox with alerts",
  192. ];
  193. export function Demo({
  194. fullscreen = false,
  195. skipSimulation = false,
  196. }: DemoProps) {
  197. const [mode, setMode] = useState<Mode>(
  198. skipSimulation ? "interactive" : "simulation",
  199. );
  200. const [phase, setPhase] = useState<Phase>(
  201. skipSimulation ? "complete" : "typing",
  202. );
  203. const [typedPrompt, setTypedPrompt] = useState("");
  204. const [userPrompt, setUserPrompt] = useState("");
  205. const [stageIndex, setStageIndex] = useState(-1);
  206. const [streamLines, setStreamLines] = useState<string[]>([]);
  207. const [activeTab, setActiveTab] = useState<Tab>("json");
  208. const [renderView, setRenderView] = useState<RenderView>("dynamic");
  209. const [simulationTree, setSimulationTree] = useState<Spec | null>(null);
  210. const [isFullscreen, setIsFullscreen] = useState(false);
  211. const [showExportModal, setShowExportModal] = useState(false);
  212. const [selectedExportFile, setSelectedExportFile] = useState<string | null>(
  213. null,
  214. );
  215. const [showMobileFileTree, setShowMobileFileTree] = useState(false);
  216. const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(
  217. new Set(),
  218. );
  219. const inputRef = useRef<HTMLInputElement>(null);
  220. const [catalogSection, setCatalogSection] = useState<
  221. "components" | "actions"
  222. >("components");
  223. // Catalog data for the catalog tab
  224. const catalogData = useMemo(
  225. () => buildCatalogDisplayData(playgroundCatalog.data),
  226. [],
  227. );
  228. // Disable body scroll when any modal is open
  229. useEffect(() => {
  230. if (isFullscreen || showExportModal) {
  231. document.body.style.overflow = "hidden";
  232. } else {
  233. document.body.style.overflow = "";
  234. }
  235. return () => {
  236. document.body.style.overflow = "";
  237. };
  238. }, [isFullscreen, showExportModal]);
  239. // Use the library's useUIStream hook for real API calls
  240. const {
  241. spec: apiSpec,
  242. isStreaming,
  243. send,
  244. clear,
  245. rawLines: apiRawLines,
  246. } = useUIStream({
  247. api: "/api/generate",
  248. onError: (err: Error) => {
  249. console.error("Generation error:", err);
  250. toast.error(err.message || "Generation failed. Please try again.");
  251. },
  252. } as Parameters<typeof useUIStream>[0]);
  253. const currentSimulationStage =
  254. stageIndex >= 0 ? SIMULATION_STAGES[stageIndex] : null;
  255. // Determine which tree to display - keep simulation tree until new API response
  256. const currentTree =
  257. mode === "simulation"
  258. ? currentSimulationStage?.tree || simulationTree
  259. : apiSpec || simulationTree;
  260. const stopGeneration = useCallback(() => {
  261. if (mode === "simulation") {
  262. setMode("interactive");
  263. setPhase("complete");
  264. setTypedPrompt(SIMULATION_PROMPT);
  265. setUserPrompt("");
  266. }
  267. clear();
  268. }, [mode, clear]);
  269. // Typing effect for simulation
  270. useEffect(() => {
  271. if (mode !== "simulation" || phase !== "typing") return;
  272. let i = 0;
  273. const interval = setInterval(() => {
  274. if (i < SIMULATION_PROMPT.length) {
  275. setTypedPrompt(SIMULATION_PROMPT.slice(0, i + 1));
  276. i++;
  277. } else {
  278. clearInterval(interval);
  279. setTimeout(() => setPhase("streaming"), 500);
  280. }
  281. }, 20);
  282. return () => clearInterval(interval);
  283. }, [mode, phase]);
  284. // Streaming effect for simulation
  285. useEffect(() => {
  286. if (mode !== "simulation" || phase !== "streaming") return;
  287. let i = 0;
  288. const interval = setInterval(() => {
  289. if (i < SIMULATION_STAGES.length) {
  290. const stage = SIMULATION_STAGES[i];
  291. if (stage) {
  292. setStageIndex(i);
  293. setStreamLines((prev) => [...prev, stage.stream]);
  294. setSimulationTree(stage.tree);
  295. }
  296. i++;
  297. } else {
  298. clearInterval(interval);
  299. setTimeout(() => {
  300. setPhase("complete");
  301. setMode("interactive");
  302. setUserPrompt("");
  303. }, 500);
  304. }
  305. }, 600);
  306. return () => clearInterval(interval);
  307. }, [mode, phase]);
  308. // Track stream lines from real API (use raw JSONL patch lines)
  309. useEffect(() => {
  310. if (mode === "interactive" && apiRawLines.length > 0) {
  311. setStreamLines(apiRawLines);
  312. }
  313. }, [mode, apiRawLines]);
  314. const handleSubmit = useCallback(async () => {
  315. if (!userPrompt.trim() || isStreaming) return;
  316. setStreamLines([]);
  317. await send(userPrompt);
  318. }, [userPrompt, isStreaming, send]);
  319. const jsonCode = currentTree
  320. ? JSON.stringify(currentTree, null, 2)
  321. : "// waiting...";
  322. const nestedCode = useMemo(() => {
  323. if (!currentTree || !currentTree.root) return "// waiting...";
  324. return JSON.stringify(specToNested(currentTree), null, 2);
  325. }, [currentTree]);
  326. // Generate all export files for Next.js project
  327. const exportedFiles = useMemo(() => {
  328. if (!currentTree || !currentTree.root) {
  329. return [];
  330. }
  331. const tree = currentTree;
  332. const components = collectUsedComponents(tree);
  333. const files: { path: string; content: string }[] = [];
  334. // Helper to generate JSX
  335. function generateJSX(key: string, indent: number): string {
  336. const element = tree.elements[key];
  337. if (!element) return "";
  338. const spaces = " ".repeat(indent);
  339. const componentName = element.type;
  340. const propsObj: Record<string, unknown> = {};
  341. for (const [k, v] of Object.entries(element.props)) {
  342. if (v !== null && v !== undefined) {
  343. propsObj[k] = v;
  344. }
  345. }
  346. const propsStr = serializeProps(propsObj);
  347. const hasChildren = element.children && element.children.length > 0;
  348. if (!hasChildren) {
  349. return propsStr
  350. ? `${spaces}<${componentName} ${propsStr} />`
  351. : `${spaces}<${componentName} />`;
  352. }
  353. const lines: string[] = [];
  354. lines.push(
  355. propsStr
  356. ? `${spaces}<${componentName} ${propsStr}>`
  357. : `${spaces}<${componentName}>`,
  358. );
  359. for (const childKey of element.children!) {
  360. lines.push(generateJSX(childKey, indent + 1));
  361. }
  362. lines.push(`${spaces}</${componentName}>`);
  363. return lines.join("\n");
  364. }
  365. // 1. package.json
  366. files.push({
  367. path: "package.json",
  368. content: JSON.stringify(
  369. {
  370. name: "generated-app",
  371. version: "0.1.0",
  372. private: true,
  373. scripts: {
  374. dev: "next dev",
  375. build: "next build",
  376. start: "next start",
  377. },
  378. dependencies: {
  379. next: "^16.1.3",
  380. react: "^19.2.3",
  381. "react-dom": "^19.2.3",
  382. },
  383. devDependencies: {
  384. "@types/node": "^25.0.9",
  385. "@types/react": "^19.2.8",
  386. typescript: "^5.9.3",
  387. },
  388. },
  389. null,
  390. 2,
  391. ),
  392. });
  393. // 2. tsconfig.json
  394. files.push({
  395. path: "tsconfig.json",
  396. content: JSON.stringify(
  397. {
  398. compilerOptions: {
  399. target: "ES2017",
  400. lib: ["dom", "dom.iterable", "esnext"],
  401. allowJs: true,
  402. skipLibCheck: true,
  403. strict: true,
  404. noEmit: true,
  405. esModuleInterop: true,
  406. module: "esnext",
  407. moduleResolution: "bundler",
  408. resolveJsonModule: true,
  409. isolatedModules: true,
  410. jsx: "preserve",
  411. incremental: true,
  412. plugins: [{ name: "next" }],
  413. paths: { "@/*": ["./*"] },
  414. },
  415. include: ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  416. exclude: ["node_modules"],
  417. },
  418. null,
  419. 2,
  420. ),
  421. });
  422. // 3. next.config.js
  423. files.push({
  424. path: "next.config.js",
  425. content: `/** @type {import('next').NextConfig} */
  426. module.exports = {
  427. reactStrictMode: true,
  428. };
  429. `,
  430. });
  431. // 4. app/globals.css
  432. files.push({
  433. path: "app/globals.css",
  434. content: `@tailwind base;
  435. @tailwind components;
  436. @tailwind utilities;
  437. :root {
  438. --background: #ffffff;
  439. --foreground: #171717;
  440. --border: #e5e5e5;
  441. --muted-foreground: #737373;
  442. }
  443. @media (prefers-color-scheme: dark) {
  444. :root {
  445. --background: #0a0a0a;
  446. --foreground: #ededed;
  447. --border: #262626;
  448. --muted-foreground: #a3a3a3;
  449. }
  450. }
  451. body {
  452. background: var(--background);
  453. color: var(--foreground);
  454. font-family: system-ui, sans-serif;
  455. }
  456. `,
  457. });
  458. // 5. tailwind.config.js
  459. files.push({
  460. path: "tailwind.config.js",
  461. content: `/** @type {import('tailwindcss').Config} */
  462. module.exports = {
  463. content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
  464. theme: {
  465. extend: {
  466. colors: {
  467. background: "var(--background)",
  468. foreground: "var(--foreground)",
  469. border: "var(--border)",
  470. "muted-foreground": "var(--muted-foreground)",
  471. },
  472. },
  473. },
  474. plugins: [],
  475. };
  476. `,
  477. });
  478. // 6. app/layout.tsx
  479. files.push({
  480. path: "app/layout.tsx",
  481. content: `import type { Metadata } from "next";
  482. import "./globals.css";
  483. export const metadata: Metadata = {
  484. title: "Generated App",
  485. };
  486. export default function RootLayout({
  487. children,
  488. }: {
  489. children: React.ReactNode;
  490. }) {
  491. return (
  492. <html lang="en">
  493. <body>{children}</body>
  494. </html>
  495. );
  496. }
  497. `,
  498. });
  499. // 7. Component files
  500. const componentTemplates: Record<string, string> = {
  501. Card: `"use client";
  502. import { ReactNode } from "react";
  503. interface CardProps {
  504. title?: string;
  505. description?: string;
  506. maxWidth?: "sm" | "md" | "lg";
  507. children?: ReactNode;
  508. }
  509. export function Card({ title, description, maxWidth, children }: CardProps) {
  510. const widthClass = maxWidth === "sm" ? "max-w-xs" : maxWidth === "md" ? "max-w-sm" : maxWidth === "lg" ? "max-w-md" : "w-full";
  511. return (
  512. <div className={\`border border-border rounded-lg p-4 bg-background \${widthClass}\`}>
  513. {title && <div className="font-semibold text-sm mb-1">{title}</div>}
  514. {description && <div className="text-xs text-muted-foreground mb-2">{description}</div>}
  515. <div className="space-y-3">{children}</div>
  516. </div>
  517. );
  518. }
  519. `,
  520. Input: `"use client";
  521. interface InputProps {
  522. label?: string;
  523. name?: string;
  524. type?: string;
  525. placeholder?: string;
  526. }
  527. export function Input({ label, name, type = "text", placeholder }: InputProps) {
  528. return (
  529. <div>
  530. {label && <label className="text-xs text-muted-foreground block mb-1">{label}</label>}
  531. <input
  532. type={type}
  533. name={name}
  534. placeholder={placeholder}
  535. className="h-9 w-full bg-background border border-border rounded px-3 text-sm focus:outline-none focus:ring-2 focus:ring-foreground/20"
  536. />
  537. </div>
  538. );
  539. }
  540. `,
  541. Textarea: `"use client";
  542. interface TextareaProps {
  543. label?: string;
  544. name?: string;
  545. placeholder?: string;
  546. rows?: number;
  547. }
  548. export function Textarea({ label, name, placeholder, rows = 3 }: TextareaProps) {
  549. return (
  550. <div>
  551. {label && <label className="text-xs text-muted-foreground block mb-1">{label}</label>}
  552. <textarea
  553. name={name}
  554. placeholder={placeholder}
  555. rows={rows}
  556. className="w-full bg-background border border-border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-foreground/20 resize-none"
  557. />
  558. </div>
  559. );
  560. }
  561. `,
  562. Button: `"use client";
  563. interface ButtonProps {
  564. label: string;
  565. variant?: "primary" | "secondary" | "outline";
  566. onClick?: () => void;
  567. }
  568. export function Button({ label, variant = "primary", onClick }: ButtonProps) {
  569. const baseClass = "px-4 py-2 rounded text-sm font-medium transition-colors";
  570. const variantClass = variant === "primary"
  571. ? "bg-foreground text-background hover:bg-foreground/90"
  572. : variant === "outline"
  573. ? "border border-border hover:bg-border/50"
  574. : "bg-border/50 hover:bg-border";
  575. return (
  576. <button onClick={onClick} className={\`\${baseClass} \${variantClass}\`}>
  577. {label}
  578. </button>
  579. );
  580. }
  581. `,
  582. Text: `"use client";
  583. interface TextProps {
  584. content: string;
  585. variant?: "body" | "caption" | "label";
  586. }
  587. export function Text({ content, variant = "body" }: TextProps) {
  588. const sizeClass = variant === "caption" ? "text-xs" : variant === "label" ? "text-sm font-medium" : "text-sm";
  589. return <p className={\`\${sizeClass} text-muted-foreground\`}>{content}</p>;
  590. }
  591. `,
  592. Heading: `"use client";
  593. interface HeadingProps {
  594. text: string;
  595. level?: "h1" | "h2" | "h3" | "h4";
  596. }
  597. export function Heading({ text, level = "h2" }: HeadingProps) {
  598. const Tag = level;
  599. const sizeClass = level === "h1" ? "text-2xl" : level === "h2" ? "text-xl" : level === "h3" ? "text-lg" : "text-base";
  600. return <Tag className={\`\${sizeClass} font-semibold\`}>{text}</Tag>;
  601. }
  602. `,
  603. Stack: `"use client";
  604. import { ReactNode } from "react";
  605. interface StackProps {
  606. direction?: "horizontal" | "vertical";
  607. gap?: "sm" | "md" | "lg";
  608. children?: ReactNode;
  609. }
  610. export function Stack({ direction = "vertical", gap = "md", children }: StackProps) {
  611. const gapClass = gap === "sm" ? "gap-2" : gap === "lg" ? "gap-6" : "gap-4";
  612. const dirClass = direction === "horizontal" ? "flex-row" : "flex-col";
  613. return <div className={\`flex \${dirClass} \${gapClass}\`}>{children}</div>;
  614. }
  615. `,
  616. Grid: `"use client";
  617. import { ReactNode } from "react";
  618. interface GridProps {
  619. columns?: number;
  620. gap?: "sm" | "md" | "lg";
  621. children?: ReactNode;
  622. }
  623. export function Grid({ columns = 2, gap = "md", children }: GridProps) {
  624. const gapClass = gap === "sm" ? "gap-2" : gap === "lg" ? "gap-6" : "gap-4";
  625. return (
  626. <div className={\`grid \${gapClass}\`} style={{ gridTemplateColumns: \`repeat(\${columns}, 1fr)\` }}>
  627. {children}
  628. </div>
  629. );
  630. }
  631. `,
  632. Select: `"use client";
  633. interface SelectProps {
  634. label?: string;
  635. name?: string;
  636. options?: Array<{ value: string; label: string }>;
  637. placeholder?: string;
  638. }
  639. export function Select({ label, name, options = [], placeholder }: SelectProps) {
  640. return (
  641. <div>
  642. {label && <label className="text-xs text-muted-foreground block mb-1">{label}</label>}
  643. <select
  644. name={name}
  645. className="h-9 w-full bg-background border border-border rounded px-3 text-sm focus:outline-none focus:ring-2 focus:ring-foreground/20"
  646. >
  647. {placeholder && <option value="">{placeholder}</option>}
  648. {options.map((opt) => (
  649. <option key={opt.value} value={opt.value}>{opt.label}</option>
  650. ))}
  651. </select>
  652. </div>
  653. );
  654. }
  655. `,
  656. Checkbox: `"use client";
  657. interface CheckboxProps {
  658. label?: string;
  659. name?: string;
  660. checked?: boolean;
  661. }
  662. export function Checkbox({ label, name, checked }: CheckboxProps) {
  663. return (
  664. <label className="flex items-center gap-2 text-sm">
  665. <input type="checkbox" name={name} defaultChecked={checked} className="rounded border-border" />
  666. {label}
  667. </label>
  668. );
  669. }
  670. `,
  671. Radio: `"use client";
  672. interface RadioProps {
  673. label?: string;
  674. name?: string;
  675. options?: Array<{ value: string; label: string }>;
  676. }
  677. export function Radio({ label, name, options = [] }: RadioProps) {
  678. return (
  679. <div>
  680. {label && <div className="text-xs text-muted-foreground mb-1">{label}</div>}
  681. <div className="space-y-1">
  682. {options.map((opt) => (
  683. <label key={opt.value} className="flex items-center gap-2 text-sm">
  684. <input type="radio" name={name} value={opt.value} className="border-border" />
  685. {opt.label}
  686. </label>
  687. ))}
  688. </div>
  689. </div>
  690. );
  691. }
  692. `,
  693. Divider: `"use client";
  694. export function Divider() {
  695. return <hr className="border-border my-4" />;
  696. }
  697. `,
  698. Badge: `"use client";
  699. interface BadgeProps {
  700. text: string;
  701. variant?: "default" | "success" | "warning" | "error";
  702. }
  703. export function Badge({ text, variant = "default" }: BadgeProps) {
  704. const colorClass = variant === "success" ? "bg-green-100 text-green-800"
  705. : variant === "warning" ? "bg-yellow-100 text-yellow-800"
  706. : variant === "error" ? "bg-red-100 text-red-800"
  707. : "bg-border text-foreground";
  708. return <span className={\`px-2 py-0.5 rounded text-xs \${colorClass}\`}>{text}</span>;
  709. }
  710. `,
  711. Switch: `"use client";
  712. interface SwitchProps {
  713. label?: string;
  714. name?: string;
  715. checked?: boolean;
  716. }
  717. export function Switch({ label, name, checked }: SwitchProps) {
  718. return (
  719. <label className="flex items-center justify-between gap-2 text-sm">
  720. {label}
  721. <input type="checkbox" name={name} defaultChecked={checked} className="sr-only peer" />
  722. <div className="w-9 h-5 bg-border rounded-full peer-checked:bg-foreground transition-colors relative after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:w-4 after:h-4 after:bg-background after:rounded-full after:transition-transform peer-checked:after:translate-x-4" />
  723. </label>
  724. );
  725. }
  726. `,
  727. Rating: `"use client";
  728. interface RatingProps {
  729. label?: string;
  730. value?: number;
  731. max?: number;
  732. }
  733. export function Rating({ label, value = 0, max = 5 }: RatingProps) {
  734. return (
  735. <div>
  736. {label && <div className="text-xs text-muted-foreground mb-1">{label}</div>}
  737. <div className="flex gap-1">
  738. {Array.from({ length: max }).map((_, i) => (
  739. <span key={i} className={\`text-lg \${i < value ? "text-yellow-400" : "text-border"}\`}>★</span>
  740. ))}
  741. </div>
  742. </div>
  743. );
  744. }
  745. `,
  746. Form: `"use client";
  747. import { ReactNode } from "react";
  748. interface FormProps {
  749. children?: ReactNode;
  750. }
  751. export function Form({ children }: FormProps) {
  752. return <form className="space-y-4" onSubmit={(e) => e.preventDefault()}>{children}</form>;
  753. }
  754. `,
  755. };
  756. // Add component files
  757. for (const comp of components) {
  758. const template = componentTemplates[comp];
  759. if (template) {
  760. files.push({
  761. path: `components/ui/${comp.toLowerCase()}.tsx`,
  762. content: template,
  763. });
  764. }
  765. }
  766. // 8. components/ui/index.ts
  767. const indexExports = Array.from(components)
  768. .filter((c) => componentTemplates[c])
  769. .map((c) => `export { ${c} } from "./${c.toLowerCase()}";`)
  770. .join("\n");
  771. files.push({
  772. path: "components/ui/index.ts",
  773. content: indexExports + "\n",
  774. });
  775. // 9. app/page.tsx
  776. const jsx = generateJSX(tree.root, 2);
  777. const imports = Array.from(components)
  778. .filter((c) => componentTemplates[c])
  779. .sort()
  780. .join(", ");
  781. files.push({
  782. path: "app/page.tsx",
  783. content: `"use client";
  784. import { ${imports} } from "@/components/ui";
  785. export default function Page() {
  786. return (
  787. <div className="min-h-screen p-8 flex items-center justify-center">
  788. ${jsx}
  789. </div>
  790. );
  791. }
  792. `,
  793. });
  794. // 10. README.md
  795. files.push({
  796. path: "README.md",
  797. content: `# Generated App
  798. This app was generated from a json-render UI tree.
  799. ## Getting Started
  800. \`\`\`bash
  801. npm install
  802. npm run dev
  803. \`\`\`
  804. Open [http://localhost:3000](http://localhost:3000) to view.
  805. `,
  806. });
  807. return files;
  808. }, [currentTree]);
  809. // Reset state when export modal closes
  810. useEffect(() => {
  811. if (!showExportModal) {
  812. setCollapsedFolders(new Set());
  813. setSelectedExportFile(null);
  814. }
  815. }, [showExportModal]);
  816. // Get active file content
  817. const activeExportFile =
  818. selectedExportFile ||
  819. (exportedFiles.length > 0 ? exportedFiles[0]?.path : null);
  820. const activeExportContent =
  821. exportedFiles.find((f) => f.path === activeExportFile)?.content || "";
  822. // Get generated page code for the code tab
  823. const generatedCode =
  824. exportedFiles.find((f) => f.path === "app/page.tsx")?.content ||
  825. "// Generate a UI to see the code";
  826. const downloadAllFiles = useCallback(() => {
  827. const allContent = exportedFiles
  828. .map((f) => `// ========== ${f.path} ==========\n${f.content}`)
  829. .join("\n\n");
  830. const blob = new Blob([allContent], { type: "text/plain" });
  831. const url = URL.createObjectURL(blob);
  832. const a = document.createElement("a");
  833. a.href = url;
  834. a.download = "generated-app.txt";
  835. a.click();
  836. URL.revokeObjectURL(url);
  837. toast("Downloaded generated-app.txt");
  838. }, [exportedFiles]);
  839. const copyFileContent = useCallback((content: string) => {
  840. navigator.clipboard.writeText(content);
  841. toast("Copied to clipboard");
  842. }, []);
  843. const isTypingSimulation = mode === "simulation" && phase === "typing";
  844. const isStreamingSimulation = mode === "simulation" && phase === "streaming";
  845. const showLoadingDots = isStreamingSimulation || isStreaming;
  846. const handleExampleClick = useCallback((prompt: string) => {
  847. setMode("interactive");
  848. setPhase("complete");
  849. setUserPrompt(prompt);
  850. setTimeout(() => {
  851. const el = inputRef.current;
  852. if (el) {
  853. el.focus();
  854. el.setSelectionRange(prompt.length, prompt.length);
  855. }
  856. }, 0);
  857. }, []);
  858. return (
  859. <div
  860. className={`w-full text-left ${fullscreen ? "h-full flex flex-col" : "max-w-5xl mx-auto"}`}
  861. >
  862. {/* Prompt input */}
  863. <div className={fullscreen ? "mb-4" : "mb-6"}>
  864. <div
  865. className="border border-border rounded p-3 bg-background font-mono text-sm min-h-[44px] flex items-center justify-between cursor-text"
  866. onClick={() => {
  867. if (mode === "simulation") {
  868. setMode("interactive");
  869. setPhase("complete");
  870. setUserPrompt("");
  871. setTimeout(() => inputRef.current?.focus(), 0);
  872. } else {
  873. inputRef.current?.focus();
  874. }
  875. }}
  876. >
  877. {mode === "simulation" ? (
  878. <div className="flex items-center flex-1">
  879. <span className="inline-flex items-center h-5">
  880. {typedPrompt}
  881. </span>
  882. {isTypingSimulation && (
  883. <span className="inline-block w-2 h-4 bg-foreground ml-0.5 animate-pulse" />
  884. )}
  885. </div>
  886. ) : (
  887. <form
  888. className="flex items-center flex-1"
  889. onSubmit={(e) => {
  890. e.preventDefault();
  891. handleSubmit();
  892. }}
  893. >
  894. <input
  895. ref={inputRef}
  896. type="text"
  897. value={userPrompt}
  898. onChange={(e) => setUserPrompt(e.target.value)}
  899. placeholder="Describe what you want to build..."
  900. className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground/50 text-base"
  901. disabled={isStreaming}
  902. maxLength={140}
  903. />
  904. </form>
  905. )}
  906. {mode === "simulation" || isStreaming ? (
  907. <button
  908. onClick={(e) => {
  909. e.stopPropagation();
  910. stopGeneration();
  911. }}
  912. className="ml-2 w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
  913. aria-label="Stop"
  914. >
  915. <svg
  916. width="16"
  917. height="16"
  918. viewBox="0 0 24 24"
  919. fill="currentColor"
  920. stroke="none"
  921. >
  922. <rect x="6" y="6" width="12" height="12" />
  923. </svg>
  924. </button>
  925. ) : (
  926. <button
  927. onClick={(e) => {
  928. e.stopPropagation();
  929. handleSubmit();
  930. }}
  931. disabled={!userPrompt.trim()}
  932. className="ml-2 w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors disabled:opacity-30"
  933. aria-label="Submit"
  934. >
  935. <svg
  936. width="16"
  937. height="16"
  938. viewBox="0 0 24 24"
  939. fill="none"
  940. stroke="currentColor"
  941. strokeWidth="2"
  942. strokeLinecap="round"
  943. strokeLinejoin="round"
  944. >
  945. <path d="M12 5v14" />
  946. <path d="M19 12l-7 7-7-7" />
  947. </svg>
  948. </button>
  949. )}
  950. </div>
  951. {fullscreen ? (
  952. <div className="mt-3 flex flex-wrap gap-2 justify-center">
  953. {EXAMPLE_PROMPTS.map((prompt) => (
  954. <button
  955. key={prompt}
  956. onClick={() => handleExampleClick(prompt)}
  957. className="text-xs px-3 py-1.5 rounded-full border border-border text-muted-foreground hover:text-foreground hover:border-foreground/50 transition-colors"
  958. >
  959. {prompt}
  960. </button>
  961. ))}
  962. </div>
  963. ) : (
  964. <div className="mt-2 flex flex-wrap gap-1.5 justify-center">
  965. {EXAMPLE_PROMPTS.slice(0, 2).map((prompt) => (
  966. <button
  967. key={prompt}
  968. onClick={() => handleExampleClick(prompt)}
  969. className="text-xs px-2 py-1 rounded-full border border-border text-muted-foreground hover:text-foreground hover:border-foreground/50 transition-colors"
  970. >
  971. {prompt}
  972. </button>
  973. ))}
  974. </div>
  975. )}
  976. </div>
  977. <div
  978. className={`grid lg:grid-cols-2 gap-4 ${fullscreen ? "flex-1 min-h-0" : ""}`}
  979. >
  980. {/* Tabbed code/stream/json panel */}
  981. <div className={`min-w-0 ${fullscreen ? "flex flex-col" : ""}`}>
  982. <div className="flex items-center gap-4 mb-2 h-6 shrink-0">
  983. {(["json", "nested", "stream", "catalog"] as const).map((tab) => (
  984. <button
  985. key={tab}
  986. onClick={() => setActiveTab(tab)}
  987. className={`text-xs font-mono transition-colors ${
  988. activeTab === tab
  989. ? "text-foreground"
  990. : "text-muted-foreground hover:text-foreground"
  991. }`}
  992. >
  993. {tab}
  994. </button>
  995. ))}
  996. </div>
  997. <div
  998. className={`border border-border rounded bg-background font-mono text-xs text-left grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[36rem]"}`}
  999. >
  1000. {activeTab !== "catalog" && (
  1001. <div className="absolute top-2 right-2 z-10">
  1002. <CopyButton
  1003. text={
  1004. activeTab === "stream"
  1005. ? streamLines.join("\n")
  1006. : activeTab === "nested"
  1007. ? nestedCode
  1008. : jsonCode
  1009. }
  1010. className="opacity-0 group-hover:opacity-100 text-muted-foreground"
  1011. />
  1012. </div>
  1013. )}
  1014. <div
  1015. className={`overflow-auto ${activeTab === "stream" ? "" : "hidden"}`}
  1016. >
  1017. {streamLines.length > 0 ? (
  1018. <>
  1019. <CodeBlock
  1020. code={streamLines.join("\n")}
  1021. lang="json"
  1022. fillHeight
  1023. hideCopyButton
  1024. />
  1025. {showLoadingDots && (
  1026. <div className="flex gap-1 p-3 pt-0">
  1027. <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse" />
  1028. <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse [animation-delay:75ms]" />
  1029. <span className="w-1 h-1 bg-muted-foreground rounded-full animate-pulse [animation-delay:150ms]" />
  1030. </div>
  1031. )}
  1032. </>
  1033. ) : (
  1034. <div className="text-muted-foreground/50 p-3 h-full">
  1035. {showLoadingDots ? "streaming..." : "waiting..."}
  1036. </div>
  1037. )}
  1038. </div>
  1039. <div
  1040. className={`overflow-auto ${activeTab === "json" ? "" : "hidden"}`}
  1041. >
  1042. <CodeBlock
  1043. code={jsonCode}
  1044. lang="json"
  1045. fillHeight
  1046. hideCopyButton
  1047. />
  1048. </div>
  1049. <div
  1050. className={`overflow-auto ${activeTab === "nested" ? "" : "hidden"}`}
  1051. >
  1052. <CodeBlock
  1053. code={nestedCode}
  1054. lang="json"
  1055. fillHeight
  1056. hideCopyButton
  1057. />
  1058. </div>
  1059. <div
  1060. className={`overflow-auto ${activeTab === "catalog" ? "" : "hidden"}`}
  1061. >
  1062. <div className="h-full flex flex-col text-sm font-sans">
  1063. <div className="flex items-center gap-3 px-3 h-9 border-b border-border">
  1064. {(
  1065. [
  1066. {
  1067. key: "components",
  1068. label: `components (${catalogData.components.length})`,
  1069. },
  1070. {
  1071. key: "actions",
  1072. label: `actions (${catalogData.actions.length})`,
  1073. },
  1074. ] as const
  1075. ).map(({ key, label }) => (
  1076. <button
  1077. key={key}
  1078. onClick={() => setCatalogSection(key)}
  1079. className={`text-xs font-mono transition-colors ${
  1080. catalogSection === key
  1081. ? "text-foreground"
  1082. : "text-muted-foreground hover:text-foreground"
  1083. }`}
  1084. >
  1085. {label}
  1086. </button>
  1087. ))}
  1088. </div>
  1089. <div className="flex-1 overflow-auto p-3">
  1090. {catalogSection === "components" ? (
  1091. <div className="space-y-3">
  1092. {catalogData.components.map((comp) => (
  1093. <div
  1094. key={comp.name}
  1095. className="pb-3 border-b border-border last:border-b-0"
  1096. >
  1097. <div className="flex items-baseline gap-2 mb-1">
  1098. <span className="font-mono font-medium text-foreground">
  1099. {comp.name}
  1100. </span>
  1101. {comp.slots.length > 0 && (
  1102. <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
  1103. slots: {comp.slots.join(", ")}
  1104. </span>
  1105. )}
  1106. </div>
  1107. {comp.description && (
  1108. <p className="text-xs text-muted-foreground mb-2">
  1109. {comp.description}
  1110. </p>
  1111. )}
  1112. {comp.props.length > 0 && (
  1113. <div className="flex flex-wrap gap-1 mb-1">
  1114. {comp.props.map((p) => (
  1115. <span
  1116. key={p.name}
  1117. className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-green-500/10 text-green-700 dark:text-green-400"
  1118. >
  1119. {p.name}
  1120. <span className="text-green-700/50 dark:text-green-400/50">
  1121. : {p.type}
  1122. </span>
  1123. </span>
  1124. ))}
  1125. </div>
  1126. )}
  1127. {comp.events.length > 0 && (
  1128. <div className="flex flex-wrap gap-1 mt-1.5">
  1129. {comp.events.map((e) => (
  1130. <span
  1131. key={e}
  1132. className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400"
  1133. >
  1134. on.{e}
  1135. </span>
  1136. ))}
  1137. </div>
  1138. )}
  1139. </div>
  1140. ))}
  1141. </div>
  1142. ) : (
  1143. <div className="space-y-3">
  1144. {catalogData.actions.map((action) => (
  1145. <div
  1146. key={action.name}
  1147. className="pb-3 border-b border-border last:border-b-0"
  1148. >
  1149. <span className="font-mono font-medium text-foreground">
  1150. {action.name}
  1151. </span>
  1152. {action.description && (
  1153. <p className="text-xs text-muted-foreground mt-1 mb-2">
  1154. {action.description}
  1155. </p>
  1156. )}
  1157. {action.params.length > 0 && (
  1158. <div className="flex flex-wrap gap-1">
  1159. {action.params.map((p) => (
  1160. <span
  1161. key={p.name}
  1162. className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-green-500/10 text-green-700 dark:text-green-400"
  1163. >
  1164. {p.name}
  1165. <span className="text-green-700/50 dark:text-green-400/50">
  1166. : {p.type}
  1167. </span>
  1168. </span>
  1169. ))}
  1170. </div>
  1171. )}
  1172. </div>
  1173. ))}
  1174. </div>
  1175. )}
  1176. </div>
  1177. </div>
  1178. </div>
  1179. </div>
  1180. </div>
  1181. {/* Rendered output using json-render */}
  1182. <div className={`min-w-0 ${fullscreen ? "flex flex-col" : ""}`}>
  1183. <div className="flex items-center justify-between mb-2 h-6 shrink-0">
  1184. <div className="flex items-center gap-4">
  1185. {(
  1186. [
  1187. { key: "dynamic", label: "live render" },
  1188. { key: "static", label: "static code" },
  1189. ] as const
  1190. ).map(({ key, label }) => (
  1191. <button
  1192. key={key}
  1193. onClick={() => setRenderView(key)}
  1194. className={`text-xs font-mono transition-colors ${
  1195. renderView === key
  1196. ? "text-foreground"
  1197. : "text-muted-foreground hover:text-foreground"
  1198. }`}
  1199. >
  1200. {label}
  1201. </button>
  1202. ))}
  1203. </div>
  1204. <div className="flex items-center gap-2">
  1205. <button
  1206. onClick={() => setShowExportModal(true)}
  1207. disabled={!currentTree?.root}
  1208. className="text-xs font-mono text-muted-foreground hover:text-foreground transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
  1209. title="Export as Next.js project"
  1210. >
  1211. export
  1212. </button>
  1213. <button
  1214. onClick={() => setIsFullscreen(true)}
  1215. className="text-muted-foreground hover:text-foreground transition-colors"
  1216. aria-label="Maximize"
  1217. >
  1218. <svg
  1219. width="14"
  1220. height="14"
  1221. viewBox="0 0 24 24"
  1222. fill="none"
  1223. stroke="currentColor"
  1224. strokeWidth="2"
  1225. strokeLinecap="round"
  1226. strokeLinejoin="round"
  1227. >
  1228. <path d="M8 3H5a2 2 0 0 0-2 2v3" />
  1229. <path d="M21 8V5a2 2 0 0 0-2-2h-3" />
  1230. <path d="M3 16v3a2 2 0 0 0 2 2h3" />
  1231. <path d="M16 21h3a2 2 0 0 0 2-2v-3" />
  1232. </svg>
  1233. </button>
  1234. </div>
  1235. </div>
  1236. <div
  1237. className={`border border-border rounded bg-background grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[36rem]"}`}
  1238. >
  1239. {renderView === "static" && (
  1240. <div className="absolute top-2 right-2 z-10">
  1241. <CopyButton
  1242. text={generatedCode}
  1243. className="opacity-0 group-hover:opacity-100 text-muted-foreground"
  1244. />
  1245. </div>
  1246. )}
  1247. {renderView === "dynamic" ? (
  1248. <div className="overflow-auto">
  1249. {currentTree && currentTree.root ? (
  1250. <div className="animate-in fade-in duration-200 w-full min-h-full flex items-center justify-center p-3 py-4">
  1251. <PlaygroundRenderer
  1252. spec={currentTree}
  1253. loading={isStreaming || isStreamingSimulation}
  1254. />
  1255. </div>
  1256. ) : (
  1257. <div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
  1258. {isStreaming ? "generating..." : "waiting..."}
  1259. </div>
  1260. )}
  1261. </div>
  1262. ) : (
  1263. <div className="overflow-auto h-full font-mono text-xs text-left">
  1264. <CodeBlock
  1265. code={generatedCode}
  1266. lang="tsx"
  1267. fillHeight
  1268. hideCopyButton
  1269. />
  1270. </div>
  1271. )}
  1272. </div>
  1273. <Toaster position="bottom-right" />
  1274. </div>
  1275. </div>
  1276. {/* Fullscreen modal */}
  1277. {isFullscreen && (
  1278. <div className="fixed inset-0 z-50 bg-background flex flex-col">
  1279. <div className="flex items-center justify-between px-6 h-14 border-b border-border">
  1280. <div className="text-sm font-mono">render</div>
  1281. <button
  1282. onClick={() => setIsFullscreen(false)}
  1283. className="text-muted-foreground hover:text-foreground transition-colors p-1"
  1284. aria-label="Close"
  1285. >
  1286. <svg
  1287. width="20"
  1288. height="20"
  1289. viewBox="0 0 24 24"
  1290. fill="none"
  1291. stroke="currentColor"
  1292. strokeWidth="2"
  1293. strokeLinecap="round"
  1294. strokeLinejoin="round"
  1295. >
  1296. <path d="M18 6L6 18" />
  1297. <path d="M6 6l12 12" />
  1298. </svg>
  1299. </button>
  1300. </div>
  1301. <div className="flex-1 overflow-auto p-6">
  1302. {currentTree && currentTree.root ? (
  1303. <div className="w-full min-h-full flex items-center justify-center">
  1304. <PlaygroundRenderer
  1305. spec={currentTree}
  1306. loading={isStreaming || isStreamingSimulation}
  1307. />
  1308. </div>
  1309. ) : (
  1310. <div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
  1311. {isStreaming ? "generating..." : "waiting..."}
  1312. </div>
  1313. )}
  1314. </div>
  1315. </div>
  1316. )}
  1317. {/* Export Modal */}
  1318. {showExportModal && (
  1319. <div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 sm:p-8">
  1320. <div className="bg-background border border-border rounded-lg w-full max-w-5xl h-full max-h-[80vh] flex flex-col shadow-2xl">
  1321. {/* Header */}
  1322. <div className="flex items-center justify-between px-4 sm:px-6 h-14 border-b border-border shrink-0">
  1323. <div className="flex items-center gap-2 sm:gap-3">
  1324. {/* Mobile file tree toggle */}
  1325. <button
  1326. onClick={() => setShowMobileFileTree(!showMobileFileTree)}
  1327. className="sm:hidden text-muted-foreground hover:text-foreground transition-colors p-1"
  1328. aria-label="Toggle file tree"
  1329. >
  1330. <svg
  1331. width="18"
  1332. height="18"
  1333. viewBox="0 0 24 24"
  1334. fill="none"
  1335. stroke="currentColor"
  1336. strokeWidth="2"
  1337. strokeLinecap="round"
  1338. strokeLinejoin="round"
  1339. >
  1340. <path d="M3 6h18M3 12h18M3 18h18" />
  1341. </svg>
  1342. </button>
  1343. <span className="text-sm font-mono">export static code</span>
  1344. <span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded hidden sm:inline">
  1345. {exportedFiles.length} files
  1346. </span>
  1347. </div>
  1348. <div className="flex items-center gap-2">
  1349. <button
  1350. onClick={downloadAllFiles}
  1351. className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-foreground text-background rounded hover:bg-foreground/90 transition-colors"
  1352. >
  1353. <svg
  1354. width="12"
  1355. height="12"
  1356. viewBox="0 0 24 24"
  1357. fill="none"
  1358. stroke="currentColor"
  1359. strokeWidth="2"
  1360. strokeLinecap="round"
  1361. strokeLinejoin="round"
  1362. >
  1363. <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
  1364. <polyline points="7 10 12 15 17 10" />
  1365. <line x1="12" y1="15" x2="12" y2="3" />
  1366. </svg>
  1367. Download All
  1368. </button>
  1369. <button
  1370. onClick={() => setShowExportModal(false)}
  1371. className="text-muted-foreground hover:text-foreground transition-colors p-1"
  1372. aria-label="Close"
  1373. >
  1374. <svg
  1375. width="20"
  1376. height="20"
  1377. viewBox="0 0 24 24"
  1378. fill="none"
  1379. stroke="currentColor"
  1380. strokeWidth="2"
  1381. strokeLinecap="round"
  1382. strokeLinejoin="round"
  1383. >
  1384. <path d="M18 6L6 18" />
  1385. <path d="M6 6l12 12" />
  1386. </svg>
  1387. </button>
  1388. </div>
  1389. </div>
  1390. {/* Content */}
  1391. <div className="flex flex-1 min-h-0 relative">
  1392. {/* File Tree - hidden on mobile, overlay when shown */}
  1393. <div
  1394. className={`
  1395. ${showMobileFileTree ? "absolute inset-0 z-10 bg-background" : "hidden"}
  1396. sm:relative sm:block sm:w-56 sm:bg-transparent
  1397. border-r border-border overflow-auto py-2
  1398. `}
  1399. >
  1400. {(() => {
  1401. // Build tree structure from flat file list
  1402. type TreeNode = {
  1403. name: string;
  1404. path: string;
  1405. isFolder: boolean;
  1406. children: TreeNode[];
  1407. file?: { path: string; content: string };
  1408. };
  1409. const root: TreeNode = {
  1410. name: "",
  1411. path: "",
  1412. isFolder: true,
  1413. children: [],
  1414. };
  1415. exportedFiles.forEach((file) => {
  1416. const parts = file.path.split("/");
  1417. let current = root;
  1418. parts.forEach((part, idx) => {
  1419. const isLast = idx === parts.length - 1;
  1420. const path = parts.slice(0, idx + 1).join("/");
  1421. let child = current.children.find((c) => c.name === part);
  1422. if (!child) {
  1423. child = {
  1424. name: part,
  1425. path,
  1426. isFolder: !isLast,
  1427. children: [],
  1428. file: isLast ? file : undefined,
  1429. };
  1430. current.children.push(child);
  1431. }
  1432. current = child;
  1433. });
  1434. });
  1435. // Sort: folders first, then alphabetically
  1436. const sortNodes = (nodes: TreeNode[]): TreeNode[] => {
  1437. return nodes.sort((a, b) => {
  1438. if (a.isFolder && !b.isFolder) return -1;
  1439. if (!a.isFolder && b.isFolder) return 1;
  1440. return a.name.localeCompare(b.name);
  1441. });
  1442. };
  1443. const toggleFolder = (path: string) => {
  1444. setCollapsedFolders((prev) => {
  1445. const next = new Set(prev);
  1446. if (next.has(path)) {
  1447. next.delete(path);
  1448. } else {
  1449. next.add(path);
  1450. }
  1451. return next;
  1452. });
  1453. };
  1454. const renderNode = (
  1455. node: TreeNode,
  1456. depth: number,
  1457. ): React.ReactNode[] => {
  1458. const result: React.ReactNode[] = [];
  1459. const isExpanded = !collapsedFolders.has(node.path);
  1460. if (node.isFolder && node.name) {
  1461. result.push(
  1462. <button
  1463. key={`folder-${node.path}`}
  1464. onClick={() => toggleFolder(node.path)}
  1465. className="w-full text-left px-3 py-1 text-xs font-mono text-muted-foreground hover:text-foreground hover:bg-foreground/5 transition-colors"
  1466. style={{ paddingLeft: `${12 + depth * 12}px` }}
  1467. >
  1468. <span className="flex items-center gap-1.5">
  1469. <span
  1470. className={`text-gray-400 transition-transform ${isExpanded ? "rotate-90" : ""}`}
  1471. >
  1472. <svg
  1473. width="8"
  1474. height="8"
  1475. viewBox="0 0 24 24"
  1476. fill="currentColor"
  1477. >
  1478. <path d="M8 5l10 7-10 7V5z" />
  1479. </svg>
  1480. </span>
  1481. <span className="text-gray-400">
  1482. <svg
  1483. width="12"
  1484. height="12"
  1485. viewBox="0 0 24 24"
  1486. fill="currentColor"
  1487. >
  1488. <path d="M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" />
  1489. </svg>
  1490. </span>
  1491. {node.name}
  1492. </span>
  1493. </button>,
  1494. );
  1495. }
  1496. if (node.file) {
  1497. const isActive = node.file.path === activeExportFile;
  1498. result.push(
  1499. <button
  1500. key={node.file.path}
  1501. onClick={() => {
  1502. setSelectedExportFile(node.file!.path);
  1503. setShowMobileFileTree(false);
  1504. }}
  1505. className={`w-full text-left px-3 py-1 text-xs font-mono transition-colors ${
  1506. isActive
  1507. ? "bg-foreground/10 text-foreground"
  1508. : "text-muted-foreground hover:text-foreground hover:bg-foreground/5"
  1509. }`}
  1510. style={{ paddingLeft: `${12 + depth * 12}px` }}
  1511. >
  1512. <span className="flex items-center gap-1.5">
  1513. {node.name.endsWith(".tsx") ||
  1514. node.name.endsWith(".ts") ? (
  1515. <span className="text-blue-400">
  1516. <svg
  1517. width="12"
  1518. height="12"
  1519. viewBox="0 0 24 24"
  1520. fill="currentColor"
  1521. >
  1522. <path d="M3 3h18v18H3V3zm16.525 13.707c-.131-.821-.666-1.511-2.252-2.155-.552-.259-1.165-.438-1.349-.854-.068-.248-.083-.382-.039-.527.11-.373.458-.487.757-.381.193.07.37.258.482.52.51-.332.51-.332.86-.553-.132-.203-.203-.293-.297-.382-.335-.382-.78-.58-1.502-.558l-.375.047c-.361.09-.705.272-.923.531-.613.721-.437 1.976.245 2.494.674.476 1.661.59 1.791 1.052.12.543-.406.717-.919.65-.387-.071-.6-.273-.831-.641l-.871.529c.1.217.217.31.39.494.803.796 2.8.749 3.163-.476.013-.04.113-.33.071-.765zm-7.158-2.032c-.227.574-.446 1.148-.677 1.722-.204-.54-.42-1.102-.648-1.68l-.002-.02h-1.09v4.4h.798v-3.269l.796 2.011h.69l.793-2.012v3.27h.798v-4.4h-1.06l-.398 1.02v-.042zm-3.39-3.15v1.2h2.99v8.424h1.524v-8.424h2.99v-1.2H8.977z" />
  1523. </svg>
  1524. </span>
  1525. ) : node.name.endsWith(".json") ? (
  1526. <span className="text-yellow-400">
  1527. <svg
  1528. width="12"
  1529. height="12"
  1530. viewBox="0 0 24 24"
  1531. fill="none"
  1532. stroke="currentColor"
  1533. strokeWidth="2"
  1534. >
  1535. <path d="M4 4h16v16H4z" />
  1536. <path d="M8 8h8M8 12h8M8 16h4" />
  1537. </svg>
  1538. </span>
  1539. ) : node.name.endsWith(".css") ? (
  1540. <span className="text-pink-400">
  1541. <svg
  1542. width="12"
  1543. height="12"
  1544. viewBox="0 0 24 24"
  1545. fill="currentColor"
  1546. >
  1547. <path d="M3 3h18v18H3V3zm15.751 10.875l-.634 7.125-6.125 2-6.125-2-.625-7.125h3.125l.312 3.625 3.313 1.125 3.312-1.125.375-3.625H6.125l-.313-3.125h12.376l-.312 3.125H9.125l.25 1.875h8.376v.125z" />
  1548. </svg>
  1549. </span>
  1550. ) : node.name.endsWith(".md") ? (
  1551. <span className="text-gray-400">
  1552. <svg
  1553. width="12"
  1554. height="12"
  1555. viewBox="0 0 24 24"
  1556. fill="currentColor"
  1557. >
  1558. <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM13 9V3.5L18.5 9H13z" />
  1559. </svg>
  1560. </span>
  1561. ) : (
  1562. <span className="text-gray-400">
  1563. <svg
  1564. width="12"
  1565. height="12"
  1566. viewBox="0 0 24 24"
  1567. fill="currentColor"
  1568. >
  1569. <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM13 9V3.5L18.5 9H13z" />
  1570. </svg>
  1571. </span>
  1572. )}
  1573. {node.name}
  1574. </span>
  1575. </button>,
  1576. );
  1577. }
  1578. // Only render children if not a folder or if folder is expanded (or root)
  1579. if (!node.isFolder || !node.name || isExpanded) {
  1580. sortNodes(node.children).forEach((child) => {
  1581. result.push(
  1582. ...renderNode(child, node.name ? depth + 1 : depth),
  1583. );
  1584. });
  1585. }
  1586. return result;
  1587. };
  1588. return renderNode(root, 0);
  1589. })()}
  1590. </div>
  1591. {/* Code Preview */}
  1592. <div className="flex-1 flex flex-col min-w-0">
  1593. <div className="flex items-center justify-between px-4 py-2 border-b border-border bg-muted/30">
  1594. <span className="text-xs font-mono text-muted-foreground">
  1595. {activeExportFile}
  1596. </span>
  1597. <button
  1598. onClick={() => copyFileContent(activeExportContent)}
  1599. className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
  1600. >
  1601. <svg
  1602. width="12"
  1603. height="12"
  1604. viewBox="0 0 24 24"
  1605. fill="none"
  1606. stroke="currentColor"
  1607. strokeWidth="2"
  1608. strokeLinecap="round"
  1609. strokeLinejoin="round"
  1610. >
  1611. <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
  1612. <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
  1613. </svg>
  1614. Copy
  1615. </button>
  1616. </div>
  1617. <div className="flex-1 overflow-auto">
  1618. <CodeBlock
  1619. code={activeExportContent}
  1620. lang={activeExportFile?.endsWith(".json") ? "json" : "tsx"}
  1621. fillHeight
  1622. hideCopyButton
  1623. />
  1624. </div>
  1625. </div>
  1626. </div>
  1627. </div>
  1628. </div>
  1629. )}
  1630. </div>
  1631. );
  1632. }