mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-01 19:32:40 +02:00
236 lines
No EOL
9.4 KiB
TypeScript
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>
|
|
);
|
|
} |