page.tsx 21 KB

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