Firecrawl UI Template

Firecrawl UI template
This commit is contained in:
Eric Ciarla 2024-07-24 15:05:55 -04:00 committed by GitHub
commit a4bccbe3bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 5973 additions and 3 deletions

1
.gitignore vendored
View File

@ -18,3 +18,4 @@ apps/test-suite/load-test-results/test-run-report.json
apps/playwright-service-ts/node_modules/
apps/playwright-service-ts/package-lock.json

View File

@ -405,12 +405,12 @@ _It is the sole responsibility of the end users to respect websites' policies wh
## License Disclaimer
This project is primarily licensed under the GNU Affero General Public License v3.0 (AGPL-3.0), as specified in the LICENSE file in the root directory of this repository. However, certain components of this project, specifically the SDKs located in the `/apps/js-sdk` and `/apps/python-sdk` directories, are licensed under the MIT License.
This project is primarily licensed under the GNU Affero General Public License v3.0 (AGPL-3.0), as specified in the LICENSE file in the root directory of this repository. However, certain components of this project are licensed under the MIT License. Refer to the LICENSE files in these specific directories for details.
Please note:
- The AGPL-3.0 license applies to all parts of the project unless otherwise specified.
- The SDKs in `/apps/js-sdk` and `/apps/python-sdk` are licensed under the MIT License. Refer to the LICENSE files in these specific directories for details.
- The SDKs and some UI components are licensed under the MIT License. Refer to the LICENSE files in these specific directories for details.
- When using or contributing to this project, ensure you comply with the appropriate license terms for the specific component you are working with.
For more details on the licensing of specific components, please refer to the LICENSE files in the respective directories or contact the project maintainers.

View File

@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

24
apps/ui/ingestion-ui/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Sideguide Technologies Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,65 @@
# Firecrawl UI Template
This template provides an easy way to spin up a UI for Firecrawl using React. It includes a pre-built component that interacts with the Firecrawl API, allowing you to quickly set up a web crawling and scraping interface.
## ⚠️ Important Security Notice
**This template exposes Firecrawl API keys in the client-side code. For production use, it is strongly recommended to move API interactions to a server-side implementation to protect your API keys.**
## Prerequisites
- Node.js (v14 or later recommended)
- npm
## Getting Started
1. Install dependencies:
```
npm install
```
2. Set up your Firecrawl API key:
Open `src/components/FirecrawlComponent.tsx` and replace the placeholder API key:
```typescript
const FIRECRAWL_API_KEY = "your-api-key-here";
```
3. Start the development server:
```
npm run dev
```
4. Open your browser and navigate to the port specified in your terminal
## Customization
The main Firecrawl component is located in `src/components/FirecrawlComponent.tsx`. You can modify this file to customize the UI or add additional features.
## Security Considerations
For production use, consider the following security measures:
1. Move API interactions to a server-side implementation to protect your Firecrawl API key.
2. Implement proper authentication and authorization for your application.
3. Set up CORS policies to restrict access to your API endpoints.
## Learn More
For more information about Firecrawl and its API, visit the [Firecrawl documentation](https://docs.firecrawl.dev/).
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## License
The Firecrawl Ingestion UI Template is licensed under the MIT License. This means you are free to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the SDK, subject to the following conditions:
- The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Please note that while this SDK is MIT licensed, it is part of a larger project which may be under different licensing terms. Always refer to the license information in the root directory of the main project for overall licensing details.

View File

@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Firecrawl UI Template</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4647
apps/ui/ingestion-ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,42 @@
{
"name": "ingestion-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-slot": "^1.1.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.414.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/node": "^20.14.12",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.15.0",
"@typescript-eslint/parser": "^7.15.0",
"@vitejs/plugin-react": "^4.3.1",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
"typescript": "^5.2.2",
"vite": "^5.3.4"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@ -0,0 +1,12 @@
import "./App.css";
import FirecrawlComponent from "./components/ingestion";
function App() {
return (
<>
<FirecrawlComponent />
</>
);
}
export default App;

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,598 @@
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 } from "lucide-react";
// Hardcoded values (not recommended for production)
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 = ""; // 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 [showAllUrls, setShowAllUrls] = useState<boolean>(false);
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 }));
} 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: {},
},
};
}
}
setScrapeResults(newScrapeResults);
setLoading(false);
setIsScraping(false);
};
return (
<div className="max-w-2xl mx-auto p-4">
<Card>
<CardHeader className="flex items-center justify-between">
<CardTitle className="flex items-center space-x-2">
<span>Extract web content with Firecrawl 🔥</span>
</CardTitle>
<div className="text-sm text-gray-500 w-11/12 items-center">
Use this component to quickly build your own UI for Firecrawl. Plug
in your API key and the component will handle the rest. 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 ? "Running..." : "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">
<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"
: "Select All"}
</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">
{(showAllUrls ? crawledUrls : crawledUrls.slice(0, 10)).map(
(url, index) => (
<li key={index} className="flex items-center space-x-2">
<Checkbox
checked={selectedUrls.includes(url)}
onCheckedChange={() =>
setSelectedUrls((prev) =>
prev.includes(url)
? prev.filter((u) => u !== url)
: [...prev, url]
)
}
/>
<span>{url}</span>
</li>
)
)}
</ul>
{crawledUrls.length > 10 && (
<div className="flex justify-center mt-2">
<Button
variant="link"
onClick={() => setShowAllUrls(!showAllUrls)}
>
{showAllUrls ? "Show Less" : "Show All"}
</Button>
</div>
)}
</>
)}
</CardContent>
<CardFooter className="flex justify-center">
{crawledUrls.length > 0 && !scrapingSelectedLoading && (
<Button
variant="default"
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-2xl font-bold mb-4">Scrape Results</h2>
<div className="grid grid-cols-2 gap-4">
{Object.entries(scrapeResults).map(([url, result]) => (
<Card key={url} className="overflow-hidden">
<CardContent>
<div className="max-h-60 overflow-y-auto">
<div className="text-base font-bold py-2">
{url
.replace(/^(https?:\/\/)?(www\.)?/, "")
.replace(/\/$/, "")}
</div>
{result.success ? (
<>
<pre className="text-xs whitespace-pre-wrap">
{result.data.markdown.trim().slice(0, 200)}...
</pre>
</>
) : (
<p className="text-red-500">Failed to scrape this URL</p>
)}
</div>
</CardContent>
<CardFooter className="flex justify-center">
<Button
variant="outline"
size="sm"
onClick={(event) => {
navigator.clipboard.writeText(result.data.markdown);
const button = event.currentTarget as HTMLButtonElement;
const originalText = button.textContent;
button.textContent = "Copied!";
setTimeout(() => {
button.textContent = originalText;
}, 2000);
}}
>
Copy Markdown
</Button>
</CardFooter>
</Card>
))}
</div>
</div>
)}
</div>
);
}

View File

@ -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 }

View File

@ -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 }

View File

@ -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 }

View File

@ -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 }

View File

@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends 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 }

View File

@ -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 }

View 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;
}
}

View 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))
}

View 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>,
)

View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,77 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
}

View File

@ -0,0 +1,33 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": ["src"]
}

View File

@ -0,0 +1,17 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,12 @@
import path from "path"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
})