page.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627
  1. "use client";
  2. import { useState, useCallback, useRef, useEffect } from "react";
  3. import { examples } from "@/lib/examples";
  4. import { createSpecStreamCompiler } from "@json-render/core";
  5. import type { Spec } from "@json-render/core";
  6. import { cn } from "@/lib/utils";
  7. import { ScrollArea } from "@/components/ui/scroll-area";
  8. import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
  9. import {
  10. ResizablePanelGroup,
  11. ResizablePanel,
  12. ResizableHandle,
  13. } from "@/components/ui/resizable";
  14. import { ImageIcon, Download, Loader2, ArrowRight, Square } from "lucide-react";
  15. type Mode = "scratch" | "example";
  16. type MobileView = "json" | "preview";
  17. interface Selection {
  18. mode: Mode;
  19. exampleName?: string;
  20. }
  21. const IMAGE_REFRESH_INTERVAL_MS = 3000;
  22. function CopyButton({ text }: { text: string }) {
  23. const [copied, setCopied] = useState(false);
  24. return (
  25. <button
  26. onClick={() => {
  27. navigator.clipboard.writeText(text);
  28. setCopied(true);
  29. setTimeout(() => setCopied(false), 1500);
  30. }}
  31. className="text-xs text-muted-foreground hover:text-foreground transition-colors font-mono"
  32. >
  33. {copied ? "copied" : "copy"}
  34. </button>
  35. );
  36. }
  37. function isRenderableSpec(spec: Spec | null): spec is Spec {
  38. if (!spec?.root || !spec.elements) return false;
  39. const root = spec.elements[spec.root];
  40. return root?.type === "Frame";
  41. }
  42. export default function Page() {
  43. const [selection, setSelection] = useState<Selection>({
  44. mode: "example",
  45. exampleName: examples[0]!.name,
  46. });
  47. const [prompt, setPrompt] = useState("");
  48. const [generating, setGenerating] = useState(false);
  49. const [generatedSpec, setGeneratedSpec] = useState<Spec | null>(null);
  50. const [imageUrl, setImageUrl] = useState<string | null>(null);
  51. const [error, setError] = useState<string | null>(null);
  52. const [mobileView, setMobileView] = useState<MobileView>("preview");
  53. const [examplesSheetOpen, setExamplesSheetOpen] = useState(false);
  54. const [refreshing, setRefreshing] = useState(false);
  55. const imageUrlRef = useRef<string | null>(null);
  56. const inputRef = useRef<HTMLTextAreaElement>(null);
  57. const mobileInputRef = useRef<HTMLTextAreaElement>(null);
  58. const abortRef = useRef<AbortController | null>(null);
  59. const codeScrollRef = useRef<HTMLDivElement>(null);
  60. const mobileCodeScrollRef = useRef<HTMLDivElement>(null);
  61. useEffect(() => {
  62. if (!generating) return;
  63. codeScrollRef.current?.scrollTo({
  64. top: codeScrollRef.current.scrollHeight,
  65. });
  66. mobileCodeScrollRef.current?.scrollTo({
  67. top: mobileCodeScrollRef.current.scrollHeight,
  68. });
  69. }, [generating, generatedSpec]);
  70. const currentExample =
  71. selection.mode === "example"
  72. ? examples.find((e) => e.name === selection.exampleName)
  73. : null;
  74. const activeSpec = generatedSpec ?? currentExample?.spec ?? null;
  75. const exampleImageUrl =
  76. selection.mode === "example" && !generatedSpec
  77. ? `/api/image?name=${selection.exampleName}`
  78. : null;
  79. const displayImageUrl = imageUrl ?? exampleImageUrl;
  80. useEffect(() => {
  81. inputRef.current?.focus();
  82. }, [selection.mode, selection.exampleName]);
  83. const fetchImageBlob = useCallback(
  84. async (spec: Spec, signal?: AbortSignal) => {
  85. const res = await fetch("/api/image", {
  86. method: "POST",
  87. headers: { "Content-Type": "application/json" },
  88. body: JSON.stringify({ spec }),
  89. signal,
  90. });
  91. if (!res.ok) throw new Error("Failed to generate image");
  92. const blob = await res.blob();
  93. const url = URL.createObjectURL(blob);
  94. const prev = imageUrlRef.current;
  95. imageUrlRef.current = url;
  96. setImageUrl(url);
  97. if (prev) URL.revokeObjectURL(prev);
  98. },
  99. [],
  100. );
  101. const lastRefreshSpec = useRef<string>("");
  102. const generatedSpecRef = useRef<Spec | null>(null);
  103. generatedSpecRef.current = generatedSpec;
  104. useEffect(() => {
  105. if (!generating) return;
  106. const interval = setInterval(() => {
  107. const spec = generatedSpecRef.current;
  108. if (!spec) return;
  109. const specKey = JSON.stringify(spec);
  110. if (specKey === lastRefreshSpec.current) return;
  111. if (!isRenderableSpec(spec)) return;
  112. lastRefreshSpec.current = specKey;
  113. setRefreshing(true);
  114. fetchImageBlob(spec)
  115. .catch(() => {})
  116. .finally(() => setRefreshing(false));
  117. }, IMAGE_REFRESH_INTERVAL_MS);
  118. return () => clearInterval(interval);
  119. }, [generating, fetchImageBlob]);
  120. const handleGenerate = useCallback(async () => {
  121. if (!prompt.trim()) return;
  122. abortRef.current?.abort();
  123. const controller = new AbortController();
  124. abortRef.current = controller;
  125. setGenerating(true);
  126. setError(null);
  127. lastRefreshSpec.current = "";
  128. try {
  129. const startingSpec =
  130. selection.mode === "example" && currentExample
  131. ? currentExample.spec
  132. : null;
  133. const res = await fetch("/api/generate", {
  134. method: "POST",
  135. headers: { "Content-Type": "application/json" },
  136. body: JSON.stringify({ prompt: prompt.trim(), startingSpec }),
  137. signal: controller.signal,
  138. });
  139. if (!res.ok) throw new Error("Generation failed");
  140. const reader = res.body?.getReader();
  141. if (!reader) throw new Error("No response body");
  142. const decoder = new TextDecoder();
  143. const compiler = createSpecStreamCompiler<Spec>(
  144. startingSpec ? { ...startingSpec } : {},
  145. );
  146. while (true) {
  147. const { done, value } = await reader.read();
  148. if (done) break;
  149. const chunk = decoder.decode(value, { stream: true });
  150. const { result, newPatches } = compiler.push(chunk);
  151. if (newPatches.length > 0) setGeneratedSpec(result);
  152. }
  153. const finalSpec = compiler.getResult();
  154. setGeneratedSpec(finalSpec);
  155. setGenerating(false);
  156. await fetchImageBlob(finalSpec);
  157. } catch (e) {
  158. if (controller.signal.aborted) return;
  159. setError(e instanceof Error ? e.message : "Something went wrong");
  160. setGenerating(false);
  161. }
  162. }, [prompt, selection, currentExample, fetchImageBlob]);
  163. const handleStop = useCallback(() => {
  164. abortRef.current?.abort();
  165. setGenerating(false);
  166. if (isRenderableSpec(generatedSpec)) {
  167. fetchImageBlob(generatedSpec).catch(() => {});
  168. }
  169. }, [generatedSpec, fetchImageBlob]);
  170. const select = (next: Selection) => {
  171. abortRef.current?.abort();
  172. setSelection(next);
  173. setGeneratedSpec(null);
  174. setImageUrl(null);
  175. setError(null);
  176. setPrompt("");
  177. setGenerating(false);
  178. setExamplesSheetOpen(false);
  179. };
  180. const handleDownload = async () => {
  181. if (!activeSpec) return;
  182. if (generatedSpec) {
  183. const res = await fetch("/api/image", {
  184. method: "POST",
  185. headers: { "Content-Type": "application/json" },
  186. body: JSON.stringify({ spec: generatedSpec, download: true }),
  187. });
  188. const blob = await res.blob();
  189. const url = URL.createObjectURL(blob);
  190. const a = document.createElement("a");
  191. a.href = url;
  192. a.download = "image.png";
  193. a.click();
  194. URL.revokeObjectURL(url);
  195. } else if (selection.mode === "example") {
  196. window.open(
  197. `/api/image?name=${selection.exampleName}&download=1`,
  198. "_blank",
  199. );
  200. }
  201. };
  202. const handleKeyDown = useCallback(
  203. (e: React.KeyboardEvent) => {
  204. if (e.key === "Enter" && !e.shiftKey) {
  205. e.preventDefault();
  206. handleGenerate();
  207. }
  208. },
  209. [handleGenerate],
  210. );
  211. const jsonCode = activeSpec
  212. ? JSON.stringify(activeSpec, null, 2)
  213. : "// select an example or generate an image";
  214. // ---------------------------------------------------------------------------
  215. // Pane: Chat / Examples
  216. // ---------------------------------------------------------------------------
  217. const chatPane = (
  218. <div className="h-full flex flex-col">
  219. <div className="border-b border-border px-3 h-9 flex items-center gap-2">
  220. <ImageIcon className="h-3.5 w-3.5 text-muted-foreground" />
  221. <span className="text-xs font-mono text-muted-foreground">
  222. json-render / image
  223. </span>
  224. </div>
  225. <ScrollArea className="flex-1">
  226. <div className="p-2 space-y-1">
  227. <p className="px-2 pt-2 pb-1 text-[11px] font-mono text-muted-foreground">
  228. start
  229. </p>
  230. <button
  231. onClick={() => select({ mode: "scratch" })}
  232. className={cn(
  233. "w-full text-left px-3 py-2 rounded text-sm transition-colors",
  234. selection.mode === "scratch"
  235. ? "bg-muted text-foreground"
  236. : "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
  237. )}
  238. >
  239. <span className="font-medium">From scratch</span>
  240. </button>
  241. <p className="px-2 pt-3 pb-1 text-[11px] font-mono text-muted-foreground">
  242. examples
  243. </p>
  244. {examples.map((ex) => (
  245. <button
  246. key={ex.name}
  247. onClick={() => select({ mode: "example", exampleName: ex.name })}
  248. className={cn(
  249. "w-full text-left px-3 py-2 rounded text-sm transition-colors",
  250. selection.mode === "example" &&
  251. selection.exampleName === ex.name
  252. ? "bg-muted text-foreground"
  253. : "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
  254. )}
  255. >
  256. <span className="font-medium">{ex.label}</span>
  257. <p className="text-xs text-muted-foreground/70 mt-0.5 leading-snug">
  258. {ex.description}
  259. </p>
  260. </button>
  261. ))}
  262. </div>
  263. </ScrollArea>
  264. <div
  265. className="border-t border-border p-3 cursor-text"
  266. onMouseDown={(e) => {
  267. const target = e.target as HTMLElement;
  268. if (!target.closest("button") && target.tagName !== "TEXTAREA") {
  269. e.preventDefault();
  270. inputRef.current?.focus();
  271. }
  272. }}
  273. >
  274. {error && (
  275. <div className="mb-2 rounded bg-destructive/10 px-3 py-1.5 text-xs text-destructive">
  276. {error}
  277. </div>
  278. )}
  279. <textarea
  280. ref={inputRef}
  281. value={prompt}
  282. onChange={(e) => setPrompt(e.target.value)}
  283. onKeyDown={handleKeyDown}
  284. placeholder={
  285. selection.mode === "scratch"
  286. ? "Describe the image you want..."
  287. : `Modify the ${currentExample?.label ?? "example"}...`
  288. }
  289. className="w-full bg-background text-sm resize-none outline-none placeholder:text-muted-foreground/50"
  290. rows={2}
  291. autoFocus
  292. />
  293. <div className="flex justify-between items-center mt-2">
  294. <span className="text-[11px] text-muted-foreground">
  295. {selection.mode === "example" && currentExample
  296. ? currentExample.label
  297. : "scratch"}
  298. </span>
  299. {generating ? (
  300. <button
  301. onClick={handleStop}
  302. className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
  303. aria-label="Stop"
  304. >
  305. <Square className="h-3 w-3" fill="currentColor" />
  306. </button>
  307. ) : (
  308. <button
  309. onClick={handleGenerate}
  310. disabled={!prompt.trim()}
  311. className="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"
  312. aria-label="Generate"
  313. >
  314. <ArrowRight className="h-3.5 w-3.5" />
  315. </button>
  316. )}
  317. </div>
  318. </div>
  319. </div>
  320. );
  321. // ---------------------------------------------------------------------------
  322. // Pane: JSON Spec
  323. // ---------------------------------------------------------------------------
  324. const codePane = (
  325. <div className="h-full flex flex-col">
  326. <div className="border-b border-border px-3 h-9 flex items-center gap-3">
  327. <span className="text-xs font-mono text-foreground">json</span>
  328. {generating && (
  329. <Loader2 className="h-3 w-3 text-muted-foreground animate-spin" />
  330. )}
  331. <div className="flex-1" />
  332. {activeSpec && <CopyButton text={jsonCode} />}
  333. </div>
  334. <div ref={codeScrollRef} className="flex-1 overflow-auto">
  335. <pre className="p-3 text-xs leading-relaxed font-mono text-muted-foreground whitespace-pre">
  336. {jsonCode}
  337. </pre>
  338. </div>
  339. </div>
  340. );
  341. // ---------------------------------------------------------------------------
  342. // Pane: Image Preview
  343. // ---------------------------------------------------------------------------
  344. const previewPane = (
  345. <div className="h-full flex flex-col">
  346. <div className="border-b border-border px-3 h-9 flex items-center gap-3">
  347. <span className="text-xs font-mono text-foreground">preview</span>
  348. {(generating || refreshing) && (
  349. <Loader2 className="h-3.5 w-3.5 text-muted-foreground animate-spin" />
  350. )}
  351. <div className="flex-1" />
  352. {activeSpec && (
  353. <button
  354. onClick={handleDownload}
  355. className="text-xs text-muted-foreground hover:text-foreground transition-colors font-mono flex items-center gap-1"
  356. >
  357. <Download className="h-3 w-3" />
  358. download
  359. </button>
  360. )}
  361. </div>
  362. <div className="flex-1 relative bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center p-4">
  363. {displayImageUrl ? (
  364. <img
  365. key={displayImageUrl}
  366. src={displayImageUrl}
  367. alt="Generated image"
  368. className="max-w-full max-h-full object-contain rounded shadow-lg"
  369. />
  370. ) : (
  371. <div className="flex flex-col items-center justify-center gap-2 text-neutral-400">
  372. <ImageIcon className="h-10 w-10" />
  373. <p className="text-sm">
  374. {selection.mode === "scratch"
  375. ? "Enter a prompt to generate an image"
  376. : "Select an example to preview"}
  377. </p>
  378. </div>
  379. )}
  380. </div>
  381. </div>
  382. );
  383. // ---------------------------------------------------------------------------
  384. // Render
  385. // ---------------------------------------------------------------------------
  386. return (
  387. <div className="h-dvh flex flex-col">
  388. {/* Desktop: 3-pane resizable layout */}
  389. <div className="hidden lg:flex flex-1 min-h-0">
  390. <ResizablePanelGroup className="flex-1">
  391. <ResizablePanel defaultSize={25} minSize={15}>
  392. {chatPane}
  393. </ResizablePanel>
  394. <ResizableHandle />
  395. <ResizablePanel defaultSize={35} minSize={20}>
  396. {codePane}
  397. </ResizablePanel>
  398. <ResizableHandle />
  399. <ResizablePanel defaultSize={40} minSize={20}>
  400. {previewPane}
  401. </ResizablePanel>
  402. </ResizablePanelGroup>
  403. </div>
  404. {/* Mobile: toolbar + content + prompt */}
  405. <div className="flex lg:hidden flex-col flex-1 min-h-0">
  406. <div className="border-b border-border h-10 flex items-center px-1">
  407. <button
  408. onClick={() => setExamplesSheetOpen(true)}
  409. className="px-3 h-full text-xs font-mono text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1.5"
  410. >
  411. <ImageIcon className="h-3.5 w-3.5" />
  412. {selection.mode === "example" && currentExample
  413. ? currentExample.label
  414. : "scratch"}
  415. </button>
  416. <div className="flex-1" />
  417. <button
  418. onClick={() => setMobileView("json")}
  419. className={cn(
  420. "px-3 h-full text-xs font-mono transition-colors",
  421. mobileView === "json"
  422. ? "text-foreground"
  423. : "text-muted-foreground hover:text-foreground",
  424. )}
  425. >
  426. json
  427. </button>
  428. <button
  429. onClick={() => setMobileView("preview")}
  430. className={cn(
  431. "px-3 h-full text-xs font-mono transition-colors",
  432. mobileView === "preview"
  433. ? "text-foreground"
  434. : "text-muted-foreground hover:text-foreground",
  435. )}
  436. >
  437. preview
  438. </button>
  439. {activeSpec && (
  440. <button
  441. onClick={handleDownload}
  442. className="px-3 h-full text-xs text-muted-foreground hover:text-foreground transition-colors"
  443. >
  444. <Download className="h-3.5 w-3.5" />
  445. </button>
  446. )}
  447. </div>
  448. <div className="flex-1 min-h-0">
  449. {mobileView === "json" ? (
  450. <div ref={mobileCodeScrollRef} className="h-full overflow-auto">
  451. <pre className="p-3 text-xs leading-relaxed font-mono text-muted-foreground whitespace-pre">
  452. {jsonCode}
  453. </pre>
  454. </div>
  455. ) : (
  456. <div className="h-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center p-4">
  457. {displayImageUrl ? (
  458. <img
  459. key={displayImageUrl}
  460. src={displayImageUrl}
  461. alt="Generated image"
  462. className="max-w-full max-h-full object-contain rounded shadow-lg"
  463. />
  464. ) : (
  465. <div className="flex flex-col items-center justify-center gap-2 text-neutral-400">
  466. <ImageIcon className="h-10 w-10" />
  467. <p className="text-sm">
  468. {selection.mode === "scratch"
  469. ? "Enter a prompt to generate an image"
  470. : "Select an example"}
  471. </p>
  472. </div>
  473. )}
  474. </div>
  475. )}
  476. </div>
  477. <div
  478. className="border-t border-border p-3 cursor-text"
  479. onMouseDown={(e) => {
  480. const target = e.target as HTMLElement;
  481. if (!target.closest("button") && target.tagName !== "TEXTAREA") {
  482. e.preventDefault();
  483. mobileInputRef.current?.focus();
  484. }
  485. }}
  486. >
  487. {error && (
  488. <div className="mb-2 rounded bg-destructive/10 px-3 py-1.5 text-xs text-destructive">
  489. {error}
  490. </div>
  491. )}
  492. <textarea
  493. ref={mobileInputRef}
  494. value={prompt}
  495. onChange={(e) => setPrompt(e.target.value)}
  496. onKeyDown={handleKeyDown}
  497. placeholder={
  498. selection.mode === "scratch"
  499. ? "Describe the image you want..."
  500. : `Modify the ${currentExample?.label ?? "example"}...`
  501. }
  502. className="w-full bg-background text-sm resize-none outline-none placeholder:text-muted-foreground/50"
  503. rows={2}
  504. />
  505. <div className="flex justify-end mt-2">
  506. {generating ? (
  507. <button
  508. onClick={handleStop}
  509. className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
  510. aria-label="Stop"
  511. >
  512. <Square className="h-3 w-3" fill="currentColor" />
  513. </button>
  514. ) : (
  515. <button
  516. onClick={handleGenerate}
  517. disabled={!prompt.trim()}
  518. className="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"
  519. aria-label="Generate"
  520. >
  521. <ArrowRight className="h-3.5 w-3.5" />
  522. </button>
  523. )}
  524. </div>
  525. </div>
  526. </div>
  527. <Sheet open={examplesSheetOpen} onOpenChange={setExamplesSheetOpen}>
  528. <SheetContent side="left" className="w-72 p-0">
  529. <SheetTitle className="sr-only">Examples</SheetTitle>
  530. <ScrollArea className="h-full">
  531. <div className="p-2 space-y-1">
  532. <p className="px-2 pt-2 pb-1 text-[11px] font-mono text-muted-foreground">
  533. start
  534. </p>
  535. <button
  536. onClick={() => select({ mode: "scratch" })}
  537. className={cn(
  538. "w-full text-left px-3 py-2 rounded text-sm transition-colors",
  539. selection.mode === "scratch"
  540. ? "bg-muted text-foreground"
  541. : "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
  542. )}
  543. >
  544. <span className="font-medium">From scratch</span>
  545. </button>
  546. <p className="px-2 pt-3 pb-1 text-[11px] font-mono text-muted-foreground">
  547. examples
  548. </p>
  549. {examples.map((ex) => (
  550. <button
  551. key={ex.name}
  552. onClick={() =>
  553. select({ mode: "example", exampleName: ex.name })
  554. }
  555. className={cn(
  556. "w-full text-left px-3 py-2 rounded text-sm transition-colors",
  557. selection.mode === "example" &&
  558. selection.exampleName === ex.name
  559. ? "bg-muted text-foreground"
  560. : "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
  561. )}
  562. >
  563. <span className="font-medium">{ex.label}</span>
  564. <p className="text-xs text-muted-foreground/70 mt-0.5 leading-snug">
  565. {ex.description}
  566. </p>
  567. </button>
  568. ))}
  569. </div>
  570. </ScrollArea>
  571. </SheetContent>
  572. </Sheet>
  573. </div>
  574. );
  575. }