index.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  1. import React, { useState, useRef, useCallback, useEffect } from "react";
  2. import {
  3. View,
  4. Text,
  5. TextInput,
  6. Pressable,
  7. ScrollView,
  8. Animated,
  9. Easing,
  10. Platform,
  11. Keyboard,
  12. StyleSheet,
  13. } from "react-native";
  14. import { fetch } from "expo/fetch";
  15. import Constants from "expo-constants";
  16. import { useSafeAreaInsets } from "react-native-safe-area-context";
  17. import * as Clipboard from "expo-clipboard";
  18. import { useUIStream } from "@json-render/react-native";
  19. import { AppRenderer } from "../lib/render/renderer";
  20. // Resolve the Metro dev server origin for API route calls.
  21. // expo/fetch doesn't resolve relative URLs like the built-in RN fetch does.
  22. function getApiBaseUrl(): string {
  23. const debuggerHost =
  24. Constants.expoConfig?.hostUri ??
  25. Constants.manifest2?.extra?.expoGo?.debuggerHost;
  26. if (debuggerHost) {
  27. const host = debuggerHost.split(":").shift();
  28. // Metro dev server runs on port 8081 by default
  29. return `http://${host}:8081`;
  30. }
  31. return "http://localhost:8081";
  32. }
  33. const API_BASE = getApiBaseUrl();
  34. export default function HomeScreen() {
  35. const insets = useSafeAreaInsets();
  36. const [prompt, setPrompt] = useState("");
  37. const { spec, isStreaming, error, rawLines, send, stop, clear } = useUIStream(
  38. {
  39. api: `${API_BASE}/api/generate`,
  40. fetch,
  41. validate: true,
  42. onError: (err) => console.error("Generation error:", err),
  43. },
  44. );
  45. const handleGenerate = async () => {
  46. if (!prompt.trim() || isStreaming) return;
  47. const text = prompt.trim();
  48. setPrompt("");
  49. Keyboard.dismiss();
  50. await send(text, {
  51. previousSpec: spec ?? undefined,
  52. });
  53. };
  54. type ViewMode = "ui" | "json" | "jsonl";
  55. const [viewMode, setViewMode] = useState<ViewMode>("ui");
  56. const [showMenu, setShowMenu] = useState(false);
  57. const [confirmingClear, setConfirmingClear] = useState(false);
  58. const [copied, setCopied] = useState(false);
  59. const handleCopy = useCallback(async () => {
  60. const text =
  61. viewMode === "jsonl"
  62. ? rawLines.join("\n")
  63. : JSON.stringify(spec, null, 2);
  64. if (!text) return;
  65. await Clipboard.setStringAsync(text);
  66. setCopied(true);
  67. setTimeout(() => setCopied(false), 2000);
  68. }, [spec, rawLines, viewMode]);
  69. const handleClear = useCallback(() => {
  70. if (!confirmingClear) {
  71. setConfirmingClear(true);
  72. return;
  73. }
  74. setConfirmingClear(false);
  75. setShowMenu(false);
  76. clear();
  77. setPrompt("");
  78. setViewMode("ui");
  79. }, [confirmingClear, clear]);
  80. const hasContent = !!spec || isStreaming || !!error;
  81. const PROMPT_BAR_HEIGHT = 80;
  82. // Animate the floating prompt bar in sync with the keyboard
  83. const keyboardOffset = useRef(new Animated.Value(0)).current;
  84. useEffect(() => {
  85. const showEvent =
  86. Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow";
  87. const hideEvent =
  88. Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide";
  89. const showSub = Keyboard.addListener(showEvent, (e) => {
  90. Animated.timing(keyboardOffset, {
  91. toValue: -Math.max(0, e.endCoordinates.height - insets.bottom - 4),
  92. duration: Platform.OS === "ios" ? e.duration : 200,
  93. easing: Easing.bezier(0.17, 0.59, 0.4, 0.99),
  94. useNativeDriver: true,
  95. }).start();
  96. });
  97. const hideSub = Keyboard.addListener(hideEvent, (e) => {
  98. Animated.timing(keyboardOffset, {
  99. toValue: 0,
  100. duration: Platform.OS === "ios" ? (e.duration ?? 250) : 200,
  101. easing: Easing.bezier(0.17, 0.59, 0.4, 0.99),
  102. useNativeDriver: true,
  103. }).start();
  104. });
  105. return () => {
  106. showSub.remove();
  107. hideSub.remove();
  108. };
  109. }, [insets.bottom, keyboardOffset]);
  110. return (
  111. <View style={styles.container}>
  112. {hasContent ? (
  113. viewMode !== "ui" ? (
  114. <ScrollView
  115. style={styles.flex}
  116. contentContainerStyle={[
  117. styles.contentContainer,
  118. {
  119. paddingTop: insets.top + 16,
  120. paddingBottom: insets.bottom + PROMPT_BAR_HEIGHT + 16,
  121. },
  122. ]}
  123. keyboardShouldPersistTaps="handled"
  124. >
  125. <View style={styles.jsonContainer}>
  126. <View style={styles.jsonHeader}>
  127. <Text style={styles.jsonHeaderTitle}>
  128. {viewMode === "json" ? "JSON Spec" : "JSONL Stream"}
  129. </Text>
  130. <Pressable style={styles.copyButton} onPress={handleCopy}>
  131. <Text style={styles.copyButtonText}>
  132. {copied ? "Copied" : "Copy"}
  133. </Text>
  134. </Pressable>
  135. </View>
  136. <Text style={styles.jsonText}>
  137. {viewMode === "json"
  138. ? JSON.stringify(spec, null, 2)
  139. : rawLines.join("\n")}
  140. </Text>
  141. </View>
  142. </ScrollView>
  143. ) : (
  144. <View
  145. style={[
  146. styles.flex,
  147. { paddingBottom: insets.bottom + PROMPT_BAR_HEIGHT + 16 },
  148. ]}
  149. >
  150. {error && (
  151. <View style={styles.errorContainer}>
  152. <Text style={styles.errorTitle}>Generation failed</Text>
  153. <Text style={styles.errorText}>{error.message}</Text>
  154. </View>
  155. )}
  156. {(spec || isStreaming) && (
  157. <AppRenderer spec={spec} loading={isStreaming} />
  158. )}
  159. </View>
  160. )
  161. ) : (
  162. <Pressable style={styles.splash} onPress={Keyboard.dismiss}>
  163. <View style={styles.splashContent}>
  164. <Text style={styles.splashTitle}>json-render</Text>
  165. <Text style={styles.splashSubtitle}>
  166. Describe a UI and watch it appear
  167. </Text>
  168. <Text style={styles.splashHint}>
  169. Try something like "a settings page with a dark mode toggle,
  170. notification preferences, and a profile card with an avatar"
  171. </Text>
  172. </View>
  173. </Pressable>
  174. )}
  175. {/* Menu popover */}
  176. {showMenu && (
  177. <Pressable
  178. style={styles.menuOverlay}
  179. onPress={() => {
  180. setShowMenu(false);
  181. setConfirmingClear(false);
  182. }}
  183. >
  184. <Animated.View
  185. style={[
  186. styles.menuPopover,
  187. { bottom: insets.bottom + 64 },
  188. { transform: [{ translateY: keyboardOffset }] },
  189. ]}
  190. >
  191. <Pressable
  192. style={styles.menuItem}
  193. onPress={() => {
  194. setViewMode((v) => (v === "json" ? "ui" : "json"));
  195. setShowMenu(false);
  196. }}
  197. >
  198. <Text style={styles.menuItemText}>
  199. {viewMode === "json" ? "Hide JSON" : "Show JSON"}
  200. </Text>
  201. </Pressable>
  202. <View style={styles.menuDivider} />
  203. <Pressable
  204. style={styles.menuItem}
  205. onPress={() => {
  206. setViewMode((v) => (v === "jsonl" ? "ui" : "jsonl"));
  207. setShowMenu(false);
  208. }}
  209. >
  210. <Text style={styles.menuItemText}>
  211. {viewMode === "jsonl" ? "Hide JSONL" : "Show JSONL"}
  212. </Text>
  213. </Pressable>
  214. <View style={styles.menuDivider} />
  215. <Pressable style={styles.menuItem} onPress={handleClear}>
  216. <Text
  217. style={[
  218. styles.menuItemText,
  219. styles.menuItemDestructive,
  220. confirmingClear && styles.menuItemDestructiveConfirm,
  221. ]}
  222. >
  223. {confirmingClear ? "Are you sure?" : "Clear"}
  224. </Text>
  225. </Pressable>
  226. </Animated.View>
  227. </Pressable>
  228. )}
  229. {/* Prompt bar */}
  230. <Animated.View
  231. style={[
  232. styles.promptArea,
  233. { bottom: insets.bottom + 12 },
  234. { transform: [{ translateY: keyboardOffset }] },
  235. ]}
  236. >
  237. <View style={styles.promptBar}>
  238. <TextInput
  239. style={styles.promptInput}
  240. value={prompt}
  241. onChangeText={setPrompt}
  242. placeholder="Describe a UI..."
  243. placeholderTextColor="#9ca3af"
  244. multiline
  245. maxLength={500}
  246. returnKeyType="send"
  247. blurOnSubmit
  248. onSubmitEditing={handleGenerate}
  249. editable
  250. />
  251. {hasContent && spec && !isStreaming && (
  252. <Pressable
  253. style={styles.menuButton}
  254. onPress={() => {
  255. setShowMenu((v) => !v);
  256. setConfirmingClear(false);
  257. }}
  258. >
  259. <Text style={styles.menuButtonText}>···</Text>
  260. </Pressable>
  261. )}
  262. {isStreaming ? (
  263. <Pressable style={styles.stopButton} onPress={stop}>
  264. <View style={styles.stopIcon} />
  265. </Pressable>
  266. ) : (
  267. <Pressable
  268. style={[
  269. styles.sendButton,
  270. !prompt.trim() && styles.sendButtonDisabled,
  271. ]}
  272. onPress={handleGenerate}
  273. disabled={!prompt.trim()}
  274. >
  275. <Text style={styles.sendButtonText}>Go</Text>
  276. </Pressable>
  277. )}
  278. </View>
  279. </Animated.View>
  280. </View>
  281. );
  282. }
  283. const styles = StyleSheet.create({
  284. container: {
  285. flex: 1,
  286. backgroundColor: "#ffffff",
  287. },
  288. flex: {
  289. flex: 1,
  290. },
  291. splash: {
  292. flex: 1,
  293. justifyContent: "center",
  294. },
  295. splashContent: {
  296. flex: 1,
  297. alignItems: "center",
  298. justifyContent: "center",
  299. paddingHorizontal: 40,
  300. },
  301. splashTitle: {
  302. fontSize: 32,
  303. fontWeight: "700",
  304. color: "#111827",
  305. marginBottom: 8,
  306. },
  307. splashSubtitle: {
  308. fontSize: 16,
  309. color: "#6b7280",
  310. marginBottom: 24,
  311. },
  312. splashHint: {
  313. fontSize: 14,
  314. color: "#9ca3af",
  315. textAlign: "center",
  316. lineHeight: 20,
  317. },
  318. contentContainer: {},
  319. jsonContainer: {
  320. margin: 16,
  321. padding: 16,
  322. backgroundColor: "#f9fafb",
  323. borderRadius: 12,
  324. borderWidth: 1,
  325. borderColor: "#e5e7eb",
  326. },
  327. jsonHeader: {
  328. flexDirection: "row",
  329. justifyContent: "space-between",
  330. alignItems: "center",
  331. marginBottom: 12,
  332. },
  333. jsonHeaderTitle: {
  334. fontSize: 13,
  335. fontWeight: "600",
  336. color: "#9ca3af",
  337. textTransform: "uppercase",
  338. letterSpacing: 0.5,
  339. },
  340. copyButton: {
  341. backgroundColor: "#e5e7eb",
  342. borderRadius: 8,
  343. paddingHorizontal: 12,
  344. paddingVertical: 6,
  345. },
  346. copyButtonText: {
  347. fontSize: 13,
  348. fontWeight: "600",
  349. color: "#374151",
  350. },
  351. jsonText: {
  352. fontSize: 12,
  353. fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
  354. color: "#374151",
  355. lineHeight: 18,
  356. },
  357. errorContainer: {
  358. backgroundColor: "#fef2f2",
  359. borderRadius: 12,
  360. padding: 16,
  361. margin: 16,
  362. borderWidth: 1,
  363. borderColor: "#fecaca",
  364. },
  365. errorTitle: {
  366. fontSize: 16,
  367. fontWeight: "600",
  368. color: "#dc2626",
  369. marginBottom: 4,
  370. },
  371. errorText: {
  372. fontSize: 14,
  373. color: "#991b1b",
  374. },
  375. promptArea: {
  376. position: "absolute",
  377. left: 12,
  378. right: 12,
  379. },
  380. menuButton: {
  381. borderRadius: 20,
  382. width: 38,
  383. height: 38,
  384. alignItems: "center",
  385. justifyContent: "center",
  386. alignSelf: "center",
  387. },
  388. menuButtonText: {
  389. fontSize: 18,
  390. color: "#9ca3af",
  391. fontWeight: "700",
  392. },
  393. menuOverlay: {
  394. ...StyleSheet.absoluteFillObject,
  395. zIndex: 10,
  396. },
  397. menuPopover: {
  398. position: "absolute",
  399. right: 12,
  400. backgroundColor: "#ffffff",
  401. borderRadius: 14,
  402. borderWidth: 1,
  403. borderColor: "#e5e7eb",
  404. shadowColor: "#000",
  405. shadowOffset: { width: 0, height: 4 },
  406. shadowOpacity: 0.15,
  407. shadowRadius: 12,
  408. elevation: 10,
  409. minWidth: 160,
  410. overflow: "hidden",
  411. },
  412. menuItem: {
  413. paddingHorizontal: 16,
  414. paddingVertical: 12,
  415. },
  416. menuItemText: {
  417. fontSize: 15,
  418. color: "#111827",
  419. fontWeight: "500",
  420. },
  421. menuItemDestructive: {
  422. color: "#ef4444",
  423. },
  424. menuItemDestructiveConfirm: {
  425. fontWeight: "700",
  426. },
  427. menuDivider: {
  428. height: StyleSheet.hairlineWidth,
  429. backgroundColor: "#e5e7eb",
  430. },
  431. promptBar: {
  432. flexDirection: "row",
  433. alignItems: "flex-end",
  434. backgroundColor: "#ffffff",
  435. borderRadius: 24,
  436. borderWidth: 1,
  437. borderColor: "#e5e7eb",
  438. paddingLeft: 18,
  439. paddingRight: 4,
  440. paddingVertical: 4,
  441. shadowColor: "#000",
  442. shadowOffset: { width: 0, height: 4 },
  443. shadowOpacity: 0.12,
  444. shadowRadius: 16,
  445. elevation: 8,
  446. },
  447. promptInput: {
  448. flex: 1,
  449. fontSize: 16,
  450. color: "#111827",
  451. paddingVertical: 10,
  452. maxHeight: 100,
  453. },
  454. sendButton: {
  455. backgroundColor: "#3b82f6",
  456. borderRadius: 20,
  457. paddingHorizontal: 18,
  458. height: 38,
  459. alignItems: "center",
  460. justifyContent: "center",
  461. },
  462. sendButtonDisabled: {
  463. opacity: 0.4,
  464. },
  465. sendButtonText: {
  466. color: "#ffffff",
  467. fontSize: 15,
  468. fontWeight: "600",
  469. },
  470. stopButton: {
  471. backgroundColor: "#ef4444",
  472. borderRadius: 20,
  473. width: 38,
  474. height: 38,
  475. alignItems: "center",
  476. justifyContent: "center",
  477. },
  478. stopIcon: {
  479. width: 14,
  480. height: 14,
  481. backgroundColor: "#ffffff",
  482. borderRadius: 2,
  483. },
  484. });