SPB Git

spb/fetcha

Public
16.1 KB · 325 lines tsx
1"use client";2import * as React from "react";3import { ChevronRight, History, Play, RotateCcw, Trash2 } from "lucide-react";4import { Badge } from "@/components/ui/badge";5import { Button } from "@/components/ui/button";6import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";7import { Input, Textarea } from "@/components/ui/input";8import { Field, Hint, Label } from "@/components/ui/label";9import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";10import { Switch } from "@/components/ui/switch";11import { SimpleTooltip } from "@/components/ui/tooltip";12import type { UrlCheck } from "@/lib/playground-url";13import { cn } from "@/lib/utils";14import { CountrySelect } from "./country-select";15import { KvRows } from "./kv-rows";16import { DEVICES, FORMATS, HTTP_METHODS, NETWORKS, NETWORK_LABELS, inspectBody, methodHasBody, type BuilderState, type CountryOption, type HttpMethod, type NetworkClass, type OutputFormat, type PlanSummary } from "./types";1718export interface RequestBuilderProps {19  state: BuilderState;20  onChange: (patch: Partial<BuilderState>) => void;21  plan: PlanSummary;22  availableNetworks: string[];23  countries: CountryOption[];24  urlCheck: UrlCheck;25  urlTouched: boolean;26  onUrlBlur: () => void;27  onRun: () => void;28  onReset: () => void;29  pending: boolean;30  recent: string[];31  onPickRecent: (url: string) => void;32  onClearRecent: () => void;33  isMac: boolean;34}3536function Section({ title, count, open, onToggle, children, hint }: { title: string; count?: number; open: boolean; onToggle: () => void; children: React.ReactNode; hint?: string }) {37  const id = React.useId();38  return (39    <div className="border-t border-border">40      <button type="button" onClick={onToggle} aria-expanded={open} aria-controls={id} className="flex w-full items-center gap-2 py-2.5 text-left text-[13px] font-medium text-fg hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 rounded-sm">41        <ChevronRight className={cn("size-3.5 text-fg-subtle transition-transform", open && "rotate-90")} aria-hidden />42        <span>{title}</span>43        {count ? (44          <Badge variant="default" className="px-1.5 py-0 text-[10.5px] leading-4">45            {count}46          </Badge>47        ) : null}48        {hint ? <span className="ml-auto text-xs font-normal text-fg-subtle">{hint}</span> : null}49      </button>50      {open ? (51        <div id={id} className="pb-4 pl-5.5">52          {children}53        </div>54      ) : null}55    </div>56  );57}5859export function RequestBuilder(p: RequestBuilderProps) {60  const { state: s, onChange, plan, availableNetworks, countries, urlCheck, urlTouched, onUrlBlur, onRun, onReset, pending, recent, onPickRecent, onClearRecent, isMac } = p;61  const [open, setOpen] = React.useState({ headers: s.headers.length > 0, query: s.query.length > 0, body: s.body.length > 0, cookies: s.cookies.length > 0, options: true });62  const toggle = (k: keyof typeof open) => setOpen((o) => ({ ...o, [k]: !o[k] }));63  const bodyState = inspectBody(s.body);64  const showUrlMessage = urlTouched && urlCheck.level !== "ok";65  const maxTimeoutS = Math.round(plan.max_timeout_ms / 1000);66  const filledHeaders = s.headers.filter((r) => r.key.trim()).length;67  const filledQuery = s.query.filter((r) => r.key.trim()).length;68  const filledCookies = s.cookies.filter((r) => r.key.trim()).length;6970  return (71    <form72      className="grid gap-4"73      onSubmit={(e) => {74        e.preventDefault();75        onRun();76      }}77    >78      {/* URL + method */}79      <div className="grid gap-2">80        <div className="flex items-center justify-between">81          <Label htmlFor="pg-url">URL</Label>82          {recent.length ? (83            <DropdownMenu>84              <DropdownMenuTrigger asChild>85                <Button type="button" variant="ghost" size="xs" className="-mr-1 text-fg-muted">86                  <History className="size-3.5" /> Recent87                </Button>88              </DropdownMenuTrigger>89              <DropdownMenuContent align="end" className="max-w-[min(90vw,28rem)]">90                <DropdownMenuLabel>Recent URLs</DropdownMenuLabel>91                {recent.map((u) => (92                  <DropdownMenuItem key={u} onSelect={() => onPickRecent(u)} className="font-mono text-[12px]">93                    <span className="truncate">{u}</span>94                  </DropdownMenuItem>95                ))}96                <DropdownMenuSeparator />97                <DropdownMenuItem onSelect={onClearRecent} destructive>98                  <Trash2 /> Clear recent99                </DropdownMenuItem>100              </DropdownMenuContent>101            </DropdownMenu>102          ) : null}103        </div>104        <div className="grid grid-cols-[6.5rem_minmax(0,1fr)] gap-2">105          <Select value={s.method} onValueChange={(v) => onChange({ method: v as HttpMethod })}>106            <SelectTrigger className="h-11 font-mono text-[13px]" aria-label="HTTP method">107              <SelectValue />108            </SelectTrigger>109            <SelectContent>110              {HTTP_METHODS.map((m) => (111                <SelectItem key={m} value={m} className="font-mono text-[13px]">112                  {m}113                </SelectItem>114              ))}115            </SelectContent>116          </Select>117          <Input118            id="pg-url"119            type="url"120            inputMode="url"121            autoFocus122            autoComplete="off"123            autoCapitalize="off"124            autoCorrect="off"125            spellCheck={false}126            placeholder="https://example.com/page"127            value={s.url}128            onChange={(e) => onChange({ url: e.target.value })}129            onBlur={onUrlBlur}130            aria-invalid={showUrlMessage && urlCheck.level === "error" ? true : undefined}131            aria-describedby={showUrlMessage ? "pg-url-msg" : undefined}132            className="h-11 font-mono text-[14px]"133          />134        </div>135        {showUrlMessage ? (136          <p id="pg-url-msg" className={cn("text-xs leading-relaxed", urlCheck.level === "error" ? "text-danger" : "text-warning")} role={urlCheck.level === "error" ? "alert" : "status"}>137            {urlCheck.message}138          </p>139        ) : null}140      </div>141142      {/* Sections */}143      <div>144        <Section title="Headers" count={filledHeaders} open={open.headers} onToggle={() => toggle("headers")}>145          <KvRows rows={s.headers} onChange={(headers) => onChange({ headers })} label="Header" keyPlaceholder="Header-Name" valuePlaceholder="value" addLabel="Add header" />146          <Hint className="mt-2">Sent to the target as-is. A realistic User-Agent is applied automatically when you don&apos;t set one.</Hint>147        </Section>148        <Section title="Query params" count={filledQuery} open={open.query} onToggle={() => toggle("query")}>149          <KvRows rows={s.query} onChange={(query) => onChange({ query })} label="Query parameter" keyPlaceholder="param" valuePlaceholder="value" addLabel="Add parameter" />150          <Hint className="mt-2">Merged into the URL when the request runs.</Hint>151        </Section>152        {methodHasBody(s.method) ? (153          <Section title="Body" open={open.body} onToggle={() => toggle("body")} hint={bodyState === "json" ? "Valid JSON" : bodyState === "text" ? "Plain text" : undefined}>154            <Textarea155              value={s.body}156              onChange={(e) => onChange({ body: e.target.value })}157              placeholder='{"query": "…"}'158              aria-label="Request body"159              spellCheck={false}160              className="min-h-[120px] font-mono text-[12.5px]"161            />162            <div className="mt-1.5 flex items-center gap-2">163              {bodyState === "json" ? (164                <Badge variant="success" dot>165                  Valid JSON166                </Badge>167              ) : bodyState === "text" ? (168                <Badge variant="outline">Not JSON — sent as text</Badge>169              ) : null}170              <Hint>Set a Content-Type header to match your body.</Hint>171            </div>172          </Section>173        ) : null}174        <Section title="Cookies" count={filledCookies} open={open.cookies} onToggle={() => toggle("cookies")}>175          <KvRows rows={s.cookies} onChange={(cookies) => onChange({ cookies })} label="Cookie" keyPlaceholder="name" valuePlaceholder="value" addLabel="Add cookie" />176        </Section>177178        <Section title="Options" open={open.options} onToggle={() => toggle("options")}>179          <div className="grid gap-4">180            <div className="grid gap-4 sm:grid-cols-2">181              <Field>182                <Label htmlFor="pg-country">Country</Label>183                <CountrySelect id="pg-country" value={s.country} onChange={(country) => onChange({ country })} countries={countries} />184              </Field>185              <Field>186                <Label htmlFor="pg-network">Network</Label>187                <Select value={s.network} onValueChange={(v) => onChange({ network: v as NetworkClass })}>188                  <SelectTrigger id="pg-network" aria-label="Network class">189                    <SelectValue />190                  </SelectTrigger>191                  <SelectContent>192                    {NETWORKS.map((n) => {193                      const live = n === "auto" || availableNetworks.includes(n);194                      const onPlan = n === "auto" || plan.networks.includes(n);195                      const disabled = !live || !onPlan;196                      const reason = !live ? "Not yet available" : !onPlan ? `Not on the ${plan.label} plan` : null;197                      return (198                        <SelectItem key={n} value={n} disabled={disabled} title={reason ?? undefined}>199                          {NETWORK_LABELS[n]}200                          {reason ? <span className="ml-1.5 text-xs text-fg-subtle">— {reason}</span> : null}201                        </SelectItem>202                      );203                    })}204                  </SelectContent>205                </Select>206              </Field>207            </div>208            <div className="grid gap-4 sm:grid-cols-2">209              <Field>210                <Label htmlFor="pg-region">Region</Label>211                <Input id="pg-region" value={s.region} onChange={(e) => onChange({ region: e.target.value })} placeholder="e.g. quebec" maxLength={64} autoCapitalize="off" />212              </Field>213              <Field>214                <Label htmlFor="pg-city">City</Label>215                <Input id="pg-city" value={s.city} onChange={(e) => onChange({ city: e.target.value })} placeholder="e.g. montreal" maxLength={128} autoCapitalize="off" />216              </Field>217            </div>218            <div className="grid gap-4 sm:grid-cols-2">219              <Field>220                <Label htmlFor="pg-session">Session</Label>221                <Input id="pg-session" value={s.session} onChange={(e) => onChange({ session: e.target.value })} placeholder="Auto (fresh IP)" className="font-mono text-[12.5px]" maxLength={64} autoCapitalize="off" spellCheck={false} />222                <Hint>Paste a <span className="font-mono">sess_…</span> id to reuse a sticky IP.</Hint>223              </Field>224              <Field>225                <Label htmlFor="pg-timeout">Timeout (ms)</Label>226                <Input227                  id="pg-timeout"228                  type="number"229                  inputMode="numeric"230                  min={1000}231                  max={plan.max_timeout_ms}232                  step={1000}233                  value={s.timeout}234                  onChange={(e) => onChange({ timeout: Number(e.target.value) })}235                  onBlur={() => onChange({ timeout: Math.min(Math.max(1000, Math.round(s.timeout || 30_000)), plan.max_timeout_ms) })}236                  className="font-mono tabular text-[12.5px]"237                />238                <Hint>239                  Up to {maxTimeoutS} s on the {plan.label} plan.240                </Hint>241              </Field>242            </div>243            <div className="grid gap-4 sm:grid-cols-3">244              <Field>245                <Label htmlFor="pg-format">Output format</Label>246                <Select value={s.format} onValueChange={(v) => onChange({ format: v as OutputFormat })}>247                  <SelectTrigger id="pg-format" aria-label="Output format">248                    <SelectValue />249                  </SelectTrigger>250                  <SelectContent>251                    {FORMATS.map((f) => (252                      <SelectItem key={f} value={f} className="font-mono text-[13px]">253                        {f}254                      </SelectItem>255                    ))}256                  </SelectContent>257                </Select>258              </Field>259              <Field>260                <Label htmlFor="pg-device">Device</Label>261                <Select value={s.device || "default"} onValueChange={(v) => onChange({ device: v === "default" ? "" : (v as BuilderState["device"]) })}>262                  <SelectTrigger id="pg-device" aria-label="Device">263                    <SelectValue />264                  </SelectTrigger>265                  <SelectContent>266                    <SelectItem value="default">Default</SelectItem>267                    {DEVICES.map((d) => (268                      <SelectItem key={d} value={d}>269                        {d.charAt(0).toUpperCase() + d.slice(1)}270                      </SelectItem>271                    ))}272                  </SelectContent>273                </Select>274              </Field>275              <Field>276                <Label htmlFor="pg-locale">Locale</Label>277                <Input id="pg-locale" value={s.locale} onChange={(e) => onChange({ locale: e.target.value })} placeholder="en-CA" maxLength={16} autoCapitalize="off" spellCheck={false} className="font-mono text-[12.5px]" />278              </Field>279            </div>280            <div className="grid gap-3 rounded-md border border-border bg-bg-subtle/50 p-3">281              <ToggleRow id="pg-follow" label="Follow redirects" hint="Each hop is validated against the URL policy." checked={s.followRedirects} onChange={(v) => onChange({ followRedirects: v })} />282              <ToggleRow id="pg-debug" label="Debug" hint="Include per-attempt routing details in the response." checked={s.debug} onChange={(v) => onChange({ debug: v })} />283              <SimpleTooltip content="Managed browser rendering is not available yet.">284                <div>285                  <ToggleRow id="pg-browser" label="Browser rendering" hint="Execute JavaScript in a managed browser." checked={false} onChange={() => {}} disabled badge="Coming soon" />286                </div>287              </SimpleTooltip>288            </div>289          </div>290        </Section>291      </div>292293      {/* Actions (desktop) */}294      <div className="hidden items-center gap-2 border-t border-border pt-4 lg:flex">295        <Button type="submit" variant="primary" loading={pending} disabled={urlCheck.level === "error"} className="min-w-[10rem]">296          {!pending ? <Play className="size-3.5" /> : null}297          {pending ? "Running…" : "Run request"}298          {!pending ? <kbd className="ml-1 rounded-[4px] border border-white/25 bg-white/10 px-1.5 font-mono text-[10.5px] font-normal">{isMac ? "⌘" : "Ctrl"}↵</kbd> : null}299        </Button>300        <Button type="button" variant="ghost" onClick={onReset} disabled={pending}>301          <RotateCcw className="size-3.5" /> Reset302        </Button>303        <p className="ml-auto text-right text-[11.5px] leading-tight text-fg-subtle">304          {plan.label} plan · {plan.concurrency} concurrent · {maxTimeoutS} s max305        </p>306      </div>307    </form>308  );309}310311function ToggleRow({ id, label, hint, checked, onChange, disabled, badge }: { id: string; label: string; hint?: string; checked: boolean; onChange: (v: boolean) => void; disabled?: boolean; badge?: string }) {312  return (313    <div className="flex items-center justify-between gap-3">314      <div className="min-w-0">315        <Label htmlFor={id} className={cn("flex items-center gap-2", disabled && "opacity-70")}>316          {label}317          {badge ? <Badge variant="outline">{badge}</Badge> : null}318        </Label>319        {hint ? <Hint className="mt-1">{hint}</Hint> : null}320      </div>321      <Switch id={id} checked={checked} onCheckedChange={onChange} disabled={disabled} aria-label={label} />322    </div>323  );324}325
Files · 6b215c2d5f54167737c2d57307f22c84bc934434
Menu

Theme