page.tsx 21 KB

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