SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
9.0 KB · 219 lines tsx
Raw Blame History
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