rowboat/apps/rowboat/app/projects/[projectId]/sources/components/scrape-source.tsx
2025-08-17 08:06:17 +05:30

236 lines
No EOL
9.4 KiB
TypeScript

"use client";
import { DataSourceDoc } from "@/src/entities/models/data-source-doc";
import { DataSource } from "@/src/entities/models/data-source";
import { z } from "zod";
import { Recrawl } from "./web-recrawl";
import { deleteDocFromDataSource, listDocsInDataSource, recrawlWebDataSource, addDocsToDataSource } from "../../../../actions/data-source.actions";
import { useState, useEffect } from "react";
import { Spinner, Pagination } from "@heroui/react";
import { ExternalLinkIcon, PlusIcon } from "lucide-react";
import { FormStatusButton } from "../../../../lib/components/form-status-button";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Section } from "./section";
function UrlListItem({ file, onDelete }: {
file: z.infer<typeof DataSourceDoc>,
onDelete: (fileId: string) => Promise<void>;
}) {
const [isDeleting, setIsDeleting] = useState(false);
if (file.data.type !== 'url') return null;
return (
<div className="flex items-center justify-between py-3 px-1 border-b border-gray-100 dark:border-gray-800 group hover:bg-gray-50/50 dark:hover:bg-gray-800/50 transition-colors">
<div className="flex items-center gap-2">
<p className="text-sm text-gray-900 dark:text-gray-100">{file.name}</p>
<a
href={file.data.url}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors"
>
<ExternalLinkIcon className="w-3.5 h-3.5" />
</a>
</div>
<button
onClick={async () => {
setIsDeleting(true);
try {
await onDelete(file.id);
} finally {
setIsDeleting(false);
}
}}
disabled={isDeleting}
className="text-sm text-gray-400 hover:text-red-600 dark:text-gray-500 dark:hover:text-red-400 transition-colors disabled:opacity-50"
>
{isDeleting ? <Spinner size="sm" /> : 'Delete'}
</button>
</div>
);
}
function UrlList({ sourceId, onDelete }: {
sourceId: string,
onDelete: (fileId: string) => Promise<void>,
}) {
const [files, setFiles] = useState<z.infer<typeof DataSourceDoc>[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const totalPages = Math.ceil(total / 10);
useEffect(() => {
let ignore = false;
async function fetchFiles() {
setLoading(true);
try {
const { files, total } = await listDocsInDataSource({ sourceId, page, limit: 10 });
if (!ignore) {
setFiles(files);
setTotal(total);
}
} catch (error) {
console.error('Error fetching files:', error);
} finally {
setLoading(false);
}
}
fetchFiles();
return () => {
ignore = true;
};
}, [sourceId, page]);
return (
<div className="mt-6 space-y-4">
{loading ? (
<div className="flex items-center justify-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<Spinner size="sm" />
<p>Loading URLs...</p>
</div>
) : files.length === 0 ? (
<div className="text-center text-sm text-gray-600 dark:text-gray-300">
No URLs added yet
</div>
) : (
<div className="space-y-2">
{files.map(file => (
<UrlListItem key={file.id} file={file} onDelete={onDelete} />
))}
{Math.ceil(total / 10) > 1 && (
<div className="mt-4">
<Pagination
total={Math.ceil(total / 10)}
page={page}
onChange={setPage}
/>
</div>
)}
</div>
)}
</div>
);
}
export function ScrapeSource({
dataSource,
handleReload,
}: {
dataSource: z.infer<typeof DataSource>,
handleReload: () => void;
}) {
const [fileListKey, setFileListKey] = useState(0);
const [showAddForm, setShowAddForm] = useState(false);
return (
<div className="space-y-6">
<Section
title="URLs"
description="Manage the URLs that will be scraped for this data source."
>
<div className="space-y-6">
{!showAddForm && (
<Button
onClick={() => setShowAddForm(true)}
variant="primary"
size="sm"
>
<div className="flex items-center gap-1.5">
<PlusIcon className="w-3.5 h-3.5" />
Add URLs
</div>
</Button>
)}
{showAddForm && (
<form
action={async (formData) => {
const urls = formData.get('urls') as string;
const urlsArray = urls.split('\n')
.map(url => url.trim())
.filter(url => url.length > 0);
const first100Urls = urlsArray.slice(0, 100);
await addDocsToDataSource({
sourceId: dataSource.id,
docData: first100Urls.map(url => ({
name: url,
data: {
type: 'url',
url,
},
})),
});
handleReload();
setShowAddForm(false);
}}
className="space-y-4"
>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Add URLs (one per line)
</label>
<Textarea
required
name="urls"
rows={5}
placeholder="https://example.com"
className="rounded-lg p-3 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750"
/>
</div>
<div className="flex gap-2">
<FormStatusButton
props={{
type: "submit",
children: "Add URLs",
startContent: <PlusIcon className="w-4 h-4" />,
}}
/>
<button
type="button"
onClick={() => setShowAddForm(false)}
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Cancel
</button>
</div>
</form>
)}
<UrlList
key={fileListKey}
sourceId={dataSource.id}
onDelete={async (docId) => {
await deleteDocFromDataSource({
docId: docId,
});
handleReload();
setFileListKey(prev => prev + 1);
}}
/>
</div>
</Section>
{(dataSource.status === 'ready' || dataSource.status === 'error') && (
<Section
title="Refresh Content"
description="Update the content by scraping the URLs again."
>
<Recrawl
handleRefresh={async () => {
await recrawlWebDataSource(dataSource.id);
handleReload();
setFileListKey(prev => prev + 1);
}}
/>
</Section>
)}
</div>
);
}