| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506 |
- import React, { useState, useRef, useCallback, useEffect } from "react";
- import {
- View,
- Text,
- TextInput,
- Pressable,
- ScrollView,
- Animated,
- Easing,
- Platform,
- Keyboard,
- StyleSheet,
- } from "react-native";
- import { fetch } from "expo/fetch";
- import Constants from "expo-constants";
- import { useSafeAreaInsets } from "react-native-safe-area-context";
- import * as Clipboard from "expo-clipboard";
- import { useUIStream } from "@json-render/react-native";
- import { AppRenderer } from "../lib/render/renderer";
- // Resolve the Metro dev server origin for API route calls.
- // expo/fetch doesn't resolve relative URLs like the built-in RN fetch does.
- function getApiBaseUrl(): string {
- const debuggerHost =
- Constants.expoConfig?.hostUri ??
- Constants.manifest2?.extra?.expoGo?.debuggerHost;
- if (debuggerHost) {
- const host = debuggerHost.split(":").shift();
- // Metro dev server runs on port 8081 by default
- return `http://${host}:8081`;
- }
- return "http://localhost:8081";
- }
- const API_BASE = getApiBaseUrl();
- export default function HomeScreen() {
- const insets = useSafeAreaInsets();
- const [prompt, setPrompt] = useState("");
- const { spec, isStreaming, error, rawLines, send, stop, clear } = useUIStream(
- {
- api: `${API_BASE}/api/generate`,
- fetch,
- validate: true,
- onError: (err) => console.error("Generation error:", err),
- },
- );
- const handleGenerate = async () => {
- if (!prompt.trim() || isStreaming) return;
- const text = prompt.trim();
- setPrompt("");
- Keyboard.dismiss();
- await send(text, {
- previousSpec: spec ?? undefined,
- });
- };
- type ViewMode = "ui" | "json" | "jsonl";
- const [viewMode, setViewMode] = useState<ViewMode>("ui");
- const [showMenu, setShowMenu] = useState(false);
- const [confirmingClear, setConfirmingClear] = useState(false);
- const [copied, setCopied] = useState(false);
- const handleCopy = useCallback(async () => {
- const text =
- viewMode === "jsonl"
- ? rawLines.join("\n")
- : JSON.stringify(spec, null, 2);
- if (!text) return;
- await Clipboard.setStringAsync(text);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- }, [spec, rawLines, viewMode]);
- const handleClear = useCallback(() => {
- if (!confirmingClear) {
- setConfirmingClear(true);
- return;
- }
- setConfirmingClear(false);
- setShowMenu(false);
- clear();
- setPrompt("");
- setViewMode("ui");
- }, [confirmingClear, clear]);
- const hasContent = !!spec || isStreaming || !!error;
- const PROMPT_BAR_HEIGHT = 80;
- // Animate the floating prompt bar in sync with the keyboard
- const keyboardOffset = useRef(new Animated.Value(0)).current;
- useEffect(() => {
- const showEvent =
- Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow";
- const hideEvent =
- Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide";
- const showSub = Keyboard.addListener(showEvent, (e) => {
- Animated.timing(keyboardOffset, {
- toValue: -Math.max(0, e.endCoordinates.height - insets.bottom - 4),
- duration: Platform.OS === "ios" ? e.duration : 200,
- easing: Easing.bezier(0.17, 0.59, 0.4, 0.99),
- useNativeDriver: true,
- }).start();
- });
- const hideSub = Keyboard.addListener(hideEvent, (e) => {
- Animated.timing(keyboardOffset, {
- toValue: 0,
- duration: Platform.OS === "ios" ? (e.duration ?? 250) : 200,
- easing: Easing.bezier(0.17, 0.59, 0.4, 0.99),
- useNativeDriver: true,
- }).start();
- });
- return () => {
- showSub.remove();
- hideSub.remove();
- };
- }, [insets.bottom, keyboardOffset]);
- return (
- <View style={styles.container}>
- {hasContent ? (
- viewMode !== "ui" ? (
- <ScrollView
- style={styles.flex}
- contentContainerStyle={[
- styles.contentContainer,
- {
- paddingTop: insets.top + 16,
- paddingBottom: insets.bottom + PROMPT_BAR_HEIGHT + 16,
- },
- ]}
- keyboardShouldPersistTaps="handled"
- >
- <View style={styles.jsonContainer}>
- <View style={styles.jsonHeader}>
- <Text style={styles.jsonHeaderTitle}>
- {viewMode === "json" ? "JSON Spec" : "JSONL Stream"}
- </Text>
- <Pressable style={styles.copyButton} onPress={handleCopy}>
- <Text style={styles.copyButtonText}>
- {copied ? "Copied" : "Copy"}
- </Text>
- </Pressable>
- </View>
- <Text style={styles.jsonText}>
- {viewMode === "json"
- ? JSON.stringify(spec, null, 2)
- : rawLines.join("\n")}
- </Text>
- </View>
- </ScrollView>
- ) : (
- <View
- style={[
- styles.flex,
- { paddingBottom: insets.bottom + PROMPT_BAR_HEIGHT + 16 },
- ]}
- >
- {error && (
- <View style={styles.errorContainer}>
- <Text style={styles.errorTitle}>Generation failed</Text>
- <Text style={styles.errorText}>{error.message}</Text>
- </View>
- )}
- {(spec || isStreaming) && (
- <AppRenderer spec={spec} loading={isStreaming} />
- )}
- </View>
- )
- ) : (
- <Pressable style={styles.splash} onPress={Keyboard.dismiss}>
- <View style={styles.splashContent}>
- <Text style={styles.splashTitle}>json-render</Text>
- <Text style={styles.splashSubtitle}>
- Describe a UI and watch it appear
- </Text>
- <Text style={styles.splashHint}>
- Try something like "a settings page with a dark mode toggle,
- notification preferences, and a profile card with an avatar"
- </Text>
- </View>
- </Pressable>
- )}
- {/* Menu popover */}
- {showMenu && (
- <Pressable
- style={styles.menuOverlay}
- onPress={() => {
- setShowMenu(false);
- setConfirmingClear(false);
- }}
- >
- <Animated.View
- style={[
- styles.menuPopover,
- { bottom: insets.bottom + 64 },
- { transform: [{ translateY: keyboardOffset }] },
- ]}
- >
- <Pressable
- style={styles.menuItem}
- onPress={() => {
- setViewMode((v) => (v === "json" ? "ui" : "json"));
- setShowMenu(false);
- }}
- >
- <Text style={styles.menuItemText}>
- {viewMode === "json" ? "Hide JSON" : "Show JSON"}
- </Text>
- </Pressable>
- <View style={styles.menuDivider} />
- <Pressable
- style={styles.menuItem}
- onPress={() => {
- setViewMode((v) => (v === "jsonl" ? "ui" : "jsonl"));
- setShowMenu(false);
- }}
- >
- <Text style={styles.menuItemText}>
- {viewMode === "jsonl" ? "Hide JSONL" : "Show JSONL"}
- </Text>
- </Pressable>
- <View style={styles.menuDivider} />
- <Pressable style={styles.menuItem} onPress={handleClear}>
- <Text
- style={[
- styles.menuItemText,
- styles.menuItemDestructive,
- confirmingClear && styles.menuItemDestructiveConfirm,
- ]}
- >
- {confirmingClear ? "Are you sure?" : "Clear"}
- </Text>
- </Pressable>
- </Animated.View>
- </Pressable>
- )}
- {/* Prompt bar */}
- <Animated.View
- style={[
- styles.promptArea,
- { bottom: insets.bottom + 12 },
- { transform: [{ translateY: keyboardOffset }] },
- ]}
- >
- <View style={styles.promptBar}>
- <TextInput
- style={styles.promptInput}
- value={prompt}
- onChangeText={setPrompt}
- placeholder="Describe a UI..."
- placeholderTextColor="#9ca3af"
- multiline
- maxLength={500}
- returnKeyType="send"
- blurOnSubmit
- onSubmitEditing={handleGenerate}
- editable
- />
- {hasContent && spec && !isStreaming && (
- <Pressable
- style={styles.menuButton}
- onPress={() => {
- setShowMenu((v) => !v);
- setConfirmingClear(false);
- }}
- >
- <Text style={styles.menuButtonText}>···</Text>
- </Pressable>
- )}
- {isStreaming ? (
- <Pressable style={styles.stopButton} onPress={stop}>
- <View style={styles.stopIcon} />
- </Pressable>
- ) : (
- <Pressable
- style={[
- styles.sendButton,
- !prompt.trim() && styles.sendButtonDisabled,
- ]}
- onPress={handleGenerate}
- disabled={!prompt.trim()}
- >
- <Text style={styles.sendButtonText}>Go</Text>
- </Pressable>
- )}
- </View>
- </Animated.View>
- </View>
- );
- }
- const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: "#ffffff",
- },
- flex: {
- flex: 1,
- },
- splash: {
- flex: 1,
- justifyContent: "center",
- },
- splashContent: {
- flex: 1,
- alignItems: "center",
- justifyContent: "center",
- paddingHorizontal: 40,
- },
- splashTitle: {
- fontSize: 32,
- fontWeight: "700",
- color: "#111827",
- marginBottom: 8,
- },
- splashSubtitle: {
- fontSize: 16,
- color: "#6b7280",
- marginBottom: 24,
- },
- splashHint: {
- fontSize: 14,
- color: "#9ca3af",
- textAlign: "center",
- lineHeight: 20,
- },
- contentContainer: {},
- jsonContainer: {
- margin: 16,
- padding: 16,
- backgroundColor: "#f9fafb",
- borderRadius: 12,
- borderWidth: 1,
- borderColor: "#e5e7eb",
- },
- jsonHeader: {
- flexDirection: "row",
- justifyContent: "space-between",
- alignItems: "center",
- marginBottom: 12,
- },
- jsonHeaderTitle: {
- fontSize: 13,
- fontWeight: "600",
- color: "#9ca3af",
- textTransform: "uppercase",
- letterSpacing: 0.5,
- },
- copyButton: {
- backgroundColor: "#e5e7eb",
- borderRadius: 8,
- paddingHorizontal: 12,
- paddingVertical: 6,
- },
- copyButtonText: {
- fontSize: 13,
- fontWeight: "600",
- color: "#374151",
- },
- jsonText: {
- fontSize: 12,
- fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
- color: "#374151",
- lineHeight: 18,
- },
- errorContainer: {
- backgroundColor: "#fef2f2",
- borderRadius: 12,
- padding: 16,
- margin: 16,
- borderWidth: 1,
- borderColor: "#fecaca",
- },
- errorTitle: {
- fontSize: 16,
- fontWeight: "600",
- color: "#dc2626",
- marginBottom: 4,
- },
- errorText: {
- fontSize: 14,
- color: "#991b1b",
- },
- promptArea: {
- position: "absolute",
- left: 12,
- right: 12,
- },
- menuButton: {
- borderRadius: 20,
- width: 38,
- height: 38,
- alignItems: "center",
- justifyContent: "center",
- alignSelf: "center",
- },
- menuButtonText: {
- fontSize: 18,
- color: "#9ca3af",
- fontWeight: "700",
- },
- menuOverlay: {
- ...StyleSheet.absoluteFillObject,
- zIndex: 10,
- },
- menuPopover: {
- position: "absolute",
- right: 12,
- backgroundColor: "#ffffff",
- borderRadius: 14,
- borderWidth: 1,
- borderColor: "#e5e7eb",
- shadowColor: "#000",
- shadowOffset: { width: 0, height: 4 },
- shadowOpacity: 0.15,
- shadowRadius: 12,
- elevation: 10,
- minWidth: 160,
- overflow: "hidden",
- },
- menuItem: {
- paddingHorizontal: 16,
- paddingVertical: 12,
- },
- menuItemText: {
- fontSize: 15,
- color: "#111827",
- fontWeight: "500",
- },
- menuItemDestructive: {
- color: "#ef4444",
- },
- menuItemDestructiveConfirm: {
- fontWeight: "700",
- },
- menuDivider: {
- height: StyleSheet.hairlineWidth,
- backgroundColor: "#e5e7eb",
- },
- promptBar: {
- flexDirection: "row",
- alignItems: "flex-end",
- backgroundColor: "#ffffff",
- borderRadius: 24,
- borderWidth: 1,
- borderColor: "#e5e7eb",
- paddingLeft: 18,
- paddingRight: 4,
- paddingVertical: 4,
- shadowColor: "#000",
- shadowOffset: { width: 0, height: 4 },
- shadowOpacity: 0.12,
- shadowRadius: 16,
- elevation: 8,
- },
- promptInput: {
- flex: 1,
- fontSize: 16,
- color: "#111827",
- paddingVertical: 10,
- maxHeight: 100,
- },
- sendButton: {
- backgroundColor: "#3b82f6",
- borderRadius: 20,
- paddingHorizontal: 18,
- height: 38,
- alignItems: "center",
- justifyContent: "center",
- },
- sendButtonDisabled: {
- opacity: 0.4,
- },
- sendButtonText: {
- color: "#ffffff",
- fontSize: 15,
- fontWeight: "600",
- },
- stopButton: {
- backgroundColor: "#ef4444",
- borderRadius: 20,
- width: 38,
- height: 38,
- alignItems: "center",
- justifyContent: "center",
- },
- stopIcon: {
- width: 14,
- height: 14,
- backgroundColor: "#ffffff",
- borderRadius: 2,
- },
- });
|