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%
5.0 KB · 100 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import { Map as MapIcon, Search } from "lucide-react";4import type { MapResult } from "@/lib/api";5import { runMap } from "@/actions/crawls";6import { Alert } from "@/components/ui/alert";7import { Badge } from "@/components/ui/badge";8import { Button } from "@/components/ui/button";9import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";10import { CopyButton } from "@/components/ui/copy-button";11import { Input } from "@/components/ui/input";12import { Field, Hint, Label } from "@/components/ui/label";13import { formatNumber } from "@/lib/format";1415/** Mini tool: discover the URLs of a site (sitemap + links) with POST /v1/map, synchronously. */16export function MapTool() {17  const [url, setUrl] = React.useState("");18  const [search, setSearch] = React.useState("");19  const [limit, setLimit] = React.useState(200);20  const [pending, startTransition] = React.useTransition();21  const [error, setError] = React.useState<string | null>(null);22  const [result, setResult] = React.useState<MapResult | null>(null);2324  function submit(e: React.FormEvent) {25    e.preventDefault();26    setError(null);27    startTransition(async () => {28      const res = await runMap({ url: url.trim(), search: search.trim() || undefined, limit: Math.min(10_000, Math.max(1, Math.round(limit || 1))) });29      if (!res.ok) {30        setResult(null);31        setError(res.error.message);32        return;33      }34      setResult(res.data);35    });36  }3738  return (39    <Card>40      <CardHeader>41        <CardTitle className="flex items-center gap-2">42          <MapIcon className="size-4 text-fg-subtle" aria-hidden /> Map a site43        </CardTitle>44        <CardDescription>45          List the URLs of a site from its sitemap and the links on the seed page, without fetching every page. Same as <code className="font-mono">POST /v1/map</code>; runs synchronously (up to 60 s).46        </CardDescription>47      </CardHeader>48      <CardContent className="grid gap-4">49        <form onSubmit={submit} className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,14rem)_6rem_auto] sm:items-end">50          <Field>51            <Label htmlFor="map-url">URL</Label>52            <Input id="map-url" type="url" inputMode="url" required value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://example.com" autoComplete="off" autoCapitalize="off" spellCheck={false} className="font-mono text-[13px]" />53          </Field>54          <Field>55            <Label htmlFor="map-search">Filter (optional)</Label>56            <Input id="map-search" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="blog, /docs/*, /regex/" maxLength={256} autoComplete="off" spellCheck={false} className="font-mono text-[12.5px]" />57          </Field>58          <Field>59            <Label htmlFor="map-limit">Limit</Label>60            <Input id="map-limit" type="number" inputMode="numeric" min={1} max={10_000} value={limit} onChange={(e) => setLimit(Number(e.target.value))} className="font-mono tabular" />61          </Field>62          <Button type="submit" variant="primary" loading={pending} className="sm:mb-px">63            {!pending ? <Search className="size-3.5" /> : null} Map64          </Button>65        </form>66        {error ? <Alert variant="danger">{error}</Alert> : null}67        {result ? (68          <div className="grid gap-2">69            <div className="flex flex-wrap items-center gap-2 text-[12.5px] text-fg-muted">70              <span className="font-mono tabular text-fg">{formatNumber(result.count)}</span> URL{result.count === 1 ? "" : "s"}71              <Badge variant="outline">sitemap {formatNumber(result.sources?.sitemap ?? 0)}</Badge>72              <Badge variant="outline">links {formatNumber(result.sources?.links ?? 0)}</Badge>73              {result.truncated ? <Badge variant="warning">truncated at {formatNumber(limit)}</Badge> : null}74              <span className="ml-auto">75                <CopyButton value={result.urls.join("\n")} label="Copy list" />76              </span>77            </div>78            {result.urls.length ? (79              <ol className="max-h-[360px] overflow-auto rounded-lg border border-border bg-bg-subtle p-3 font-mono text-[12px] leading-relaxed scrollbar-thin">80                {result.urls.map((u, i) => (81                  <li key={`${u}-${i}`} className="flex gap-3">82                    <span className="w-8 shrink-0 select-none text-right text-fg-subtle/70">{i + 1}</span>83                    <a href={u} target="_blank" rel="noreferrer noopener nofollow" className="min-w-0 truncate text-fg hover:text-accent hover:underline" title={u}>84                      {u}85                    </a>86                  </li>87                ))}88              </ol>89            ) : (90              <Hint>No URLs found. The site may have no sitemap and the seed page no links, or the filter matched nothing.</Hint>91            )}92          </div>93        ) : (94          <Hint>Use the result to pick include/exclude patterns before starting a crawl.</Hint>95        )}96      </CardContent>97    </Card>98  );99}100