TypeScript 97.5%
SQL 1.4%
Python 0.8%
1"use client";2import * as React from "react";3import Link from "next/link";4import type { FetchResponseBody } from "@fetcha/core/client";5import { Play, RotateCcw, X } from "lucide-react";6import { runPlayground, type PlaygroundError } from "@/actions/playground";7import { Button } from "@/components/ui/button";8import { generateCode, type CodegenLang } from "@/lib/codegen";9import { checkPlaygroundUrl } from "@/lib/playground-url";10import { keyPlaceholder } from "./code-panel";11import { RequestBuilder } from "./request-builder";12import { ResultsPanel, type ResultPhase } from "./results-panel";13import { DEFAULT_STATE, clearRecent, loadLastState, loadRecent, pushRecent, sanitizeState, saveLastState, toFetchRequest, type BuilderState, type PlaygroundProps } from "./types";1415function buildInitialState(props: PlaygroundProps): BuilderState {16 return { ...DEFAULT_STATE, ...sanitizeState(props.projectDefaults ?? {}), ...sanitizeState(props.initial ?? {}) };17}1819export function Playground(props: PlaygroundProps) {20 const { keys, plan, availableNetworks, countries, baseUrl, initial, replayId } = props;21 const hasInitial = !!initial && Object.keys(initial).length > 0;2223 const [state, setState] = React.useState<BuilderState>(() => buildInitialState(props));24 const [hydrated, setHydrated] = React.useState(false);25 const [recent, setRecent] = React.useState<string[]>([]);26 const [urlTouched, setUrlTouched] = React.useState(hasInitial);27 const [isMac, setIsMac] = React.useState(false);28 const [phase, setPhase] = React.useState<ResultPhase>("idle");29 const [result, setResult] = React.useState<FetchResponseBody | null>(null);30 const [error, setError] = React.useState<PlaygroundError | null>(null);31 const [startedAt, setStartedAt] = React.useState<number | null>(null);32 const [elapsedMs, setElapsedMs] = React.useState(0);33 const [codeLang, setCodeLang] = React.useState<CodegenLang>("curl");34 const [keyId, setKeyId] = React.useState<string>(keys[0]?.id ?? "");35 const [pending, startTransition] = React.useTransition();36 const [replayNotice, setReplayNotice] = React.useState(!!replayId);37 const resultsRef = React.useRef<HTMLDivElement | null>(null);38 const runRef = React.useRef<() => void>(() => {});3940 // Hydrate from localStorage after the first paint (the server cannot know the browser state, so41 // applying it synchronously during hydration would produce a markup mismatch). `?url=` / `?request=` win.42 React.useEffect(() => {43 const id = requestAnimationFrame(() => {44 if (!hasInitial) {45 const last = loadLastState();46 if (last) setState((s) => ({ ...s, ...last }));47 }48 setRecent(loadRecent());49 setIsMac(/Mac|iPhone|iPad/.test(navigator.platform) || /Mac OS/.test(navigator.userAgent));50 setHydrated(true);51 });52 return () => cancelAnimationFrame(id);53 }, [hasInitial]);5455 // Persist (debounced).56 React.useEffect(() => {57 if (!hydrated) return;58 const t = setTimeout(() => saveLastState(state), 300);59 return () => clearTimeout(t);60 }, [state, hydrated]);6162 // Elapsed timer while running.63 React.useEffect(() => {64 if (phase !== "running" || startedAt === null) return;65 const id = setInterval(() => setElapsedMs(Date.now() - startedAt), 100);66 return () => clearInterval(id);67 }, [phase, startedAt]);6869 const urlCheck = React.useMemo(() => checkPlaygroundUrl(state.url), [state.url]);70 const request = React.useMemo(() => toFetchRequest(state, plan.max_timeout_ms), [state, plan.max_timeout_ms]);7172 const patch = React.useCallback((p: Partial<BuilderState>) => setState((s) => ({ ...s, ...p })), []);7374 const run = React.useCallback(() => {75 if (pending) return;76 setUrlTouched(true);77 if (urlCheck.level === "error") {78 document.getElementById("pg-url")?.focus();79 return;80 }81 const req = toFetchRequest(state, plan.max_timeout_ms);82 const t0 = Date.now();83 setStartedAt(t0);84 setElapsedMs(0);85 setPhase("running");86 setResult(null);87 setError(null);88 setRecent(pushRecent(state.url.trim()));89 startTransition(async () => {90 const res = await runPlayground(req);91 setElapsedMs(Date.now() - t0);92 if (res.ok) {93 setResult(res.data);94 setPhase("success");95 } else {96 setError(res.error);97 setPhase("error");98 }99 if (typeof window !== "undefined" && window.innerWidth < 1024) {100 requestAnimationFrame(() => resultsRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }));101 }102 });103 }, [pending, urlCheck.level, state, plan.max_timeout_ms]);104 React.useEffect(() => {105 runRef.current = run;106 }, [run]);107108 // ⌘/Ctrl+Enter anywhere on the page.109 React.useEffect(() => {110 const onKey = (e: KeyboardEvent) => {111 if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {112 e.preventDefault();113 runRef.current();114 }115 };116 window.addEventListener("keydown", onKey);117 return () => window.removeEventListener("keydown", onKey);118 }, []);119120 const reset = React.useCallback(() => {121 setState({ ...DEFAULT_STATE, ...sanitizeState(props.projectDefaults ?? {}) });122 setUrlTouched(false);123 setPhase("idle");124 setResult(null);125 setError(null);126 setReplayNotice(false);127 requestAnimationFrame(() => document.getElementById("pg-url")?.focus());128 }, [props.projectDefaults]);129130 const saveCurl = React.useCallback(() => {131 const key = keys.find((k) => k.id === keyId) ?? keys[0];132 const code = generateCode("curl", request, { apiKeyPlaceholder: keyPlaceholder(key), baseUrl });133 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" });134 const a = document.createElement("a");135 a.href = URL.createObjectURL(blob);136 a.download = "fetcha-request.sh";137 document.body.appendChild(a);138 a.click();139 a.remove();140 setTimeout(() => URL.revokeObjectURL(a.href), 1000);141 }, [keys, keyId, request, baseUrl]);142143 return (144 <div className="pb-24 lg:pb-0">145 {replayNotice && replayId ? (146 <div className="mb-4 flex items-center gap-3 rounded-md border border-border bg-bg-subtle px-3 py-2 text-[13px]">147 <span className="text-fg-muted">148 Replaying <span className="font-mono text-fg">{replayId}</span> — settings were loaded from that request. Press Run to send it again.149 </span>150 <Link href={`/dashboard/requests/${replayId}`} className="text-accent underline-offset-4 hover:underline">151 View original152 </Link>153 <button type="button" onClick={() => setReplayNotice(false)} className="ml-auto rounded-sm p-1 text-fg-subtle hover:bg-bg-muted hover:text-fg" aria-label="Dismiss">154 <X className="size-3.5" />155 </button>156 </div>157 ) : null}158159 <div className="grid gap-8 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] lg:gap-10">160 <section aria-label="Request" className="min-w-0">161 <RequestBuilder162 state={state}163 onChange={patch}164 plan={plan}165 availableNetworks={availableNetworks}166 countries={countries}167 urlCheck={urlCheck}168 urlTouched={urlTouched}169 onUrlBlur={() => setUrlTouched(true)}170 onRun={run}171 onReset={reset}172 pending={pending || phase === "running"}173 recent={recent}174 onPickRecent={(url) => {175 patch({ url });176 setUrlTouched(true);177 }}178 onClearRecent={() => {179 clearRecent();180 setRecent([]);181 }}182 isMac={isMac}183 />184 </section>185 <section aria-label="Response" className="min-w-0 lg:sticky lg:top-6 lg:self-start" ref={resultsRef}>186 <ResultsPanel187 phase={phase}188 result={result}189 error={error}190 elapsedMs={elapsedMs}191 request={request}192 requestNetwork={state.network}193 keys={keys}194 baseUrl={baseUrl}195 codeLang={codeLang}196 onCodeLangChange={setCodeLang}197 keyId={keyId}198 onKeyChange={setKeyId}199 onSaveCurl={saveCurl}200 />201 </section>202 </div>203204 {/* Mobile sticky run bar */}205 <div className="fixed inset-x-0 bottom-0 z-30 border-t border-border bg-bg/90 px-4 py-3 backdrop-blur lg:hidden" style={{ paddingBottom: "max(0.75rem, env(safe-area-inset-bottom))" }}>206 <div className="mx-auto flex max-w-[1200px] items-center gap-2">207 <Button type="button" variant="primary" className="flex-1" loading={pending || phase === "running"} disabled={urlCheck.level === "error"} onClick={run}>208 {!(pending || phase === "running") ? <Play className="size-3.5" /> : null}209 {pending || phase === "running" ? "Running…" : "Run request"}210 </Button>211 <Button type="button" variant="outline" size="icon" onClick={reset} disabled={pending} aria-label="Reset request">212 <RotateCcw className="size-4" />213 </Button>214 </div>215 </div>216 </div>217 );218}219