참고소스 수정본
This commit is contained in:
37
참고/firecrawl-main/apps/ui/ingestion-ui/src/App.tsx
Normal file
37
참고/firecrawl-main/apps/ui/ingestion-ui/src/App.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useState } from "react";
|
||||
import FirecrawlComponent from "./components/ingestion";
|
||||
import FirecrawlComponentV1 from "./components/ingestionV1";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
function App() {
|
||||
const [selectedComponent, setSelectedComponent] = useState<"v0" | "v1">("v1");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-center items-center space-x-2 p-4">
|
||||
<RadioGroup
|
||||
defaultValue="v1"
|
||||
onValueChange={(value) => setSelectedComponent(value as "v0" | "v1")}
|
||||
className="flex space-x-6 mt-6"
|
||||
>
|
||||
<div className="flex items-center space-x-2 p-2">
|
||||
<RadioGroupItem value="v0" id="v0"></RadioGroupItem>
|
||||
<Label htmlFor="v0">Firecrawl Component V0</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 p-2">
|
||||
<RadioGroupItem value="v1" id="v1"></RadioGroupItem>
|
||||
<Label htmlFor="v1">Firecrawl Component V1</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
{selectedComponent === "v1" ? (
|
||||
<FirecrawlComponentV1 />
|
||||
) : (
|
||||
<FirecrawlComponent />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,656 @@
|
||||
import { useState, ChangeEvent, FormEvent, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
//! Hardcoded values (not recommended for production)
|
||||
//! Highly recommended to move all Firecrawl API calls to the backend (e.g. Next.js API route)
|
||||
const FIRECRAWL_API_URL = "https://api.firecrawl.dev"; // Replace with your actual API URL whether it is local or using Firecrawl Cloud
|
||||
const FIRECRAWL_API_KEY = "fc-YOUR_API_KEY"; // Replace with your actual API key
|
||||
|
||||
interface FormData {
|
||||
url: string;
|
||||
crawlSubPages: boolean;
|
||||
limit: string;
|
||||
maxDepth: string;
|
||||
excludePaths: string;
|
||||
includePaths: string;
|
||||
extractMainContent: boolean;
|
||||
}
|
||||
|
||||
interface CrawlerOptions {
|
||||
includes?: string[];
|
||||
excludes?: string[];
|
||||
maxDepth?: number;
|
||||
limit?: number;
|
||||
returnOnlyUrls: boolean;
|
||||
}
|
||||
|
||||
interface PageOptions {
|
||||
onlyMainContent: boolean;
|
||||
}
|
||||
|
||||
interface RequestBody {
|
||||
url: string;
|
||||
crawlerOptions?: CrawlerOptions;
|
||||
pageOptions: PageOptions;
|
||||
}
|
||||
|
||||
interface ScrapeResultMetadata {
|
||||
title: string;
|
||||
description: string;
|
||||
language: string;
|
||||
sourceURL: string;
|
||||
pageStatusCode: number;
|
||||
pageError?: string;
|
||||
[key: string]: string | number | undefined;
|
||||
}
|
||||
|
||||
interface ScrapeResultData {
|
||||
markdown: string;
|
||||
content: string;
|
||||
html: string;
|
||||
rawHtml: string;
|
||||
metadata: ScrapeResultMetadata;
|
||||
llm_extraction: Record<string, unknown>;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
interface ScrapeResult {
|
||||
success: boolean;
|
||||
data: ScrapeResultData;
|
||||
}
|
||||
|
||||
export default function FirecrawlComponent() {
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
url: "",
|
||||
crawlSubPages: false,
|
||||
limit: "",
|
||||
maxDepth: "",
|
||||
excludePaths: "",
|
||||
includePaths: "",
|
||||
extractMainContent: false,
|
||||
});
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [scrapingSelectedLoading, setScrapingSelectedLoading] =
|
||||
useState<boolean>(false);
|
||||
const [crawledUrls, setCrawledUrls] = useState<string[]>([]);
|
||||
const [selectedUrls, setSelectedUrls] = useState<string[]>([]);
|
||||
const [scrapeResults, setScrapeResults] = useState<
|
||||
Record<string, ScrapeResult>
|
||||
>({});
|
||||
const [isCollapsibleOpen, setIsCollapsibleOpen] = useState(true);
|
||||
const [crawlStatus, setCrawlStatus] = useState<{
|
||||
current: number;
|
||||
total: number | null;
|
||||
}>({ current: 0, total: null });
|
||||
const [elapsedTime, setElapsedTime] = useState<number>(0);
|
||||
const [showCrawlStatus, setShowCrawlStatus] = useState<boolean>(false);
|
||||
const [isScraping, setIsScraping] = useState<boolean>(false);
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const urlsPerPage = 10;
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (loading) {
|
||||
setShowCrawlStatus(true);
|
||||
timer = setInterval(() => {
|
||||
setElapsedTime((prevTime) => prevTime + 1);
|
||||
}, 1000);
|
||||
}
|
||||
return () => {
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
}, [loading]);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData((prevData) => ({
|
||||
...prevData,
|
||||
[name]: type === "checkbox" ? checked : value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setIsCollapsibleOpen(false);
|
||||
setElapsedTime(0);
|
||||
setCrawlStatus({ current: 0, total: null });
|
||||
setIsScraping(!formData.crawlSubPages);
|
||||
setCrawledUrls([]);
|
||||
setSelectedUrls([]);
|
||||
setScrapeResults({});
|
||||
setScrapingSelectedLoading(false);
|
||||
setShowCrawlStatus(false);
|
||||
|
||||
try {
|
||||
const endpoint = `${FIRECRAWL_API_URL}/v0/${
|
||||
formData.crawlSubPages ? "crawl" : "scrape"
|
||||
}`;
|
||||
|
||||
const requestBody: RequestBody = formData.crawlSubPages
|
||||
? {
|
||||
url: formData.url,
|
||||
crawlerOptions: {
|
||||
includes: formData.includePaths
|
||||
? formData.includePaths.split(",").map((p) => p.trim())
|
||||
: undefined,
|
||||
excludes: formData.excludePaths
|
||||
? formData.excludePaths.split(",").map((p) => p.trim())
|
||||
: undefined,
|
||||
maxDepth: formData.maxDepth
|
||||
? parseInt(formData.maxDepth)
|
||||
: undefined,
|
||||
limit: formData.limit ? parseInt(formData.limit) : undefined,
|
||||
returnOnlyUrls: true,
|
||||
},
|
||||
pageOptions: {
|
||||
onlyMainContent: formData.extractMainContent,
|
||||
},
|
||||
}
|
||||
: {
|
||||
url: formData.url,
|
||||
pageOptions: {
|
||||
onlyMainContent: formData.extractMainContent,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${FIRECRAWL_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (formData.crawlSubPages) {
|
||||
const jobId = data.jobId;
|
||||
if (jobId) {
|
||||
const statusEndpoint = `${FIRECRAWL_API_URL}/v0/crawl/status/${jobId}`;
|
||||
let statusData: {
|
||||
status: string;
|
||||
data?: { url: string }[];
|
||||
current?: number;
|
||||
total?: number;
|
||||
};
|
||||
do {
|
||||
const statusResponse = await fetch(statusEndpoint, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${FIRECRAWL_API_KEY}`,
|
||||
},
|
||||
});
|
||||
if (statusResponse.ok) {
|
||||
statusData = await statusResponse.json();
|
||||
|
||||
const urls = statusData.data
|
||||
? statusData.data.map((urlObj) => urlObj.url)
|
||||
: [];
|
||||
setCrawledUrls(urls);
|
||||
setSelectedUrls(urls);
|
||||
setCrawlStatus({
|
||||
current: urls.length || 0,
|
||||
total: urls.length || null,
|
||||
});
|
||||
if (statusData.status !== "completed") {
|
||||
// Wait for 1 second before polling again
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
console.log("Polling again...");
|
||||
console.log(statusData);
|
||||
} else {
|
||||
console.log("Crawl completed with status:", statusData.status);
|
||||
console.log(statusData);
|
||||
}
|
||||
} else {
|
||||
console.error("Failed to fetch crawl status");
|
||||
break;
|
||||
}
|
||||
} while (statusData.status !== "completed");
|
||||
} else {
|
||||
console.error("No jobId received from crawl request");
|
||||
}
|
||||
} else {
|
||||
setScrapeResults({ [formData.url]: data });
|
||||
setCrawlStatus({ current: 1, total: 1 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
setScrapeResults({
|
||||
error: {
|
||||
success: false,
|
||||
data: {
|
||||
metadata: {
|
||||
pageError: "Error occurred while fetching data",
|
||||
title: "",
|
||||
description: "",
|
||||
language: "",
|
||||
sourceURL: "",
|
||||
pageStatusCode: 0,
|
||||
},
|
||||
markdown: "",
|
||||
content: "",
|
||||
html: "",
|
||||
rawHtml: "",
|
||||
llm_extraction: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScrapeSelected = async () => {
|
||||
setLoading(true);
|
||||
setElapsedTime(0);
|
||||
setCrawlStatus({ current: 0, total: selectedUrls.length });
|
||||
setIsScraping(true);
|
||||
setScrapingSelectedLoading(true);
|
||||
const newScrapeResults: Record<string, ScrapeResult> = {};
|
||||
|
||||
for (const [index, url] of selectedUrls.entries()) {
|
||||
try {
|
||||
const response = await fetch(`${FIRECRAWL_API_URL}/v0/scrape`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${FIRECRAWL_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: url,
|
||||
pageOptions: {
|
||||
onlyMainContent: formData.extractMainContent,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ScrapeResult = await response.json();
|
||||
newScrapeResults[url] = data;
|
||||
setCrawlStatus((prev) => ({ ...prev, current: index + 1 }));
|
||||
setScrapeResults({ ...scrapeResults, ...newScrapeResults });
|
||||
} catch (error) {
|
||||
console.error(`Error scraping ${url}:`, error);
|
||||
newScrapeResults[url] = {
|
||||
success: false,
|
||||
data: {
|
||||
markdown: "",
|
||||
content: "",
|
||||
html: "",
|
||||
rawHtml: "",
|
||||
metadata: {
|
||||
title: "",
|
||||
description: "",
|
||||
language: "",
|
||||
sourceURL: url,
|
||||
pageStatusCode: 0,
|
||||
pageError: (error as Error).message,
|
||||
},
|
||||
llm_extraction: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
setIsScraping(false);
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrentPage(newPage);
|
||||
};
|
||||
|
||||
const paginatedUrls = crawledUrls.slice(
|
||||
(currentPage - 1) * urlsPerPage,
|
||||
currentPage * urlsPerPage
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-4">
|
||||
<Card>
|
||||
<CardHeader className="flex items-start justify-between mb-0 pb-4">
|
||||
<CardTitle className="flex items-center justify-between w-full space-x-2">
|
||||
<span className="text-base">Extract web content</span>
|
||||
<a
|
||||
href="https://www.firecrawl.dev"
|
||||
className="text-xs text-gray-500 font-normal px-3 py-1 bg-zinc-100 rounded-xl hover:bg-zinc-200 transition-colors"
|
||||
>
|
||||
Powered by Firecrawl 🔥
|
||||
</a>
|
||||
</CardTitle>
|
||||
<div className="text-sm text-gray-500 w-11/12 items-center">
|
||||
Use this component to quickly give your users the ability to connect
|
||||
their AI apps to web data with Firecrawl. Learn more on the{" "}
|
||||
<a
|
||||
href="https://docs.firecrawl.dev/"
|
||||
className="text-sm text-blue-500"
|
||||
>
|
||||
Firecrawl docs!
|
||||
</a>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
placeholder="https://www.firecrawl.dev/"
|
||||
className="flex-grow"
|
||||
name="url"
|
||||
value={formData.url}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Button type="submit" variant="default" disabled={loading}>
|
||||
{loading ? (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-center justify-between space-x-2"
|
||||
>
|
||||
<svg
|
||||
className="animate-spin h-4 w-4 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
) : (
|
||||
"Run"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Collapsible
|
||||
open={isCollapsibleOpen}
|
||||
onOpenChange={setIsCollapsibleOpen}
|
||||
className="mt-2"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="w-full justify-between pl-2">
|
||||
Advanced Options
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 mt-4 px-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="crawlSubPages"
|
||||
name="crawlSubPages"
|
||||
checked={formData.crawlSubPages}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
crawlSubPages: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<label htmlFor="crawlSubPages" className="text-sm">
|
||||
Crawl sub-pages
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label
|
||||
htmlFor="limit"
|
||||
className="block text-left w-full pb-2"
|
||||
>
|
||||
Limit *
|
||||
</Label>
|
||||
<Input
|
||||
id="limit"
|
||||
name="limit"
|
||||
placeholder="10"
|
||||
value={formData.limit}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
htmlFor="maxDepth"
|
||||
className="block text-left w-full pb-2"
|
||||
>
|
||||
Max depth
|
||||
</Label>
|
||||
<Input
|
||||
id="maxDepth"
|
||||
name="maxDepth"
|
||||
placeholder="5"
|
||||
value={formData.maxDepth}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label
|
||||
htmlFor="excludePaths"
|
||||
className="block text-left w-full pb-2"
|
||||
>
|
||||
Exclude paths
|
||||
</Label>
|
||||
<Input
|
||||
id="excludePaths"
|
||||
name="excludePaths"
|
||||
placeholder="blog/, /about/"
|
||||
value={formData.excludePaths}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
htmlFor="includePaths"
|
||||
className="block text-left w-full pb-2"
|
||||
>
|
||||
Include only paths
|
||||
</Label>
|
||||
<Input
|
||||
id="includePaths"
|
||||
name="includePaths"
|
||||
placeholder="articles/"
|
||||
value={formData.includePaths}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="extractMainContent"
|
||||
name="extractMainContent"
|
||||
checked={formData.extractMainContent}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
extractMainContent: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<label htmlFor="extractMainContent" className="text-sm">
|
||||
Extract only main content (no headers, navs, footers, etc.)
|
||||
</label>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</form>
|
||||
{showCrawlStatus && (
|
||||
<div className="flex items-center justify-between mb-2 space-x-2 bg-gray-100 p-2 rounded-md">
|
||||
<div className="flex items-center space-x-2">
|
||||
{!isScraping &&
|
||||
crawledUrls.length > 0 &&
|
||||
!scrapingSelectedLoading && (
|
||||
<>
|
||||
<Checkbox
|
||||
id="selectAll"
|
||||
checked={selectedUrls.length === crawledUrls.length}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setSelectedUrls([...crawledUrls]);
|
||||
} else {
|
||||
setSelectedUrls([]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="selectAll"
|
||||
className="text-sm cursor-pointer"
|
||||
>
|
||||
{selectedUrls.length === crawledUrls.length
|
||||
? `Unselect All (${selectedUrls.length})`
|
||||
: `Select All (${selectedUrls.length})`}
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{isScraping
|
||||
? `Scraped ${crawlStatus.current} page(s) in ${elapsedTime}s`
|
||||
: `Crawled ${crawlStatus.current} pages in ${elapsedTime}s`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{crawledUrls.length > 0 &&
|
||||
!scrapingSelectedLoading &&
|
||||
!isScraping && (
|
||||
<>
|
||||
<ul className="pl-2">
|
||||
{paginatedUrls.map((url, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="flex items-center space-x-2 my-2 text-sm"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedUrls.includes(url)}
|
||||
onCheckedChange={() =>
|
||||
setSelectedUrls((prev) =>
|
||||
prev.includes(url)
|
||||
? prev.filter((u) => u !== url)
|
||||
: [...prev, url]
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="flex items-center max-w-lg">
|
||||
{url.length > 70 ? `${url.slice(0, 70)}...` : url}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="px-2"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<span className="text-sm text-gray-500">
|
||||
Page {currentPage} of{" "}
|
||||
{Math.ceil(crawledUrls.length / urlsPerPage)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="px-2"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage * urlsPerPage >= crawledUrls.length}
|
||||
>
|
||||
<ChevronRight className="h-5 w-5 " />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="w-full flex justify-center">
|
||||
{crawledUrls.length > 0 && !scrapingSelectedLoading && (
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full"
|
||||
onClick={handleScrapeSelected}
|
||||
disabled={loading || selectedUrls.length === 0}
|
||||
>
|
||||
Scrape Selected URLs
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{Object.keys(scrapeResults).length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h2 className="text-base font-bold ">Scrape Results</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
You can do whatever you want with the scrape results. Here is a
|
||||
basic showcase of the markdown.
|
||||
</p>
|
||||
<div className="flex flex-col gap-4 mt-4 w-full">
|
||||
{Object.entries(scrapeResults).map(([url, result]) => (
|
||||
<Card key={url} className="relative p-4 w-full">
|
||||
<CardTitle className="text-sm font-normal flex flex-col">
|
||||
<span>{result.data.metadata.title}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{url
|
||||
.replace(/^(https?:\/\/)?(www\.)?/, "")
|
||||
.replace(/\/$/, "")}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardContent className="relative px-0 pt-2 !text-xs w-full">
|
||||
<div className=" overflow-y-auto h-32 bg-zinc-100 rounded-md p-2 w-full">
|
||||
{result.success ? (
|
||||
<>
|
||||
<pre className="text-xs whitespace-pre-wrap">
|
||||
{result.data.markdown.trim()}
|
||||
</pre>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-red-500">
|
||||
Failed to scrape this URL
|
||||
</p>
|
||||
<p className="text-zinc-500 font-mono">
|
||||
{result.toString()}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
import { useState, ChangeEvent, FormEvent, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
//! Hardcoded values (not recommended for production)
|
||||
//! Highly recommended to move all Firecrawl API calls to the backend (e.g. Next.js API route)
|
||||
const FIRECRAWL_API_URL = "https://api.firecrawl.dev"; // Replace with your actual API URL whether it is local or using Firecrawl Cloud
|
||||
const FIRECRAWL_API_KEY = "fc-YOUR_API_KEY"; // Replace with your actual API key
|
||||
|
||||
interface FormData {
|
||||
url: string;
|
||||
crawlSubPages: boolean;
|
||||
search: string;
|
||||
limit: string;
|
||||
maxDepth: string;
|
||||
excludePaths: string;
|
||||
includePaths: string;
|
||||
extractMainContent: boolean;
|
||||
}
|
||||
|
||||
interface CrawlerOptions {
|
||||
includes?: string[];
|
||||
excludes?: string[];
|
||||
maxDepth?: number;
|
||||
limit?: number;
|
||||
returnOnlyUrls: boolean;
|
||||
}
|
||||
|
||||
interface ScrapeOptions {
|
||||
formats?: string[];
|
||||
onlyMainContent?: boolean;
|
||||
}
|
||||
|
||||
interface PageOptions {
|
||||
onlyMainContent: boolean;
|
||||
}
|
||||
|
||||
interface RequestBody {
|
||||
url: string;
|
||||
crawlerOptions?: CrawlerOptions;
|
||||
pageOptions?: PageOptions;
|
||||
search?: string;
|
||||
excludePaths?: string[];
|
||||
includePaths?: string[];
|
||||
maxDepth?: number;
|
||||
limit?: number;
|
||||
scrapeOptions?: ScrapeOptions;
|
||||
formats?: string[];
|
||||
}
|
||||
|
||||
interface ScrapeResultMetadata {
|
||||
title: string;
|
||||
description: string;
|
||||
language: string;
|
||||
sourceURL: string;
|
||||
pageStatusCode: number;
|
||||
pageError?: string;
|
||||
[key: string]: string | number | undefined;
|
||||
}
|
||||
|
||||
interface ScrapeResultData {
|
||||
markdown: string;
|
||||
content: string;
|
||||
html: string;
|
||||
rawHtml: string;
|
||||
metadata: ScrapeResultMetadata;
|
||||
llm_extraction: Record<string, unknown>;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
interface ScrapeResult {
|
||||
success: boolean;
|
||||
data: ScrapeResultData;
|
||||
}
|
||||
|
||||
export default function FirecrawlComponentV1() {
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
url: "",
|
||||
crawlSubPages: false,
|
||||
search: "",
|
||||
limit: "",
|
||||
maxDepth: "",
|
||||
excludePaths: "",
|
||||
includePaths: "",
|
||||
extractMainContent: false,
|
||||
});
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [scrapingSelectedLoading, setScrapingSelectedLoading] =
|
||||
useState<boolean>(false);
|
||||
const [crawledUrls, setCrawledUrls] = useState<string[]>([]);
|
||||
const [selectedUrls, setSelectedUrls] = useState<string[]>([]);
|
||||
const [scrapeResults, setScrapeResults] = useState<
|
||||
Record<string, ScrapeResult>
|
||||
>({});
|
||||
const [isCollapsibleOpen, setIsCollapsibleOpen] = useState(true);
|
||||
const [crawlStatus, setCrawlStatus] = useState<{
|
||||
current: number;
|
||||
total: number | null;
|
||||
}>({ current: 0, total: null });
|
||||
const [elapsedTime, setElapsedTime] = useState<number>(0);
|
||||
const [showCrawlStatus, setShowCrawlStatus] = useState<boolean>(false);
|
||||
const [isScraping, setIsScraping] = useState<boolean>(false);
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const urlsPerPage = 10;
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (loading) {
|
||||
setShowCrawlStatus(true);
|
||||
timer = setInterval(() => {
|
||||
setElapsedTime((prevTime) => prevTime + 1);
|
||||
}, 1000);
|
||||
}
|
||||
return () => {
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
}, [loading]);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData((prevData) => {
|
||||
const newData = {
|
||||
...prevData,
|
||||
[name]: type === "checkbox" ? checked : value,
|
||||
};
|
||||
|
||||
// Automatically check "Crawl Sub-pages" if limit or search have content
|
||||
if (name === "limit" || name === "search") {
|
||||
newData.crawlSubPages = !!value || !!newData.limit || !!newData.search;
|
||||
}
|
||||
|
||||
return newData;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setIsCollapsibleOpen(false);
|
||||
setElapsedTime(0);
|
||||
setCrawlStatus({ current: 0, total: null });
|
||||
setIsScraping(!formData.crawlSubPages);
|
||||
setCrawledUrls([]);
|
||||
setSelectedUrls([]);
|
||||
setScrapeResults({});
|
||||
setScrapingSelectedLoading(false);
|
||||
setShowCrawlStatus(false);
|
||||
|
||||
try {
|
||||
const endpoint = `${FIRECRAWL_API_URL}/v1/${
|
||||
formData.crawlSubPages ? "map" : "scrape"
|
||||
}`;
|
||||
|
||||
const requestBody: RequestBody = formData.crawlSubPages
|
||||
? {
|
||||
url: formData.url,
|
||||
search: formData.search || undefined,
|
||||
limit: formData.limit ? parseInt(formData.limit) : undefined,
|
||||
}
|
||||
: {
|
||||
url: formData.url,
|
||||
formats: ["markdown"],
|
||||
};
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${FIRECRAWL_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (formData.crawlSubPages) {
|
||||
if (data.success === true && Array.isArray(data.links)) {
|
||||
setCrawledUrls(data.links);
|
||||
setSelectedUrls(data.links);
|
||||
setCrawlStatus({
|
||||
current: data.links.length,
|
||||
total: data.links.length,
|
||||
});
|
||||
|
||||
// Set scrape results with the links
|
||||
const linkResults: Record<string, ScrapeResult> = {};
|
||||
data.links.forEach((link: string) => {
|
||||
linkResults[link] = {
|
||||
success: true,
|
||||
data: {
|
||||
metadata: {
|
||||
sourceURL: link,
|
||||
title: "",
|
||||
description: "",
|
||||
language: "",
|
||||
pageStatusCode: 200,
|
||||
},
|
||||
markdown: "",
|
||||
content: "",
|
||||
html: "",
|
||||
rawHtml: "",
|
||||
llm_extraction: {},
|
||||
},
|
||||
};
|
||||
});
|
||||
} else {
|
||||
console.error("Unexpected response format from map endpoint");
|
||||
console.log(data);
|
||||
}
|
||||
} else {
|
||||
setScrapeResults({ [formData.url]: data });
|
||||
setCrawlStatus({ current: 1, total: 1 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
setScrapeResults({
|
||||
error: {
|
||||
success: false,
|
||||
data: {
|
||||
metadata: {
|
||||
pageError: "Error occurred while fetching data",
|
||||
title: "",
|
||||
description: "",
|
||||
language: "",
|
||||
sourceURL: "",
|
||||
pageStatusCode: 0,
|
||||
},
|
||||
markdown: "",
|
||||
content: "",
|
||||
html: "",
|
||||
rawHtml: "",
|
||||
llm_extraction: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScrapeSelected = async () => {
|
||||
setLoading(true);
|
||||
setElapsedTime(0);
|
||||
setCrawlStatus({ current: 0, total: selectedUrls.length });
|
||||
setIsScraping(true);
|
||||
setScrapingSelectedLoading(true);
|
||||
const newScrapeResults: Record<string, ScrapeResult> = {};
|
||||
|
||||
for (const [index, url] of selectedUrls.entries()) {
|
||||
try {
|
||||
const response = await fetch(`${FIRECRAWL_API_URL}/v1/scrape`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${FIRECRAWL_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: url,
|
||||
formats: ["markdown"],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: ScrapeResult = await response.json();
|
||||
newScrapeResults[url] = data;
|
||||
setCrawlStatus((prev) => ({ ...prev, current: index + 1 }));
|
||||
setScrapeResults({ ...scrapeResults, ...newScrapeResults });
|
||||
} catch (error) {
|
||||
console.error(`Error scraping ${url}:`, error);
|
||||
newScrapeResults[url] = {
|
||||
success: false,
|
||||
data: {
|
||||
markdown: "",
|
||||
content: "",
|
||||
html: "",
|
||||
rawHtml: "",
|
||||
metadata: {
|
||||
title: "",
|
||||
description: "",
|
||||
language: "",
|
||||
sourceURL: url,
|
||||
pageStatusCode: 0,
|
||||
pageError: (error as Error).message,
|
||||
},
|
||||
llm_extraction: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
setIsScraping(false);
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrentPage(newPage);
|
||||
};
|
||||
|
||||
const paginatedUrls = crawledUrls.slice(
|
||||
(currentPage - 1) * urlsPerPage,
|
||||
currentPage * urlsPerPage
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-4">
|
||||
<Card>
|
||||
<CardHeader className="flex items-start justify-between mb-0 pb-4">
|
||||
<CardTitle className="flex items-center justify-between w-full space-x-2">
|
||||
<span className="text-base">Extract web content (V1)</span>
|
||||
<a
|
||||
href="https://www.firecrawl.dev"
|
||||
className="text-xs text-gray-500 font-normal px-3 py-1 bg-zinc-100 rounded-xl hover:bg-zinc-200 transition-colors"
|
||||
>
|
||||
Powered by Firecrawl 🔥
|
||||
</a>
|
||||
</CardTitle>
|
||||
<div className="text-sm text-gray-500 w-11/12 items-center">
|
||||
Use this component to quickly give your users the ability to connect
|
||||
their AI apps to web data with Firecrawl. Learn more on the{" "}
|
||||
<a
|
||||
href="https://docs.firecrawl.dev/"
|
||||
className="text-sm text-blue-500"
|
||||
>
|
||||
Firecrawl docs!
|
||||
</a>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
placeholder="https://www.firecrawl.dev/"
|
||||
className="flex-grow"
|
||||
name="url"
|
||||
value={formData.url}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Button type="submit" variant="default" disabled={loading}>
|
||||
{loading ? (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-center justify-between space-x-2"
|
||||
>
|
||||
<svg
|
||||
className="animate-spin h-4 w-4 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
) : (
|
||||
"Run"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Collapsible
|
||||
open={isCollapsibleOpen}
|
||||
onOpenChange={setIsCollapsibleOpen}
|
||||
className="mt-2"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="w-full justify-between pl-2">
|
||||
Advanced Options
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 mt-4 px-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="crawlSubPages"
|
||||
name="crawlSubPages"
|
||||
checked={formData.crawlSubPages}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
crawlSubPages: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<label htmlFor="crawlSubPages" className="text-sm">
|
||||
Crawl Sub-pages
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label
|
||||
htmlFor="search"
|
||||
className="block text-left w-full pb-2"
|
||||
>
|
||||
Search for specific pages in crawl *
|
||||
</Label>
|
||||
<Input
|
||||
id="search"
|
||||
name="search"
|
||||
placeholder="python sdk"
|
||||
value={formData.search}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
htmlFor="limit"
|
||||
className="block text-left w-full pb-2"
|
||||
>
|
||||
Limit *
|
||||
</Label>
|
||||
<Input
|
||||
id="limit"
|
||||
name="limit"
|
||||
placeholder="10"
|
||||
value={formData.limit}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</form>
|
||||
{showCrawlStatus && (
|
||||
<div className="flex items-center justify-between mb-2 space-x-2 bg-gray-100 p-2 rounded-md">
|
||||
<div className="flex items-center space-x-2">
|
||||
{!isScraping &&
|
||||
crawledUrls.length > 0 &&
|
||||
!scrapingSelectedLoading && (
|
||||
<>
|
||||
<Checkbox
|
||||
id="selectAll"
|
||||
checked={selectedUrls.length === crawledUrls.length}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setSelectedUrls([...crawledUrls]);
|
||||
} else {
|
||||
setSelectedUrls([]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="selectAll"
|
||||
className="text-sm cursor-pointer"
|
||||
>
|
||||
{selectedUrls.length === crawledUrls.length
|
||||
? `Unselect All (${selectedUrls.length})`
|
||||
: `Select All (${selectedUrls.length})`}
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{isScraping
|
||||
? `Scraped ${crawlStatus.current} page(s) in ${elapsedTime}s`
|
||||
: `Crawled ${crawlStatus.current} pages in ${elapsedTime}s`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{crawledUrls.length > 0 &&
|
||||
!scrapingSelectedLoading &&
|
||||
!isScraping && (
|
||||
<>
|
||||
<ul className="pl-2">
|
||||
{paginatedUrls.map((url, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="flex items-center space-x-2 my-2 text-sm"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedUrls.includes(url)}
|
||||
onCheckedChange={() =>
|
||||
setSelectedUrls((prev) =>
|
||||
prev.includes(url)
|
||||
? prev.filter((u) => u !== url)
|
||||
: [...prev, url]
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="flex items-center max-w-lg">
|
||||
{url.length > 70 ? `${url.slice(0, 70)}...` : url}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="px-2"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<span className="text-sm text-gray-500">
|
||||
Page {currentPage} of{" "}
|
||||
{Math.ceil(crawledUrls.length / urlsPerPage)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="px-2"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage * urlsPerPage >= crawledUrls.length}
|
||||
>
|
||||
<ChevronRight className="h-5 w-5 " />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="w-full flex justify-center">
|
||||
{crawledUrls.length > 0 && !scrapingSelectedLoading && (
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full"
|
||||
onClick={handleScrapeSelected}
|
||||
disabled={loading || selectedUrls.length === 0}
|
||||
>
|
||||
Scrape Selected URLs
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{Object.keys(scrapeResults).length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h2 className="text-base font-bold ">Scrape Results</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
You can do whatever you want with the scrape results. Here is a
|
||||
basic showcase of the markdown.
|
||||
</p>
|
||||
<div className="flex flex-col gap-4 mt-4 w-full">
|
||||
{Object.entries(scrapeResults).map(([url, result]) => (
|
||||
<Card key={url} className="relative p-4 w-full">
|
||||
<CardTitle className="text-sm font-normal flex flex-col">
|
||||
<span>{result.data.metadata.title}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{url
|
||||
.replace(/^(https?:\/\/)?(www\.)?/, "")
|
||||
.replace(/\/$/, "")}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardContent className="relative px-0 pt-2 !text-xs w-full">
|
||||
<div className=" overflow-y-auto h-32 bg-zinc-100 rounded-md p-2 w-full">
|
||||
{result.success ? (
|
||||
<>
|
||||
<pre className="text-xs whitespace-pre-wrap">
|
||||
{result.data.markdown.trim()}
|
||||
</pre>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-red-500">
|
||||
Failed to scrape this URL
|
||||
</p>
|
||||
<p className="text-zinc-500 font-mono">
|
||||
{result.toString()}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,9 @@
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
69
참고/firecrawl-main/apps/ui/ingestion-ui/src/index.css
Normal file
69
참고/firecrawl-main/apps/ui/ingestion-ui/src/index.css
Normal file
@@ -0,0 +1,69 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
6
참고/firecrawl-main/apps/ui/ingestion-ui/src/lib/utils.ts
Normal file
6
참고/firecrawl-main/apps/ui/ingestion-ui/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
10
참고/firecrawl-main/apps/ui/ingestion-ui/src/main.tsx
Normal file
10
참고/firecrawl-main/apps/ui/ingestion-ui/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
1
참고/firecrawl-main/apps/ui/ingestion-ui/src/vite-env.d.ts
vendored
Normal file
1
참고/firecrawl-main/apps/ui/ingestion-ui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user