"use client"; import * as React from "react"; import Link from "next/link"; import type { FetchResponseBody } from "@fetcha/core/client"; import { Play, RotateCcw, X } from "lucide-react"; import { runPlayground, type PlaygroundError } from "@/actions/playground"; import { Button } from "@/components/ui/button"; import { generateCode, type CodegenLang } from "@/lib/codegen"; import { checkPlaygroundUrl } from "@/lib/playground-url"; import { keyPlaceholder } from "./code-panel"; import { RequestBuilder } from "./request-builder"; import { ResultsPanel, type ResultPhase } from "./results-panel"; import { DEFAULT_STATE, clearRecent, loadLastState, loadRecent, pushRecent, sanitizeState, saveLastState, toFetchRequest, type BuilderState, type PlaygroundProps } from "./types"; function buildInitialState(props: PlaygroundProps): BuilderState { return { ...DEFAULT_STATE, ...sanitizeState(props.projectDefaults ?? {}), ...sanitizeState(props.initial ?? {}) }; } export function Playground(props: PlaygroundProps) { const { keys, plan, availableNetworks, countries, baseUrl, initial, replayId } = props; const hasInitial = !!initial && Object.keys(initial).length > 0; const [state, setState] = React.useState(() => buildInitialState(props)); const [hydrated, setHydrated] = React.useState(false); const [recent, setRecent] = React.useState([]); const [urlTouched, setUrlTouched] = React.useState(hasInitial); const [isMac, setIsMac] = React.useState(false); const [phase, setPhase] = React.useState("idle"); const [result, setResult] = React.useState(null); const [error, setError] = React.useState(null); const [startedAt, setStartedAt] = React.useState(null); const [elapsedMs, setElapsedMs] = React.useState(0); const [codeLang, setCodeLang] = React.useState("curl"); const [keyId, setKeyId] = React.useState(keys[0]?.id ?? ""); const [pending, startTransition] = React.useTransition(); const [replayNotice, setReplayNotice] = React.useState(!!replayId); const resultsRef = React.useRef(null); const runRef = React.useRef<() => void>(() => {}); // Hydrate from localStorage after the first paint (the server cannot know the browser state, so // applying it synchronously during hydration would produce a markup mismatch). `?url=` / `?request=` win. React.useEffect(() => { const id = requestAnimationFrame(() => { if (!hasInitial) { const last = loadLastState(); if (last) setState((s) => ({ ...s, ...last })); } setRecent(loadRecent()); setIsMac(/Mac|iPhone|iPad/.test(navigator.platform) || /Mac OS/.test(navigator.userAgent)); setHydrated(true); }); return () => cancelAnimationFrame(id); }, [hasInitial]); // Persist (debounced). React.useEffect(() => { if (!hydrated) return; const t = setTimeout(() => saveLastState(state), 300); return () => clearTimeout(t); }, [state, hydrated]); // Elapsed timer while running. React.useEffect(() => { if (phase !== "running" || startedAt === null) return; const id = setInterval(() => setElapsedMs(Date.now() - startedAt), 100); return () => clearInterval(id); }, [phase, startedAt]); const urlCheck = React.useMemo(() => checkPlaygroundUrl(state.url), [state.url]); const request = React.useMemo(() => toFetchRequest(state, plan.max_timeout_ms), [state, plan.max_timeout_ms]); const patch = React.useCallback((p: Partial) => setState((s) => ({ ...s, ...p })), []); const run = React.useCallback(() => { if (pending) return; setUrlTouched(true); if (urlCheck.level === "error") { document.getElementById("pg-url")?.focus(); return; } const req = toFetchRequest(state, plan.max_timeout_ms); const t0 = Date.now(); setStartedAt(t0); setElapsedMs(0); setPhase("running"); setResult(null); setError(null); setRecent(pushRecent(state.url.trim())); startTransition(async () => { const res = await runPlayground(req); setElapsedMs(Date.now() - t0); if (res.ok) { setResult(res.data); setPhase("success"); } else { setError(res.error); setPhase("error"); } if (typeof window !== "undefined" && window.innerWidth < 1024) { requestAnimationFrame(() => resultsRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })); } }); }, [pending, urlCheck.level, state, plan.max_timeout_ms]); React.useEffect(() => { runRef.current = run; }, [run]); // ⌘/Ctrl+Enter anywhere on the page. React.useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); runRef.current(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); const reset = React.useCallback(() => { setState({ ...DEFAULT_STATE, ...sanitizeState(props.projectDefaults ?? {}) }); setUrlTouched(false); setPhase("idle"); setResult(null); setError(null); setReplayNotice(false); requestAnimationFrame(() => document.getElementById("pg-url")?.focus()); }, [props.projectDefaults]); const saveCurl = React.useCallback(() => { const key = keys.find((k) => k.id === keyId) ?? keys[0]; const code = generateCode("curl", request, { apiKeyPlaceholder: keyPlaceholder(key), baseUrl }); const blob = new Blob([`#!/usr/bin/env bash\n# Fetcha request — replace the API key placeholder with your key.\n${code}\n`], { type: "text/x-shellscript" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "fetcha-request.sh"; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); }, [keys, keyId, request, baseUrl]); return (
{replayNotice && replayId ? (
Replaying {replayId} — settings were loaded from that request. Press Run to send it again. View original
) : null}
setUrlTouched(true)} onRun={run} onReset={reset} pending={pending || phase === "running"} recent={recent} onPickRecent={(url) => { patch({ url }); setUrlTouched(true); }} onClearRecent={() => { clearRecent(); setRecent([]); }} isMac={isMac} />
{/* Mobile sticky run bar */}
); }