mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-25 19:15:18 +02:00
Merge pull request #646 from AnishSarkar22/fix/connector
Move conenctors page to popup
This commit is contained in:
commit
d6bf4d7ce1
120 changed files with 12848 additions and 8830 deletions
|
|
@ -255,9 +255,10 @@ async def airtable_callback(
|
|||
await session.commit()
|
||||
logger.info(f"Successfully saved Airtable connector for user {user_id}")
|
||||
|
||||
# Redirect to the frontend success page
|
||||
# Redirect to the frontend with success params for indexing config
|
||||
# Using query params to auto-open the popup with config view on new-chat page
|
||||
return RedirectResponse(
|
||||
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/connectors/add/airtable-connector?success=true"
|
||||
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=airtable-connector"
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
|
|
|
|||
|
|
@ -131,8 +131,10 @@ async def calendar_callback(
|
|||
session.add(db_connector)
|
||||
await session.commit()
|
||||
await session.refresh(db_connector)
|
||||
# Redirect to the frontend with success params for indexing config
|
||||
# Using query params to auto-open the popup with config view on new-chat page
|
||||
return RedirectResponse(
|
||||
f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/connectors/add/google-calendar-connector?success=true"
|
||||
f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=google-calendar-connector"
|
||||
)
|
||||
except ValidationError as e:
|
||||
await session.rollback()
|
||||
|
|
|
|||
|
|
@ -212,9 +212,8 @@ async def drive_callback(
|
|||
f"Successfully created Google Drive connector {db_connector.id} for user {user_id}"
|
||||
)
|
||||
|
||||
# Redirect to connectors management page (not to folder selection)
|
||||
return RedirectResponse(
|
||||
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/connectors?success=google-drive-connected"
|
||||
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=google-drive-connector"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
|
|
|
|||
|
|
@ -135,9 +135,10 @@ async def gmail_callback(
|
|||
f"Successfully created Gmail connector for user {user_id} with ID {db_connector.id}"
|
||||
)
|
||||
|
||||
# Redirect to the frontend success page
|
||||
# Redirect to the frontend with success params for indexing config
|
||||
# Using query params to auto-open the popup with config view on new-chat page
|
||||
return RedirectResponse(
|
||||
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/connectors/add/google-gmail-connector?success=true"
|
||||
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=google-gmail-connector"
|
||||
)
|
||||
|
||||
except IntegrityError as e:
|
||||
|
|
|
|||
|
|
@ -322,6 +322,9 @@ async def get_logs_summary(
|
|||
document_id = (
|
||||
log.log_metadata.get("document_id") if log.log_metadata else None
|
||||
)
|
||||
connector_id = (
|
||||
log.log_metadata.get("connector_id") if log.log_metadata else None
|
||||
)
|
||||
summary["active_tasks"].append(
|
||||
{
|
||||
"id": log.id,
|
||||
|
|
@ -330,6 +333,7 @@ async def get_logs_summary(
|
|||
"started_at": log.created_at,
|
||||
"source": log.source,
|
||||
"document_id": document_id,
|
||||
"connector_id": connector_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ import {
|
|||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -349,221 +348,213 @@ export default function ConnectorsPage() {
|
|||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>{t("your_connectors")}</CardTitle>
|
||||
<CardDescription>{t("view_manage")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-pulse text-center">
|
||||
<div className="h-6 w-32 bg-muted rounded mx-auto mb-2"></div>
|
||||
<div className="h-4 w-48 bg-muted rounded mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : connectors.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-medium mb-2">{t("no_connectors")}</h3>
|
||||
<p className="text-muted-foreground mb-6">{t("no_connectors_desc")}</p>
|
||||
<Button onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("add_first")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("type")}</TableHead>
|
||||
<TableHead>{t("last_indexed")}</TableHead>
|
||||
<TableHead>{t("periodic")}</TableHead>
|
||||
<TableHead className="text-right">{t("actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{connectors.map((connector) => (
|
||||
<TableRow key={connector.id}>
|
||||
<TableCell className="font-medium">{connector.name}</TableCell>
|
||||
<TableCell>{getConnectorIcon(connector.connector_type)}</TableCell>
|
||||
<TableCell>
|
||||
{connector.is_indexable
|
||||
? formatDateTime(connector.last_indexed_at)
|
||||
: t("not_indexable")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{connector.is_indexable ? (
|
||||
connector.periodic_indexing_enabled ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">
|
||||
{connector.indexing_frequency_minutes
|
||||
? formatFrequency(connector.indexing_frequency_minutes)
|
||||
: "Enabled"}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Runs every {connector.indexing_frequency_minutes} minutes
|
||||
{connector.next_scheduled_at && (
|
||||
<>
|
||||
<br />
|
||||
Next: {formatDateTime(connector.next_scheduled_at)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">Disabled</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{connector.is_indexable && (
|
||||
<div className="flex gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleOpenDatePicker(connector.id)}
|
||||
disabled={indexingConnectorId === connector.id}
|
||||
>
|
||||
{indexingConnectorId === connector.id ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : connector.connector_type === EnumConnectorName.GOOGLE_DRIVE_CONNECTOR ? (
|
||||
<Folder className="h-4 w-4" />
|
||||
) : (
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{connector.connector_type === EnumConnectorName.GOOGLE_DRIVE_CONNECTOR
|
||||
? "Select folder to index"
|
||||
: t("index_date_range")}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{connector.connector_type === EnumConnectorName.GOOGLE_DRIVE_CONNECTOR
|
||||
? "Select folder to index"
|
||||
: t("index_date_range")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{/* Hide quick index button for Google Drive (requires folder selection) */}
|
||||
{connector.connector_type !== EnumConnectorName.GOOGLE_DRIVE_CONNECTOR && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleQuickIndexConnector(connector.id)}
|
||||
disabled={indexingConnectorId === connector.id}
|
||||
>
|
||||
{indexingConnectorId === connector.id ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">{t("quick_index")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("quick_index_auto")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{connector.is_indexable && (
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-pulse text-center">
|
||||
<div className="h-6 w-32 bg-muted rounded mx-auto mb-2"></div>
|
||||
<div className="h-4 w-48 bg-muted rounded mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : connectors.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-medium mb-2">{t("no_connectors")}</h3>
|
||||
<p className="text-muted-foreground mb-6">{t("no_connectors_desc")}</p>
|
||||
<Button onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("add_first")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("type")}</TableHead>
|
||||
<TableHead>{t("last_indexed")}</TableHead>
|
||||
<TableHead>{t("periodic")}</TableHead>
|
||||
<TableHead className="text-right">{t("actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{connectors.map((connector) => (
|
||||
<TableRow key={connector.id}>
|
||||
<TableCell className="font-medium">{connector.name}</TableCell>
|
||||
<TableCell>{getConnectorIcon(connector.connector_type)}</TableCell>
|
||||
<TableCell>
|
||||
{connector.is_indexable
|
||||
? formatDateTime(connector.last_indexed_at)
|
||||
: t("not_indexable")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{connector.is_indexable ? (
|
||||
connector.periodic_indexing_enabled ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">
|
||||
{connector.indexing_frequency_minutes
|
||||
? formatFrequency(connector.indexing_frequency_minutes)
|
||||
: "Enabled"}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Runs every {connector.indexing_frequency_minutes} minutes
|
||||
{connector.next_scheduled_at && (
|
||||
<>
|
||||
<br />
|
||||
Next: {formatDateTime(connector.next_scheduled_at)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">Disabled</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{connector.is_indexable && (
|
||||
<div className="flex gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleOpenDatePicker(connector.id)}
|
||||
disabled={indexingConnectorId === connector.id}
|
||||
>
|
||||
{indexingConnectorId === connector.id ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : connector.connector_type === EnumConnectorName.GOOGLE_DRIVE_CONNECTOR ? (
|
||||
<Folder className="h-4 w-4" />
|
||||
) : (
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{connector.connector_type === EnumConnectorName.GOOGLE_DRIVE_CONNECTOR
|
||||
? "Select folder to index"
|
||||
: t("index_date_range")}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{connector.connector_type === EnumConnectorName.GOOGLE_DRIVE_CONNECTOR
|
||||
? "Select folder to index"
|
||||
: t("index_date_range")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{/* Hide quick index button for Google Drive (requires folder selection) */}
|
||||
{connector.connector_type !== EnumConnectorName.GOOGLE_DRIVE_CONNECTOR && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleOpenPeriodicDialog(connector.id)}
|
||||
onClick={() => handleQuickIndexConnector(connector.id)}
|
||||
disabled={indexingConnectorId === connector.id}
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="sr-only">Configure Periodic Indexing</span>
|
||||
{indexingConnectorId === connector.id ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">{t("quick_index")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Configure Periodic Indexing</p>
|
||||
<p>{t("quick_index_auto")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/dashboard/${searchSpaceId}/connectors/${connector.id}/edit`
|
||||
)
|
||||
}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
<span className="sr-only">{tCommon("edit")}</span>
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
</div>
|
||||
)}
|
||||
{connector.is_indexable && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive-foreground hover:bg-destructive/10"
|
||||
onClick={() => setConnectorToDelete(connector.id)}
|
||||
onClick={() => handleOpenPeriodicDialog(connector.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">{tCommon("delete")}</span>
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="sr-only">Configure Periodic Indexing</span>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("delete_connector")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("delete_confirm")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setConnectorToDelete(null)}>
|
||||
{tCommon("cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={handleDeleteConnector}
|
||||
>
|
||||
{tCommon("delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Configure Periodic Indexing</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/dashboard/${searchSpaceId}/connectors/${connector.id}/edit`
|
||||
)
|
||||
}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
<span className="sr-only">{tCommon("edit")}</span>
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive-foreground hover:bg-destructive/10"
|
||||
onClick={() => setConnectorToDelete(connector.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">{tCommon("delete")}</span>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("delete_connector")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("delete_confirm")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setConnectorToDelete(null)}>
|
||||
{tCommon("cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={handleDeleteConnector}
|
||||
>
|
||||
{tCommon("delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date Picker Dialog */}
|
||||
<Dialog open={datePickerOpen} onOpenChange={setDatePickerOpen}>
|
||||
|
|
|
|||
|
|
@ -1,336 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, Check, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { EditConnectorLoadingSkeleton } from "@/components/editConnector/EditConnectorLoadingSkeleton";
|
||||
import { EditConnectorNameForm } from "@/components/editConnector/EditConnectorNameForm";
|
||||
import { EditGitHubConnectorConfig } from "@/components/editConnector/EditGitHubConnectorConfig";
|
||||
import { EditSimpleTokenForm } from "@/components/editConnector/EditSimpleTokenForm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { useConnectorEditPage } from "@/hooks/use-connector-edit-page";
|
||||
// Import Utils, Types, Hook, and Components
|
||||
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
|
||||
|
||||
export default function EditConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
// Ensure connectorId is parsed safely
|
||||
const connectorIdParam = params.connector_id as string;
|
||||
const connectorId = connectorIdParam ? parseInt(connectorIdParam, 10) : NaN;
|
||||
|
||||
// Use the custom hook to manage state and logic
|
||||
const {
|
||||
connectorsLoading,
|
||||
connector,
|
||||
isSaving,
|
||||
editForm,
|
||||
patForm, // Needed for GitHub child component
|
||||
handleSaveChanges,
|
||||
// GitHub specific props for the child component
|
||||
editMode,
|
||||
setEditMode, // Pass down if needed by GitHub component
|
||||
originalPat,
|
||||
currentSelectedRepos,
|
||||
fetchedRepos,
|
||||
setFetchedRepos,
|
||||
newSelectedRepos,
|
||||
setNewSelectedRepos,
|
||||
isFetchingRepos,
|
||||
handleFetchRepositories,
|
||||
handleRepoSelectionChange,
|
||||
} = useConnectorEditPage(connectorId, searchSpaceId);
|
||||
|
||||
// Redirect if connectorId is not a valid number after parsing
|
||||
useEffect(() => {
|
||||
if (Number.isNaN(connectorId)) {
|
||||
toast.error("Invalid Connector ID.");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
}
|
||||
}, [connectorId, router, searchSpaceId]);
|
||||
|
||||
// Loading State
|
||||
if (connectorsLoading || !connector) {
|
||||
// Handle NaN case before showing skeleton
|
||||
if (Number.isNaN(connectorId)) return null;
|
||||
return <EditConnectorLoadingSkeleton />;
|
||||
}
|
||||
|
||||
// Main Render using data/handlers from the hook
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Connectors
|
||||
</Button>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
{getConnectorIcon(connector.connector_type)}
|
||||
Edit {getConnectorTypeDisplay(connector.connector_type)} Connector
|
||||
</CardTitle>
|
||||
<CardDescription>Modify connector name and configuration.</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<Form {...editForm}>
|
||||
{/* Pass hook's handleSaveChanges */}
|
||||
<form onSubmit={editForm.handleSubmit(handleSaveChanges)} className="space-y-6">
|
||||
<CardContent className="space-y-6">
|
||||
{/* Pass form control from hook */}
|
||||
<EditConnectorNameForm control={editForm.control} />
|
||||
|
||||
<hr />
|
||||
|
||||
<h3 className="text-lg font-semibold">Configuration</h3>
|
||||
|
||||
{/* == GitHub == */}
|
||||
{connector.connector_type === "GITHUB_CONNECTOR" && (
|
||||
<EditGitHubConnectorConfig
|
||||
// Pass relevant state and handlers from hook
|
||||
editMode={editMode}
|
||||
setEditMode={setEditMode} // Pass setter if child manages mode
|
||||
originalPat={originalPat}
|
||||
currentSelectedRepos={currentSelectedRepos}
|
||||
fetchedRepos={fetchedRepos}
|
||||
newSelectedRepos={newSelectedRepos}
|
||||
isFetchingRepos={isFetchingRepos}
|
||||
patForm={patForm}
|
||||
handleFetchRepositories={handleFetchRepositories}
|
||||
handleRepoSelectionChange={handleRepoSelectionChange}
|
||||
setNewSelectedRepos={setNewSelectedRepos}
|
||||
setFetchedRepos={setFetchedRepos}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* == Slack == */}
|
||||
{connector.connector_type === "SLACK_CONNECTOR" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="SLACK_BOT_TOKEN"
|
||||
fieldLabel="Slack Bot Token"
|
||||
fieldDescription="Update the Slack Bot Token if needed."
|
||||
placeholder="Begins with xoxb-..."
|
||||
/>
|
||||
)}
|
||||
{/* == Notion == */}
|
||||
{connector.connector_type === "NOTION_CONNECTOR" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="NOTION_INTEGRATION_TOKEN"
|
||||
fieldLabel="Notion Integration Token"
|
||||
fieldDescription="Update the Notion Integration Token if needed."
|
||||
placeholder="Begins with secret_..."
|
||||
/>
|
||||
)}
|
||||
{/* == Tavily == */}
|
||||
{connector.connector_type === "TAVILY_API" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="TAVILY_API_KEY"
|
||||
fieldLabel="Tavily API Key"
|
||||
fieldDescription="Update the Tavily API Key if needed."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* == Linear == */}
|
||||
{connector.connector_type === "LINEAR_CONNECTOR" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="LINEAR_API_KEY"
|
||||
fieldLabel="Linear API Key"
|
||||
fieldDescription="Update your Linear API Key if needed."
|
||||
placeholder="Begins with lin_api_..."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* == Jira == */}
|
||||
{connector.connector_type === "JIRA_CONNECTOR" && (
|
||||
<div className="space-y-4">
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="JIRA_BASE_URL"
|
||||
fieldLabel="Jira Base URL"
|
||||
fieldDescription="Update your Jira instance URL if needed."
|
||||
placeholder="https://yourcompany.atlassian.net"
|
||||
/>
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="JIRA_EMAIL"
|
||||
fieldLabel="Jira Email"
|
||||
fieldDescription="Update your Atlassian account email if needed."
|
||||
placeholder="your.email@company.com"
|
||||
/>
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="JIRA_API_TOKEN"
|
||||
fieldLabel="Jira API Token"
|
||||
fieldDescription="Update your Jira API Token if needed."
|
||||
placeholder="Your Jira API Token"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* == Confluence == */}
|
||||
{connector.connector_type === "CONFLUENCE_CONNECTOR" && (
|
||||
<div className="space-y-4">
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="CONFLUENCE_BASE_URL"
|
||||
fieldLabel="Confluence Base URL"
|
||||
fieldDescription="Update your Confluence instance URL if needed."
|
||||
placeholder="https://yourcompany.atlassian.net"
|
||||
/>
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="CONFLUENCE_EMAIL"
|
||||
fieldLabel="Confluence Email"
|
||||
fieldDescription="Update your Atlassian account email if needed."
|
||||
placeholder="your.email@company.com"
|
||||
/>
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="CONFLUENCE_API_TOKEN"
|
||||
fieldLabel="Confluence API Token"
|
||||
fieldDescription="Update your Confluence API Token if needed."
|
||||
placeholder="Your Confluence API Token"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* == ClickUp == */}
|
||||
{connector.connector_type === "CLICKUP_CONNECTOR" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="CLICKUP_API_TOKEN"
|
||||
fieldLabel="ClickUp API Token"
|
||||
fieldDescription="Update your ClickUp API Token if needed."
|
||||
placeholder="pk_..."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* == Linkup == */}
|
||||
{connector.connector_type === "LINKUP_API" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="LINKUP_API_KEY"
|
||||
fieldLabel="Linkup API Key"
|
||||
fieldDescription="Update your Linkup API Key if needed."
|
||||
placeholder="Begins with linkup_..."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* == Discord == */}
|
||||
{connector.connector_type === "DISCORD_CONNECTOR" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="DISCORD_BOT_TOKEN"
|
||||
fieldLabel="Discord Bot Token"
|
||||
fieldDescription="Update the Discord Bot Token if needed."
|
||||
placeholder="Bot token..."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* == Luma == */}
|
||||
{connector.connector_type === "LUMA_CONNECTOR" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="LUMA_API_KEY"
|
||||
fieldLabel="Luma API Key"
|
||||
fieldDescription="Update the Luma API Key if needed."
|
||||
placeholder="API Key..."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* == Elasticsearch == */}
|
||||
{connector.connector_type === "ELASTICSEARCH_CONNECTOR" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="ELASTICSEARCH_API_KEY"
|
||||
fieldLabel="Elasticsearch API Key"
|
||||
fieldDescription="Update your Elasticsearch API Key if needed."
|
||||
placeholder="Your Elasticsearch API Key"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* == Webcrawler == */}
|
||||
{connector.connector_type === "WEBCRAWLER_CONNECTOR" && (
|
||||
<div className="space-y-4">
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="FIRECRAWL_API_KEY"
|
||||
fieldLabel="Firecrawl API Key (Optional)"
|
||||
fieldDescription="Add a Firecrawl API key for enhanced crawling capabilities. If not provided, will use AsyncChromiumLoader as fallback."
|
||||
placeholder="fc-xxxxxxxxxxxxx"
|
||||
/>
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="INITIAL_URLS"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>URLs to Crawl</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="https://example.com https://docs.example.com https://blog.example.com"
|
||||
className="min-h-[150px] font-mono text-sm"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter URLs to crawl (one per line). These URLs will be indexed when you
|
||||
trigger indexing.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="border-t pt-6">
|
||||
<Button type="submit" disabled={isSaving} className="w-full sm:w-auto">
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,301 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { updateConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { SearchSourceConnector } from "@/hooks/use-search-source-connectors";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const apiConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().min(10, {
|
||||
message: "API key is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Helper function to get connector type display name
|
||||
const getConnectorTypeDisplay = (type: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
TAVILY_API: "Tavily API",
|
||||
SLACK_CONNECTOR: "Slack Connector",
|
||||
NOTION_CONNECTOR: "Notion Connector",
|
||||
GITHUB_CONNECTOR: "GitHub Connector",
|
||||
LINEAR_CONNECTOR: "Linear Connector",
|
||||
JIRA_CONNECTOR: "Jira Connector",
|
||||
DISCORD_CONNECTOR: "Discord Connector",
|
||||
LINKUP_API: "Linkup",
|
||||
CONFLUENCE_CONNECTOR: "Confluence Connector",
|
||||
CLICKUP_CONNECTOR: "ClickUp Connector",
|
||||
GOOGLE_CALENDAR_CONNECTOR: "Google Calendar Connector",
|
||||
GOOGLE_GMAIL_CONNECTOR: "Google Gmail Connector",
|
||||
AIRTABLE_CONNECTOR: "Airtable Connector",
|
||||
LUMA_CONNECTOR: "Luma Connector",
|
||||
ELASTICSEARCH_CONNECTOR: "Elasticsearch Connector",
|
||||
WEBCRAWLER_CONNECTOR: "Web Page Connector",
|
||||
// Add other connector types here as needed
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
||||
// Define the type for the form values
|
||||
type ApiConnectorFormValues = z.infer<typeof apiConnectorFormSchema>;
|
||||
|
||||
// Get API key field name based on connector type
|
||||
const getApiKeyFieldName = (connectorType: string): string => {
|
||||
const fieldMap: Record<string, string> = {
|
||||
TAVILY_API: "TAVILY_API_KEY",
|
||||
SLACK_CONNECTOR: "SLACK_BOT_TOKEN",
|
||||
NOTION_CONNECTOR: "NOTION_INTEGRATION_TOKEN",
|
||||
GITHUB_CONNECTOR: "GITHUB_PAT",
|
||||
DISCORD_CONNECTOR: "DISCORD_BOT_TOKEN",
|
||||
LINKUP_API: "LINKUP_API_KEY",
|
||||
LUMA_CONNECTOR: "LUMA_API_KEY",
|
||||
ELASTICSEARCH_CONNECTOR: "ELASTICSEARCH_API_KEY",
|
||||
WEBCRAWLER_CONNECTOR: "FIRECRAWL_API_KEY",
|
||||
};
|
||||
return fieldMap[connectorType] || "";
|
||||
};
|
||||
|
||||
export default function EditConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const connectorId = parseInt(params.connector_id as string, 10);
|
||||
|
||||
const { data: connectors = [] } = useAtomValue(connectorsAtom);
|
||||
const { mutateAsync: updateConnector } = useAtomValue(updateConnectorMutationAtom);
|
||||
const [connector, setConnector] = useState<SearchSourceConnector | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
// console.log("connector", connector);
|
||||
// Initialize the form
|
||||
const form = useForm<ApiConnectorFormValues>({
|
||||
resolver: zodResolver(apiConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const currentConnector = connectors.find((c) => c.id === connectorId);
|
||||
|
||||
if (currentConnector) {
|
||||
setConnector(currentConnector);
|
||||
|
||||
const apiKeyField = getApiKeyFieldName(currentConnector.connector_type);
|
||||
if (apiKeyField) {
|
||||
form.reset({
|
||||
name: currentConnector.name,
|
||||
api_key: currentConnector.config[apiKeyField] || "",
|
||||
});
|
||||
} else {
|
||||
toast.error("This connector type is not supported for editing");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
} else if (!isLoading && connectors.length > 0) {
|
||||
toast.error("Connector not found");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
}
|
||||
}, [connectors, connectorId, form, router, searchSpaceId, isLoading]);
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: ApiConnectorFormValues) => {
|
||||
if (!connector) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const apiKeyField = getApiKeyFieldName(connector.connector_type);
|
||||
|
||||
const updatedConfig = { ...connector.config };
|
||||
if (values.api_key) {
|
||||
updatedConfig[apiKeyField] = values.api_key;
|
||||
}
|
||||
|
||||
await updateConnector({
|
||||
id: connectorId,
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: connector.connector_type as EnumConnectorName,
|
||||
config: updatedConfig,
|
||||
is_indexable: connector.is_indexable,
|
||||
last_indexed_at: connector.last_indexed_at,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Connector updated successfully!");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error updating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to update connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl flex justify-center items-center min-h-[60vh]">
|
||||
<div className="animate-pulse text-center">
|
||||
<div className="h-8 w-48 bg-muted rounded mx-auto mb-4"></div>
|
||||
<div className="h-4 w-64 bg-muted rounded mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">
|
||||
Edit {connector ? getConnectorTypeDisplay(connector.connector_type) : ""} Connector
|
||||
</CardTitle>
|
||||
<CardDescription>Update your connector settings.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>API Key Security</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your API key is stored securely. For security reasons, we don't display your
|
||||
existing API key. If you don't update the API key field, your existing key will be
|
||||
preserved.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My API Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>A friendly name to identify this connector.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{connector?.connector_type === "SLACK_CONNECTOR"
|
||||
? "Slack Bot Token"
|
||||
: connector?.connector_type === "NOTION_CONNECTOR"
|
||||
? "Notion Integration Token"
|
||||
: connector?.connector_type === "GITHUB_CONNECTOR"
|
||||
? "GitHub Personal Access Token (PAT)"
|
||||
: connector?.connector_type === "LINKUP_API"
|
||||
? "Linkup API Key"
|
||||
: connector?.connector_type === "ELASTICSEARCH_CONNECTOR"
|
||||
? "Elasticsearch API Key"
|
||||
: "API Key"}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={
|
||||
connector?.connector_type === "SLACK_CONNECTOR"
|
||||
? "Enter new Slack Bot Token (optional)"
|
||||
: connector?.connector_type === "NOTION_CONNECTOR"
|
||||
? "Enter new Notion Token (optional)"
|
||||
: connector?.connector_type === "GITHUB_CONNECTOR"
|
||||
? "Enter new GitHub PAT (optional)"
|
||||
: connector?.connector_type === "LINKUP_API"
|
||||
? "Enter new Linkup API Key (optional)"
|
||||
: connector?.connector_type === "ELASTICSEARCH_CONNECTOR"
|
||||
? "Enter new Elasticsearch API Key (optional)"
|
||||
: "Enter new API key (optional)"
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{connector?.connector_type === "SLACK_CONNECTOR"
|
||||
? "Enter a new Slack Bot Token or leave blank to keep your existing token."
|
||||
: connector?.connector_type === "NOTION_CONNECTOR"
|
||||
? "Enter a new Notion Integration Token or leave blank to keep your existing token."
|
||||
: connector?.connector_type === "GITHUB_CONNECTOR"
|
||||
? "Enter a new GitHub PAT or leave blank to keep your existing token."
|
||||
: connector?.connector_type === "LINKUP_API"
|
||||
? "Enter a new Linkup API Key or leave blank to keep your existing key."
|
||||
: connector?.connector_type === "ELASTICSEARCH_CONNECTOR"
|
||||
? "Enter a new Elasticsearch API Key or leave blank to keep your existing key."
|
||||
: "Enter a new API key or leave blank to keep your existing key."}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Updating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Update Connector
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, ExternalLink, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
// import { IconBrandAirtable } from "@tabler/icons-react";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
export default function AirtableConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [doesConnectorExist, setDoesConnectorExist] = useState(false);
|
||||
|
||||
const { refetch: fetchConnectors } = useAtomValue(connectorsAtom);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnectors().then((data) => {
|
||||
const connectors = data.data || [];
|
||||
const connector = connectors.find(
|
||||
(c: SearchSourceConnector) => c.connector_type === EnumConnectorName.AIRTABLE_CONNECTOR
|
||||
);
|
||||
if (connector) {
|
||||
setDoesConnectorExist(true);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleConnectAirtable = async () => {
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/auth/airtable/connector/add/?space_id=${searchSpaceId}`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to initiate Airtable OAuth");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Redirect to Airtable for authentication
|
||||
window.location.href = data.auth_url;
|
||||
} catch (error) {
|
||||
console.error("Error connecting to Airtable:", error);
|
||||
toast.error("Failed to connect to Airtable");
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-2xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/connectors/add`}
|
||||
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to connectors
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
{getConnectorIcon(EnumConnectorName.AIRTABLE_CONNECTOR, "h-6 w-6")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connect Airtable</h1>
|
||||
<p className="text-muted-foreground">Connect your Airtable to search records.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OAuth Connection Card */}
|
||||
{!doesConnectorExist ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Connect Your Airtable Account</CardTitle>
|
||||
<CardDescription>
|
||||
Connect your Airtable account to access your records. We'll only request read-only
|
||||
access to your records.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>Read-only access to your records</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>Access works even when you're offline</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>You can disconnect anytime</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConnectAirtable} disabled={isConnecting}>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Connect Your Airtable Account
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
) : (
|
||||
/* Configuration Form Card */
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>✅ Your Airtable is successfully connected!</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Help Section */}
|
||||
{!doesConnectorExist && (
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">How It Works</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">1. Connect Your Account</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click "Connect Your Airtable Account" to start the secure OAuth process. You'll be
|
||||
redirected to Airtable to sign in.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">2. Grant Permissions</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Airtable will ask for permission to read your records. We only request read-only
|
||||
access to keep your data safe.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,323 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const baiduSearchApiFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().min(10, {
|
||||
message: "API key is required and must be valid.",
|
||||
}),
|
||||
model: z.string().optional(),
|
||||
search_source: z.enum(["baidu_search_v1", "baidu_search_v2"]).optional(),
|
||||
enable_deep_search: z.boolean().default(false),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type BaiduSearchApiFormValues = z.infer<typeof baiduSearchApiFormSchema>;
|
||||
|
||||
export default function BaiduSearchApiPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<BaiduSearchApiFormValues>({
|
||||
resolver: zodResolver(baiduSearchApiFormSchema),
|
||||
defaultValues: {
|
||||
name: "Baidu Search Connector",
|
||||
api_key: "",
|
||||
model: "ernie-3.5-8k",
|
||||
search_source: "baidu_search_v2",
|
||||
enable_deep_search: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: BaiduSearchApiFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Build config object
|
||||
const config: Record<string, unknown> = {
|
||||
BAIDU_API_KEY: values.api_key,
|
||||
};
|
||||
|
||||
// Add optional parameters if provided
|
||||
if (values.model) {
|
||||
config.BAIDU_MODEL = values.model;
|
||||
}
|
||||
if (values.search_source) {
|
||||
config.BAIDU_SEARCH_SOURCE = values.search_source;
|
||||
}
|
||||
if (values.enable_deep_search !== undefined) {
|
||||
config.BAIDU_ENABLE_DEEP_SEARCH = values.enable_deep_search;
|
||||
}
|
||||
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.BAIDU_SEARCH_API,
|
||||
config,
|
||||
is_indexable: false,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Baidu Search connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
{getConnectorIcon(EnumConnectorName.BAIDU_SEARCH_API, "h-6 w-6")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connect Baidu Search</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Connect Baidu AI Search for intelligent Chinese web search capabilities.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Baidu Search</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Baidu AI Search to enhance your search capabilities with intelligent
|
||||
Chinese web search results.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>API Key Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Baidu AppBuilder API key to use this connector. You can get one by
|
||||
signing up at{" "}
|
||||
<a
|
||||
href="https://qianfan.cloud.baidu.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
qianfan.cloud.baidu.com
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Baidu Search Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>A friendly name to identify this connector.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Baidu AppBuilder API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="Enter your Baidu API key" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your API key will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="model"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Model (Optional)</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="ernie-3.5-8k">ERNIE 3.5 8K</SelectItem>
|
||||
<SelectItem value="ernie-4.5-turbo-32k">ERNIE 4.5 Turbo 32K</SelectItem>
|
||||
<SelectItem value="ernie-4.5-turbo-128k">ERNIE 4.5 Turbo 128K</SelectItem>
|
||||
<SelectItem value="deepseek-v3">DeepSeek V3</SelectItem>
|
||||
<SelectItem value="qwen3-235b-a22b-instruct-2507">Qwen3 235B</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
The language model used for search summarization. Default: ERNIE 3.5 8K.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="search_source"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Search Source (Optional)</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select search source" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="baidu_search_v1">Baidu Search V1</SelectItem>
|
||||
<SelectItem value="baidu_search_v2">
|
||||
Baidu Search V2 (Recommended)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
V2 provides better performance and richer content. Default: V2.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enable_deep_search"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Enable Deep Search</FormLabel>
|
||||
<FormDescription>
|
||||
Deep search retrieves up to 100 results per type (may incur additional
|
||||
costs).
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Baidu Search
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Baidu Search:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Intelligent search tailored for Chinese web content</li>
|
||||
<li>Real-time information from Baidu's search index</li>
|
||||
<li>AI-powered summarization with source references</li>
|
||||
<li>Support for web, video, and image search results</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,306 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const bookstackConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
base_url: z.string().url({
|
||||
message: "Please enter a valid BookStack URL (e.g., https://docs.example.com)",
|
||||
}),
|
||||
token_id: z.string().min(10, {
|
||||
message: "BookStack Token ID is required.",
|
||||
}),
|
||||
token_secret: z.string().min(10, {
|
||||
message: "BookStack Token Secret is required.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type BookStackConnectorFormValues = z.infer<typeof bookstackConnectorFormSchema>;
|
||||
|
||||
export default function BookStackConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<BookStackConnectorFormValues>({
|
||||
resolver: zodResolver(bookstackConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "BookStack Connector",
|
||||
base_url: "",
|
||||
token_id: "",
|
||||
token_secret: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: BookStackConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.BOOKSTACK_CONNECTOR,
|
||||
config: {
|
||||
BOOKSTACK_BASE_URL: values.base_url,
|
||||
BOOKSTACK_TOKEN_ID: values.token_id,
|
||||
BOOKSTACK_TOKEN_SECRET: values.token_secret,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("BookStack connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
{getConnectorIcon(EnumConnectorName.BOOKSTACK_CONNECTOR, "h-6 w-6")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connect BookStack</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Connect your BookStack instance to search wiki pages.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="connect">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Connect to BookStack</CardTitle>
|
||||
<CardDescription>
|
||||
Connect your BookStack instance to index pages from your wiki.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
You'll need to create an API token from your BookStack instance. Go to{" "}
|
||||
<strong>Edit Profile → API Tokens → Create Token</strong>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My BookStack Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="base_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>BookStack Instance URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://docs.example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your BookStack instance URL (e.g., https://wiki.yourcompany.com)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="token_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Token ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Your BookStack Token ID" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The Token ID from your BookStack API token.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="token_secret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Token Secret</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Your BookStack Token Secret"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Token Secret will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect BookStack
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documentation">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>BookStack Integration Guide</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to set up and use the BookStack connector.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">What gets indexed?</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-sm text-muted-foreground">
|
||||
<li>All pages from your BookStack instance</li>
|
||||
<li>Page content in Markdown format</li>
|
||||
<li>Page titles and metadata</li>
|
||||
<li>Book and chapter hierarchy information</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Setup Instructions</h3>
|
||||
<ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground">
|
||||
<li>Log in to your BookStack instance</li>
|
||||
<li>Click on your profile icon → Edit Profile</li>
|
||||
<li>Navigate to the "API Tokens" tab</li>
|
||||
<li>Click "Create Token" and give it a name</li>
|
||||
<li>Copy both the Token ID and Token Secret</li>
|
||||
<li>Paste them in the form above</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Permissions Required</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-sm text-muted-foreground">
|
||||
<li>Your user account must have "Access System API" permission</li>
|
||||
<li>Read access to books and pages you want to index</li>
|
||||
<li>The connector will only index content your account can view</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
BookStack API has a rate limit of 180 requests per minute. The connector
|
||||
automatically handles rate limiting to ensure reliable indexing.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,363 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Copy, ExternalLink, Loader2, Webhook } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const circlebackConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type CirclebackConnectorFormValues = z.infer<typeof circlebackConnectorFormSchema>;
|
||||
|
||||
export default function CirclebackConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [doesConnectorExist, setDoesConnectorExist] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const { data: connectors } = useAtomValue(connectorsAtom);
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Construct the webhook URL
|
||||
const apiBaseUrl = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8000";
|
||||
const webhookUrl = `${apiBaseUrl}/api/v1/webhooks/circleback/${searchSpaceId}`;
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<CirclebackConnectorFormValues>({
|
||||
resolver: zodResolver(circlebackConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Circleback Meetings",
|
||||
},
|
||||
});
|
||||
|
||||
const { refetch: fetchConnectors } = useAtomValue(connectorsAtom);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnectors().then((data) => {
|
||||
const connectors = data.data || [];
|
||||
const connector = connectors.find(
|
||||
(c: SearchSourceConnector) => c.connector_type === EnumConnectorName.CIRCLEBACK_CONNECTOR
|
||||
);
|
||||
if (connector) {
|
||||
setDoesConnectorExist(true);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Copy webhook URL to clipboard
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(webhookUrl);
|
||||
setCopied(true);
|
||||
toast.success("Webhook URL copied to clipboard!");
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error("Failed to copy to clipboard");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: CirclebackConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.CIRCLEBACK_CONNECTOR,
|
||||
config: {
|
||||
webhook_url: webhookUrl,
|
||||
},
|
||||
is_indexable: false, // Webhooks push data, not indexed
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Circleback connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-2xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/connectors/add`}
|
||||
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to connectors
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
{getConnectorIcon(EnumConnectorName.CIRCLEBACK_CONNECTOR, "h-6 w-6")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connect Circleback</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Receive meeting notes and transcripts via webhook.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Card */}
|
||||
{!doesConnectorExist ? (
|
||||
<>
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Webhook Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
Use this webhook URL in your Circleback automation to send meeting data to
|
||||
SurfSense.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Webhook URL</label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={webhookUrl} readOnly className="font-mono text-sm" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={copyToClipboard}
|
||||
className="shrink-0"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Copy this URL and paste it in your Circleback automation settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<Webhook className="h-4 w-4" />
|
||||
<AlertTitle>How it works</AlertTitle>
|
||||
<AlertDescription>
|
||||
When you configure this webhook in Circleback, it will automatically send
|
||||
meeting notes, transcripts, and action items to SurfSense after each meeting.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create Connector</CardTitle>
|
||||
<CardDescription>
|
||||
Register the Circleback connector to track incoming meeting data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Circleback Meetings" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>Automatic meeting notes import</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>Full transcripts with speaker identification</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>Action items and insights extraction</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Webhook className="mr-2 h-4 w-4" />
|
||||
Create Connector
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
/* Success Card */
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>✅ Circleback connector is active!</CardTitle>
|
||||
<CardDescription>
|
||||
Your Circleback meetings will be automatically imported to this search space.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Webhook URL</label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={webhookUrl} readOnly className="font-mono text-sm" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={copyToClipboard}
|
||||
className="shrink-0"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Help Section */}
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Setup Instructions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">1. Copy the Webhook URL</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Copy the webhook URL shown above. You'll need this for the next step.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">2. Open Circleback Automations</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Go to{" "}
|
||||
<a
|
||||
href="https://app.circleback.ai/automations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Circleback Automations
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>{" "}
|
||||
and click "Create automation".
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">3. Configure the Webhook</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Set your automation conditions, then select "Send webhook request" and paste the
|
||||
webhook URL.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">4. Select Meeting Outcomes</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose which meeting data to include: notes, transcript, action items, and
|
||||
insights.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">5. Create & Test</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Give your automation a name and create it. You can send a test request to verify
|
||||
the integration works.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,241 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, ExternalLink, Eye, EyeOff } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const clickupConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_token: z.string().min(10, {
|
||||
message: "ClickUp API Token is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type ClickUpConnectorFormValues = z.infer<typeof clickupConnectorFormSchema>;
|
||||
|
||||
export default function ClickUpConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showApiToken, setShowApiToken] = useState(false);
|
||||
|
||||
// Initialize the form with react-hook-form and zod validation
|
||||
const form = useForm<ClickUpConnectorFormValues>({
|
||||
resolver: zodResolver(clickupConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "ClickUp Connector",
|
||||
api_token: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
async function onSubmit(values: ClickUpConnectorFormValues) {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.CLICKUP_CONNECTOR,
|
||||
is_indexable: true,
|
||||
config: {
|
||||
CLICKUP_API_TOKEN: values.api_token,
|
||||
},
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("ClickUp connector created successfully!");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating ClickUp connector:", error);
|
||||
toast.error("Failed to create ClickUp connector. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6 max-w-2xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
{getConnectorIcon(EnumConnectorName.CLICKUP_CONNECTOR, "h-6 w-6")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connect ClickUp</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Connect your ClickUp workspace to search tasks and projects.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ClickUp Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your ClickUp API token to connect your workspace. You can generate a personal API
|
||||
token from your ClickUp settings.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="ClickUp Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this ClickUp connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>ClickUp API Token</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showApiToken ? "text" : "password"}
|
||||
placeholder="pk_..."
|
||||
{...field}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowApiToken(!showApiToken)}
|
||||
>
|
||||
{showApiToken ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your ClickUp personal API token. You can generate one in your{" "}
|
||||
<Link
|
||||
href="https://app.clickup.com/settings/apps"
|
||||
target="_blank"
|
||||
className="text-primary hover:underline inline-flex items-center"
|
||||
>
|
||||
ClickUp settings
|
||||
<ExternalLink className="ml-1 h-3 w-3" />
|
||||
</Link>
|
||||
.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? "Creating..." : "Create Connector"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">How to get your ClickUp API Token</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">1. Log in to your ClickUp account</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
2. Click your avatar in the upper-right corner and select "Settings"
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">3. In the sidebar, click "Apps"</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
4. Under "API Token", click "Generate" or "Regenerate"
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
5. Copy the generated token and paste it above
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Link
|
||||
href="https://app.clickup.com/settings/apps"
|
||||
target="_blank"
|
||||
className="inline-flex items-center text-sm text-primary hover:underline"
|
||||
>
|
||||
Go to ClickUp API Settings
|
||||
<ExternalLink className="ml-1 h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,322 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const confluenceConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
base_url: z
|
||||
.string()
|
||||
.url({
|
||||
message: "Please enter a valid Confluence URL (e.g., https://yourcompany.atlassian.net)",
|
||||
})
|
||||
.refine(
|
||||
(url) => {
|
||||
return url.includes("atlassian.net") || url.includes("confluence");
|
||||
},
|
||||
{
|
||||
message: "Please enter a valid Confluence instance URL",
|
||||
}
|
||||
),
|
||||
email: z.string().email({
|
||||
message: "Please enter a valid email address.",
|
||||
}),
|
||||
api_token: z.string().min(10, {
|
||||
message: "Confluence API Token is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type ConfluenceConnectorFormValues = z.infer<typeof confluenceConnectorFormSchema>;
|
||||
|
||||
export default function ConfluenceConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<ConfluenceConnectorFormValues>({
|
||||
resolver: zodResolver(confluenceConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Confluence Connector",
|
||||
base_url: "",
|
||||
email: "",
|
||||
api_token: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: ConfluenceConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.CONFLUENCE_CONNECTOR,
|
||||
config: {
|
||||
CONFLUENCE_BASE_URL: values.base_url,
|
||||
CONFLUENCE_EMAIL: values.email,
|
||||
CONFLUENCE_API_TOKEN: values.api_token,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Confluence connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
{getConnectorIcon(EnumConnectorName.CONFLUENCE_CONNECTOR, "h-6 w-6")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connect Confluence</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Connect your Confluence instance to search pages and spaces.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="connect">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Connect to Confluence</CardTitle>
|
||||
<CardDescription>
|
||||
Connect your Confluence instance to index pages and comments from your spaces.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
You'll need to create an API token from your{" "}
|
||||
<a
|
||||
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Atlassian Account Settings
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Confluence Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="base_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confluence Instance URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://yourcompany.atlassian.net" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Confluence instance URL. For Atlassian Cloud, this is typically
|
||||
https://yourcompany.atlassian.net
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" placeholder="your.email@company.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Your Atlassian account email address.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Your Confluence API Token"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Confluence API Token will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Confluence
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documentation">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Confluence Integration Guide</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to set up and use the Confluence connector.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">What gets indexed?</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-sm text-muted-foreground">
|
||||
<li>All pages from accessible spaces</li>
|
||||
<li>Page content and metadata</li>
|
||||
<li>Comments on pages (both footer and inline comments)</li>
|
||||
<li>Page titles and descriptions</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Setup Instructions</h3>
|
||||
<ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground">
|
||||
<li>Go to your Atlassian Account Settings</li>
|
||||
<li>Navigate to Security → API tokens</li>
|
||||
<li>Create a new API token with appropriate permissions</li>
|
||||
<li>Copy the token and paste it in the form above</li>
|
||||
<li>Ensure your account has read access to the spaces you want to index</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Permissions Required</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-sm text-muted-foreground">
|
||||
<li>Read access to Confluence spaces</li>
|
||||
<li>View pages and comments</li>
|
||||
<li>Access to space metadata</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
The connector will only index content that your account has permission to view.
|
||||
Make sure your API token has the necessary permissions for the spaces you want
|
||||
to index.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,371 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const discordConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
bot_token: z
|
||||
.string()
|
||||
.min(50, { message: "Discord Bot Token appears to be too short." })
|
||||
.regex(/^[A-Za-z0-9._-]+$/, { message: "Discord Bot Token contains invalid characters." }),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type DiscordConnectorFormValues = z.infer<typeof discordConnectorFormSchema>;
|
||||
|
||||
export default function DiscordConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<DiscordConnectorFormValues>({
|
||||
resolver: zodResolver(discordConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Discord Connector",
|
||||
bot_token: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: DiscordConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.DISCORD_CONNECTOR,
|
||||
config: {
|
||||
DISCORD_BOT_TOKEN: values.bot_token,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Discord connector created successfully!");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg ">
|
||||
{getConnectorIcon(EnumConnectorName.DISCORD_CONNECTOR, "h-6 w-6")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connect Discord</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Connect your Discord server to search messages and channels.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="connect">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Discord Server</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Discord to search and retrieve information from your servers and
|
||||
channels. This connector can index your Discord messages for search.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Bot Token Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Discord Bot Token to use this connector. You can create a Discord
|
||||
bot and get the token from the{" "}
|
||||
<a
|
||||
href="https://discord.com/developers/applications"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Discord Developer Portal
|
||||
</a>
|
||||
.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Discord Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bot_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Discord Bot Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="Bot Token..." {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Discord Bot Token will be encrypted and stored securely. You can
|
||||
find it in the Bot section of your application in the Discord Developer
|
||||
Portal.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Discord
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Discord integration:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Search through your Discord servers and channels</li>
|
||||
<li>Access historical messages and shared files</li>
|
||||
<li>Connect your team's knowledge directly to your search space</li>
|
||||
<li>Keep your search results up-to-date with latest communications</li>
|
||||
<li>Index your Discord messages for enhanced search capabilities</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documentation">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">
|
||||
Discord Connector Documentation
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to set up and use the Discord connector to index your server data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The Discord connector indexes all accessible channels for a given bot in your
|
||||
servers.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||
<li>Upcoming: Support for private channels by granting the bot access.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="authorization">
|
||||
<AccordionTrigger className="text-lg font-medium">
|
||||
Authorization
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Bot Setup Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You must create a Discord bot and add it to your server with the correct
|
||||
permissions.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>
|
||||
Go to{" "}
|
||||
<a
|
||||
href="https://discord.com/developers/applications"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
https://discord.com/developers/applications
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>Create a new application and add a bot to it.</li>
|
||||
<li>Copy the Bot Token from the Bot section.</li>
|
||||
<li>
|
||||
Invite the bot to your server with the following OAuth2 scopes and
|
||||
permissions:
|
||||
<ul className="list-disc pl-5 mt-1">
|
||||
<li>
|
||||
Scopes: <code>bot</code>
|
||||
</li>
|
||||
<li>
|
||||
Bot Permissions: <code>Read Messages/View Channels</code>,{" "}
|
||||
<code>Read Message History</code>, <code>Send Messages</code>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Paste the Bot Token above to connect.</li>
|
||||
</ol>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="indexing">
|
||||
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>
|
||||
Navigate to the Connector Dashboard and select the{" "}
|
||||
<strong>Discord</strong> Connector.
|
||||
</li>
|
||||
<li>
|
||||
Place the <strong>Bot Token</strong> under{" "}
|
||||
<strong>Step 1 Provide Credentials</strong>.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Connect</strong> to establish the connection.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Important: Bot Channel Access</AlertTitle>
|
||||
<AlertDescription>
|
||||
After connecting, ensure the bot has access to all channels you want to
|
||||
index. You may need to adjust channel permissions in Discord.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Alert className="bg-muted mt-4">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>First Indexing</AlertTitle>
|
||||
<AlertDescription>
|
||||
The first indexing pulls all accessible channels and may take longer than
|
||||
future updates. Only channels where the bot has access will be indexed.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="mt-4">
|
||||
<h4 className="font-medium mb-2">Troubleshooting:</h4>
|
||||
<ul className="list-disc pl-5 space-y-2 text-muted-foreground">
|
||||
<li>
|
||||
<strong>Missing messages:</strong> If you don't see messages from a
|
||||
channel, check the bot's permissions for that channel.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Bot not responding:</strong> Make sure the bot is online and the
|
||||
token is correct.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Private channels:</strong> The bot must be explicitly granted
|
||||
access to private channels.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,755 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useId, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const elasticsearchConnectorFormSchema = z
|
||||
.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
endpoint_url: z.string().url({ message: "Please enter a valid Elasticsearch endpoint URL." }),
|
||||
auth_method: z.enum(["basic", "api_key"]).default("api_key"),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
ELASTICSEARCH_API_KEY: z.string().optional(),
|
||||
indices: z.string().optional(),
|
||||
query: z.string().default("*"),
|
||||
search_fields: z.string().optional(),
|
||||
max_documents: z.number().min(1).max(10000).optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.auth_method === "basic") {
|
||||
return Boolean(data.username?.trim() && data.password?.trim());
|
||||
}
|
||||
if (data.auth_method === "api_key") {
|
||||
return Boolean(data.ELASTICSEARCH_API_KEY?.trim());
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Authentication credentials are required for the selected method.",
|
||||
path: ["auth_method"],
|
||||
}
|
||||
);
|
||||
|
||||
// Define the type for the form values
|
||||
type ElasticsearchConnectorFormValues = z.infer<typeof elasticsearchConnectorFormSchema>;
|
||||
|
||||
export default function ElasticsearchConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
// match pattern used in other connector pages: prefer route param, fallback to query param
|
||||
const searchSpaceId = (params.search_space_id ?? searchParams?.get("search_space_id")) as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const authBasicId = useId();
|
||||
const authApiKeyId = useId();
|
||||
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<ElasticsearchConnectorFormValues>({
|
||||
resolver: zodResolver(elasticsearchConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Elasticsearch Connector",
|
||||
endpoint_url: "",
|
||||
auth_method: "api_key",
|
||||
username: "",
|
||||
password: "",
|
||||
ELASTICSEARCH_API_KEY: "",
|
||||
indices: "",
|
||||
query: "*",
|
||||
search_fields: "",
|
||||
max_documents: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const stringToArray = (str: string): string[] => {
|
||||
const items = str
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
return Array.from(new Set(items));
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: ElasticsearchConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
if (!searchSpaceId) {
|
||||
toast.error(
|
||||
"Missing search_space_id (route or ?search_space_id=). Provide it in the URL or pick a search space."
|
||||
);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
const searchSpaceIdNum = Number(searchSpaceId);
|
||||
if (!Number.isInteger(searchSpaceIdNum) || searchSpaceIdNum <= 0) {
|
||||
toast.error("Invalid search_space_id. It must be a positive integer.");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Send full URL to backend (backend expects ELASTICSEARCH_URL)
|
||||
const config: Record<string, string | number | boolean | string[]> = {
|
||||
ELASTICSEARCH_URL: values.endpoint_url,
|
||||
// default to verifying certs; expose fields for CA/verify if UI added later
|
||||
ELASTICSEARCH_VERIFY_CERTS: true,
|
||||
};
|
||||
|
||||
if (values.auth_method === "basic") {
|
||||
if (values.username) config.ELASTICSEARCH_USERNAME = values.username;
|
||||
if (values.password) config.ELASTICSEARCH_PASSWORD = values.password;
|
||||
} else if (values.auth_method === "api_key") {
|
||||
if (values.ELASTICSEARCH_API_KEY)
|
||||
config.ELASTICSEARCH_API_KEY = values.ELASTICSEARCH_API_KEY;
|
||||
}
|
||||
|
||||
const indicesInput = values.indices?.trim() ?? "";
|
||||
const indicesArr = stringToArray(indicesInput);
|
||||
config.ELASTICSEARCH_INDEX =
|
||||
indicesArr.length === 0 ? "*" : indicesArr.length === 1 ? indicesArr[0] : indicesArr;
|
||||
|
||||
if (values.query && values.query !== "*") {
|
||||
config.ELASTICSEARCH_QUERY = values.query;
|
||||
}
|
||||
|
||||
if (values.search_fields?.trim()) {
|
||||
// config.ELASTICSEARCH_FIELDS = stringToArray(values.search_fields);
|
||||
const fields = stringToArray(values.search_fields);
|
||||
config.ELASTICSEARCH_FIELDS = fields;
|
||||
config.ELASTICSEARCH_CONTENT_FIELDS = fields;
|
||||
if (fields.includes("title")) {
|
||||
config.ELASTICSEARCH_TITLE_FIELD = "title";
|
||||
}
|
||||
}
|
||||
|
||||
if (values.max_documents !== undefined && values.max_documents > 0) {
|
||||
config.ELASTICSEARCH_MAX_DOCUMENTS = values.max_documents;
|
||||
}
|
||||
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.ELASTICSEARCH_CONNECTOR,
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
config,
|
||||
},
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Elasticsearch connector created successfully!");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
{getConnectorIcon(EnumConnectorName.ELASTICSEARCH_CONNECTOR, "h-6 w-6")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connect Elasticsearch</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Connect to your Elasticsearch cluster to search and index documents.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="connect">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Elasticsearch Cluster</CardTitle>
|
||||
<CardDescription>
|
||||
Connect to your Elasticsearch instance to search and index documents for enhanced
|
||||
search capabilities.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Connector Name */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Elasticsearch Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Connection Details */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Connection Details</h3>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="endpoint_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Elasticsearch Endpoint URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="url"
|
||||
autoComplete="off"
|
||||
placeholder="https://your-cluster.es.region.aws.com:443"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter the complete Elasticsearch endpoint URL. We'll automatically
|
||||
extract the hostname, port, and SSL settings.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Show parsed URL details */}
|
||||
{form.watch("endpoint_url") && (
|
||||
<div className="rounded-lg border bg-muted/50 p-3">
|
||||
<h4 className="text-sm font-medium mb-2">Parsed Connection Details:</h4>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
{(() => {
|
||||
try {
|
||||
const url = new URL(form.watch("endpoint_url"));
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<strong>Hostname:</strong> {url.hostname}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Port:</strong>{" "}
|
||||
{url.port || (url.protocol === "https:" ? "443" : "80")}
|
||||
</div>
|
||||
<div>
|
||||
<strong>SSL/TLS:</strong>{" "}
|
||||
{url.protocol === "https:" ? "Enabled" : "Disabled"}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
} catch {
|
||||
return <div className="text-destructive">Invalid URL format</div>;
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Authentication */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Authentication</h3>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="auth_method"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<FormControl>
|
||||
<RadioGroup.Root
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
// Clear auth fields when method changes
|
||||
if (value !== "basic") {
|
||||
form.setValue("username", "");
|
||||
form.setValue("password", "");
|
||||
}
|
||||
if (value !== "api_key") {
|
||||
form.setValue("ELASTICSEARCH_API_KEY", "");
|
||||
}
|
||||
}}
|
||||
value={field.value}
|
||||
className="flex flex-col space-y-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroup.Item
|
||||
value="api_key"
|
||||
id={authApiKeyId}
|
||||
className="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 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
|
||||
>
|
||||
<RadioGroup.Indicator className="flex items-center justify-center">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-current" />
|
||||
</RadioGroup.Indicator>
|
||||
</RadioGroup.Item>
|
||||
<Label htmlFor={authApiKeyId}>API Key</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroup.Item
|
||||
value="basic"
|
||||
id={authBasicId}
|
||||
className="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 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
|
||||
>
|
||||
<RadioGroup.Indicator className="flex items-center justify-center">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-current" />
|
||||
</RadioGroup.Indicator>
|
||||
</RadioGroup.Item>
|
||||
<Label htmlFor={authBasicId}>Username & Password</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Basic Auth Fields */}
|
||||
{form.watch("auth_method") === "basic" && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="elastic" autoComplete="username" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
autoComplete="current-password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key Field */}
|
||||
{form.watch("auth_method") === "api_key" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ELASTICSEARCH_API_KEY"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Your API Key Here"
|
||||
autoComplete="off"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter your Elasticsearch API key (base64 encoded). This will be
|
||||
stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Index Selection */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="indices"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Index Selection </FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="logs-*, documents-*, app-logs" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Comma-separated indices to search (e.g., "logs-*, documents-*").
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Show parsed indices as badges */}
|
||||
{form.watch("indices")?.trim() && (
|
||||
<div className="rounded-lg border bg-muted/50 p-3">
|
||||
<h4 className="text-sm font-medium mb-2">Selected Indices:</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stringToArray(form.watch("indices") ?? "").map((index) => (
|
||||
<Badge key={index} variant="secondary" className="text-xs">
|
||||
{index}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Index Selection Tips</AlertTitle>
|
||||
<AlertDescription className="mt-2">
|
||||
<ul className="list-disc pl-4 space-y-1 text-sm">
|
||||
<li>Use wildcards like "logs-*" to match multiple indices</li>
|
||||
<li>Separate multiple indices with commas</li>
|
||||
<li>
|
||||
Leave empty to search all accessible indices including internal ones
|
||||
</li>
|
||||
<li>Choosing specific indices improves search performance</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
{/* Advanced Configuration */}
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="advanced">
|
||||
<AccordionTrigger>Advanced Configuration</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
{/* Default Search Query */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="query"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Default Search Query{" "}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="*" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Default Elasticsearch query to use for searches. Use "*" to match
|
||||
all documents.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Form Fields */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="search_fields"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Search Fields{" "}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="title, content, description" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Comma-separated list of specific fields to search in (e.g.,
|
||||
"title, content, description"). Leave empty to search all fields.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Show parsed search fields as badges */}
|
||||
{form.watch("search_fields")?.trim() && (
|
||||
<div className="rounded-lg border bg-muted/50 p-3">
|
||||
<h4 className="text-sm font-medium mb-2">Search Fields:</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stringToArray(form.watch("search_fields") ?? "").map((field) => (
|
||||
<Badge key={field} variant="outline" className="text-xs">
|
||||
{field}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="max_documents"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Maximum Documents{" "}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="1000"
|
||||
min="1"
|
||||
max="10000"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: parseInt(e.target.value, 10)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum number of documents to retrieve per search (1-10,000).
|
||||
Leave empty to use Elasticsearch's default limit.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Elasticsearch
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">
|
||||
What you get with Elasticsearch integration:
|
||||
</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Search across your indexed documents and logs</li>
|
||||
<li>Access structured and unstructured data from your cluster</li>
|
||||
<li>Leverage existing Elasticsearch indices for enhanced search</li>
|
||||
<li>Real-time search capabilities with powerful query features</li>
|
||||
<li>Integration with your existing Elasticsearch infrastructure</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documentation">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">
|
||||
Elasticsearch Connector Documentation
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to set up and use the Elasticsearch connector to search your data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The Elasticsearch connector allows you to search and retrieve documents from
|
||||
your Elasticsearch cluster. Configure connection details, select specific
|
||||
indices, and set search parameters to make your existing data searchable within
|
||||
SurfSense.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="connection">
|
||||
<AccordionTrigger className="text-lg font-medium">
|
||||
Connection Setup
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>
|
||||
<strong>Endpoint URL:</strong> Enter the complete Elasticsearch endpoint
|
||||
URL (e.g., https://your-cluster.es.region.aws.com:443). We'll
|
||||
automatically extract hostname, port, and SSL settings.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Authentication:</strong> Choose the appropriate method:
|
||||
<ul className="list-disc pl-5 mt-1">
|
||||
<li>
|
||||
<strong>API Key:</strong> Base64 encoded API key (recommended for
|
||||
security)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Username/Password:</strong> Basic authentication credentials
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Index Selection:</strong> Specify which indices to search using
|
||||
comma-separated patterns (e.g., "logs-*, documents-*")
|
||||
</li>
|
||||
</ol>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="advanced">
|
||||
<AccordionTrigger className="text-lg font-medium">
|
||||
Advanced Configuration
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
Fine-tune your Elasticsearch connector with these optional settings:
|
||||
</p>
|
||||
<ul className="list-disc pl-5 space-y-2">
|
||||
<li>
|
||||
<strong>Search Fields:</strong> Limit searches to specific fields (e.g.,
|
||||
"title, content") for better relevance
|
||||
</li>
|
||||
<li>
|
||||
<strong>Default Query:</strong> Set a default Elasticsearch query pattern
|
||||
</li>
|
||||
<li>
|
||||
<strong>Max Documents:</strong> Limit the number of documents returned per
|
||||
search (1-10,000)
|
||||
</li>
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="troubleshooting">
|
||||
<AccordionTrigger className="text-lg font-medium">
|
||||
Troubleshooting
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Common Connection Issues:</h4>
|
||||
<ul className="list-disc pl-5 space-y-2 text-muted-foreground">
|
||||
<li>
|
||||
<strong>Connection Refused:</strong> Check hostname and port. Ensure
|
||||
Elasticsearch is running.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Authentication Failed:</strong> Verify credentials. For API
|
||||
keys, ensure they have proper permissions.
|
||||
</li>
|
||||
<li>
|
||||
<strong>SSL Errors:</strong> Try disabling SSL for local development
|
||||
or check certificate validity.
|
||||
</li>
|
||||
<li>
|
||||
<strong>No Indices Found:</strong> Ensure your credentials have
|
||||
permission to list and read indices.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Security Note</AlertTitle>
|
||||
<AlertDescription>
|
||||
For production environments, use API keys with minimal required
|
||||
permissions: cluster monitoring and read access to specific indices.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,531 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, CircleAlert, Github, Info, ListChecks, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { authenticatedFetch, redirectToLogin } from "@/lib/auth-utils";
|
||||
|
||||
// Define the form schema with Zod for GitHub PAT entry step
|
||||
const githubPatFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
github_pat: z
|
||||
.string()
|
||||
.min(20, {
|
||||
// Apply min length first
|
||||
message: "GitHub Personal Access Token seems too short.",
|
||||
})
|
||||
.refine((pat) => pat.startsWith("ghp_") || pat.startsWith("github_pat_"), {
|
||||
// Then refine the pattern
|
||||
message: "GitHub PAT should start with 'ghp_' or 'github_pat_'",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type GithubPatFormValues = z.infer<typeof githubPatFormSchema>;
|
||||
|
||||
// Type for fetched GitHub repositories
|
||||
interface GithubRepo {
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
private: boolean;
|
||||
url: string;
|
||||
description: string | null;
|
||||
last_updated: string | null;
|
||||
}
|
||||
|
||||
export default function GithubConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [step, setStep] = useState<"enter_pat" | "select_repos">("enter_pat");
|
||||
const [isFetchingRepos, setIsFetchingRepos] = useState(false);
|
||||
const [isCreatingConnector, setIsCreatingConnector] = useState(false);
|
||||
const [repositories, setRepositories] = useState<GithubRepo[]>([]);
|
||||
const [selectedRepos, setSelectedRepos] = useState<string[]>([]);
|
||||
const [connectorName, setConnectorName] = useState<string>("GitHub Connector");
|
||||
const [validatedPat, setValidatedPat] = useState<string>(""); // Store the validated PAT
|
||||
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form for PAT entry
|
||||
const form = useForm<GithubPatFormValues>({
|
||||
resolver: zodResolver(githubPatFormSchema),
|
||||
defaultValues: {
|
||||
name: connectorName,
|
||||
github_pat: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Function to fetch repositories using the new backend endpoint
|
||||
const fetchRepositories = async (values: GithubPatFormValues) => {
|
||||
setIsFetchingRepos(true);
|
||||
setConnectorName(values.name); // Store the name
|
||||
setValidatedPat(values.github_pat); // Store the PAT temporarily
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/github/repositories`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ github_pat: values.github_pat }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || `Failed to fetch repositories: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: GithubRepo[] = await response.json();
|
||||
setRepositories(data);
|
||||
setStep("select_repos"); // Move to the next step
|
||||
toast.success(`Found ${data.length} repositories.`);
|
||||
} catch (error) {
|
||||
console.error("Error fetching GitHub repositories:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch repositories. Please check the PAT and try again.";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsFetchingRepos(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle final connector creation
|
||||
const handleCreateConnector = async () => {
|
||||
if (selectedRepos.length === 0) {
|
||||
toast.warning("Please select at least one repository to index.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingConnector(true);
|
||||
try {
|
||||
await createConnector({
|
||||
data: {
|
||||
name: connectorName, // Use the stored name
|
||||
connector_type: EnumConnectorName.GITHUB_CONNECTOR,
|
||||
config: {
|
||||
GITHUB_PAT: validatedPat, // Use the stored validated PAT
|
||||
repo_full_names: selectedRepos, // Add the selected repo names
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("GitHub connector created successfully!");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating GitHub connector:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to create GitHub connector.";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsCreatingConnector(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle checkbox changes
|
||||
const handleRepoSelection = (repoFullName: string, checked: boolean) => {
|
||||
setSelectedRepos((prev) =>
|
||||
checked ? [...prev, repoFullName] : prev.filter((name) => name !== repoFullName)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => {
|
||||
if (step === "select_repos") {
|
||||
// Go back to PAT entry, clear sensitive/fetched data
|
||||
setStep("enter_pat");
|
||||
setRepositories([]);
|
||||
setSelectedRepos([]);
|
||||
setValidatedPat("");
|
||||
// Reset form PAT field, keep name
|
||||
form.reset({ name: connectorName, github_pat: "" });
|
||||
} else {
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors/add`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{step === "select_repos" ? "Back to PAT Entry" : "Back to Add Connectors"}
|
||||
</Button>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect GitHub</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Setup Guide</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="connect">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
{step === "enter_pat" ? (
|
||||
getConnectorIcon(EnumConnectorName.GITHUB_CONNECTOR, "h-6 w-6")
|
||||
) : (
|
||||
<ListChecks className="h-6 w-6" />
|
||||
)}
|
||||
{step === "enter_pat" ? "Connect GitHub Account" : "Select Repositories to Index"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{step === "enter_pat"
|
||||
? "Provide a name and GitHub Personal Access Token (PAT) to fetch accessible repositories."
|
||||
: `Select which repositories you want SurfSense to index for search. Found ${repositories.length} repositories accessible via your PAT.`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<Form {...form}>
|
||||
{step === "enter_pat" && (
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>GitHub Personal Access Token (PAT) Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a GitHub PAT with the appropriate scopes (e.g., 'repo') to fetch
|
||||
repositories. You can create one from your{" "}
|
||||
<a
|
||||
href="https://github.com/settings/personal-access-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
GitHub Developer Settings
|
||||
</a>
|
||||
. The PAT will be used to fetch repositories and then stored securely to
|
||||
enable indexing.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<form onSubmit={form.handleSubmit(fetchRepositories)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My GitHub Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this GitHub connection.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="github_pat"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>GitHub Personal Access Token (PAT)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="ghp_... or github_pat_..."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter your GitHub PAT here to fetch your repositories. It will be
|
||||
stored encrypted later.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isFetchingRepos}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isFetchingRepos ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Fetching Repositories...
|
||||
</>
|
||||
) : (
|
||||
"Fetch Repositories"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{step === "select_repos" && (
|
||||
<CardContent>
|
||||
{repositories.length === 0 ? (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlert className="h-4 w-4" />
|
||||
<AlertTitle>No Repositories Found</AlertTitle>
|
||||
<AlertDescription>
|
||||
No repositories were found or accessible with the provided PAT. Please
|
||||
check the token and its permissions, then go back and try again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<FormLabel>Repositories ({selectedRepos.length} selected)</FormLabel>
|
||||
<div className="h-64 w-full rounded-md border p-4 overflow-y-auto">
|
||||
{repositories.map((repo) => (
|
||||
<div key={repo.id} className="flex items-center space-x-2 mb-2 py-1">
|
||||
<Checkbox
|
||||
id={`repo-${repo.id}`}
|
||||
checked={selectedRepos.includes(repo.full_name)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRepoSelection(repo.full_name, !!checked)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`repo-${repo.id}`}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{repo.full_name} {repo.private && "(Private)"}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<FormDescription>
|
||||
Select the repositories you wish to index. Only checked repositories will
|
||||
be processed.
|
||||
</FormDescription>
|
||||
|
||||
<div className="flex justify-between items-center pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setStep("enter_pat");
|
||||
setRepositories([]);
|
||||
setSelectedRepos([]);
|
||||
setValidatedPat("");
|
||||
form.reset({ name: connectorName, github_pat: "" });
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateConnector}
|
||||
disabled={isCreatingConnector || selectedRepos.length === 0}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isCreatingConnector ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating Connector...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Create Connector
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with GitHub integration:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Search through code and documentation in your selected repositories</li>
|
||||
<li>Access READMEs, Markdown files, and common code files</li>
|
||||
<li>Connect your project knowledge directly to your search space</li>
|
||||
<li>Index your selected repositories for enhanced search capabilities</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documentation">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">GitHub Connector Setup Guide</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to generate a Personal Access Token (PAT) and connect your GitHub
|
||||
account.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The GitHub connector uses a Personal Access Token (PAT) to authenticate with the
|
||||
GitHub API. First, it fetches a list of repositories accessible to the token.
|
||||
You then select which repositories you want to index. The connector indexes
|
||||
relevant files (code, markdown, text) from only the selected repositories.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||
<li>
|
||||
The connector indexes files based on common code and documentation extensions.
|
||||
</li>
|
||||
<li>Large files (over 1MB) are skipped during indexing.</li>
|
||||
<li>Only selected repositories are indexed.</li>
|
||||
<li>
|
||||
Indexing runs periodically (check connector settings for frequency) to keep
|
||||
content up-to-date.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="create_pat">
|
||||
<AccordionTrigger className="text-lg font-medium">
|
||||
Step 1: Generate GitHub PAT
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Generating a Token:</h4>
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>
|
||||
Go to your GitHub{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Developer settings
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>
|
||||
Click on <strong>Personal access tokens</strong>, then choose{" "}
|
||||
<strong>Tokens (classic)</strong> or{" "}
|
||||
<strong>Fine-grained tokens</strong> (recommended if available and
|
||||
suitable).
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Generate new token</strong> (and choose the appropriate
|
||||
type).
|
||||
</li>
|
||||
<li>
|
||||
Give your token a descriptive name (e.g., "SurfSense Connector").
|
||||
</li>
|
||||
<li>
|
||||
Set an expiration date for the token (recommended for security).
|
||||
</li>
|
||||
<li>
|
||||
Under <strong>Select scopes</strong> (for classic tokens) or{" "}
|
||||
<strong>Repository access</strong> (for fine-grained), grant the
|
||||
necessary permissions. At minimum, the <strong>`repo`</strong> scope
|
||||
(or equivalent read access to repositories for fine-grained tokens) is
|
||||
required to read repository content.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Generate token</strong>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Important:</strong> Copy your new PAT immediately. You won't
|
||||
be able to see it again after leaving the page.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="connect_app">
|
||||
<AccordionTrigger className="text-lg font-medium">
|
||||
Step 2: Connect in SurfSense
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Navigate to the "Connect GitHub" tab.</li>
|
||||
<li>Enter a name for your connector.</li>
|
||||
<li>
|
||||
Paste the copied GitHub PAT into the "GitHub Personal Access Token (PAT)"
|
||||
field.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Fetch Repositories</strong>.
|
||||
</li>
|
||||
<li>
|
||||
If the PAT is valid, you'll see a list of your accessible repositories.
|
||||
</li>
|
||||
<li>
|
||||
Select the repositories you want SurfSense to index using the checkboxes.
|
||||
</li>
|
||||
<li>
|
||||
Click the <strong>Create Connector</strong> button.
|
||||
</li>
|
||||
<li>
|
||||
If the connection is successful, you will be redirected and can start
|
||||
indexing from the Connectors page.
|
||||
</li>
|
||||
</ol>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, ExternalLink, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
export default function GoogleCalendarConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [doesConnectorExist, setDoesConnectorExist] = useState(false);
|
||||
|
||||
const { refetch: fetchConnectors } = useAtomValue(connectorsAtom);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnectors().then((data) => {
|
||||
const connectors = data.data || [];
|
||||
const connector = connectors.find(
|
||||
(c: SearchSourceConnector) =>
|
||||
c.connector_type === EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR
|
||||
);
|
||||
if (connector) {
|
||||
setDoesConnectorExist(true);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle Google OAuth connection
|
||||
const handleConnectGoogle = async () => {
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
// Call backend to initiate authorization flow
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/auth/google/calendar/connector/add/?space_id=${searchSpaceId}`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to initiate Google OAuth");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Redirect to Google for authentication
|
||||
window.location.href = data.auth_url;
|
||||
} catch (error) {
|
||||
console.error("Error connecting to Google:", error);
|
||||
toast.error("Failed to connect to Google Calendar");
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-2xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/connectors/add`}
|
||||
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to connectors
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
{getConnectorIcon(EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR, "h-6 w-6")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connect Google Calendar</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Connect your Google Calendar to search events.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OAuth Connection Card */}
|
||||
{!doesConnectorExist ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Connect Your Google Account</CardTitle>
|
||||
<CardDescription>
|
||||
Connect your Google account to access your calendar events. We'll only request
|
||||
read-only access to your calendars.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>Read-only access to your calendar events</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>Access works even when you're offline</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>You can disconnect anytime</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConnectGoogle} disabled={isConnecting}>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Connect Your Google Account
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
) : (
|
||||
/* Configuration Form Card */
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>✅ Your Google calendar is successfully connected!</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Help Section */}
|
||||
{!doesConnectorExist && (
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">How It Works</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">1. Connect Your Account</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click "Connect Your Google Account" to start the secure OAuth process. You'll be
|
||||
redirected to Google to sign in.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">2. Grant Permissions</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Google will ask for permission to read your calendar events. We only request
|
||||
read-only access to keep your data safe.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, ExternalLink, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
export default function GoogleDriveConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [doesConnectorExist, setDoesConnectorExist] = useState(false);
|
||||
|
||||
const { refetch: fetchConnectors } = useAtomValue(connectorsAtom);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnectors().then((data) => {
|
||||
const connectors = data.data || [];
|
||||
const connector = connectors.find(
|
||||
(c: SearchSourceConnector) => c.connector_type === EnumConnectorName.GOOGLE_DRIVE_CONNECTOR
|
||||
);
|
||||
if (connector) {
|
||||
setDoesConnectorExist(true);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleConnectGoogle = async () => {
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/auth/google/drive/connector/add/?space_id=${searchSpaceId}`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to initiate Google OAuth");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
window.location.href = data.auth_url;
|
||||
} catch (error) {
|
||||
console.error("Error connecting to Google:", error);
|
||||
toast.error("Failed to connect to Google Drive");
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-2xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/connectors/add`}
|
||||
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to connectors
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
{getConnectorIcon(EnumConnectorName.GOOGLE_DRIVE_CONNECTOR, "h-6 w-6")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connect Google Drive</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Securely connect your Google Drive account
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Card */}
|
||||
{!doesConnectorExist ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Connect Your Google Account</CardTitle>
|
||||
<CardDescription>
|
||||
Authorize read-only access to your Google Drive. You'll select which folder to
|
||||
index when you start indexing.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>Read-only access to your Drive files</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>Index documents, spreadsheets, presentations, PDFs & more</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>Automatic updates with change tracking</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>Secure OAuth 2.0 authentication</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConnectGoogle} disabled={isConnecting}>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Connect Google Drive
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>✅ Already Connected</CardTitle>
|
||||
<CardDescription>
|
||||
Your Google Drive connector is already set up. Go to the connectors page to
|
||||
start indexing.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
<Button onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors`)}>
|
||||
Go to Connectors
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Information Card */}
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>How Google Drive Integration Works</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">1️⃣ Connect Your Account</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
First, securely connect your Google Drive account using OAuth 2.0. We only
|
||||
request read-only access.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">2️⃣ Select Folder to Index</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
When you're ready to index, go to the connectors page and click "Index". You'll
|
||||
choose which folder to process.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">3️⃣ Automatic Change Detection</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We use Google Drive's change tracking API to detect when files are modified,
|
||||
added, or deleted. Only changed files are re-indexed.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">📄 Comprehensive File Support</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Supports Google Workspace files (Docs, Sheets, Slides), Microsoft Office
|
||||
documents, PDFs, text files, images (with OCR), and more.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, ExternalLink, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
export default function GoogleGmailConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [doesConnectorExist, setDoesConnectorExist] = useState(false);
|
||||
|
||||
const { refetch: fetchConnectors } = useAtomValue(connectorsAtom);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnectors().then((data) => {
|
||||
const connectors = data.data || [];
|
||||
const connector = connectors.find(
|
||||
(c: SearchSourceConnector) => c.connector_type === EnumConnectorName.GOOGLE_GMAIL_CONNECTOR
|
||||
);
|
||||
if (connector) {
|
||||
setDoesConnectorExist(true);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle Google OAuth connection
|
||||
const handleConnectGoogle = async () => {
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
// Call backend to initiate authorization flow
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/auth/google/gmail/connector/add/?space_id=${searchSpaceId}`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to initiate Google OAuth");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Redirect to Google for authentication
|
||||
window.location.href = data.auth_url;
|
||||
} catch (error) {
|
||||
console.error("Error connecting to Google:", error);
|
||||
toast.error("Failed to connect to Google Gmail");
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-2xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/connectors/add`}
|
||||
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to connectors
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
{getConnectorIcon(EnumConnectorName.GOOGLE_GMAIL_CONNECTOR, "h-6 w-6")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connect Google Gmail</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Connect your Gmail account to search through your emails
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Card */}
|
||||
{!doesConnectorExist ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Connect Your Gmail Account</CardTitle>
|
||||
<CardDescription>
|
||||
Securely connect your Gmail account to enable email search within SurfSense. We'll
|
||||
only access your emails with read-only permissions.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>Read-only access to your emails</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>Search through email content and metadata</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>Secure OAuth 2.0 authentication</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>You can disconnect anytime</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConnectGoogle} disabled={isConnecting}>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Connect Your Google Account
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
) : (
|
||||
/* Configuration Form Card */
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>✅ Your Gmail is successfully connected!</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Information Card */}
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>What data will be indexed?</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">Email Content</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We'll index the content of your emails including subject lines, sender information,
|
||||
and message body text to make them searchable.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">Email Metadata</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Information like sender, recipient, date, and labels will be indexed to provide
|
||||
better search context and filtering options.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">Privacy & Security</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your emails are processed securely and stored with encryption. We only access emails
|
||||
with read-only permissions and never modify or send emails on your behalf.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,427 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const jiraConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
base_url: z
|
||||
.string()
|
||||
.url({
|
||||
message: "Please enter a valid Jira URL (e.g., https://yourcompany.atlassian.net)",
|
||||
})
|
||||
.refine(
|
||||
(url) => {
|
||||
return url.includes("atlassian.net") || url.includes("jira");
|
||||
},
|
||||
{
|
||||
message: "Please enter a valid Jira instance URL",
|
||||
}
|
||||
),
|
||||
email: z.string().email({
|
||||
message: "Please enter a valid email address.",
|
||||
}),
|
||||
api_token: z.string().min(10, {
|
||||
message: "Jira API Token is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type JiraConnectorFormValues = z.infer<typeof jiraConnectorFormSchema>;
|
||||
|
||||
export default function JiraConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<JiraConnectorFormValues>({
|
||||
resolver: zodResolver(jiraConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Jira Connector",
|
||||
base_url: "",
|
||||
email: "",
|
||||
api_token: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: JiraConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.JIRA_CONNECTOR,
|
||||
config: {
|
||||
JIRA_BASE_URL: values.base_url,
|
||||
JIRA_EMAIL: values.email,
|
||||
JIRA_API_TOKEN: values.api_token,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Jira connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
{getConnectorIcon(EnumConnectorName.JIRA_CONNECTOR, "h-6 w-6")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connect Jira</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Connect your Jira instance to search issues and tickets.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="connect">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Jira Instance</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Jira to search and retrieve information from your issues, tickets,
|
||||
and comments. This connector can index your Jira content for search.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Jira Personal Access Token Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Jira Personal Access Token to use this connector. You can create
|
||||
one from{" "}
|
||||
<a
|
||||
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Atlassian Account Settings
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Jira Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="base_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Jira Instance URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://yourcompany.atlassian.net" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Jira instance URL. For Atlassian Cloud, this is typically
|
||||
https://yourcompany.atlassian.net
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" placeholder="your.email@company.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Your Atlassian account email address.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="Your Jira API Token" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Jira API Token will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Jira
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Jira integration:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Search through all your Jira issues and tickets</li>
|
||||
<li>Access issue descriptions, comments, and full discussion threads</li>
|
||||
<li>Connect your team's project management directly to your search space</li>
|
||||
<li>Keep your search results up-to-date with latest Jira content</li>
|
||||
<li>Index your Jira issues for enhanced search capabilities</li>
|
||||
<li>Search by issue keys, status, priority, and assignee information</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documentation">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Jira Connector Documentation</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to set up and use the Jira connector to index your project management
|
||||
data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The Jira connector uses the Jira REST API with Basic Authentication to fetch all
|
||||
issues and comments that your account has access to within your Jira instance.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||
<li>
|
||||
For follow up indexing runs, the connector retrieves issues and comments that
|
||||
have been updated since the last indexing attempt.
|
||||
</li>
|
||||
<li>
|
||||
Indexing is configured to run periodically, so updates should appear in your
|
||||
search results within minutes.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="authorization">
|
||||
<AccordionTrigger className="text-lg font-medium">
|
||||
Authorization
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Read-Only Access is Sufficient</AlertTitle>
|
||||
<AlertDescription>
|
||||
You only need read access for this connector to work. The API Token will
|
||||
only be used to read your Jira data.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Step 1: Create an API Token</h4>
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Log in to your Atlassian account</li>
|
||||
<li>
|
||||
Navigate to{" "}
|
||||
<a
|
||||
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
https://id.atlassian.com/manage-profile/security/api-tokens
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Create API token</strong>
|
||||
</li>
|
||||
<li>Enter a label for your token (like "SurfSense Connector")</li>
|
||||
<li>
|
||||
Click <strong>Create</strong>
|
||||
</li>
|
||||
<li>Copy the generated token as it will only be shown once</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Step 2: Grant necessary access</h4>
|
||||
<p className="text-muted-foreground mb-3">
|
||||
The API Token will have access to all projects and issues that your user
|
||||
account can see. Make sure your account has appropriate permissions for
|
||||
the projects you want to index.
|
||||
</p>
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Data Privacy</AlertTitle>
|
||||
<AlertDescription>
|
||||
Only issues, comments, and basic metadata will be indexed. Jira
|
||||
attachments and linked files are not indexed by this connector.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="indexing">
|
||||
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>
|
||||
Navigate to the Connector Dashboard and select the <strong>Jira</strong>{" "}
|
||||
Connector.
|
||||
</li>
|
||||
<li>
|
||||
Enter your <strong>Jira Instance URL</strong> (e.g.,
|
||||
https://yourcompany.atlassian.net)
|
||||
</li>
|
||||
<li>
|
||||
Place your <strong>Personal Access Token</strong> in the form field.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Connect</strong> to establish the connection.
|
||||
</li>
|
||||
<li>Once connected, your Jira issues will be indexed automatically.</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>What Gets Indexed</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p className="mb-2">The Jira connector indexes the following data:</p>
|
||||
<ul className="list-disc pl-5">
|
||||
<li>Issue keys and summaries (e.g., PROJ-123)</li>
|
||||
<li>Issue descriptions</li>
|
||||
<li>Issue comments and discussion threads</li>
|
||||
<li>Issue status, priority, and type information</li>
|
||||
<li>Assignee and reporter information</li>
|
||||
<li>Project information</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,379 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const linearConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z
|
||||
.string()
|
||||
.min(10, {
|
||||
message: "Linear API Key is required and must be valid.",
|
||||
})
|
||||
.regex(/^lin_api_/, {
|
||||
message: "Linear API Key should start with 'lin_api_'",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type LinearConnectorFormValues = z.infer<typeof linearConnectorFormSchema>;
|
||||
|
||||
export default function LinearConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<LinearConnectorFormValues>({
|
||||
resolver: zodResolver(linearConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Linear Connector",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: LinearConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.LINEAR_CONNECTOR,
|
||||
config: {
|
||||
LINEAR_API_KEY: values.api_key,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Linear connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
{getConnectorIcon(EnumConnectorName.LINEAR_CONNECTOR, "h-6 w-6")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connect Linear</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Connect your Linear workspace to search issues and projects.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="connect">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Linear Workspace</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Linear to search and retrieve information from your issues and
|
||||
comments. This connector can index your Linear content for search.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Linear API Key Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Linear API Key to use this connector. You can create a Linear API
|
||||
key from{" "}
|
||||
<a
|
||||
href="https://linear.app/settings/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Linear API Settings
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Linear Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Linear API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="lin_api_..." {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Linear API Key will be encrypted and stored securely. It typically
|
||||
starts with "lin_api_".
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Linear
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Linear integration:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Search through all your Linear issues and comments</li>
|
||||
<li>Access issue titles, descriptions, and full discussion threads</li>
|
||||
<li>Connect your team's project management directly to your search space</li>
|
||||
<li>Keep your search results up-to-date with latest Linear content</li>
|
||||
<li>Index your Linear issues for enhanced search capabilities</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documentation">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Linear Connector Documentation</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to set up and use the Linear connector to index your project management
|
||||
data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The Linear connector uses the Linear GraphQL API to fetch all issues and
|
||||
comments that the API key has access to within a workspace.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||
<li>
|
||||
For follow up indexing runs, the connector retrieves issues and comments that
|
||||
have been updated since the last indexing attempt.
|
||||
</li>
|
||||
<li>
|
||||
Indexing is configured to run periodically, so updates should appear in your
|
||||
search results within minutes.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="authorization">
|
||||
<AccordionTrigger className="text-lg font-medium">
|
||||
Authorization
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Read-Only Access is Sufficient</AlertTitle>
|
||||
<AlertDescription>
|
||||
You only need a read-only API key for this connector to work. This limits
|
||||
the permissions to just reading your Linear data.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Step 1: Create an API key</h4>
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Log in to your Linear account</li>
|
||||
<li>
|
||||
Navigate to{" "}
|
||||
<a
|
||||
href="https://linear.app/settings/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
https://linear.app/settings/api
|
||||
</a>{" "}
|
||||
in your browser.
|
||||
</li>
|
||||
<li>Alternatively, click on your profile picture → Settings → API</li>
|
||||
<li>
|
||||
Click the <strong>+ New API key</strong> button.
|
||||
</li>
|
||||
<li>Enter a description for your key (like "Search Connector").</li>
|
||||
<li>Select "Read-only" as the permission.</li>
|
||||
<li>
|
||||
Click <strong>Create</strong> to generate the API key.
|
||||
</li>
|
||||
<li>
|
||||
Copy the generated API key that starts with 'lin_api_' as it will only
|
||||
be shown once.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Step 2: Grant necessary access</h4>
|
||||
<p className="text-muted-foreground mb-3">
|
||||
The API key will have access to all issues and comments that your user
|
||||
account can see. If you're creating the key as an admin, it will have
|
||||
access to all issues in the workspace.
|
||||
</p>
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Data Privacy</AlertTitle>
|
||||
<AlertDescription>
|
||||
Only issues and comments will be indexed. Linear attachments and
|
||||
linked files are not indexed by this connector.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="indexing">
|
||||
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>
|
||||
Navigate to the Connector Dashboard and select the <strong>Linear</strong>{" "}
|
||||
Connector.
|
||||
</li>
|
||||
<li>
|
||||
Place the <strong>API Key</strong> in the form field.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Connect</strong> to establish the connection.
|
||||
</li>
|
||||
<li>Once connected, your Linear issues will be indexed automatically.</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>What Gets Indexed</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p className="mb-2">The Linear connector indexes the following data:</p>
|
||||
<ul className="list-disc pl-5">
|
||||
<li>Issue titles and identifiers (e.g., PROJ-123)</li>
|
||||
<li>Issue descriptions</li>
|
||||
<li>Issue comments</li>
|
||||
<li>Issue status and metadata</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const linkupApiFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().min(10, {
|
||||
message: "API key is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type LinkupApiFormValues = z.infer<typeof linkupApiFormSchema>;
|
||||
|
||||
export default function LinkupApiPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<LinkupApiFormValues>({
|
||||
resolver: zodResolver(linkupApiFormSchema),
|
||||
defaultValues: {
|
||||
name: "Linkup API Connector",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: LinkupApiFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.LINKUP_API,
|
||||
config: {
|
||||
LINKUP_API_KEY: values.api_key,
|
||||
},
|
||||
is_indexable: false,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Linkup API connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
{getConnectorIcon(EnumConnectorName.LINKUP_API, "h-6 w-6")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connect Linkup API</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Connect Linkup API for enhanced search capabilities.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Linkup API</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Linkup API to enhance your search capabilities with AI-powered search
|
||||
results.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>API Key Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Linkup API key to use this connector. You can get one by signing up at{" "}
|
||||
<a
|
||||
href="https://linkup.so"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
linkup.so
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Linkup API Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>A friendly name to identify this connector.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Linkup API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="Enter your Linkup API key" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your API key will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Linkup API
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Linkup API:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>AI-powered search results tailored to your queries</li>
|
||||
<li>Real-time information from the web</li>
|
||||
<li>Enhanced search capabilities for your projects</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,268 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Key, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const lumaConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().min(10, {
|
||||
message: "API key is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type LumaConnectorFormValues = z.infer<typeof lumaConnectorFormSchema>;
|
||||
|
||||
export default function LumaConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [doesConnectorExist, setDoesConnectorExist] = useState(false);
|
||||
|
||||
const { data: connectors } = useAtomValue(connectorsAtom);
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<LumaConnectorFormValues>({
|
||||
resolver: zodResolver(lumaConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Luma Events",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
const { refetch: fetchConnectors } = useAtomValue(connectorsAtom);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnectors().then((data) => {
|
||||
const connectors = data.data || [];
|
||||
const connector = connectors.find(
|
||||
(c: SearchSourceConnector) => c.connector_type === EnumConnectorName.LUMA_CONNECTOR
|
||||
);
|
||||
if (connector) {
|
||||
setDoesConnectorExist(true);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: LumaConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.LUMA_CONNECTOR,
|
||||
config: {
|
||||
LUMA_API_KEY: values.api_key,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Luma connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-2xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/connectors/add`}
|
||||
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to connectors
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
{getConnectorIcon(EnumConnectorName.LUMA_CONNECTOR, "h-6 w-6")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connect Luma</h1>
|
||||
<p className="text-muted-foreground">Connect your Luma account to search events.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Card */}
|
||||
{!doesConnectorExist ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Connect Your Luma Account</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your Luma API key to connect your account. We'll use this to access your
|
||||
events in read-only mode.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Luma Events" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="Enter your Luma API key" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your API key will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>Read-only access to your Luma events</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>Access works even when you're offline</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>You can disconnect anytime</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Key className="mr-2 h-4 w-4" />
|
||||
Connect Luma
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</Card>
|
||||
) : (
|
||||
/* Success Card */
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>✅ Your Luma account is successfully connected!</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Help Section */}
|
||||
{!doesConnectorExist && (
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">How It Works</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">1. Get Your API Key</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Log into your Luma account and navigate to your account settings to generate an
|
||||
API key.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">2. Enter Your API Key</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Paste your API key in the field above. We'll use this to securely access your
|
||||
events with read-only permissions.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,390 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const notionConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
integration_token: z.string().min(10, {
|
||||
message: "Notion Integration Token is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type NotionConnectorFormValues = z.infer<typeof notionConnectorFormSchema>;
|
||||
|
||||
export default function NotionConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<NotionConnectorFormValues>({
|
||||
resolver: zodResolver(notionConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Notion Connector",
|
||||
integration_token: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: NotionConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.NOTION_CONNECTOR,
|
||||
config: {
|
||||
NOTION_INTEGRATION_TOKEN: values.integration_token,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Notion connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
{getConnectorIcon(EnumConnectorName.NOTION_CONNECTOR, "h-6 w-6")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connect Notion</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Connect your Notion workspace to search pages and databases.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="connect">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Notion Workspace</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Notion to search and retrieve information from your workspace pages
|
||||
and databases. This connector can index your Notion content for search.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Notion Integration Token Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Notion Integration Token to use this connector. You can create a
|
||||
Notion integration and get the token from{" "}
|
||||
<a
|
||||
href="https://www.notion.so/my-integrations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Notion Integrations Dashboard
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Notion Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="integration_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Notion Integration Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="ntn_.." {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Notion Integration Token will be encrypted and stored securely. It
|
||||
typically starts with "ntn_".
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Notion
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Notion integration:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Search through your Notion pages and databases</li>
|
||||
<li>Access documents, wikis, and knowledge bases</li>
|
||||
<li>Connect your team's knowledge directly to your search space</li>
|
||||
<li>Keep your search results up-to-date with latest Notion content</li>
|
||||
<li>Index your Notion documents for enhanced search capabilities</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documentation">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Notion Connector Documentation</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to set up and use the Notion connector to index your workspace data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The Notion connector uses the Notion search API to fetch all pages that the
|
||||
connector has access to within a workspace.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||
<li>
|
||||
For follow up indexing runs, the connector only retrieves pages that have been
|
||||
updated since the last indexing attempt.
|
||||
</li>
|
||||
<li>
|
||||
Indexing is configured to run every <strong>10 minutes</strong>, so page
|
||||
updates should appear within 10 minutes.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="authorization">
|
||||
<AccordionTrigger className="text-lg font-medium">
|
||||
Authorization
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>No Admin Access Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
There's no requirement to be an Admin to share information with an
|
||||
integration. Any member can share pages and databases with it.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Step 1: Create an integration</h4>
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>
|
||||
Visit{" "}
|
||||
<a
|
||||
href="https://www.notion.com/my-integrations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
https://www.notion.com/my-integrations
|
||||
</a>{" "}
|
||||
in your browser.
|
||||
</li>
|
||||
<li>
|
||||
Click the <strong>+ New integration</strong> button.
|
||||
</li>
|
||||
<li>
|
||||
Name the integration (something like "Search Connector" could work).
|
||||
</li>
|
||||
<li>Select "Read content" as the only capability required.</li>
|
||||
<li>
|
||||
Click <strong>Submit</strong> to create the integration.
|
||||
</li>
|
||||
<li>
|
||||
On the next page, you'll find your Notion integration token. Make a
|
||||
copy of it as you'll need it to configure the connector.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">
|
||||
Step 2: Share pages/databases with your integration
|
||||
</h4>
|
||||
<p className="text-muted-foreground mb-3">
|
||||
To keep your information secure, integrations don't have access to any
|
||||
pages or databases in the workspace at first. You must share specific
|
||||
pages with an integration in order for the connector to access those
|
||||
pages.
|
||||
</p>
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Go to the page/database in your workspace.</li>
|
||||
<li>
|
||||
Click the <code>•••</code> on the top right corner of the page.
|
||||
</li>
|
||||
<li>
|
||||
Scroll to the bottom of the pop-up and click{" "}
|
||||
<strong>Add connections</strong>.
|
||||
</li>
|
||||
<li>
|
||||
Search for and select the new integration in the{" "}
|
||||
<code>Search for connections...</code> menu.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Important:</strong>
|
||||
<ul className="list-disc pl-5 mt-1">
|
||||
<li>
|
||||
If you've added a page, all child pages also become accessible.
|
||||
</li>
|
||||
<li>
|
||||
If you've added a database, all rows (and their children) become
|
||||
accessible.
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="indexing">
|
||||
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>
|
||||
Navigate to the Connector Dashboard and select the <strong>Notion</strong>{" "}
|
||||
Connector.
|
||||
</li>
|
||||
<li>
|
||||
Place the <strong>Integration Token</strong> under{" "}
|
||||
<strong>Step 1 Provide Credentials</strong>.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Connect</strong> to establish the connection.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Indexing Behavior</AlertTitle>
|
||||
<AlertDescription>
|
||||
The Notion connector currently indexes everything it has access to. If you
|
||||
want to limit specific content being indexed, simply unshare the database
|
||||
from Notion with the integration.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function AddConnectorRedirect() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const search_space_id = params.search_space_id as string;
|
||||
|
||||
useEffect(() => {
|
||||
router.replace(`/dashboard/${search_space_id}/sources/add?tab=connectors`);
|
||||
}, [search_space_id, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,370 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
|
||||
const searxngFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
host: z
|
||||
.string({ required_error: "Host is required." })
|
||||
.url({ message: "Enter a valid SearxNG host URL (e.g. https://searxng.example.org)." }),
|
||||
api_key: z.string().optional(),
|
||||
engines: z.string().optional(),
|
||||
categories: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
safesearch: z
|
||||
.string()
|
||||
.regex(/^[0-2]?$/, { message: "SafeSearch must be 0, 1, or 2." })
|
||||
.optional(),
|
||||
verify_ssl: z.boolean().default(true),
|
||||
});
|
||||
|
||||
type SearxngFormValues = z.infer<typeof searxngFormSchema>;
|
||||
|
||||
const parseCommaSeparated = (value?: string | null) => {
|
||||
if (!value) return undefined;
|
||||
const items = value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
return items.length > 0 ? items : undefined;
|
||||
};
|
||||
|
||||
export default function SearxngConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
const form = useForm<SearxngFormValues>({
|
||||
resolver: zodResolver(searxngFormSchema),
|
||||
defaultValues: {
|
||||
name: "SearxNG Connector",
|
||||
host: "",
|
||||
api_key: "",
|
||||
engines: "",
|
||||
categories: "",
|
||||
language: "",
|
||||
safesearch: "",
|
||||
verify_ssl: true,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: SearxngFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const config: Record<string, unknown> = {
|
||||
SEARXNG_HOST: values.host.trim(),
|
||||
};
|
||||
|
||||
const apiKey = values.api_key?.trim();
|
||||
if (apiKey) config.SEARXNG_API_KEY = apiKey;
|
||||
|
||||
const engines = parseCommaSeparated(values.engines);
|
||||
if (engines) config.SEARXNG_ENGINES = engines;
|
||||
|
||||
const categories = parseCommaSeparated(values.categories);
|
||||
if (categories) config.SEARXNG_CATEGORIES = categories;
|
||||
|
||||
const language = values.language?.trim();
|
||||
if (language) config.SEARXNG_LANGUAGE = language;
|
||||
|
||||
const safesearch = values.safesearch?.trim();
|
||||
if (safesearch) {
|
||||
const parsed = Number(safesearch);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
config.SEARXNG_SAFESEARCH = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
// Include verify flag only when disabled to keep config minimal
|
||||
if (values.verify_ssl === false) {
|
||||
config.SEARXNG_VERIFY_SSL = false;
|
||||
}
|
||||
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.SEARXNG_API,
|
||||
config,
|
||||
is_indexable: false,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("SearxNG connector created successfully!");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating SearxNG connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
{getConnectorIcon(EnumConnectorName.SEARXNG_API, "h-6 w-6")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connect SearxNG</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Bring your self-hosted SearxNG meta-search engine into SurfSense.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect SearxNG</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate SurfSense with any SearxNG instance to broaden your search coverage while
|
||||
preserving privacy and control.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>SearxNG Instance Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You need access to a running SearxNG instance. Refer to the{" "}
|
||||
<a
|
||||
href="https://docs.searxng.org/admin/installation-docker.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
SearxNG installation guide
|
||||
</a>{" "}
|
||||
for setup instructions. If your instance requires an API key, include it below.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My SearxNG Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>A friendly name to identify this connector.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SearxNG Host</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://searxng.example.org" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Provide the full base URL to your SearxNG instance. Include the protocol
|
||||
(http/https).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Key (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter API key if your instance requires one"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Leave empty if your SearxNG instance does not enforce API keys.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="engines"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Engines (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="google,bing,duckduckgo" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Comma-separated list to target specific engines.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="categories"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Categories (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="general,it,science" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Comma-separated list of SearxNG categories.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Preferred Language (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="en-US" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
IETF language tag (e.g. en, en-US). Leave blank to inherit defaults.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="safesearch"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SafeSearch Level (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="0 (off), 1 (moderate), 2 (strict)" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Set 0, 1, or 2 to adjust SafeSearch filtering. Leave blank to use the
|
||||
instance default.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="verify_ssl"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div>
|
||||
<FormLabel>Verify SSL Certificates</FormLabel>
|
||||
<FormDescription>
|
||||
Disable only when connecting to instances with self-signed certificates.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<CardFooter className="flex justify-end px-0">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect SearxNG
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,421 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const slackConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
bot_token: z.string().min(10, {
|
||||
message: "Bot User OAuth Token is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type SlackConnectorFormValues = z.infer<typeof slackConnectorFormSchema>;
|
||||
|
||||
export default function SlackConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<SlackConnectorFormValues>({
|
||||
resolver: zodResolver(slackConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Slack Connector",
|
||||
bot_token: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: SlackConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.SLACK_CONNECTOR,
|
||||
config: {
|
||||
SLACK_BOT_TOKEN: values.bot_token,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Slack connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
{getConnectorIcon(EnumConnectorName.SLACK_CONNECTOR, "h-6 w-6")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connect Slack</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Connect your Slack workspace to search messages and channels.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="connect">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Slack Workspace</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Slack to search and retrieve information from your workspace
|
||||
channels and conversations. This connector can index your Slack messages for
|
||||
search.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Bot User OAuth Token Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Slack Bot User OAuth Token to use this connector. You can create a
|
||||
Slack app and get the token from{" "}
|
||||
<a
|
||||
href="https://api.slack.com/apps"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Slack API Dashboard
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Slack Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bot_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slack Bot User OAuth Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="xoxb-..." {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Bot User OAuth Token will be encrypted and stored securely. It
|
||||
typically starts with "xoxb-".
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Slack
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Slack integration:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Search through your Slack channels and conversations</li>
|
||||
<li>Access historical messages and shared files</li>
|
||||
<li>Connect your team's knowledge directly to your search space</li>
|
||||
<li>Keep your search results up-to-date with latest communications</li>
|
||||
<li>Index your Slack messages for enhanced search capabilities</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documentation">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Slack Connector Documentation</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to set up and use the Slack connector to index your workspace data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The Slack connector indexes all public channels for a given workspace.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||
<li>
|
||||
Upcoming: Support for private channels by tagging/adding the Slack Bot to
|
||||
private channels.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="authorization">
|
||||
<AccordionTrigger className="text-lg font-medium">
|
||||
Authorization
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Admin Access Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You must be an admin of the Slack workspace to set up the connector.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>
|
||||
Navigate and sign in to{" "}
|
||||
<a
|
||||
href="https://api.slack.com/apps"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
https://api.slack.com/apps
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>
|
||||
Create a new Slack app:
|
||||
<ul className="list-disc pl-5 mt-1">
|
||||
<li>
|
||||
Click the <strong>Create New App</strong> button in the top right.
|
||||
</li>
|
||||
<li>
|
||||
Select <strong>From an app manifest</strong> option.
|
||||
</li>
|
||||
<li>
|
||||
Select the relevant workspace from the dropdown and click{" "}
|
||||
<strong>Next</strong>.
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
Select the "YAML" tab, paste the following manifest into the text box, and
|
||||
click <strong>Next</strong>:
|
||||
<div className="bg-muted p-4 rounded-md mt-2 overflow-x-auto">
|
||||
<pre className="text-xs">
|
||||
{`display_information:
|
||||
name: SlackConnector
|
||||
description: ReadOnly Connector for indexing
|
||||
features:
|
||||
bot_user:
|
||||
display_name: SlackConnector
|
||||
always_online: false
|
||||
oauth_config:
|
||||
scopes:
|
||||
bot:
|
||||
- channels:history
|
||||
- channels:read
|
||||
- groups:history
|
||||
- groups:read
|
||||
- channels:join
|
||||
- im:history
|
||||
- users:read
|
||||
- users:read.email
|
||||
- usergroups:read
|
||||
settings:
|
||||
org_deploy_enabled: false
|
||||
socket_mode_enabled: false
|
||||
token_rotation_enabled: false`}
|
||||
</pre>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
Click the <strong>Create</strong> button.
|
||||
</li>
|
||||
<li>
|
||||
In the app page, navigate to the <strong>OAuth & Permissions</strong> tab
|
||||
under the <strong>Features</strong> header.
|
||||
</li>
|
||||
<li>
|
||||
Copy the <strong>Bot User OAuth Token</strong>, this will be used to
|
||||
access Slack.
|
||||
</li>
|
||||
</ol>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="indexing">
|
||||
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>
|
||||
Navigate to the Connector Dashboard and select the <strong>Slack</strong>{" "}
|
||||
Connector.
|
||||
</li>
|
||||
<li>
|
||||
Place the <strong>Bot User OAuth Token</strong> under{" "}
|
||||
<strong>Step 1 Provide Credentials</strong>.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Connect</strong> to establish the connection.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Important: Invite Bot to Channels</AlertTitle>
|
||||
<AlertDescription>
|
||||
After connecting, you must invite the bot to each channel you want to
|
||||
index. In each Slack channel, type:
|
||||
<pre className="mt-2 bg-background p-2 rounded-md text-xs">
|
||||
/invite @YourBotName
|
||||
</pre>
|
||||
<p className="mt-2">
|
||||
Without this step, you'll get a "not_in_channel" error when the
|
||||
connector tries to access channel messages.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Alert className="bg-muted mt-4">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>First Indexing</AlertTitle>
|
||||
<AlertDescription>
|
||||
The first indexing pulls all of the public channels and takes longer than
|
||||
future updates. Only channels where the bot has been invited will be fully
|
||||
indexed.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="mt-4">
|
||||
<h4 className="font-medium mb-2">Troubleshooting:</h4>
|
||||
<ul className="list-disc pl-5 space-y-2 text-muted-foreground">
|
||||
<li>
|
||||
<strong>not_in_channel error:</strong> If you see this error in logs, it
|
||||
means the bot hasn't been invited to a channel it's trying to access.
|
||||
Use the <code>/invite @YourBotName</code> command in that channel.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Alternative approach:</strong> You can add the{" "}
|
||||
<code>chat:write.public</code> scope to your Slack app to allow it to
|
||||
access public channels without an explicit invitation.
|
||||
</li>
|
||||
<li>
|
||||
<strong>For private channels:</strong> The bot must always be invited
|
||||
using the <code>/invite</code> command.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const tavilyApiFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().min(10, {
|
||||
message: "API key is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type TavilyApiFormValues = z.infer<typeof tavilyApiFormSchema>;
|
||||
|
||||
export default function TavilyApiPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<TavilyApiFormValues>({
|
||||
resolver: zodResolver(tavilyApiFormSchema),
|
||||
defaultValues: {
|
||||
name: "Tavily API Connector",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: TavilyApiFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.TAVILY_API,
|
||||
config: {
|
||||
TAVILY_API_KEY: values.api_key,
|
||||
},
|
||||
is_indexable: false,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Tavily API connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
{getConnectorIcon(EnumConnectorName.TAVILY_API, "h-6 w-6")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connect Tavily API</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Connect Tavily API for AI-powered search capabilities.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Tavily API</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Tavily API to enhance your search capabilities with AI-powered search
|
||||
results.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>API Key Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Tavily API key to use this connector. You can get one by signing up at{" "}
|
||||
<a
|
||||
href="https://tavily.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
tavily.com
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Tavily API Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>A friendly name to identify this connector.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tavily API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="Enter your Tavily API key" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your API key will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Tavily API
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Tavily API:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>AI-powered search results tailored to your queries</li>
|
||||
<li>Real-time information from the web</li>
|
||||
<li>Enhanced search capabilities for your projects</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,325 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Check, Globe, Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const webcrawlerConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().optional(),
|
||||
initial_urls: z.string().optional(),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type WebcrawlerConnectorFormValues = z.infer<typeof webcrawlerConnectorFormSchema>;
|
||||
|
||||
export default function WebcrawlerConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [doesConnectorExist, setDoesConnectorExist] = useState(false);
|
||||
|
||||
const { refetch: fetchConnectors } = useAtomValue(connectorsAtom);
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<WebcrawlerConnectorFormValues>({
|
||||
resolver: zodResolver(webcrawlerConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Web Pages",
|
||||
api_key: "",
|
||||
initial_urls: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnectors().then((data) => {
|
||||
const connectors = data.data || [];
|
||||
const connector = connectors.find(
|
||||
(c: SearchSourceConnector) => c.connector_type === EnumConnectorName.WEBCRAWLER_CONNECTOR
|
||||
);
|
||||
if (connector) {
|
||||
setDoesConnectorExist(true);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: WebcrawlerConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const config: Record<string, string> = {};
|
||||
|
||||
// Only add API key to config if provided
|
||||
if (values.api_key && values.api_key.trim()) {
|
||||
config.FIRECRAWL_API_KEY = values.api_key;
|
||||
}
|
||||
|
||||
// Parse initial URLs if provided
|
||||
if (values.initial_urls && values.initial_urls.trim()) {
|
||||
config.INITIAL_URLS = values.initial_urls;
|
||||
}
|
||||
|
||||
await createConnector({
|
||||
data: {
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.WEBCRAWLER_CONNECTOR,
|
||||
config: config,
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Webcrawler connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-2xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/connectors/add`}
|
||||
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to connectors
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
{getConnectorIcon(EnumConnectorName.WEBCRAWLER_CONNECTOR, "h-6 w-6")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connect Web Pages</h1>
|
||||
<p className="text-muted-foreground">Crawl and index web pages for search.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Card */}
|
||||
{!doesConnectorExist ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Set Up Web Page crawler</CardTitle>
|
||||
<CardDescription>
|
||||
Configure your web page crawler to index web pages. Optionally add a Firecrawl API
|
||||
key for enhanced crawling capabilities.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Web Crawler" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Firecrawl API Key (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="fc-xxxxxxxxxxxxx" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Add a Firecrawl API key for enhanced crawling. If not provided, will use
|
||||
AsyncChromiumLoader as fallback.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="initial_urls"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Initial URLs (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="https://example.com https://docs.example.com https://blog.example.com"
|
||||
className="min-h-[100px] font-mono text-sm"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter URLs to crawl (one per line). You can add more URLs later.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>Crawl any public web page</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>Extract markdown content automatically</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>Detect content changes and update documents</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>Works with or without Firecrawl API key</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Setting up...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Globe className="mr-2 h-4 w-4" />
|
||||
Create Crawler
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</Card>
|
||||
) : (
|
||||
/* Success Card */
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>✅ Your web page crawler is successfully set up!</CardTitle>
|
||||
<CardDescription>
|
||||
You can now add URLs to crawl from the connector management page.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Help Section */}
|
||||
{!doesConnectorExist && (
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">How It Works</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">1. Choose Your Crawler Method</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<strong>With Firecrawl (Recommended):</strong> Get your API key from{" "}
|
||||
<a
|
||||
href="https://firecrawl.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
firecrawl.dev
|
||||
</a>{" "}
|
||||
for faster, more reliable crawling with better content extraction.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
<strong>Without Firecrawl:</strong> The crawler will use AsyncChromiumLoader as a
|
||||
free fallback option. This works well for most websites but may be slower.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">2. Add URLs to Crawl (Optional)</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You can add initial URLs now or add them later from the connector management page.
|
||||
Enter one URL per line.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">3. Manage Your Crawler</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
After setup, you can add more URLs, trigger manual crawls, or set up periodic
|
||||
indexing to keep your content up-to-date.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -137,19 +137,19 @@ export function DocumentsTableShell({
|
|||
<div className="rounded-full bg-muted p-4">
|
||||
<FileX className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">{t("no_documents")}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Get started by adding your first data source.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/sources/add`)}
|
||||
className="mt-2"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Sources
|
||||
</Button>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">{t("no_documents")}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Get started by uploading your first document.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/documents/upload`)}
|
||||
className="mt-2"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Upload Documents
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -125,14 +125,22 @@ export default function DocumentsTable() {
|
|||
setColumnVisibility((prev) => ({ ...prev, [id]: checked }));
|
||||
};
|
||||
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const refreshCurrentView = useCallback(async () => {
|
||||
if (debouncedSearch.trim()) {
|
||||
await refetchSearch();
|
||||
} else {
|
||||
await refetchDocuments();
|
||||
if (isRefreshing) return;
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
if (debouncedSearch.trim()) {
|
||||
await refetchSearch();
|
||||
} else {
|
||||
await refetchDocuments();
|
||||
}
|
||||
toast.success(t("refresh_success") || "Documents refreshed");
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
toast.success(t("refresh_success") || "Documents refreshed");
|
||||
}, [debouncedSearch, refetchSearch, refetchDocuments, t]);
|
||||
}, [debouncedSearch, refetchSearch, refetchDocuments, t, isRefreshing]);
|
||||
|
||||
// Set up smart polling for active tasks - only polls when tasks are in progress
|
||||
const { summary } = useLogsSummary(searchSpaceId, 24, {
|
||||
|
|
@ -230,8 +238,8 @@ export default function DocumentsTable() {
|
|||
<h2 className="text-xl md:text-2xl font-bold tracking-tight">{t("title")}</h2>
|
||||
<p className="text-xs md:text-sm text-muted-foreground">{t("subtitle")}</p>
|
||||
</div>
|
||||
<Button onClick={refreshCurrentView} variant="outline" size="sm">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
<Button onClick={refreshCurrentView} variant="outline" size="sm" disabled={isRefreshing}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,36 @@
|
|||
"use client";
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { Upload } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { DocumentUploadTab } from "@/components/sources/DocumentUploadTab";
|
||||
|
||||
export default function UploadDocumentsRedirect() {
|
||||
export default function UploadDocumentsPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const search_space_id = params.search_space_id as string;
|
||||
|
||||
useEffect(() => {
|
||||
router.replace(`/dashboard/${search_space_id}/sources/add?tab=documents`);
|
||||
}, [search_space_id, router]);
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4 min-h-[calc(100vh-64px)]">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-2xl sm:text-4xl font-bold tracking-tight flex items-center justify-center gap-3">
|
||||
<Upload className="h-6 w-6 sm:h-8 sm:w-8" />
|
||||
Upload Documents
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm sm:text-lg">
|
||||
Upload documents to your search space for AI-powered search and chat
|
||||
</p>
|
||||
</div>
|
||||
|
||||
return null;
|
||||
{/* Document Upload */}
|
||||
<DocumentUploadTab searchSpaceId={search_space_id} />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function YouTubeRedirect() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const search_space_id = params.search_space_id as string;
|
||||
|
||||
useEffect(() => {
|
||||
router.replace(`/dashboard/${search_space_id}/sources/add?tab=youtube`);
|
||||
}, [search_space_id, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -38,10 +38,6 @@ export default function DashboardLayout({
|
|||
url: "#",
|
||||
icon: "Database",
|
||||
items: [
|
||||
{
|
||||
title: "Add Sources",
|
||||
url: `/dashboard/${search_space_id}/sources/add`,
|
||||
},
|
||||
{
|
||||
title: "Manage Documents",
|
||||
url: `/dashboard/${search_space_id}/documents`,
|
||||
|
|
|
|||
|
|
@ -472,9 +472,18 @@ export default function LogsManagePage() {
|
|||
}
|
||||
};
|
||||
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isSummaryRefreshing, setIsSummaryRefreshing] = useState(false);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
await Promise.all([refreshLogs(), refreshSummary()]);
|
||||
toast.success("Logs refreshed");
|
||||
if (isRefreshing) return;
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await Promise.all([refreshLogs(), refreshSummary()]);
|
||||
toast.success("Logs refreshed");
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -495,7 +504,16 @@ export default function LogsManagePage() {
|
|||
summary={summary}
|
||||
loading={summaryLoading}
|
||||
error={summaryError?.message ?? null}
|
||||
onRefresh={refreshSummary}
|
||||
onRefresh={async () => {
|
||||
if (isSummaryRefreshing) return;
|
||||
setIsSummaryRefreshing(true);
|
||||
try {
|
||||
await refreshSummary();
|
||||
} finally {
|
||||
setIsSummaryRefreshing(false);
|
||||
}
|
||||
}}
|
||||
isRefreshing={isSummaryRefreshing}
|
||||
/>
|
||||
|
||||
{/* Logs Table Header */}
|
||||
|
|
@ -509,8 +527,8 @@ export default function LogsManagePage() {
|
|||
<h2 className="text-xl md:text-2xl font-bold tracking-tight">{t("title")}</h2>
|
||||
<p className="text-xs md:text-sm text-muted-foreground">{t("subtitle")}</p>
|
||||
</div>
|
||||
<Button onClick={handleRefresh} variant="outline" size="sm">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
<Button onClick={handleRefresh} variant="outline" size="sm" disabled={isRefreshing}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
|
@ -546,11 +564,13 @@ function LogsSummaryDashboard({
|
|||
loading,
|
||||
error,
|
||||
onRefresh,
|
||||
isRefreshing = false,
|
||||
}: {
|
||||
summary: any;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onRefresh: () => void;
|
||||
onRefresh: () => void | Promise<void>;
|
||||
isRefreshing?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("logs");
|
||||
if (loading) {
|
||||
|
|
@ -581,7 +601,8 @@ function LogsSummaryDashboard({
|
|||
<div className="flex flex-col items-center gap-2">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
<p className="text-sm text-destructive">{t("failed_load_summary")}</p>
|
||||
<Button variant="outline" size="sm" onClick={onRefresh}>
|
||||
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isRefreshing}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,104 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { IconBrandYoutube } from "@tabler/icons-react";
|
||||
import { Cable, Database, Globe, Upload } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ConnectorsTab } from "@/components/sources/ConnectorsTab";
|
||||
import { DocumentUploadTab } from "@/components/sources/DocumentUploadTab";
|
||||
import { YouTubeTab } from "@/components/sources/YouTubeTab";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { trackSourcesTabViewed } from "@/lib/posthog/events";
|
||||
|
||||
export default function AddSourcesPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const search_space_id = params.search_space_id as string;
|
||||
const [activeTab, setActiveTab] = useState("documents");
|
||||
|
||||
// Handle tab from query parameter
|
||||
useEffect(() => {
|
||||
const tabParam = searchParams.get("tab");
|
||||
if (tabParam && ["documents", "youtube", "connectors"].includes(tabParam)) {
|
||||
setActiveTab(tabParam);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
if (value === "webpages") {
|
||||
router.push(`/dashboard/${search_space_id}/connectors/add/webcrawler-connector`);
|
||||
} else {
|
||||
setActiveTab(value);
|
||||
// Track tab view
|
||||
trackSourcesTabViewed(Number(search_space_id), value);
|
||||
}
|
||||
};
|
||||
|
||||
// Track initial tab view
|
||||
useEffect(() => {
|
||||
trackSourcesTabViewed(Number(search_space_id), activeTab);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 px-4 min-h-[calc(100vh-64px)]">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-2xl sm:text-4xl font-bold tracking-tight flex items-center justify-center gap-3">
|
||||
<Database className="h-6 w-6 sm:h-8 sm:w-8" />
|
||||
Add Sources
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm sm:text-lg">
|
||||
Add your sources to your search space
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||
<TabsList className="grid w-full max-w-3xl mx-auto grid-cols-4 h-12">
|
||||
<TabsTrigger value="documents" className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Documents</span>
|
||||
<span className="sm:hidden">Docs</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="youtube" className="flex items-center gap-2">
|
||||
<IconBrandYoutube className="h-4 w-4" />
|
||||
YouTube
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="webpages" className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Web Pages</span>
|
||||
<span className="sm:hidden">Web</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="connectors" className="flex items-center gap-2">
|
||||
<Cable className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Connectors</span>
|
||||
<span className="sm:hidden">More</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="mt-8">
|
||||
<TabsContent value="documents" className="space-y-6">
|
||||
<DocumentUploadTab searchSpaceId={search_space_id} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="youtube" className="space-y-6">
|
||||
<YouTubeTab searchSpaceId={search_space_id} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="connectors" className="space-y-6">
|
||||
<ConnectorsTab searchSpaceId={search_space_id} />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,12 +7,19 @@ import {
|
|||
useAssistantApi,
|
||||
useAssistantState,
|
||||
} from "@assistant-ui/react";
|
||||
import { FileText, Loader2, PlusIcon, XIcon } from "lucide-react";
|
||||
import { FileText, Loader2, Paperclip, PlusIcon, Upload, XIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { type FC, type PropsWithChildren, useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { type FC, type PropsWithChildren, useRef, useEffect, useState } from "react";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -184,23 +191,26 @@ const AttachmentUI: FC = () => {
|
|||
>
|
||||
<AttachmentPreviewDialog>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"aui-attachment-tile size-14 cursor-pointer overflow-hidden rounded-[14px] border bg-muted transition-opacity hover:opacity-75",
|
||||
isComposer && "aui-attachment-tile-composer border-foreground/20",
|
||||
isProcessing && "animate-pulse"
|
||||
)}
|
||||
role="button"
|
||||
id="attachment-tile"
|
||||
aria-label={isProcessing ? "Processing attachment..." : `${typeLabel} attachment`}
|
||||
>
|
||||
<AttachmentThumb />
|
||||
</div>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
</AttachmentPreviewDialog>
|
||||
{isComposer && !isProcessing && <AttachmentRemove />}
|
||||
</AttachmentPrimitive.Root>
|
||||
<TooltipContent side="top">
|
||||
<TooltipContent
|
||||
side="top"
|
||||
className="bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
|
|
@ -309,18 +319,54 @@ export const ComposerAttachments: FC = () => {
|
|||
};
|
||||
|
||||
export const ComposerAddAttachment: FC = () => {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const chatAttachmentInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileUpload = () => {
|
||||
router.push(`/dashboard/${searchSpaceId}/documents/upload`);
|
||||
};
|
||||
|
||||
const handleChatAttachment = () => {
|
||||
chatAttachmentInputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.AddAttachment asChild>
|
||||
<TooltipIconButton
|
||||
tooltip="Add Attachment"
|
||||
side="bottom"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="aui-composer-add-attachment size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
|
||||
aria-label="Add Attachment"
|
||||
>
|
||||
<PlusIcon className="aui-attachment-add-icon size-5 stroke-[1.5px]" />
|
||||
</TooltipIconButton>
|
||||
</ComposerPrimitive.AddAttachment>
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<TooltipIconButton
|
||||
tooltip="Upload documents or add attachment"
|
||||
side="bottom"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="aui-composer-add-attachment size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
|
||||
aria-label="Upload documents or add attachment"
|
||||
>
|
||||
<PlusIcon className="aui-attachment-add-icon size-5 stroke-[1.5px]" />
|
||||
</TooltipIconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48 bg-background border-border">
|
||||
<DropdownMenuItem onSelect={handleChatAttachment} className="cursor-pointer">
|
||||
<Paperclip className="size-4" />
|
||||
<span>Add attachment(s)</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleFileUpload} className="cursor-pointer">
|
||||
<Upload className="size-4" />
|
||||
<span>File upload</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ComposerPrimitive.AddAttachment asChild>
|
||||
<input
|
||||
ref={chatAttachmentInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
accept="image/*,application/pdf,.doc,.docx,.txt"
|
||||
/>
|
||||
</ComposerPrimitive.AddAttachment>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
312
surfsense_web/components/assistant-ui/connector-popup.tsx
Normal file
312
surfsense_web/components/assistant-ui/connector-popup.tsx
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Cable, Loader2 } from "lucide-react";
|
||||
import { type FC, useMemo, useEffect } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
||||
import { useLogsSummary } from "@/hooks/use-logs";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
} from "@/components/ui/tabs";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
|
||||
import { ActiveConnectorsTab } from "./connector-popup/tabs/active-connectors-tab";
|
||||
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
|
||||
import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view";
|
||||
import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view";
|
||||
import { IndexingConfigurationView } from "./connector-popup/connector-configs/views/indexing-configuration-view";
|
||||
import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
|
||||
import { useConnectorDialog } from "./connector-popup/hooks/use-connector-dialog";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
|
||||
export const ConnectorIndicator: FC = () => {
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const searchParams = useSearchParams();
|
||||
const { data: documentTypeCounts, isLoading: documentTypesLoading } =
|
||||
useAtomValue(documentTypeCountsAtom);
|
||||
|
||||
// Check if YouTube view is active
|
||||
const isYouTubeView = searchParams.get("view") === "youtube";
|
||||
|
||||
// Track active indexing tasks
|
||||
const { summary: logsSummary } = useLogsSummary(
|
||||
searchSpaceId ? Number(searchSpaceId) : 0,
|
||||
24,
|
||||
{
|
||||
enablePolling: true,
|
||||
refetchInterval: 5000,
|
||||
}
|
||||
);
|
||||
|
||||
// Use the custom hook for dialog state management
|
||||
const {
|
||||
isOpen,
|
||||
activeTab,
|
||||
connectingId,
|
||||
isScrolled,
|
||||
searchQuery,
|
||||
indexingConfig,
|
||||
indexingConnector,
|
||||
indexingConnectorConfig,
|
||||
editingConnector,
|
||||
connectingConnectorType,
|
||||
isCreatingConnector,
|
||||
startDate,
|
||||
endDate,
|
||||
isStartingIndexing,
|
||||
isSaving,
|
||||
isDisconnecting,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
allConnectors,
|
||||
setSearchQuery,
|
||||
setStartDate,
|
||||
setEndDate,
|
||||
setPeriodicEnabled,
|
||||
setFrequencyMinutes,
|
||||
handleOpenChange,
|
||||
handleTabChange,
|
||||
handleScroll,
|
||||
handleConnectOAuth,
|
||||
handleConnectNonOAuth,
|
||||
handleCreateWebcrawler,
|
||||
handleCreateYouTubeCrawler,
|
||||
handleSubmitConnectForm,
|
||||
handleStartIndexing,
|
||||
handleSkipIndexing,
|
||||
handleStartEdit,
|
||||
handleSaveConnector,
|
||||
handleDisconnectConnector,
|
||||
handleBackFromEdit,
|
||||
handleBackFromConnect,
|
||||
handleBackFromYouTube,
|
||||
connectorConfig,
|
||||
setConnectorConfig,
|
||||
setIndexingConnectorConfig,
|
||||
setConnectorName,
|
||||
} = useConnectorDialog();
|
||||
|
||||
// Fetch connectors using React Query with conditional refetchInterval
|
||||
// This automatically refetches when mutations invalidate the cache (event-driven)
|
||||
// and also polls when dialog is open to catch external changes
|
||||
const {
|
||||
data: connectors = [],
|
||||
isLoading: connectorsLoading,
|
||||
refetch: refreshConnectors,
|
||||
} = useQuery({
|
||||
queryKey: cacheKeys.connectors.all(searchSpaceId || ""),
|
||||
queryFn: () =>
|
||||
connectorsApiService.getConnectors({
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId ? Number(searchSpaceId) : undefined,
|
||||
},
|
||||
}),
|
||||
enabled: !!searchSpaceId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes (same as connectorsAtom)
|
||||
// Poll when dialog is open to catch external changes
|
||||
refetchInterval: isOpen ? 5000 : false, // 5 seconds when open, no polling when closed
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Also refresh document type counts when dialog is open
|
||||
useEffect(() => {
|
||||
if (!isOpen || !searchSpaceId) return;
|
||||
|
||||
const POLL_INTERVAL = 5000; // 5 seconds, same as connectors
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
// Invalidate document type counts to refresh active document types
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.documents.typeCounts(searchSpaceId),
|
||||
});
|
||||
}, POLL_INTERVAL);
|
||||
|
||||
// Cleanup interval on unmount or when dialog closes
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [isOpen, searchSpaceId, queryClient]);
|
||||
|
||||
// Get connector IDs that are currently being indexed
|
||||
const indexingConnectorIds = useMemo(() => {
|
||||
if (!logsSummary?.active_tasks) return new Set<number>();
|
||||
return new Set(
|
||||
logsSummary.active_tasks
|
||||
.filter((task) => task.source?.includes("connector_indexing") && task.connector_id != null)
|
||||
.map((task) => task.connector_id as number)
|
||||
);
|
||||
}, [logsSummary?.active_tasks]);
|
||||
|
||||
const isLoading = connectorsLoading || documentTypesLoading;
|
||||
|
||||
// Get document types that have documents in the search space
|
||||
const activeDocumentTypes = documentTypeCounts
|
||||
? Object.entries(documentTypeCounts).filter(([, count]) => count > 0)
|
||||
: [];
|
||||
|
||||
const hasConnectors = connectors.length > 0;
|
||||
const hasSources = hasConnectors || activeDocumentTypes.length > 0;
|
||||
const totalSourceCount = connectors.length + activeDocumentTypes.length;
|
||||
|
||||
// Check which connectors are already connected
|
||||
const connectedTypes = new Set(
|
||||
(allConnectors || []).map((c: SearchSourceConnector) => c.connector_type)
|
||||
);
|
||||
|
||||
if (!searchSpaceId) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<TooltipIconButton
|
||||
tooltip={hasSources ? `Manage ${totalSourceCount} sources` : "Connect your data"}
|
||||
side="bottom"
|
||||
className={cn(
|
||||
"size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative",
|
||||
"hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30",
|
||||
"outline-none focus:outline-none focus-visible:outline-none font-semibold text-xs",
|
||||
"border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none"
|
||||
)}
|
||||
aria-label={
|
||||
hasSources ? `View ${totalSourceCount} connected sources` : "Add your first connector"
|
||||
}
|
||||
onClick={() => handleOpenChange(true)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Cable className="size-4 stroke-[1.5px]" />
|
||||
{totalSourceCount > 0 && (
|
||||
<span className="absolute -top-0.5 right-0 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full bg-primary text-primary-foreground shadow-sm">
|
||||
{totalSourceCount > 99 ? "99+" : totalSourceCount}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TooltipIconButton>
|
||||
|
||||
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[90vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-6 sm:[&>button]:right-12 [&>button]:top-8 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5">
|
||||
{/* YouTube Crawler View - shown when adding YouTube videos */}
|
||||
{isYouTubeView && searchSpaceId ? (
|
||||
<YouTubeCrawlerView
|
||||
searchSpaceId={searchSpaceId}
|
||||
onBack={handleBackFromYouTube}
|
||||
/>
|
||||
) : connectingConnectorType ? (
|
||||
<ConnectorConnectView
|
||||
connectorType={connectingConnectorType}
|
||||
onSubmit={handleSubmitConnectForm}
|
||||
onBack={handleBackFromConnect}
|
||||
isSubmitting={isCreatingConnector}
|
||||
/>
|
||||
) : editingConnector ? (
|
||||
<ConnectorEditView
|
||||
connector={{
|
||||
...editingConnector,
|
||||
config: connectorConfig || editingConnector.config,
|
||||
name: editingConnector.name,
|
||||
}}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
periodicEnabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
isSaving={isSaving}
|
||||
isDisconnecting={isDisconnecting}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
onPeriodicEnabledChange={setPeriodicEnabled}
|
||||
onFrequencyChange={setFrequencyMinutes}
|
||||
onSave={() => handleSaveConnector(() => refreshConnectors())}
|
||||
onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
|
||||
onBack={handleBackFromEdit}
|
||||
onConfigChange={setConnectorConfig}
|
||||
onNameChange={setConnectorName}
|
||||
/>
|
||||
) : indexingConfig ? (
|
||||
<IndexingConfigurationView
|
||||
config={indexingConfig}
|
||||
connector={indexingConnector ? {
|
||||
...indexingConnector,
|
||||
config: indexingConnectorConfig || indexingConnector.config,
|
||||
} : undefined}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
periodicEnabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
isStartingIndexing={isStartingIndexing}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
onPeriodicEnabledChange={setPeriodicEnabled}
|
||||
onFrequencyChange={setFrequencyMinutes}
|
||||
onConfigChange={setIndexingConnectorConfig}
|
||||
onStartIndexing={() => handleStartIndexing(() => refreshConnectors())}
|
||||
onSkip={handleSkipIndexing}
|
||||
/>
|
||||
) : (
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="flex-1 flex flex-col min-h-0">
|
||||
{/* Header */}
|
||||
<ConnectorDialogHeader
|
||||
activeTab={activeTab}
|
||||
totalSourceCount={totalSourceCount}
|
||||
searchQuery={searchQuery}
|
||||
onTabChange={handleTabChange}
|
||||
onSearchChange={setSearchQuery}
|
||||
isScrolled={isScrolled}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||
<div className="h-full overflow-y-auto" onScroll={handleScroll}>
|
||||
<div className="px-6 sm:px-12 py-6 sm:py-8 pb-16 sm:pb-16">
|
||||
<TabsContent value="all" className="m-0">
|
||||
<AllConnectorsTab
|
||||
searchQuery={searchQuery}
|
||||
searchSpaceId={searchSpaceId}
|
||||
connectedTypes={connectedTypes}
|
||||
connectingId={connectingId}
|
||||
allConnectors={allConnectors}
|
||||
documentTypeCounts={documentTypeCounts}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
logsSummary={logsSummary}
|
||||
onConnectOAuth={handleConnectOAuth}
|
||||
onConnectNonOAuth={handleConnectNonOAuth}
|
||||
onCreateWebcrawler={handleCreateWebcrawler}
|
||||
onCreateYouTubeCrawler={handleCreateYouTubeCrawler}
|
||||
onManage={handleStartEdit}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<ActiveConnectorsTab
|
||||
hasSources={hasSources}
|
||||
totalSourceCount={totalSourceCount}
|
||||
activeDocumentTypes={activeDocumentTypes}
|
||||
connectors={connectors as SearchSourceConnector[]}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
logsSummary={logsSummary}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onTabChange={handleTabChange}
|
||||
onManage={handleStartEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Bottom fade shadow */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-7 bg-gradient-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
"use client";
|
||||
|
||||
import { IconBrandYoutube } from "@tabler/icons-react";
|
||||
import { FileText, Loader2 } from "lucide-react";
|
||||
import { type FC } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { LogActiveTask } from "@/contracts/types/log.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ConnectorCardProps {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
connectorType?: string;
|
||||
isConnected?: boolean;
|
||||
isConnecting?: boolean;
|
||||
documentCount?: number;
|
||||
isIndexing?: boolean;
|
||||
activeTask?: LogActiveTask;
|
||||
onConnect?: () => void;
|
||||
onManage?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a number from the active task message for display
|
||||
* Looks for patterns like "45 indexed", "Processing 123", etc.
|
||||
*/
|
||||
function extractIndexedCount(message: string | undefined): number | null {
|
||||
if (!message) return null;
|
||||
// Try to find a number in the message
|
||||
const match = message.match(/(\d+)/);
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
}
|
||||
|
||||
export const ConnectorCard: FC<ConnectorCardProps> = ({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
connectorType,
|
||||
isConnected = false,
|
||||
isConnecting = false,
|
||||
documentCount,
|
||||
isIndexing = false,
|
||||
activeTask,
|
||||
onConnect,
|
||||
onManage,
|
||||
}) => {
|
||||
// Extract count from active task message during indexing
|
||||
const indexingCount = extractIndexedCount(activeTask?.message);
|
||||
|
||||
// Determine the status content to display
|
||||
const getStatusContent = () => {
|
||||
if (isIndexing) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full max-w-[200px]">
|
||||
<span className="text-[11px] text-primary font-medium whitespace-nowrap">
|
||||
{indexingCount !== null ? (
|
||||
<>{indexingCount.toLocaleString()} indexed</>
|
||||
) : (
|
||||
"Syncing..."
|
||||
)}
|
||||
</span>
|
||||
{/* Indeterminate progress bar with animation */}
|
||||
<div className="relative flex-1 h-1 overflow-hidden rounded-full bg-primary/20">
|
||||
<div className="absolute h-full bg-primary rounded-full animate-progress-indeterminate" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isConnected) {
|
||||
if (documentCount !== undefined && documentCount > 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<FileText className="size-3 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">
|
||||
{documentCount.toLocaleString()} document{documentCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// Fallback for connected but no documents yet
|
||||
return <span className="whitespace-nowrap">No documents indexed</span>;
|
||||
}
|
||||
|
||||
return description;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group relative flex items-center gap-4 p-4 rounded-xl text-left transition-all duration-200 w-full border border-border bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg transition-colors flex-shrink-0 bg-slate-400/5 dark:bg-white/5 border border-slate-400/5 dark:border-white/5">
|
||||
{connectorType ? (
|
||||
getConnectorIcon(connectorType, "size-6")
|
||||
) : id === "youtube-crawler" ? (
|
||||
<IconBrandYoutube className="size-6" />
|
||||
) : (
|
||||
<FileText className="size-6" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[14px] font-semibold leading-tight">{title}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-1">
|
||||
{getStatusContent()}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isConnected ? "secondary" : "default"}
|
||||
className={cn(
|
||||
"h-8 text-[11px] px-3 rounded-lg flex-shrink-0 font-medium",
|
||||
isConnected && "bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80",
|
||||
!isConnected && "shadow-xs"
|
||||
)}
|
||||
onClick={isConnected ? onManage : onConnect}
|
||||
disabled={isConnecting || isIndexing}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : isIndexing ? (
|
||||
"Syncing..."
|
||||
) : isConnected ? (
|
||||
"Manage"
|
||||
) : connectorType ? (
|
||||
"Connect"
|
||||
) : (
|
||||
"Add"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
"use client";
|
||||
|
||||
import { Search } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ConnectorDialogHeaderProps {
|
||||
activeTab: string;
|
||||
totalSourceCount: number;
|
||||
searchQuery: string;
|
||||
onTabChange: (value: string) => void;
|
||||
onSearchChange: (query: string) => void;
|
||||
isScrolled: boolean;
|
||||
}
|
||||
|
||||
export const ConnectorDialogHeader: FC<ConnectorDialogHeaderProps> = ({
|
||||
totalSourceCount,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
isScrolled,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10 transition-shadow duration-200 relative z-10",
|
||||
isScrolled && "shadow-xl bg-muted/50 backdrop-blur-md"
|
||||
)}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl sm:text-3xl font-semibold tracking-tight">
|
||||
Connectors
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm sm:text-base text-muted-foreground/80 mt-1 sm:mt-1.5">
|
||||
Search across all your apps and data in one place.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:items-end justify-between gap-6 sm:gap-8 mt-6 sm:mt-8 border-b border-border/80 dark:border-white/5">
|
||||
<TabsList className="bg-transparent p-0 gap-4 sm:gap-8 h-auto w-full sm:w-auto justify-center sm:justify-start">
|
||||
<TabsTrigger
|
||||
value="all"
|
||||
className="px-0 pb-3 bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none rounded-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white transition-all text-base font-medium text-muted-foreground data-[state=active]:text-foreground"
|
||||
>
|
||||
All Connectors
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="active"
|
||||
className="group px-0 pb-3 bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none rounded-none border-b-[1.5px] border-transparent transition-all text-base font-medium flex items-center gap-2 text-muted-foreground data-[state=active]:text-foreground relative"
|
||||
>
|
||||
<span className="relative">
|
||||
Active
|
||||
<span className="absolute bottom-[-13.5px] left-1/2 -translate-x-1/2 w-12 h-[1.5px] bg-foreground dark:bg-white opacity-0 group-data-[state=active]:opacity-100 transition-all duration-200" />
|
||||
</span>
|
||||
{totalSourceCount > 0 && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-muted-foreground/15 text-[10px] font-bold">
|
||||
{totalSourceCount}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="w-full sm:w-72 sm:pb-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground/60" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
className="w-full bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10 focus:bg-slate-400/10 dark:focus:bg-white/10 border border-border rounded-xl pl-9 pr-4 py-2 text-sm transition-all outline-none placeholder:text-muted-foreground/50"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
"use client";
|
||||
|
||||
import { format, subDays, subYears } from "date-fns";
|
||||
import { Calendar as CalendarIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DateRangeSelectorProps {
|
||||
startDate: Date | undefined;
|
||||
endDate: Date | undefined;
|
||||
onStartDateChange: (date: Date | undefined) => void;
|
||||
onEndDateChange: (date: Date | undefined) => void;
|
||||
}
|
||||
|
||||
export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
}) => {
|
||||
const handleLast30Days = () => {
|
||||
const today = new Date();
|
||||
onStartDateChange(subDays(today, 30));
|
||||
onEndDateChange(today);
|
||||
};
|
||||
|
||||
const handleLastYear = () => {
|
||||
const today = new Date();
|
||||
onStartDateChange(subYears(today, 1));
|
||||
onEndDateChange(today);
|
||||
};
|
||||
|
||||
const handleClearDates = () => {
|
||||
onStartDateChange(undefined);
|
||||
onEndDateChange(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
|
||||
<h3 className="font-medium text-sm sm:text-base mb-4">Select Date Range</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mb-6">
|
||||
Choose how far back you want to sync your data. You can always re-index later with different dates.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Start Date */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="start-date" className="text-xs sm:text-sm">Start Date</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id="start-date"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm",
|
||||
!startDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{startDate ? format(startDate, "PPP") : "Default (1 year ago)"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0 z-[100]" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={startDate}
|
||||
onSelect={onStartDateChange}
|
||||
disabled={(date) => date > new Date()}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* End Date */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="end-date" className="text-xs sm:text-sm">End Date</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id="end-date"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm",
|
||||
!endDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{endDate ? format(endDate, "PPP") : "Default (Today)"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0 z-[100]" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={endDate}
|
||||
onSelect={onEndDateChange}
|
||||
disabled={(date) => date > new Date() || (startDate ? date < startDate : false)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick date range buttons */}
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearDates}
|
||||
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-slate-400/10"
|
||||
>
|
||||
Clear Dates
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLast30Days}
|
||||
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-slate-400/10"
|
||||
>
|
||||
Last 30 Days
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLastYear}
|
||||
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-slate-400/10"
|
||||
>
|
||||
Last Year
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
"use client";
|
||||
|
||||
import { type FC } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface PeriodicSyncConfigProps {
|
||||
enabled: boolean;
|
||||
frequencyMinutes: string;
|
||||
onEnabledChange: (enabled: boolean) => void;
|
||||
onFrequencyChange: (frequency: string) => void;
|
||||
}
|
||||
|
||||
export const PeriodicSyncConfig: FC<PeriodicSyncConfigProps> = ({
|
||||
enabled,
|
||||
frequencyMinutes,
|
||||
onEnabledChange,
|
||||
onFrequencyChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Automatically re-index at regular intervals
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={enabled} onCheckedChange={onEnabledChange} />
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
<div className="mt-4 pt-4 border-t border-border/100 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="frequency" className="text-xs sm:text-sm">Sync Frequency</Label>
|
||||
<Select value={frequencyMinutes} onValueChange={onFrequencyChange}>
|
||||
<SelectTrigger
|
||||
id="frequency"
|
||||
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
|
||||
>
|
||||
<SelectValue placeholder="Select frequency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[100]">
|
||||
<SelectItem value="15" className="text-xs sm:text-sm">Every 15 minutes</SelectItem>
|
||||
<SelectItem value="60" className="text-xs sm:text-sm">Every hour</SelectItem>
|
||||
<SelectItem value="360" className="text-xs sm:text-sm">Every 6 hours</SelectItem>
|
||||
<SelectItem value="720" className="text-xs sm:text-sm">Every 12 hours</SelectItem>
|
||||
<SelectItem value="1440" className="text-xs sm:text-sm">Daily</SelectItem>
|
||||
<SelectItem value="10080" className="text-xs sm:text-sm">Weekly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Info } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { ConnectFormProps } from "../index";
|
||||
import { getConnectorBenefits } from "../connector-benefits";
|
||||
|
||||
const baiduSearchApiFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().min(10, {
|
||||
message: "API key is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
type BaiduSearchApiFormValues = z.infer<typeof baiduSearchApiFormSchema>;
|
||||
|
||||
export const BaiduSearchApiConnectForm: FC<ConnectFormProps> = ({
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}) => {
|
||||
const isSubmittingRef = useRef(false);
|
||||
const form = useForm<BaiduSearchApiFormValues>({
|
||||
resolver: zodResolver(baiduSearchApiFormSchema),
|
||||
defaultValues: {
|
||||
name: "Baidu Search Connector",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: BaiduSearchApiFormValues) => {
|
||||
// Prevent multiple submissions
|
||||
if (isSubmittingRef.current || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmittingRef.current = true;
|
||||
try {
|
||||
await onSubmit({
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.BAIDU_SEARCH_API,
|
||||
config: {
|
||||
BAIDU_API_KEY: values.api_key,
|
||||
},
|
||||
is_indexable: false,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
});
|
||||
} finally {
|
||||
isSubmittingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
You'll need a Baidu AppBuilder API key to use this connector. You can get one by signing up at{" "}
|
||||
<a
|
||||
href="https://qianfan.cloud.baidu.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
qianfan.cloud.baidu.com
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<Form {...form}>
|
||||
<form id="baidu-search-api-connect-form" onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 sm:space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="My Baidu Search Connector"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Baidu AppBuilder API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your Baidu API key"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Your API key will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* What you get section */}
|
||||
{getConnectorBenefits(EnumConnectorName.BAIDU_SEARCH_API) && (
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
|
||||
<h4 className="text-xs sm:text-sm font-medium">What you get with Baidu Search:</h4>
|
||||
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
{getConnectorBenefits(EnumConnectorName.BAIDU_SEARCH_API)?.map((benefit) => (
|
||||
<li key={benefit}>{benefit}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,379 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Info } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { ConnectFormProps } from "../index";
|
||||
import { getConnectorBenefits } from "../connector-benefits";
|
||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
||||
import { useState } from "react";
|
||||
|
||||
const bookstackConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
base_url: z.string().url({ message: "Please enter a valid BookStack base URL." }),
|
||||
token_id: z.string().min(1, {
|
||||
message: "BookStack Token ID is required.",
|
||||
}),
|
||||
token_secret: z.string().min(1, {
|
||||
message: "BookStack Token Secret is required.",
|
||||
}),
|
||||
});
|
||||
|
||||
type BookStackConnectorFormValues = z.infer<typeof bookstackConnectorFormSchema>;
|
||||
|
||||
export const BookStackConnectForm: FC<ConnectFormProps> = ({
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}) => {
|
||||
const isSubmittingRef = useRef(false);
|
||||
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
|
||||
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
|
||||
const [periodicEnabled, setPeriodicEnabled] = useState(false);
|
||||
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
|
||||
const form = useForm<BookStackConnectorFormValues>({
|
||||
resolver: zodResolver(bookstackConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "BookStack Connector",
|
||||
base_url: "",
|
||||
token_id: "",
|
||||
token_secret: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: BookStackConnectorFormValues) => {
|
||||
// Prevent multiple submissions
|
||||
if (isSubmittingRef.current || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmittingRef.current = true;
|
||||
try {
|
||||
await onSubmit({
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.BOOKSTACK_CONNECTOR,
|
||||
config: {
|
||||
BOOKSTACK_BASE_URL: values.base_url,
|
||||
BOOKSTACK_TOKEN_ID: values.token_id,
|
||||
BOOKSTACK_TOKEN_SECRET: values.token_secret,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: periodicEnabled,
|
||||
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
|
||||
next_scheduled_at: null,
|
||||
startDate,
|
||||
endDate,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
});
|
||||
} finally {
|
||||
isSubmittingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">API Token Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
You'll need a BookStack API Token to use this connector. You can create one from your BookStack instance settings.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<Form {...form}>
|
||||
<form id="bookstack-connect-form" onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 sm:space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="My BookStack Connector"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="base_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">BookStack Base URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://your-bookstack-instance.com"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
The base URL of your BookStack instance (e.g., https://your-bookstack-instance.com).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="token_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Token ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Your Token ID"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Your BookStack API Token ID.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="token_secret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Token Secret</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Your Token Secret"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Your BookStack API Token Secret will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Indexing Configuration */}
|
||||
<div className="space-y-4 pt-4 border-t border-slate-400/20">
|
||||
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
|
||||
|
||||
{/* Date Range Selector */}
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
|
||||
{/* Periodic Sync Config */}
|
||||
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Automatically re-index at regular intervals
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={periodicEnabled} onCheckedChange={setPeriodicEnabled} disabled={isSubmitting} />
|
||||
</div>
|
||||
|
||||
{periodicEnabled && (
|
||||
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="frequency" className="text-xs sm:text-sm">Sync Frequency</Label>
|
||||
<Select value={frequencyMinutes} onValueChange={setFrequencyMinutes} disabled={isSubmitting}>
|
||||
<SelectTrigger
|
||||
id="frequency"
|
||||
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
|
||||
>
|
||||
<SelectValue placeholder="Select frequency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[100]">
|
||||
<SelectItem value="15" className="text-xs sm:text-sm">Every 15 minutes</SelectItem>
|
||||
<SelectItem value="60" className="text-xs sm:text-sm">Every hour</SelectItem>
|
||||
<SelectItem value="360" className="text-xs sm:text-sm">Every 6 hours</SelectItem>
|
||||
<SelectItem value="720" className="text-xs sm:text-sm">Every 12 hours</SelectItem>
|
||||
<SelectItem value="1440" className="text-xs sm:text-sm">Daily</SelectItem>
|
||||
<SelectItem value="10080" className="text-xs sm:text-sm">Weekly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* What you get section */}
|
||||
{getConnectorBenefits(EnumConnectorName.BOOKSTACK_CONNECTOR) && (
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
|
||||
<h4 className="text-xs sm:text-sm font-medium">What you get with BookStack integration:</h4>
|
||||
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
{getConnectorBenefits(EnumConnectorName.BOOKSTACK_CONNECTOR)?.map((benefit) => (
|
||||
<li key={benefit}>{benefit}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documentation Section */}
|
||||
<Accordion type="single" collapsible className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5">
|
||||
<AccordionItem value="documentation" className="border-0">
|
||||
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
|
||||
Documentation
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
The BookStack connector uses the BookStack REST API to fetch all pages from your BookStack instance that your account has access to.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
<li>
|
||||
For follow up indexing runs, the connector retrieves pages that have been updated since the last indexing attempt.
|
||||
</li>
|
||||
<li>
|
||||
Indexing is configured to run periodically, so updates should appear in your search results within minutes.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">API Token Required</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
You need to create an API token from your BookStack instance. The token requires "Access System API" permission.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 1: Create an API Token</h4>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>Log in to your BookStack instance</li>
|
||||
<li>Click on your profile icon → Edit Profile</li>
|
||||
<li>Navigate to the "API Tokens" tab</li>
|
||||
<li>Click "Create Token" and give it a name</li>
|
||||
<li>Copy both the Token ID and Token Secret</li>
|
||||
<li>Paste them in the form above</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 2: Grant necessary access</h4>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
|
||||
Your user account must have "Access System API" permission. The connector will only index content your account can view.
|
||||
</p>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">Rate Limiting</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
BookStack API has a rate limit of 180 requests per minute. The connector automatically handles rate limiting to ensure reliable indexing.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
|
||||
<li>
|
||||
Navigate to the Connector Dashboard and select the <strong>BookStack</strong> Connector.
|
||||
</li>
|
||||
<li>
|
||||
Enter your <strong>BookStack Instance URL</strong> (e.g., https://docs.example.com)
|
||||
</li>
|
||||
<li>
|
||||
Enter your <strong>Token ID</strong> and <strong>Token Secret</strong> from your BookStack API token.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Connect</strong> to establish the connection.
|
||||
</li>
|
||||
<li>Once connected, your BookStack pages will be indexed automatically.</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
<p className="mb-2">The BookStack connector indexes the following data:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>All pages from your BookStack instance</li>
|
||||
<li>Page content in Markdown format</li>
|
||||
<li>Page titles and metadata</li>
|
||||
<li>Book and chapter hierarchy information</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Info, Webhook } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { ConnectFormProps } from "../index";
|
||||
import { getConnectorBenefits } from "../connector-benefits";
|
||||
|
||||
const circlebackFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
});
|
||||
|
||||
type CirclebackFormValues = z.infer<typeof circlebackFormSchema>;
|
||||
|
||||
export const CirclebackConnectForm: FC<ConnectFormProps> = ({
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}) => {
|
||||
const isSubmittingRef = useRef(false);
|
||||
const form = useForm<CirclebackFormValues>({
|
||||
resolver: zodResolver(circlebackFormSchema),
|
||||
defaultValues: {
|
||||
name: "Circleback Connector",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: CirclebackFormValues) => {
|
||||
// Prevent multiple submissions
|
||||
if (isSubmittingRef.current || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmittingRef.current = true;
|
||||
try {
|
||||
await onSubmit({
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.CIRCLEBACK_CONNECTOR,
|
||||
config: {},
|
||||
is_indexable: false,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
});
|
||||
} finally {
|
||||
isSubmittingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Webhook className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">Webhook-Based Integration</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
Circleback uses webhooks to automatically send meeting data. After connecting, you'll receive a webhook URL to configure in your Circleback settings.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<Form {...form}>
|
||||
<form id="circleback-connect-form" onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 sm:space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="My Circleback Connector"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* What you get section */}
|
||||
{getConnectorBenefits(EnumConnectorName.CIRCLEBACK_CONNECTOR) && (
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
|
||||
<h4 className="text-xs sm:text-sm font-medium">What you get with Circleback:</h4>
|
||||
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
{getConnectorBenefits(EnumConnectorName.CIRCLEBACK_CONNECTOR)?.map((benefit) => (
|
||||
<li key={benefit}>{benefit}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,344 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Info } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { ConnectFormProps } from "../index";
|
||||
import { getConnectorBenefits } from "../connector-benefits";
|
||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
||||
import { useState } from "react";
|
||||
|
||||
const clickupConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_token: z.string().min(10, {
|
||||
message: "ClickUp API Token is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
type ClickUpConnectorFormValues = z.infer<typeof clickupConnectorFormSchema>;
|
||||
|
||||
export const ClickUpConnectForm: FC<ConnectFormProps> = ({
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}) => {
|
||||
const isSubmittingRef = useRef(false);
|
||||
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
|
||||
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
|
||||
const [periodicEnabled, setPeriodicEnabled] = useState(false);
|
||||
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
|
||||
const form = useForm<ClickUpConnectorFormValues>({
|
||||
resolver: zodResolver(clickupConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "ClickUp Connector",
|
||||
api_token: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: ClickUpConnectorFormValues) => {
|
||||
// Prevent multiple submissions
|
||||
if (isSubmittingRef.current || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmittingRef.current = true;
|
||||
try {
|
||||
await onSubmit({
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.CLICKUP_CONNECTOR,
|
||||
config: {
|
||||
CLICKUP_API_TOKEN: values.api_token,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: periodicEnabled,
|
||||
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
|
||||
next_scheduled_at: null,
|
||||
startDate,
|
||||
endDate,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
});
|
||||
} finally {
|
||||
isSubmittingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">API Token Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
You'll need a ClickUp API Token to use this connector. You can create one from{" "}
|
||||
<a
|
||||
href="https://app.clickup.com/settings/apps"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
ClickUp Settings
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<Form {...form}>
|
||||
<form id="clickup-connect-form" onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 sm:space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="My ClickUp Connector"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">ClickUp API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="pk_..."
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Your ClickUp API Token will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Indexing Configuration */}
|
||||
<div className="space-y-4 pt-4 border-t border-slate-400/20">
|
||||
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
|
||||
|
||||
{/* Date Range Selector */}
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
|
||||
{/* Periodic Sync Config */}
|
||||
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Automatically re-index at regular intervals
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={periodicEnabled} onCheckedChange={setPeriodicEnabled} disabled={isSubmitting} />
|
||||
</div>
|
||||
|
||||
{periodicEnabled && (
|
||||
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="frequency" className="text-xs sm:text-sm">Sync Frequency</Label>
|
||||
<Select value={frequencyMinutes} onValueChange={setFrequencyMinutes} disabled={isSubmitting}>
|
||||
<SelectTrigger
|
||||
id="frequency"
|
||||
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
|
||||
>
|
||||
<SelectValue placeholder="Select frequency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[100]">
|
||||
<SelectItem value="15" className="text-xs sm:text-sm">Every 15 minutes</SelectItem>
|
||||
<SelectItem value="60" className="text-xs sm:text-sm">Every hour</SelectItem>
|
||||
<SelectItem value="360" className="text-xs sm:text-sm">Every 6 hours</SelectItem>
|
||||
<SelectItem value="720" className="text-xs sm:text-sm">Every 12 hours</SelectItem>
|
||||
<SelectItem value="1440" className="text-xs sm:text-sm">Daily</SelectItem>
|
||||
<SelectItem value="10080" className="text-xs sm:text-sm">Weekly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* What you get section */}
|
||||
{getConnectorBenefits(EnumConnectorName.CLICKUP_CONNECTOR) && (
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
|
||||
<h4 className="text-xs sm:text-sm font-medium">What you get with ClickUp integration:</h4>
|
||||
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
{getConnectorBenefits(EnumConnectorName.CLICKUP_CONNECTOR)?.map((benefit) => (
|
||||
<li key={benefit}>{benefit}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documentation Section */}
|
||||
<Accordion type="single" collapsible className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5">
|
||||
<AccordionItem value="documentation" className="border-0">
|
||||
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
|
||||
Documentation
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
The ClickUp connector uses the ClickUp API to fetch all tasks and projects that your API token has access to within your workspace.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
<li>
|
||||
For follow up indexing runs, the connector retrieves tasks that have been updated since the last indexing attempt.
|
||||
</li>
|
||||
<li>
|
||||
Indexing is configured to run periodically, so updates should appear in your search results within minutes.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">API Token Required</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
You need a ClickUp personal API token to use this connector. The token will be used to read your ClickUp data.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 1: Get Your API Token</h4>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>Log in to your ClickUp account</li>
|
||||
<li>Click your avatar in the upper-right corner and select "Settings"</li>
|
||||
<li>In the sidebar, click "Apps"</li>
|
||||
<li>
|
||||
Under "API Token", click <strong>Generate</strong> or <strong>Regenerate</strong>
|
||||
</li>
|
||||
<li>Copy the generated token (it typically starts with "pk_")</li>
|
||||
<li>
|
||||
Paste it in the form above. You can also visit{" "}
|
||||
<a
|
||||
href="https://app.clickup.com/settings/apps"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
ClickUp API Settings
|
||||
</a>{" "}
|
||||
directly.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 2: Grant necessary access</h4>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
|
||||
The API Token will have access to all tasks and projects that your user account can see. Make sure your account has appropriate permissions for the workspaces you want to index.
|
||||
</p>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">Data Privacy</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
Only tasks, comments, and basic metadata will be indexed. ClickUp attachments and linked files are not indexed by this connector.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
|
||||
<li>
|
||||
Navigate to the Connector Dashboard and select the <strong>ClickUp</strong> Connector.
|
||||
</li>
|
||||
<li>
|
||||
Place your <strong>API Token</strong> in the form field.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Connect</strong> to establish the connection.
|
||||
</li>
|
||||
<li>Once connected, your ClickUp tasks will be indexed automatically.</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
<p className="mb-2">The ClickUp connector indexes the following data:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Task names and descriptions</li>
|
||||
<li>Task comments and discussion threads</li>
|
||||
<li>Task status, priority, and assignee information</li>
|
||||
<li>Project and workspace information</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,405 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Info } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { ConnectFormProps } from "../index";
|
||||
import { getConnectorBenefits } from "../connector-benefits";
|
||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
||||
import { useState } from "react";
|
||||
|
||||
const confluenceConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
base_url: z.string().url({ message: "Please enter a valid Confluence base URL." }),
|
||||
email: z.string().email({ message: "Please enter a valid email address." }),
|
||||
api_token: z.string().min(10, {
|
||||
message: "Confluence API Token is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
type ConfluenceConnectorFormValues = z.infer<typeof confluenceConnectorFormSchema>;
|
||||
|
||||
export const ConfluenceConnectForm: FC<ConnectFormProps> = ({
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}) => {
|
||||
const isSubmittingRef = useRef(false);
|
||||
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
|
||||
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
|
||||
const [periodicEnabled, setPeriodicEnabled] = useState(false);
|
||||
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
|
||||
const form = useForm<ConfluenceConnectorFormValues>({
|
||||
resolver: zodResolver(confluenceConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Confluence Connector",
|
||||
base_url: "",
|
||||
email: "",
|
||||
api_token: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: ConfluenceConnectorFormValues) => {
|
||||
// Prevent multiple submissions
|
||||
if (isSubmittingRef.current || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmittingRef.current = true;
|
||||
try {
|
||||
await onSubmit({
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.CONFLUENCE_CONNECTOR,
|
||||
config: {
|
||||
CONFLUENCE_BASE_URL: values.base_url,
|
||||
CONFLUENCE_EMAIL: values.email,
|
||||
CONFLUENCE_API_TOKEN: values.api_token,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: periodicEnabled,
|
||||
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
|
||||
next_scheduled_at: null,
|
||||
startDate,
|
||||
endDate,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
});
|
||||
} finally {
|
||||
isSubmittingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">API Token Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
You'll need a Confluence API Token to use this connector. You can create one from{" "}
|
||||
<a
|
||||
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Atlassian Account Settings
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<Form {...form}>
|
||||
<form id="confluence-connect-form" onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 sm:space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="My Confluence Connector"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="base_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Confluence Base URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://your-domain.atlassian.net"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
The base URL of your Confluence instance (e.g., https://your-domain.atlassian.net).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Email Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="your-email@example.com"
|
||||
autoComplete="email"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
The email address associated with your Atlassian account.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Your API Token"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Your Confluence API Token will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Indexing Configuration */}
|
||||
<div className="space-y-4 pt-4 border-t border-slate-400/20">
|
||||
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
|
||||
|
||||
{/* Date Range Selector */}
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
|
||||
{/* Periodic Sync Config */}
|
||||
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Automatically re-index at regular intervals
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={periodicEnabled} onCheckedChange={setPeriodicEnabled} disabled={isSubmitting} />
|
||||
</div>
|
||||
|
||||
{periodicEnabled && (
|
||||
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="frequency" className="text-xs sm:text-sm">Sync Frequency</Label>
|
||||
<Select value={frequencyMinutes} onValueChange={setFrequencyMinutes} disabled={isSubmitting}>
|
||||
<SelectTrigger
|
||||
id="frequency"
|
||||
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
|
||||
>
|
||||
<SelectValue placeholder="Select frequency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[100]">
|
||||
<SelectItem value="15" className="text-xs sm:text-sm">Every 15 minutes</SelectItem>
|
||||
<SelectItem value="60" className="text-xs sm:text-sm">Every hour</SelectItem>
|
||||
<SelectItem value="360" className="text-xs sm:text-sm">Every 6 hours</SelectItem>
|
||||
<SelectItem value="720" className="text-xs sm:text-sm">Every 12 hours</SelectItem>
|
||||
<SelectItem value="1440" className="text-xs sm:text-sm">Daily</SelectItem>
|
||||
<SelectItem value="10080" className="text-xs sm:text-sm">Weekly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* What you get section */}
|
||||
{getConnectorBenefits(EnumConnectorName.CONFLUENCE_CONNECTOR) && (
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
|
||||
<h4 className="text-xs sm:text-sm font-medium">What you get with Confluence integration:</h4>
|
||||
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
{getConnectorBenefits(EnumConnectorName.CONFLUENCE_CONNECTOR)?.map((benefit) => (
|
||||
<li key={benefit}>{benefit}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documentation Section */}
|
||||
<Accordion type="single" collapsible className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5">
|
||||
<AccordionItem value="documentation" className="border-0">
|
||||
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
|
||||
Documentation
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
The Confluence connector uses the Confluence REST API to fetch all pages and comments that your account has access to within your Confluence instance.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
<li>
|
||||
For follow up indexing runs, the connector retrieves pages and comments that have been updated since the last indexing attempt.
|
||||
</li>
|
||||
<li>
|
||||
Indexing is configured to run periodically, so updates should appear in your search results within minutes.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">Read-Only Access is Sufficient</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
You only need read access for this connector to work. The API Token will only be used to read your Confluence data.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 1: Create an API Token</h4>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>Log in to your Atlassian account</li>
|
||||
<li>
|
||||
Navigate to{" "}
|
||||
<a
|
||||
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
https://id.atlassian.com/manage-profile/security/api-tokens
|
||||
</a>{" "}
|
||||
in your browser.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Create API token</strong>
|
||||
</li>
|
||||
<li>Enter a label for your token (like "SurfSense Connector")</li>
|
||||
<li>
|
||||
Click <strong>Create</strong>
|
||||
</li>
|
||||
<li>Copy the generated token as it will only be shown once</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 2: Grant necessary access</h4>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
|
||||
The API Token will have access to all spaces and pages that your user account can see. Make sure your account has appropriate permissions for the spaces you want to index.
|
||||
</p>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">Data Privacy</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
Only pages, comments, and basic metadata will be indexed. Confluence attachments and linked files are not indexed by this connector.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
|
||||
<li>
|
||||
Navigate to the Connector Dashboard and select the <strong>Confluence</strong> Connector.
|
||||
</li>
|
||||
<li>
|
||||
Enter your <strong>Confluence Instance URL</strong> (e.g., https://yourcompany.atlassian.net)
|
||||
</li>
|
||||
<li>
|
||||
Enter your <strong>Email Address</strong> associated with your Atlassian account
|
||||
</li>
|
||||
<li>
|
||||
Place your <strong>API Token</strong> in the form field.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Connect</strong> to establish the connection.
|
||||
</li>
|
||||
<li>Once connected, your Confluence pages will be indexed automatically.</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
<p className="mb-2">The Confluence connector indexes the following data:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>All pages from accessible spaces</li>
|
||||
<li>Page content and metadata</li>
|
||||
<li>Comments on pages (both footer and inline comments)</li>
|
||||
<li>Page titles and descriptions</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,354 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Info } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { ConnectFormProps } from "../index";
|
||||
import { getConnectorBenefits } from "../connector-benefits";
|
||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
||||
import { useState } from "react";
|
||||
|
||||
const discordConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
bot_token: z
|
||||
.string()
|
||||
.min(10, {
|
||||
message: "Discord Bot Token is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
type DiscordConnectorFormValues = z.infer<typeof discordConnectorFormSchema>;
|
||||
|
||||
export const DiscordConnectForm: FC<ConnectFormProps> = ({
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}) => {
|
||||
const isSubmittingRef = useRef(false);
|
||||
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
|
||||
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
|
||||
const [periodicEnabled, setPeriodicEnabled] = useState(false);
|
||||
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
|
||||
const form = useForm<DiscordConnectorFormValues>({
|
||||
resolver: zodResolver(discordConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Discord Connector",
|
||||
bot_token: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: DiscordConnectorFormValues) => {
|
||||
// Prevent multiple submissions
|
||||
if (isSubmittingRef.current || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmittingRef.current = true;
|
||||
try {
|
||||
await onSubmit({
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.DISCORD_CONNECTOR,
|
||||
config: {
|
||||
DISCORD_BOT_TOKEN: values.bot_token,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: periodicEnabled,
|
||||
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
|
||||
next_scheduled_at: null,
|
||||
startDate,
|
||||
endDate,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
});
|
||||
} finally {
|
||||
isSubmittingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">Bot Token Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
You'll need a Discord Bot Token to use this connector. You can create one from{" "}
|
||||
<a
|
||||
href="https://discord.com/developers/applications"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Discord Developer Portal
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<Form {...form}>
|
||||
<form id="discord-connect-form" onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 sm:space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="My Discord Connector"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bot_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Discord Bot Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Your Bot Token"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Your Discord Bot Token will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Indexing Configuration */}
|
||||
<div className="space-y-4 pt-4 border-t border-slate-400/20">
|
||||
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
|
||||
|
||||
{/* Date Range Selector */}
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
|
||||
{/* Periodic Sync Config */}
|
||||
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Automatically re-index at regular intervals
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={periodicEnabled} onCheckedChange={setPeriodicEnabled} disabled={isSubmitting} />
|
||||
</div>
|
||||
|
||||
{periodicEnabled && (
|
||||
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="frequency" className="text-xs sm:text-sm">Sync Frequency</Label>
|
||||
<Select value={frequencyMinutes} onValueChange={setFrequencyMinutes} disabled={isSubmitting}>
|
||||
<SelectTrigger
|
||||
id="frequency"
|
||||
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
|
||||
>
|
||||
<SelectValue placeholder="Select frequency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[100]">
|
||||
<SelectItem value="15" className="text-xs sm:text-sm">Every 15 minutes</SelectItem>
|
||||
<SelectItem value="60" className="text-xs sm:text-sm">Every hour</SelectItem>
|
||||
<SelectItem value="360" className="text-xs sm:text-sm">Every 6 hours</SelectItem>
|
||||
<SelectItem value="720" className="text-xs sm:text-sm">Every 12 hours</SelectItem>
|
||||
<SelectItem value="1440" className="text-xs sm:text-sm">Daily</SelectItem>
|
||||
<SelectItem value="10080" className="text-xs sm:text-sm">Weekly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* What you get section */}
|
||||
{getConnectorBenefits(EnumConnectorName.DISCORD_CONNECTOR) && (
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
|
||||
<h4 className="text-xs sm:text-sm font-medium">What you get with Discord integration:</h4>
|
||||
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
{getConnectorBenefits(EnumConnectorName.DISCORD_CONNECTOR)?.map((benefit) => (
|
||||
<li key={benefit}>{benefit}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documentation Section */}
|
||||
<Accordion type="single" collapsible className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5">
|
||||
<AccordionItem value="documentation" className="border-0">
|
||||
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
|
||||
Documentation
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
The Discord connector uses the Discord API to fetch messages from all accessible channels
|
||||
that the bot token has access to within a server.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
<li>
|
||||
For follow up indexing runs, the connector retrieves messages that
|
||||
have been updated since the last indexing attempt.
|
||||
</li>
|
||||
<li>
|
||||
Indexing is configured to run periodically, so updates should appear in your
|
||||
search results within minutes.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">Bot Token Required</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
You need to create a Discord application and bot to get a bot token.
|
||||
The bot needs read access to channels and messages.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 1: Create a Discord Application</h4>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>Go to{" "}
|
||||
<a
|
||||
href="https://discord.com/developers/applications"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
https://discord.com/developers/applications
|
||||
</a>
|
||||
</li>
|
||||
<li>Click <strong>New Application</strong></li>
|
||||
<li>Enter an application name and click <strong>Create</strong></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 2: Create a Bot</h4>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>Navigate to <strong>Bot</strong> in the sidebar</li>
|
||||
<li>Click <strong>Add Bot</strong> and confirm</li>
|
||||
<li>Under <strong>Privileged Gateway Intents</strong>, enable:
|
||||
<ul className="list-disc pl-5 mt-1 space-y-1">
|
||||
<li><code className="bg-muted px-1 py-0.5 rounded">MESSAGE CONTENT INTENT</code> - Required to read message content</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 3: Get Bot Token and Invite Bot</h4>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>Under <strong>Token</strong>, click <strong>Reset Token</strong> and copy the token</li>
|
||||
<li>Navigate to <strong>OAuth2 → URL Generator</strong></li>
|
||||
<li>Select <strong>bot</strong> scope and <strong>Read Messages</strong> permission</li>
|
||||
<li>Copy the generated URL and open it in your browser</li>
|
||||
<li>Select your server and authorize the bot</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
|
||||
<li>
|
||||
Navigate to the Connector Dashboard and select the <strong>Discord</strong>{" "}
|
||||
Connector.
|
||||
</li>
|
||||
<li>
|
||||
Place the <strong>Bot Token</strong> in the form field.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Connect</strong> to establish the connection.
|
||||
</li>
|
||||
<li>Once connected, your Discord messages will be indexed automatically.</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
<p className="mb-2">The Discord connector indexes the following data:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Messages from all accessible channels</li>
|
||||
<li>Direct messages (if bot has access)</li>
|
||||
<li>Message timestamps and metadata</li>
|
||||
<li>Thread replies and conversations</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,791 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { Info } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useId, useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { ConnectFormProps } from "../index";
|
||||
import { getConnectorBenefits } from "../connector-benefits";
|
||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
||||
import { useState } from "react";
|
||||
|
||||
const elasticsearchConnectorFormSchema = z
|
||||
.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
endpoint_url: z.string().url({ message: "Please enter a valid Elasticsearch endpoint URL." }),
|
||||
auth_method: z.enum(["basic", "api_key"]),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
ELASTICSEARCH_API_KEY: z.string().optional(),
|
||||
indices: z.string().optional(),
|
||||
query: z.string(),
|
||||
search_fields: z.string().optional(),
|
||||
max_documents: z.number().min(1).max(10000).optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.auth_method === "basic") {
|
||||
return Boolean(data.username?.trim() && data.password?.trim());
|
||||
}
|
||||
if (data.auth_method === "api_key") {
|
||||
return Boolean(data.ELASTICSEARCH_API_KEY?.trim());
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Authentication credentials are required for the selected method.",
|
||||
path: ["auth_method"],
|
||||
}
|
||||
);
|
||||
|
||||
type ElasticsearchConnectorFormValues = z.infer<typeof elasticsearchConnectorFormSchema>;
|
||||
|
||||
export const ElasticsearchConnectForm: FC<ConnectFormProps> = ({
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}) => {
|
||||
const isSubmittingRef = useRef(false);
|
||||
const authBasicId = useId();
|
||||
const authApiKeyId = useId();
|
||||
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
|
||||
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
|
||||
const [periodicEnabled, setPeriodicEnabled] = useState(false);
|
||||
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
|
||||
|
||||
const form = useForm<ElasticsearchConnectorFormValues>({
|
||||
resolver: zodResolver(elasticsearchConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Elasticsearch Connector",
|
||||
endpoint_url: "",
|
||||
auth_method: "api_key",
|
||||
username: "",
|
||||
password: "",
|
||||
ELASTICSEARCH_API_KEY: "",
|
||||
indices: "",
|
||||
query: "*",
|
||||
search_fields: "",
|
||||
max_documents: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const stringToArray = (str: string): string[] => {
|
||||
const items = str
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
return Array.from(new Set(items));
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: ElasticsearchConnectorFormValues) => {
|
||||
// Prevent multiple submissions
|
||||
if (isSubmittingRef.current || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmittingRef.current = true;
|
||||
try {
|
||||
// Send full URL to backend (backend expects ELASTICSEARCH_URL)
|
||||
const config: Record<string, string | number | boolean | string[]> = {
|
||||
ELASTICSEARCH_URL: values.endpoint_url,
|
||||
// default to verifying certs; expose fields for CA/verify if UI added later
|
||||
ELASTICSEARCH_VERIFY_CERTS: true,
|
||||
};
|
||||
|
||||
if (values.auth_method === "basic") {
|
||||
if (values.username) config.ELASTICSEARCH_USERNAME = values.username;
|
||||
if (values.password) config.ELASTICSEARCH_PASSWORD = values.password;
|
||||
} else if (values.auth_method === "api_key") {
|
||||
if (values.ELASTICSEARCH_API_KEY)
|
||||
config.ELASTICSEARCH_API_KEY = values.ELASTICSEARCH_API_KEY;
|
||||
}
|
||||
|
||||
const indicesInput = values.indices?.trim() ?? "";
|
||||
const indicesArr = stringToArray(indicesInput);
|
||||
config.ELASTICSEARCH_INDEX =
|
||||
indicesArr.length === 0 ? "*" : indicesArr.length === 1 ? indicesArr[0] : indicesArr;
|
||||
|
||||
if (values.query && values.query !== "*") {
|
||||
config.ELASTICSEARCH_QUERY = values.query;
|
||||
}
|
||||
|
||||
if (values.search_fields?.trim()) {
|
||||
const fields = stringToArray(values.search_fields);
|
||||
config.ELASTICSEARCH_FIELDS = fields;
|
||||
config.ELASTICSEARCH_CONTENT_FIELDS = fields;
|
||||
if (fields.includes("title")) {
|
||||
config.ELASTICSEARCH_TITLE_FIELD = "title";
|
||||
}
|
||||
}
|
||||
|
||||
if (values.max_documents !== undefined && values.max_documents > 0) {
|
||||
config.ELASTICSEARCH_MAX_DOCUMENTS = values.max_documents;
|
||||
}
|
||||
|
||||
await onSubmit({
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.ELASTICSEARCH_CONNECTOR,
|
||||
config,
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: periodicEnabled,
|
||||
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
|
||||
next_scheduled_at: null,
|
||||
startDate,
|
||||
endDate,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
});
|
||||
} finally {
|
||||
isSubmittingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
Enter your Elasticsearch cluster endpoint URL and authentication credentials to connect.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<Form {...form}>
|
||||
<form id="elasticsearch-connect-form" onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 sm:space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="My Elasticsearch Connector"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Connection Details */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm sm:text-base font-medium">Connection Details</h3>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="endpoint_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Elasticsearch Endpoint URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="url"
|
||||
autoComplete="off"
|
||||
placeholder="https://your-cluster.es.region.aws.com:443"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Enter the complete Elasticsearch endpoint URL. We'll automatically extract the hostname, port, and SSL settings.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Show parsed URL details */}
|
||||
{form.watch("endpoint_url") && (
|
||||
<div className="rounded-lg border border-border bg-muted/50 p-3">
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Parsed Connection Details:</h4>
|
||||
<div className="text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
{(() => {
|
||||
try {
|
||||
const url = new URL(form.watch("endpoint_url"));
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<strong>Hostname:</strong> {url.hostname}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Port:</strong>{" "}
|
||||
{url.port || (url.protocol === "https:" ? "443" : "80")}
|
||||
</div>
|
||||
<div>
|
||||
<strong>SSL/TLS:</strong>{" "}
|
||||
{url.protocol === "https:" ? "Enabled" : "Disabled"}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
} catch {
|
||||
return <div className="text-destructive">Invalid URL format</div>;
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Authentication */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm sm:text-base font-medium">Authentication</h3>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="auth_method"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<FormControl>
|
||||
<RadioGroup.Root
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
// Clear auth fields when method changes
|
||||
if (value !== "basic") {
|
||||
form.setValue("username", "");
|
||||
form.setValue("password", "");
|
||||
}
|
||||
if (value !== "api_key") {
|
||||
form.setValue("ELASTICSEARCH_API_KEY", "");
|
||||
}
|
||||
}}
|
||||
value={field.value}
|
||||
className="flex flex-col space-y-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroup.Item
|
||||
value="api_key"
|
||||
id={authApiKeyId}
|
||||
className="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 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
|
||||
>
|
||||
<RadioGroup.Indicator className="flex items-center justify-center">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-current" />
|
||||
</RadioGroup.Indicator>
|
||||
</RadioGroup.Item>
|
||||
<Label htmlFor={authApiKeyId} className="text-xs sm:text-sm">API Key</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroup.Item
|
||||
value="basic"
|
||||
id={authBasicId}
|
||||
className="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 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
|
||||
>
|
||||
<RadioGroup.Indicator className="flex items-center justify-center">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-current" />
|
||||
</RadioGroup.Indicator>
|
||||
</RadioGroup.Item>
|
||||
<Label htmlFor={authBasicId} className="text-xs sm:text-sm">Username & Password</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Basic Auth Fields */}
|
||||
{form.watch("auth_method") === "basic" && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="elastic"
|
||||
autoComplete="username"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
autoComplete="current-password"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key Field */}
|
||||
{form.watch("auth_method") === "api_key" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ELASTICSEARCH_API_KEY"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Your API Key Here"
|
||||
autoComplete="off"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Enter your Elasticsearch API key (base64 encoded). This will be stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Index Selection */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="indices"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Index Selection</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="logs-*, documents-*, app-logs"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Comma-separated indices to search (e.g., "logs-*, documents-*").
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Show parsed indices as badges */}
|
||||
{form.watch("indices")?.trim() && (
|
||||
<div className="rounded-lg border border-border bg-muted/50 p-3">
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Selected Indices:</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stringToArray(form.watch("indices") ?? "").map((index) => (
|
||||
<Badge key={index} variant="secondary" className="text-[10px]">
|
||||
{index}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">Index Selection Tips</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px] mt-2">
|
||||
<ul className="list-disc pl-4 space-y-1">
|
||||
<li>Use wildcards like "logs-*" to match multiple indices</li>
|
||||
<li>Separate multiple indices with commas</li>
|
||||
<li>Leave empty to search all accessible indices including internal ones</li>
|
||||
<li>Choosing specific indices improves search performance</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Advanced Configuration */}
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="advanced">
|
||||
<AccordionTrigger className="text-xs sm:text-sm">Advanced Configuration</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
{/* Default Search Query */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="query"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">
|
||||
Default Search Query{" "}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="*"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Default Elasticsearch query to use for searches. Use "*" to match all documents.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Form Fields */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="search_fields"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">
|
||||
Search Fields{" "}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="title, content, description"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Comma-separated list of specific fields to search in (e.g., "title, content, description"). Leave empty to search all fields.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Show parsed search fields as badges */}
|
||||
{form.watch("search_fields")?.trim() && (
|
||||
<div className="rounded-lg border border-border bg-muted/50 p-3">
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Search Fields:</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stringToArray(form.watch("search_fields") ?? "").map((field) => (
|
||||
<Badge key={field} variant="outline" className="text-[10px]">
|
||||
{field}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="max_documents"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">
|
||||
Maximum Documents{" "}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="1000"
|
||||
min="1"
|
||||
max="10000"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: parseInt(e.target.value, 10)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Maximum number of documents to retrieve per search (1-10,000). Leave empty to use Elasticsearch's default limit.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
{/* Indexing Configuration */}
|
||||
<div className="space-y-4 pt-4 border-t border-slate-400/20">
|
||||
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
|
||||
|
||||
{/* Date Range Selector */}
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
|
||||
{/* Periodic Sync Config */}
|
||||
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Automatically re-index at regular intervals
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={periodicEnabled} onCheckedChange={setPeriodicEnabled} disabled={isSubmitting} />
|
||||
</div>
|
||||
|
||||
{periodicEnabled && (
|
||||
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="frequency" className="text-xs sm:text-sm">Sync Frequency</Label>
|
||||
<Select value={frequencyMinutes} onValueChange={setFrequencyMinutes} disabled={isSubmitting}>
|
||||
<SelectTrigger
|
||||
id="frequency"
|
||||
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
|
||||
>
|
||||
<SelectValue placeholder="Select frequency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[100]">
|
||||
<SelectItem value="15" className="text-xs sm:text-sm">Every 15 minutes</SelectItem>
|
||||
<SelectItem value="60" className="text-xs sm:text-sm">Every hour</SelectItem>
|
||||
<SelectItem value="360" className="text-xs sm:text-sm">Every 6 hours</SelectItem>
|
||||
<SelectItem value="720" className="text-xs sm:text-sm">Every 12 hours</SelectItem>
|
||||
<SelectItem value="1440" className="text-xs sm:text-sm">Daily</SelectItem>
|
||||
<SelectItem value="10080" className="text-xs sm:text-sm">Weekly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* What you get section */}
|
||||
{getConnectorBenefits(EnumConnectorName.ELASTICSEARCH_CONNECTOR) && (
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
|
||||
<h4 className="text-xs sm:text-sm font-medium">What you get with Elasticsearch integration:</h4>
|
||||
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
{getConnectorBenefits(EnumConnectorName.ELASTICSEARCH_CONNECTOR)?.map((benefit) => (
|
||||
<li key={benefit}>{benefit}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documentation Section */}
|
||||
<Accordion type="single" collapsible className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5">
|
||||
<AccordionItem value="documentation" className="border-0">
|
||||
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
|
||||
Documentation
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
The Elasticsearch connector allows you to search and retrieve documents from your Elasticsearch cluster. Configure connection details, select specific indices, and set search parameters to make your existing data searchable within SurfSense.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Connection Setup</h3>
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 1: Get your Elasticsearch endpoint</h4>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
|
||||
You'll need the endpoint URL for your Elasticsearch cluster. This typically looks like:
|
||||
</p>
|
||||
<ul className="list-disc pl-5 space-y-1 text-[10px] sm:text-xs text-muted-foreground mb-4">
|
||||
<li>Cloud: <code className="bg-muted px-1 py-0.5 rounded">https://your-cluster.es.region.aws.com:443</code></li>
|
||||
<li>Self-hosted: <code className="bg-muted px-1 py-0.5 rounded">https://elasticsearch.example.com:9200</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 2: Configure authentication</h4>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
|
||||
Elasticsearch requires authentication. You can use either:
|
||||
</p>
|
||||
<ul className="list-disc pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
|
||||
<li>
|
||||
<strong>API Key:</strong> A base64-encoded API key. You can create one in Elasticsearch by running:
|
||||
<pre className="bg-muted p-2 rounded mt-1 text-[9px] overflow-x-auto">
|
||||
<code>POST /_security/api_key</code>
|
||||
</pre>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Username & Password:</strong> Basic authentication using your Elasticsearch username and password.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 3: Select indices</h4>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
|
||||
Specify which indices to search. You can:
|
||||
</p>
|
||||
<ul className="list-disc pl-5 space-y-1 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>Use wildcards: <code className="bg-muted px-1 py-0.5 rounded">logs-*</code> to match multiple indices</li>
|
||||
<li>List specific indices: <code className="bg-muted px-1 py-0.5 rounded">logs-2024, documents-2024</code></li>
|
||||
<li>Leave empty to search all accessible indices (not recommended for performance)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Advanced Configuration</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Search Query</h4>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-2">
|
||||
The default query used for searches. Use <code className="bg-muted px-1 py-0.5 rounded">*</code> to match all documents, or specify a more complex Elasticsearch query.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Search Fields</h4>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-2">
|
||||
Limit searches to specific fields for better performance. Common fields include:
|
||||
</p>
|
||||
<ul className="list-disc pl-5 space-y-1 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li><code className="bg-muted px-1 py-0.5 rounded">title</code> - Document titles</li>
|
||||
<li><code className="bg-muted px-1 py-0.5 rounded">content</code> - Main content</li>
|
||||
<li><code className="bg-muted px-1 py-0.5 rounded">description</code> - Descriptions</li>
|
||||
</ul>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mt-2">
|
||||
Leave empty to search all fields in your documents.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Maximum Documents</h4>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Set a limit on the number of documents retrieved per search (1-10,000). This helps control response times and resource usage. Leave empty to use Elasticsearch's default limit.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Troubleshooting</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Connection Issues</h4>
|
||||
<ul className="list-disc pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>
|
||||
<strong>Invalid URL:</strong> Ensure your endpoint URL includes the protocol (https://) and port number if required.
|
||||
</li>
|
||||
<li>
|
||||
<strong>SSL/TLS Errors:</strong> Verify that your cluster uses HTTPS and the certificate is valid. Self-signed certificates may require additional configuration.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Connection Timeout:</strong> Check your network connectivity and firewall settings. Ensure the Elasticsearch cluster is accessible from SurfSense servers.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Authentication Issues</h4>
|
||||
<ul className="list-disc pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>
|
||||
<strong>Invalid Credentials:</strong> Double-check your username/password or API key. API keys must be base64-encoded.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Permission Denied:</strong> Ensure your API key or user account has read permissions for the indices you want to search.
|
||||
</li>
|
||||
<li>
|
||||
<strong>API Key Format:</strong> Elasticsearch API keys are typically base64-encoded strings. Make sure you're using the full key value.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Search Issues</h4>
|
||||
<ul className="list-disc pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>
|
||||
<strong>No Results:</strong> Verify that your index selection matches existing indices. Use wildcards carefully.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Slow Searches:</strong> Limit the number of indices or use specific index names instead of wildcards. Reduce the maximum documents limit.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Field Not Found:</strong> Ensure the search fields you specify actually exist in your Elasticsearch documents.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mt-4">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">Need More Help?</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
If you continue to experience issues, check your Elasticsearch cluster logs and ensure your cluster version is compatible. For Elasticsearch Cloud deployments, verify your access policies and IP allowlists.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,419 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Info } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { ConnectFormProps } from "../index";
|
||||
import { getConnectorBenefits } from "../connector-benefits";
|
||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
||||
|
||||
const githubConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
github_pat: z
|
||||
.string()
|
||||
.min(20, {
|
||||
message: "GitHub Personal Access Token seems too short.",
|
||||
})
|
||||
.refine((pat) => pat.startsWith("ghp_") || pat.startsWith("github_pat_"), {
|
||||
message: "GitHub PAT should start with 'ghp_' or 'github_pat_'",
|
||||
}),
|
||||
repo_full_names: z.string().min(1, {
|
||||
message: "At least one repository is required.",
|
||||
}),
|
||||
});
|
||||
|
||||
type GithubConnectorFormValues = z.infer<typeof githubConnectorFormSchema>;
|
||||
|
||||
export const GithubConnectForm: FC<ConnectFormProps> = ({
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}) => {
|
||||
const isSubmittingRef = useRef(false);
|
||||
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
|
||||
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
|
||||
const [periodicEnabled, setPeriodicEnabled] = useState(false);
|
||||
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
|
||||
const form = useForm<GithubConnectorFormValues>({
|
||||
resolver: zodResolver(githubConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "GitHub Connector",
|
||||
github_pat: "",
|
||||
repo_full_names: "",
|
||||
},
|
||||
});
|
||||
|
||||
const stringToArray = (str: string): string[] => {
|
||||
const items = str
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
return Array.from(new Set(items));
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: GithubConnectorFormValues) => {
|
||||
// Prevent multiple submissions
|
||||
if (isSubmittingRef.current || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmittingRef.current = true;
|
||||
try {
|
||||
const repoList = stringToArray(values.repo_full_names);
|
||||
|
||||
await onSubmit({
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.GITHUB_CONNECTOR,
|
||||
config: {
|
||||
GITHUB_PAT: values.github_pat,
|
||||
repo_full_names: repoList,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: periodicEnabled,
|
||||
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
|
||||
next_scheduled_at: null,
|
||||
startDate,
|
||||
endDate,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
});
|
||||
} finally {
|
||||
isSubmittingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">Personal Access Token Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
You'll need a GitHub Personal Access Token to use this connector. You can create one from{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
GitHub Settings
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<Form {...form}>
|
||||
<form id="github-connect-form" onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 sm:space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="My GitHub Connector"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="github_pat"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">GitHub Personal Access Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="ghp_..."
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Your GitHub PAT will be encrypted and stored securely. It typically starts with "ghp_" or "github_pat_".
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="repo_full_names"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Repository Names</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="owner/repo1, owner/repo2"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Comma-separated list of repository full names (e.g., "owner/repo1, owner/repo2").
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Show parsed repositories as badges */}
|
||||
{form.watch("repo_full_names")?.trim() && (
|
||||
<div className="rounded-lg border border-border bg-muted/50 p-3">
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Selected Repositories:</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stringToArray(form.watch("repo_full_names") ?? "").map((repo) => (
|
||||
<Badge key={repo} variant="secondary" className="text-[10px]">
|
||||
{repo}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Indexing Configuration */}
|
||||
<div className="space-y-4 pt-4 border-t border-slate-400/20">
|
||||
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
|
||||
|
||||
{/* Date Range Selector */}
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
|
||||
{/* Periodic Sync Config */}
|
||||
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Automatically re-index at regular intervals
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={periodicEnabled} onCheckedChange={setPeriodicEnabled} disabled={isSubmitting} />
|
||||
</div>
|
||||
|
||||
{periodicEnabled && (
|
||||
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="frequency" className="text-xs sm:text-sm">Sync Frequency</Label>
|
||||
<Select value={frequencyMinutes} onValueChange={setFrequencyMinutes} disabled={isSubmitting}>
|
||||
<SelectTrigger
|
||||
id="frequency"
|
||||
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
|
||||
>
|
||||
<SelectValue placeholder="Select frequency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[100]">
|
||||
<SelectItem value="15" className="text-xs sm:text-sm">Every 15 minutes</SelectItem>
|
||||
<SelectItem value="60" className="text-xs sm:text-sm">Every hour</SelectItem>
|
||||
<SelectItem value="360" className="text-xs sm:text-sm">Every 6 hours</SelectItem>
|
||||
<SelectItem value="720" className="text-xs sm:text-sm">Every 12 hours</SelectItem>
|
||||
<SelectItem value="1440" className="text-xs sm:text-sm">Daily</SelectItem>
|
||||
<SelectItem value="10080" className="text-xs sm:text-sm">Weekly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* What you get section */}
|
||||
{getConnectorBenefits(EnumConnectorName.GITHUB_CONNECTOR) && (
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
|
||||
<h4 className="text-xs sm:text-sm font-medium">What you get with GitHub integration:</h4>
|
||||
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
{getConnectorBenefits(EnumConnectorName.GITHUB_CONNECTOR)?.map((benefit) => (
|
||||
<li key={benefit}>{benefit}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documentation Section */}
|
||||
<Accordion type="single" collapsible className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5">
|
||||
<AccordionItem value="documentation" className="border-0">
|
||||
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
|
||||
Documentation
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
The GitHub connector uses a Personal Access Token (PAT) to authenticate with the GitHub API. You provide a comma-separated list of repository full names (e.g., "owner/repo1, owner/repo2") that you want to index. The connector indexes relevant files (code, markdown, text) from the selected repositories.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
<li>
|
||||
The connector indexes files based on common code and documentation extensions.
|
||||
</li>
|
||||
<li>Large files (over 1MB) are skipped during indexing.</li>
|
||||
<li>Only specified repositories are indexed.</li>
|
||||
<li>
|
||||
Indexing runs periodically (check connector settings for frequency) to keep content up-to-date.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">Personal Access Token Required</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
You'll need a GitHub PAT with the appropriate scopes (e.g., 'repo') to fetch repositories. The PAT will be stored securely to enable indexing.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 1: Generate GitHub PAT</h4>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>
|
||||
Go to your GitHub{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Developer settings
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
Click on <strong>Personal access tokens</strong>, then choose{" "}
|
||||
<strong>Tokens (classic)</strong> or <strong>Fine-grained tokens</strong> (recommended if available).
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Generate new token</strong> (and choose the appropriate type).
|
||||
</li>
|
||||
<li>
|
||||
Give your token a descriptive name (e.g., "SurfSense Connector").
|
||||
</li>
|
||||
<li>
|
||||
Set an expiration date for the token (recommended for security).
|
||||
</li>
|
||||
<li>
|
||||
Under <strong>Select scopes</strong> (for classic tokens) or <strong>Repository access</strong> (for fine-grained), grant the necessary permissions. At minimum, the <strong>`repo`</strong> scope (or equivalent read access to repositories for fine-grained tokens) is required to read repository content.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Generate token</strong>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Important:</strong> Copy your new PAT immediately. You won't be able to see it again after leaving the page.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 2: Specify repositories</h4>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
|
||||
Enter a comma-separated list of repository full names in the format "owner/repo1, owner/repo2". The connector will index files from only the specified repositories.
|
||||
</p>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">Repository Access</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
Make sure your PAT has access to all repositories you want to index. Private repositories require appropriate permissions.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
|
||||
<li>
|
||||
Navigate to the Connector Dashboard and select the <strong>GitHub</strong> Connector.
|
||||
</li>
|
||||
<li>
|
||||
Enter your <strong>GitHub Personal Access Token</strong> in the form field.
|
||||
</li>
|
||||
<li>
|
||||
Enter a comma-separated list of <strong>Repository Names</strong> (e.g., "owner/repo1, owner/repo2").
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Connect</strong> to establish the connection.
|
||||
</li>
|
||||
<li>Once connected, your GitHub repositories will be indexed automatically.</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
<p className="mb-2">The GitHub connector indexes the following data:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Code files from selected repositories</li>
|
||||
<li>README files and Markdown documentation</li>
|
||||
<li>Common text-based file formats</li>
|
||||
<li>Repository metadata and structure</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,407 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Info } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { ConnectFormProps } from "../index";
|
||||
import { getConnectorBenefits } from "../connector-benefits";
|
||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
||||
import { useState } from "react";
|
||||
|
||||
const jiraConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
base_url: z.string().url({ message: "Please enter a valid Jira base URL." }),
|
||||
email: z.string().email({ message: "Please enter a valid email address." }),
|
||||
api_token: z.string().min(10, {
|
||||
message: "Jira API Token is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
type JiraConnectorFormValues = z.infer<typeof jiraConnectorFormSchema>;
|
||||
|
||||
export const JiraConnectForm: FC<ConnectFormProps> = ({
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}) => {
|
||||
const isSubmittingRef = useRef(false);
|
||||
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
|
||||
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
|
||||
const [periodicEnabled, setPeriodicEnabled] = useState(false);
|
||||
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
|
||||
const form = useForm<JiraConnectorFormValues>({
|
||||
resolver: zodResolver(jiraConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Jira Connector",
|
||||
base_url: "",
|
||||
email: "",
|
||||
api_token: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: JiraConnectorFormValues) => {
|
||||
// Prevent multiple submissions
|
||||
if (isSubmittingRef.current || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmittingRef.current = true;
|
||||
try {
|
||||
await onSubmit({
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.JIRA_CONNECTOR,
|
||||
config: {
|
||||
JIRA_BASE_URL: values.base_url,
|
||||
JIRA_EMAIL: values.email,
|
||||
JIRA_API_TOKEN: values.api_token,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: periodicEnabled,
|
||||
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
|
||||
next_scheduled_at: null,
|
||||
startDate,
|
||||
endDate,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
});
|
||||
} finally {
|
||||
isSubmittingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">API Token Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
You'll need a Jira API Token to use this connector. You can create one from{" "}
|
||||
<a
|
||||
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Atlassian Account Settings
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<Form {...form}>
|
||||
<form id="jira-connect-form" onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 sm:space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="My Jira Connector"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="base_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Jira Base URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://your-domain.atlassian.net"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
The base URL of your Jira instance (e.g., https://your-domain.atlassian.net).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Email Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="your-email@example.com"
|
||||
autoComplete="email"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
The email address associated with your Atlassian account.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Your API Token"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Your Jira API Token will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Indexing Configuration */}
|
||||
<div className="space-y-4 pt-4 border-t border-slate-400/20">
|
||||
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
|
||||
|
||||
{/* Date Range Selector */}
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
|
||||
{/* Periodic Sync Config */}
|
||||
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Automatically re-index at regular intervals
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={periodicEnabled} onCheckedChange={setPeriodicEnabled} disabled={isSubmitting} />
|
||||
</div>
|
||||
|
||||
{periodicEnabled && (
|
||||
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="frequency" className="text-xs sm:text-sm">Sync Frequency</Label>
|
||||
<Select value={frequencyMinutes} onValueChange={setFrequencyMinutes} disabled={isSubmitting}>
|
||||
<SelectTrigger
|
||||
id="frequency"
|
||||
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
|
||||
>
|
||||
<SelectValue placeholder="Select frequency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[100]">
|
||||
<SelectItem value="15" className="text-xs sm:text-sm">Every 15 minutes</SelectItem>
|
||||
<SelectItem value="60" className="text-xs sm:text-sm">Every hour</SelectItem>
|
||||
<SelectItem value="360" className="text-xs sm:text-sm">Every 6 hours</SelectItem>
|
||||
<SelectItem value="720" className="text-xs sm:text-sm">Every 12 hours</SelectItem>
|
||||
<SelectItem value="1440" className="text-xs sm:text-sm">Daily</SelectItem>
|
||||
<SelectItem value="10080" className="text-xs sm:text-sm">Weekly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* What you get section */}
|
||||
{getConnectorBenefits(EnumConnectorName.JIRA_CONNECTOR) && (
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
|
||||
<h4 className="text-xs sm:text-sm font-medium">What you get with Jira integration:</h4>
|
||||
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
{getConnectorBenefits(EnumConnectorName.JIRA_CONNECTOR)?.map((benefit) => (
|
||||
<li key={benefit}>{benefit}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documentation Section */}
|
||||
<Accordion type="single" collapsible className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5">
|
||||
<AccordionItem value="documentation" className="border-0">
|
||||
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
|
||||
Documentation
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
The Jira connector uses the Jira REST API with Basic Authentication to fetch all issues and comments that your account has access to within your Jira instance.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
<li>
|
||||
For follow up indexing runs, the connector retrieves issues and comments that have been updated since the last indexing attempt.
|
||||
</li>
|
||||
<li>
|
||||
Indexing is configured to run periodically, so updates should appear in your search results within minutes.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">Read-Only Access is Sufficient</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
You only need read access for this connector to work. The API Token will only be used to read your Jira data.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 1: Create an API Token</h4>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>Log in to your Atlassian account</li>
|
||||
<li>
|
||||
Navigate to{" "}
|
||||
<a
|
||||
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
https://id.atlassian.com/manage-profile/security/api-tokens
|
||||
</a>{" "}
|
||||
in your browser.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Create API token</strong>
|
||||
</li>
|
||||
<li>Enter a label for your token (like "SurfSense Connector")</li>
|
||||
<li>
|
||||
Click <strong>Create</strong>
|
||||
</li>
|
||||
<li>Copy the generated token as it will only be shown once</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 2: Grant necessary access</h4>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
|
||||
The API Token will have access to all projects and issues that your user account can see. Make sure your account has appropriate permissions for the projects you want to index.
|
||||
</p>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">Data Privacy</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
Only issues, comments, and basic metadata will be indexed. Jira attachments and linked files are not indexed by this connector.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
|
||||
<li>
|
||||
Navigate to the Connector Dashboard and select the <strong>Jira</strong> Connector.
|
||||
</li>
|
||||
<li>
|
||||
Enter your <strong>Jira Instance URL</strong> (e.g., https://yourcompany.atlassian.net)
|
||||
</li>
|
||||
<li>
|
||||
Enter your <strong>Email Address</strong> associated with your Atlassian account
|
||||
</li>
|
||||
<li>
|
||||
Place your <strong>API Token</strong> in the form field.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Connect</strong> to establish the connection.
|
||||
</li>
|
||||
<li>Once connected, your Jira issues will be indexed automatically.</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
<p className="mb-2">The Jira connector indexes the following data:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Issue keys and summaries (e.g., PROJ-123)</li>
|
||||
<li>Issue descriptions</li>
|
||||
<li>Issue comments and discussion threads</li>
|
||||
<li>Issue status, priority, and type information</li>
|
||||
<li>Assignee and reporter information</li>
|
||||
<li>Project information</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,364 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Info } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { ConnectFormProps } from "../index";
|
||||
import { getConnectorBenefits } from "../connector-benefits";
|
||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
||||
import { useState } from "react";
|
||||
|
||||
const linearConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z
|
||||
.string()
|
||||
.min(10, {
|
||||
message: "Linear API Key is required and must be valid.",
|
||||
})
|
||||
.regex(/^lin_api_/, {
|
||||
message: "Linear API Key should start with 'lin_api_'",
|
||||
}),
|
||||
});
|
||||
|
||||
type LinearConnectorFormValues = z.infer<typeof linearConnectorFormSchema>;
|
||||
|
||||
export const LinearConnectForm: FC<ConnectFormProps> = ({
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}) => {
|
||||
const isSubmittingRef = useRef(false);
|
||||
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
|
||||
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
|
||||
const [periodicEnabled, setPeriodicEnabled] = useState(false);
|
||||
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
|
||||
const form = useForm<LinearConnectorFormValues>({
|
||||
resolver: zodResolver(linearConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Linear Connector",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: LinearConnectorFormValues) => {
|
||||
// Prevent multiple submissions
|
||||
if (isSubmittingRef.current || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmittingRef.current = true;
|
||||
try {
|
||||
await onSubmit({
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.LINEAR_CONNECTOR,
|
||||
config: {
|
||||
LINEAR_API_KEY: values.api_key,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: periodicEnabled,
|
||||
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
|
||||
next_scheduled_at: null,
|
||||
startDate,
|
||||
endDate,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
});
|
||||
} finally {
|
||||
isSubmittingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
You'll need a Linear API Key to use this connector. You can create one from{" "}
|
||||
<a
|
||||
href="https://linear.app/settings/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Linear API Settings
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<Form {...form}>
|
||||
<form id="linear-connect-form" onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 sm:space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="My Linear Connector"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Linear API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="lin_api_..."
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Your Linear API Key will be encrypted and stored securely. It typically starts with "lin_api_".
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Indexing Configuration */}
|
||||
<div className="space-y-4 pt-4 border-t border-slate-400/20">
|
||||
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
|
||||
|
||||
{/* Date Range Selector */}
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
|
||||
{/* Periodic Sync Config */}
|
||||
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Automatically re-index at regular intervals
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={periodicEnabled} onCheckedChange={setPeriodicEnabled} disabled={isSubmitting} />
|
||||
</div>
|
||||
|
||||
{periodicEnabled && (
|
||||
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="frequency" className="text-xs sm:text-sm">Sync Frequency</Label>
|
||||
<Select value={frequencyMinutes} onValueChange={setFrequencyMinutes} disabled={isSubmitting}>
|
||||
<SelectTrigger
|
||||
id="frequency"
|
||||
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
|
||||
>
|
||||
<SelectValue placeholder="Select frequency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[100]">
|
||||
<SelectItem value="15" className="text-xs sm:text-sm">Every 15 minutes</SelectItem>
|
||||
<SelectItem value="60" className="text-xs sm:text-sm">Every hour</SelectItem>
|
||||
<SelectItem value="360" className="text-xs sm:text-sm">Every 6 hours</SelectItem>
|
||||
<SelectItem value="720" className="text-xs sm:text-sm">Every 12 hours</SelectItem>
|
||||
<SelectItem value="1440" className="text-xs sm:text-sm">Daily</SelectItem>
|
||||
<SelectItem value="10080" className="text-xs sm:text-sm">Weekly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* What you get section */}
|
||||
{getConnectorBenefits(EnumConnectorName.LINEAR_CONNECTOR) && (
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
|
||||
<h4 className="text-xs sm:text-sm font-medium">What you get with Linear integration:</h4>
|
||||
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
{getConnectorBenefits(EnumConnectorName.LINEAR_CONNECTOR)?.map((benefit) => (
|
||||
<li key={benefit}>{benefit}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documentation Section */}
|
||||
<Accordion type="single" collapsible className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5">
|
||||
<AccordionItem value="documentation" className="border-0">
|
||||
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
|
||||
Documentation
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
The Linear connector uses the Linear GraphQL API to fetch all issues and
|
||||
comments that the API key has access to within a workspace.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
<li>
|
||||
For follow up indexing runs, the connector retrieves issues and comments that
|
||||
have been updated since the last indexing attempt.
|
||||
</li>
|
||||
<li>
|
||||
Indexing is configured to run periodically, so updates should appear in your
|
||||
search results within minutes.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">Read-Only Access is Sufficient</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
You only need a read-only API key for this connector to work. This limits
|
||||
the permissions to just reading your Linear data.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 1: Create an API key</h4>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>Log in to your Linear account</li>
|
||||
<li>
|
||||
Navigate to{" "}
|
||||
<a
|
||||
href="https://linear.app/settings/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
https://linear.app/settings/api
|
||||
</a>{" "}
|
||||
in your browser.
|
||||
</li>
|
||||
<li>Alternatively, click on your profile picture → Settings → API</li>
|
||||
<li>
|
||||
Click the <strong>+ New API key</strong> button.
|
||||
</li>
|
||||
<li>Enter a description for your key (like "Search Connector").</li>
|
||||
<li>Select "Read-only" as the permission.</li>
|
||||
<li>
|
||||
Click <strong>Create</strong> to generate the API key.
|
||||
</li>
|
||||
<li>
|
||||
Copy the generated API key that starts with 'lin_api_' as it will only
|
||||
be shown once.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 2: Grant necessary access</h4>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
|
||||
The API key will have access to all issues and comments that your user
|
||||
account can see. If you're creating the key as an admin, it will have
|
||||
access to all issues in the workspace.
|
||||
</p>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">Data Privacy</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
Only issues and comments will be indexed. Linear attachments and
|
||||
linked files are not indexed by this connector.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
|
||||
<li>
|
||||
Navigate to the Connector Dashboard and select the <strong>Linear</strong>{" "}
|
||||
Connector.
|
||||
</li>
|
||||
<li>
|
||||
Place the <strong>API Key</strong> in the form field.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Connect</strong> to establish the connection.
|
||||
</li>
|
||||
<li>Once connected, your Linear issues will be indexed automatically.</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
<p className="mb-2">The Linear connector indexes the following data:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Issue titles and identifiers (e.g., PROJ-123)</li>
|
||||
<li>Issue descriptions</li>
|
||||
<li>Issue comments</li>
|
||||
<li>Issue status and metadata</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Info } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { ConnectFormProps } from "../index";
|
||||
import { getConnectorBenefits } from "../connector-benefits";
|
||||
|
||||
const linkupApiFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().min(10, {
|
||||
message: "API key is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
type LinkupApiFormValues = z.infer<typeof linkupApiFormSchema>;
|
||||
|
||||
export const LinkupApiConnectForm: FC<ConnectFormProps> = ({
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}) => {
|
||||
const isSubmittingRef = useRef(false);
|
||||
const form = useForm<LinkupApiFormValues>({
|
||||
resolver: zodResolver(linkupApiFormSchema),
|
||||
defaultValues: {
|
||||
name: "Linkup API Connector",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: LinkupApiFormValues) => {
|
||||
// Prevent multiple submissions
|
||||
if (isSubmittingRef.current || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmittingRef.current = true;
|
||||
try {
|
||||
await onSubmit({
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.LINKUP_API,
|
||||
config: {
|
||||
LINKUP_API_KEY: values.api_key,
|
||||
},
|
||||
is_indexable: false,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
});
|
||||
} finally {
|
||||
isSubmittingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
You'll need a Linkup API key to use this connector. You can get one by signing up at{" "}
|
||||
<a
|
||||
href="https://linkup.ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
linkup.ai
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<Form {...form}>
|
||||
<form id="linkup-api-connect-form" onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 sm:space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="My Linkup API Connector"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Linkup API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your Linkup API key"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Your API key will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* What you get section */}
|
||||
{getConnectorBenefits(EnumConnectorName.LINKUP_API) && (
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
|
||||
<h4 className="text-xs sm:text-sm font-medium">What you get with Linkup API:</h4>
|
||||
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
{getConnectorBenefits(EnumConnectorName.LINKUP_API)?.map((benefit) => (
|
||||
<li key={benefit}>{benefit}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,342 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Info } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { ConnectFormProps } from "../index";
|
||||
import { getConnectorBenefits } from "../connector-benefits";
|
||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
||||
import { useState } from "react";
|
||||
|
||||
const lumaConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().min(10, {
|
||||
message: "Luma API Key is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
type LumaConnectorFormValues = z.infer<typeof lumaConnectorFormSchema>;
|
||||
|
||||
export const LumaConnectForm: FC<ConnectFormProps> = ({
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}) => {
|
||||
const isSubmittingRef = useRef(false);
|
||||
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
|
||||
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
|
||||
const [periodicEnabled, setPeriodicEnabled] = useState(false);
|
||||
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
|
||||
const form = useForm<LumaConnectorFormValues>({
|
||||
resolver: zodResolver(lumaConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Luma Connector",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: LumaConnectorFormValues) => {
|
||||
// Prevent multiple submissions
|
||||
if (isSubmittingRef.current || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmittingRef.current = true;
|
||||
try {
|
||||
await onSubmit({
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.LUMA_CONNECTOR,
|
||||
config: {
|
||||
LUMA_API_KEY: values.api_key,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: periodicEnabled,
|
||||
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
|
||||
next_scheduled_at: null,
|
||||
startDate,
|
||||
endDate,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
});
|
||||
} finally {
|
||||
isSubmittingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
You'll need a Luma API Key to use this connector. You can create one from{" "}
|
||||
<a
|
||||
href="https://lu.ma/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Luma API Settings
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<Form {...form}>
|
||||
<form id="luma-connect-form" onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 sm:space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="My Luma Connector"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Luma API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Your API Key"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Your Luma API Key will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Indexing Configuration */}
|
||||
<div className="space-y-4 pt-4 border-t border-slate-400/20">
|
||||
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
|
||||
|
||||
{/* Date Range Selector */}
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
|
||||
{/* Periodic Sync Config */}
|
||||
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Automatically re-index at regular intervals
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={periodicEnabled} onCheckedChange={setPeriodicEnabled} disabled={isSubmitting} />
|
||||
</div>
|
||||
|
||||
{periodicEnabled && (
|
||||
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="frequency" className="text-xs sm:text-sm">Sync Frequency</Label>
|
||||
<Select value={frequencyMinutes} onValueChange={setFrequencyMinutes} disabled={isSubmitting}>
|
||||
<SelectTrigger
|
||||
id="frequency"
|
||||
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
|
||||
>
|
||||
<SelectValue placeholder="Select frequency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[100]">
|
||||
<SelectItem value="15" className="text-xs sm:text-sm">Every 15 minutes</SelectItem>
|
||||
<SelectItem value="60" className="text-xs sm:text-sm">Every hour</SelectItem>
|
||||
<SelectItem value="360" className="text-xs sm:text-sm">Every 6 hours</SelectItem>
|
||||
<SelectItem value="720" className="text-xs sm:text-sm">Every 12 hours</SelectItem>
|
||||
<SelectItem value="1440" className="text-xs sm:text-sm">Daily</SelectItem>
|
||||
<SelectItem value="10080" className="text-xs sm:text-sm">Weekly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* What you get section */}
|
||||
{getConnectorBenefits(EnumConnectorName.LUMA_CONNECTOR) && (
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
|
||||
<h4 className="text-xs sm:text-sm font-medium">What you get with Luma integration:</h4>
|
||||
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
{getConnectorBenefits(EnumConnectorName.LUMA_CONNECTOR)?.map((benefit) => (
|
||||
<li key={benefit}>{benefit}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documentation Section */}
|
||||
<Accordion type="single" collapsible className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5">
|
||||
<AccordionItem value="documentation" className="border-0">
|
||||
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
|
||||
Documentation
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
The Luma connector uses the Luma API to fetch all events that your API key has access to.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
<li>
|
||||
For follow up indexing runs, the connector retrieves events that have been updated since the last indexing attempt.
|
||||
</li>
|
||||
<li>
|
||||
Indexing is configured to run periodically, so updates should appear in your search results within minutes.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">API Key Required</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
You need a Luma API key to use this connector. The key will be used to read your Luma events with read-only permissions.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 1: Get Your API Key</h4>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>Log into your Luma account</li>
|
||||
<li>Navigate to your account settings</li>
|
||||
<li>Go to API settings or Developer settings</li>
|
||||
<li>Generate a new API key</li>
|
||||
<li>Copy the generated API key</li>
|
||||
<li>
|
||||
You can also visit{" "}
|
||||
<a
|
||||
href="https://lu.ma/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Luma API Settings
|
||||
</a>{" "}
|
||||
for more information.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 2: Grant necessary access</h4>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
|
||||
The API key will have access to all events that your user account can see. Make sure your account has appropriate permissions for the events you want to index.
|
||||
</p>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">Data Privacy</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
Only event details, descriptions, and attendee information will be indexed. Event attachments and linked files are not indexed by this connector.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
|
||||
<li>
|
||||
Navigate to the Connector Dashboard and select the <strong>Luma</strong> Connector.
|
||||
</li>
|
||||
<li>
|
||||
Place your <strong>API Key</strong> in the form field.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Connect</strong> to establish the connection.
|
||||
</li>
|
||||
<li>Once connected, your Luma events will be indexed automatically.</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
<p className="mb-2">The Luma connector indexes the following data:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Event titles and descriptions</li>
|
||||
<li>Event details and metadata</li>
|
||||
<li>Attendee information</li>
|
||||
<li>Event dates and locations</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Info } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { ConnectFormProps } from "../index";
|
||||
import { getConnectorBenefits } from "../connector-benefits";
|
||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
||||
import { useState } from "react";
|
||||
|
||||
const notionConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
integration_token: z
|
||||
.string()
|
||||
.min(10, {
|
||||
message: "Notion Integration Token is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
type NotionConnectorFormValues = z.infer<typeof notionConnectorFormSchema>;
|
||||
|
||||
export const NotionConnectForm: FC<ConnectFormProps> = ({
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}) => {
|
||||
const isSubmittingRef = useRef(false);
|
||||
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
|
||||
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
|
||||
const [periodicEnabled, setPeriodicEnabled] = useState(false);
|
||||
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
|
||||
const form = useForm<NotionConnectorFormValues>({
|
||||
resolver: zodResolver(notionConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Notion Connector",
|
||||
integration_token: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: NotionConnectorFormValues) => {
|
||||
// Prevent multiple submissions
|
||||
if (isSubmittingRef.current || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmittingRef.current = true;
|
||||
try {
|
||||
await onSubmit({
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.NOTION_CONNECTOR,
|
||||
config: {
|
||||
NOTION_INTEGRATION_TOKEN: values.integration_token,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: periodicEnabled,
|
||||
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
|
||||
next_scheduled_at: null,
|
||||
startDate,
|
||||
endDate,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
});
|
||||
} finally {
|
||||
isSubmittingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">Integration Token Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
You'll need a Notion Integration Token to use this connector. You can create one from{" "}
|
||||
<a
|
||||
href="https://www.notion.so/my-integrations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Notion Integrations
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<Form {...form}>
|
||||
<form id="notion-connect-form" onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 sm:space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="My Notion Connector"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="integration_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Notion Integration Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="ntn_..."
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Your Notion Integration Token will be encrypted and stored securely. It typically starts with "ntn_".
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Indexing Configuration */}
|
||||
<div className="space-y-4 pt-4 border-t border-slate-400/20">
|
||||
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
|
||||
|
||||
{/* Date Range Selector */}
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
|
||||
{/* Periodic Sync Config */}
|
||||
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Automatically re-index at regular intervals
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={periodicEnabled} onCheckedChange={setPeriodicEnabled} disabled={isSubmitting} />
|
||||
</div>
|
||||
|
||||
{periodicEnabled && (
|
||||
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="frequency" className="text-xs sm:text-sm">Sync Frequency</Label>
|
||||
<Select value={frequencyMinutes} onValueChange={setFrequencyMinutes} disabled={isSubmitting}>
|
||||
<SelectTrigger
|
||||
id="frequency"
|
||||
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
|
||||
>
|
||||
<SelectValue placeholder="Select frequency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[100]">
|
||||
<SelectItem value="15" className="text-xs sm:text-sm">Every 15 minutes</SelectItem>
|
||||
<SelectItem value="60" className="text-xs sm:text-sm">Every hour</SelectItem>
|
||||
<SelectItem value="360" className="text-xs sm:text-sm">Every 6 hours</SelectItem>
|
||||
<SelectItem value="720" className="text-xs sm:text-sm">Every 12 hours</SelectItem>
|
||||
<SelectItem value="1440" className="text-xs sm:text-sm">Daily</SelectItem>
|
||||
<SelectItem value="10080" className="text-xs sm:text-sm">Weekly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* What you get section */}
|
||||
{getConnectorBenefits(EnumConnectorName.NOTION_CONNECTOR) && (
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
|
||||
<h4 className="text-xs sm:text-sm font-medium">What you get with Notion integration:</h4>
|
||||
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
{getConnectorBenefits(EnumConnectorName.NOTION_CONNECTOR)?.map((benefit) => (
|
||||
<li key={benefit}>{benefit}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documentation Section */}
|
||||
<Accordion type="single" collapsible className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5">
|
||||
<AccordionItem value="documentation" className="border-0">
|
||||
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
|
||||
Documentation
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
The Notion connector uses the Notion API to fetch pages from all accessible workspaces
|
||||
that the integration token has access to.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
<li>
|
||||
For follow up indexing runs, the connector retrieves pages that
|
||||
have been updated since the last indexing attempt.
|
||||
</li>
|
||||
<li>
|
||||
Indexing is configured to run periodically, so updates should appear in your
|
||||
search results within minutes.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">Integration Token Required</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
You need to create a Notion integration and share pages with it to get access.
|
||||
The integration needs read access to pages.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 1: Create a Notion Integration</h4>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>Go to{" "}
|
||||
<a
|
||||
href="https://www.notion.so/my-integrations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
https://www.notion.so/my-integrations
|
||||
</a>
|
||||
</li>
|
||||
<li>Click <strong>+ New integration</strong></li>
|
||||
<li>Enter a name for your integration (e.g., "Search Connector")</li>
|
||||
<li>Select your workspace</li>
|
||||
<li>Under <strong>Capabilities</strong>, enable <strong>Read content</strong></li>
|
||||
<li>Click <strong>Submit</strong> to create the integration</li>
|
||||
<li>Copy the <strong>Internal Integration Token</strong> (starts with "ntn_")</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 2: Share Pages with Integration</h4>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>Open the Notion pages or databases you want to index</li>
|
||||
<li>Click the <strong>⋯</strong> (three dots) menu in the top right</li>
|
||||
<li>Select <strong>Add connections</strong> or <strong>Connections</strong></li>
|
||||
<li>Search for and select your integration</li>
|
||||
<li>Repeat for all pages you want to index</li>
|
||||
</ol>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mt-3">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">Important</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
The integration can only access pages that have been explicitly shared with it.
|
||||
Make sure to share all pages you want to index.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
|
||||
<li>
|
||||
Navigate to the Connector Dashboard and select the <strong>Notion</strong>{" "}
|
||||
Connector.
|
||||
</li>
|
||||
<li>
|
||||
Place the <strong>Integration Token</strong> in the form field.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Connect</strong> to establish the connection.
|
||||
</li>
|
||||
<li>Once connected, your Notion pages will be indexed automatically.</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
<p className="mb-2">The Notion connector indexes the following data:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Page titles and content</li>
|
||||
<li>Database entries and properties</li>
|
||||
<li>Page metadata and properties</li>
|
||||
<li>Nested pages and sub-pages</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,344 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Info } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { ConnectFormProps } from "../index";
|
||||
import { getConnectorBenefits } from "../connector-benefits";
|
||||
|
||||
const searxngFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
host: z
|
||||
.string()
|
||||
.min(1, { message: "Host is required." })
|
||||
.url({ message: "Enter a valid SearxNG host URL (e.g. https://searxng.example.org)." }),
|
||||
api_key: z.string().optional(),
|
||||
engines: z.string().optional(),
|
||||
categories: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
safesearch: z
|
||||
.string()
|
||||
.regex(/^[0-2]?$/, { message: "SafeSearch must be 0, 1, or 2." })
|
||||
.optional(),
|
||||
verify_ssl: z.boolean(),
|
||||
});
|
||||
|
||||
type SearxngFormValues = z.infer<typeof searxngFormSchema>;
|
||||
|
||||
const parseCommaSeparated = (value?: string | null) => {
|
||||
if (!value) return undefined;
|
||||
const items = value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
return items.length > 0 ? items : undefined;
|
||||
};
|
||||
|
||||
export const SearxngConnectForm: FC<ConnectFormProps> = ({
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}) => {
|
||||
const isSubmittingRef = useRef(false);
|
||||
const form = useForm<SearxngFormValues>({
|
||||
resolver: zodResolver(searxngFormSchema),
|
||||
defaultValues: {
|
||||
name: "SearxNG Connector",
|
||||
host: "",
|
||||
api_key: "",
|
||||
engines: "",
|
||||
categories: "",
|
||||
language: "",
|
||||
safesearch: "",
|
||||
verify_ssl: true,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: SearxngFormValues) => {
|
||||
// Prevent multiple submissions
|
||||
if (isSubmittingRef.current || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmittingRef.current = true;
|
||||
try {
|
||||
const config: Record<string, unknown> = {
|
||||
SEARXNG_HOST: values.host.trim(),
|
||||
};
|
||||
|
||||
const apiKey = values.api_key?.trim();
|
||||
if (apiKey) config.SEARXNG_API_KEY = apiKey;
|
||||
|
||||
const engines = parseCommaSeparated(values.engines);
|
||||
if (engines) config.SEARXNG_ENGINES = engines;
|
||||
|
||||
const categories = parseCommaSeparated(values.categories);
|
||||
if (categories) config.SEARXNG_CATEGORIES = categories;
|
||||
|
||||
const language = values.language?.trim();
|
||||
if (language) config.SEARXNG_LANGUAGE = language;
|
||||
|
||||
const safesearch = values.safesearch?.trim();
|
||||
if (safesearch) {
|
||||
const parsed = Number(safesearch);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
config.SEARXNG_SAFESEARCH = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
// Include verify flag only when disabled to keep config minimal
|
||||
if (values.verify_ssl === false) {
|
||||
config.SEARXNG_VERIFY_SSL = false;
|
||||
}
|
||||
|
||||
await onSubmit({
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.SEARXNG_API,
|
||||
config,
|
||||
is_indexable: false,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
});
|
||||
} finally {
|
||||
isSubmittingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">SearxNG Instance Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
You need access to a running SearxNG instance. Refer to the{" "}
|
||||
<a
|
||||
href="https://docs.searxng.org/admin/installation-docker.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
SearxNG installation guide
|
||||
</a>{" "}
|
||||
for setup instructions. If your instance requires an API key, include it below.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<Form {...form}>
|
||||
<form id="searxng-connect-form" onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 sm:space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="My SearxNG Connector"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">SearxNG Host</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://searxng.example.org"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Provide the full base URL to your SearxNG instance. Include the protocol (http/https).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">API Key (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter API key if your instance requires one"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Leave empty if your SearxNG instance does not enforce API keys.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="engines"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Engines (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="google,bing,duckduckgo"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Comma-separated list to target specific engines.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="categories"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Categories (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="general,it,science"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Comma-separated list of SearxNG categories.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Preferred Language (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="en-US"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
IETF language tag (e.g. en, en-US). Leave blank to inherit defaults.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="safesearch"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">SafeSearch Level (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="0 (off), 1 (moderate), 2 (strict)"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Set 0, 1, or 2 to adjust SafeSearch filtering. Leave blank to use the instance default.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="verify_ssl"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border border-slate-400/20 p-3 sm:p-4">
|
||||
<div>
|
||||
<FormLabel className="text-xs sm:text-sm">Verify SSL Certificates</FormLabel>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Disable only when connecting to instances with self-signed certificates.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} disabled={isSubmitting} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* What you get section */}
|
||||
{getConnectorBenefits(EnumConnectorName.SEARXNG_API) && (
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
|
||||
<h4 className="text-xs sm:text-sm font-medium">What you get with SearxNG:</h4>
|
||||
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
{getConnectorBenefits(EnumConnectorName.SEARXNG_API)?.map((benefit) => (
|
||||
<li key={benefit}>{benefit}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,358 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Info } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { ConnectFormProps } from "../index";
|
||||
import { getConnectorBenefits } from "../connector-benefits";
|
||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
||||
import { useState } from "react";
|
||||
|
||||
const slackConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
bot_token: z
|
||||
.string()
|
||||
.min(10, {
|
||||
message: "Slack Bot Token is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
type SlackConnectorFormValues = z.infer<typeof slackConnectorFormSchema>;
|
||||
|
||||
export const SlackConnectForm: FC<ConnectFormProps> = ({
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}) => {
|
||||
const isSubmittingRef = useRef(false);
|
||||
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
|
||||
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
|
||||
const [periodicEnabled, setPeriodicEnabled] = useState(false);
|
||||
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
|
||||
const form = useForm<SlackConnectorFormValues>({
|
||||
resolver: zodResolver(slackConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Slack Connector",
|
||||
bot_token: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: SlackConnectorFormValues) => {
|
||||
// Prevent multiple submissions
|
||||
if (isSubmittingRef.current || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmittingRef.current = true;
|
||||
try {
|
||||
await onSubmit({
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.SLACK_CONNECTOR,
|
||||
config: {
|
||||
SLACK_BOT_TOKEN: values.bot_token,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: periodicEnabled,
|
||||
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
|
||||
next_scheduled_at: null,
|
||||
startDate,
|
||||
endDate,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
});
|
||||
} finally {
|
||||
isSubmittingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">Bot User OAuth Token Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
You'll need a Slack Bot User OAuth Token to use this connector. You can create a Slack app and get the token from{" "}
|
||||
<a
|
||||
href="https://api.slack.com/apps"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Slack API Dashboard
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<Form {...form}>
|
||||
<form id="slack-connect-form" onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 sm:space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="My Slack Connector"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bot_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Slack Bot User OAuth Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="xoxb-..."
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Your Bot User OAuth Token will be encrypted and stored securely. It typically starts with "xoxb-".
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Indexing Configuration */}
|
||||
<div className="space-y-4 pt-4 border-t border-slate-400/20">
|
||||
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
|
||||
|
||||
{/* Date Range Selector */}
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
|
||||
{/* Periodic Sync Config */}
|
||||
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Automatically re-index at regular intervals
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={periodicEnabled} onCheckedChange={setPeriodicEnabled} disabled={isSubmitting} />
|
||||
</div>
|
||||
|
||||
{periodicEnabled && (
|
||||
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="frequency" className="text-xs sm:text-sm">Sync Frequency</Label>
|
||||
<Select value={frequencyMinutes} onValueChange={setFrequencyMinutes} disabled={isSubmitting}>
|
||||
<SelectTrigger
|
||||
id="frequency"
|
||||
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
|
||||
>
|
||||
<SelectValue placeholder="Select frequency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[100]">
|
||||
<SelectItem value="15" className="text-xs sm:text-sm">Every 15 minutes</SelectItem>
|
||||
<SelectItem value="60" className="text-xs sm:text-sm">Every hour</SelectItem>
|
||||
<SelectItem value="360" className="text-xs sm:text-sm">Every 6 hours</SelectItem>
|
||||
<SelectItem value="720" className="text-xs sm:text-sm">Every 12 hours</SelectItem>
|
||||
<SelectItem value="1440" className="text-xs sm:text-sm">Daily</SelectItem>
|
||||
<SelectItem value="10080" className="text-xs sm:text-sm">Weekly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* What you get section */}
|
||||
{getConnectorBenefits(EnumConnectorName.SLACK_CONNECTOR) && (
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
|
||||
<h4 className="text-xs sm:text-sm font-medium">What you get with Slack integration:</h4>
|
||||
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
{getConnectorBenefits(EnumConnectorName.SLACK_CONNECTOR)?.map((benefit) => (
|
||||
<li key={benefit}>{benefit}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documentation Section */}
|
||||
<Accordion type="single" collapsible className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5">
|
||||
<AccordionItem value="documentation" className="border-0">
|
||||
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
|
||||
Documentation
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
The Slack connector uses the Slack Web API to fetch messages from all accessible channels
|
||||
that the bot token has access to within a workspace.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
<li>
|
||||
For follow up indexing runs, the connector retrieves messages that
|
||||
have been updated since the last indexing attempt.
|
||||
</li>
|
||||
<li>
|
||||
Indexing is configured to run periodically, so updates should appear in your
|
||||
search results within minutes.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">Bot User OAuth Token Required</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
You need to create a Slack app and install it to your workspace to get a Bot User OAuth Token.
|
||||
The bot needs read access to channels and messages.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 1: Create a Slack App</h4>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>Go to{" "}
|
||||
<a
|
||||
href="https://api.slack.com/apps"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
https://api.slack.com/apps
|
||||
</a>
|
||||
</li>
|
||||
<li>Click <strong>Create New App</strong> and choose "From scratch"</li>
|
||||
<li>Enter an app name and select your workspace</li>
|
||||
<li>Click <strong>Create App</strong></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 2: Configure Bot Scopes</h4>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>Navigate to <strong>OAuth & Permissions</strong> in the sidebar</li>
|
||||
<li>Under <strong>Bot Token Scopes</strong>, add the following scopes:
|
||||
<ul className="list-disc pl-5 mt-1 space-y-1">
|
||||
<li><code className="bg-muted px-1 py-0.5 rounded">channels:read</code> - View basic information about public channels</li>
|
||||
<li><code className="bg-muted px-1 py-0.5 rounded">channels:history</code> - View messages in public channels</li>
|
||||
<li><code className="bg-muted px-1 py-0.5 rounded">groups:read</code> - View basic information about private channels</li>
|
||||
<li><code className="bg-muted px-1 py-0.5 rounded">groups:history</code> - View messages in private channels</li>
|
||||
<li><code className="bg-muted px-1 py-0.5 rounded">im:read</code> - View basic information about direct messages</li>
|
||||
<li><code className="bg-muted px-1 py-0.5 rounded">im:history</code> - View messages in direct messages</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Step 3: Install App to Workspace</h4>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>Go to <strong>Install App</strong> in the sidebar</li>
|
||||
<li>Click <strong>Install to Workspace</strong></li>
|
||||
<li>Review the permissions and click <strong>Allow</strong></li>
|
||||
<li>Copy the <strong>Bot User OAuth Token</strong> from the "OAuth & Permissions" page (starts with "xoxb-")</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
|
||||
<li>
|
||||
Navigate to the Connector Dashboard and select the <strong>Slack</strong>{" "}
|
||||
Connector.
|
||||
</li>
|
||||
<li>
|
||||
Place the <strong>Bot User OAuth Token</strong> in the form field.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Connect</strong> to establish the connection.
|
||||
</li>
|
||||
<li>Once connected, your Slack messages will be indexed automatically.</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
<p className="mb-2">The Slack connector indexes the following data:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Messages from all accessible channels (public and private)</li>
|
||||
<li>Direct messages (if bot has access)</li>
|
||||
<li>Message timestamps and metadata</li>
|
||||
<li>Thread replies and conversations</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Info } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { ConnectFormProps } from "../index";
|
||||
import { getConnectorBenefits } from "../connector-benefits";
|
||||
|
||||
const tavilyApiFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().min(10, {
|
||||
message: "API key is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
type TavilyApiFormValues = z.infer<typeof tavilyApiFormSchema>;
|
||||
|
||||
export const TavilyApiConnectForm: FC<ConnectFormProps> = ({
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}) => {
|
||||
const isSubmittingRef = useRef(false);
|
||||
const form = useForm<TavilyApiFormValues>({
|
||||
resolver: zodResolver(tavilyApiFormSchema),
|
||||
defaultValues: {
|
||||
name: "Tavily API Connector",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: TavilyApiFormValues) => {
|
||||
// Prevent multiple submissions
|
||||
if (isSubmittingRef.current || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmittingRef.current = true;
|
||||
try {
|
||||
await onSubmit({
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.TAVILY_API,
|
||||
config: {
|
||||
TAVILY_API_KEY: values.api_key,
|
||||
},
|
||||
is_indexable: false,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
});
|
||||
} finally {
|
||||
isSubmittingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
You'll need a Tavily API key to use this connector. You can get one by signing up at{" "}
|
||||
<a
|
||||
href="https://tavily.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
tavily.com
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<Form {...form}>
|
||||
<form id="tavily-connect-form" onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 sm:space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="My Tavily API Connector"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Tavily API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your Tavily API key"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Your API key will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* What you get section */}
|
||||
{getConnectorBenefits(EnumConnectorName.TAVILY_API) && (
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
|
||||
<h4 className="text-xs sm:text-sm font-medium">What you get with Tavily API:</h4>
|
||||
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
{getConnectorBenefits(EnumConnectorName.TAVILY_API)?.map((benefit) => (
|
||||
<li key={benefit}>{benefit}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* Helper function to get connector-specific benefits list
|
||||
* Returns null if no benefits are defined for the connector
|
||||
*/
|
||||
export function getConnectorBenefits(connectorType: string): string[] | null {
|
||||
const benefits: Record<string, string[]> = {
|
||||
LINEAR_CONNECTOR: [
|
||||
"Search through all your Linear issues and comments",
|
||||
"Access issue titles, descriptions, and full discussion threads",
|
||||
"Connect your team's project management directly to your search space",
|
||||
"Keep your search results up-to-date with latest Linear content",
|
||||
"Index your Linear issues for enhanced search capabilities",
|
||||
],
|
||||
ELASTICSEARCH_CONNECTOR: [
|
||||
"Search across your indexed documents and logs",
|
||||
"Access structured and unstructured data from your cluster",
|
||||
"Leverage existing Elasticsearch indices for enhanced search",
|
||||
"Real-time search capabilities with powerful query features",
|
||||
"Integration with your existing Elasticsearch infrastructure",
|
||||
],
|
||||
TAVILY_API: [
|
||||
"AI-powered search results tailored to your queries",
|
||||
"Real-time information from the web",
|
||||
"Enhanced search capabilities for your projects",
|
||||
],
|
||||
SEARXNG_API: [
|
||||
"Privacy-focused meta-search across multiple engines",
|
||||
"Self-hosted search instance for full control",
|
||||
"Real-time web search results from multiple sources",
|
||||
],
|
||||
LINKUP_API: [
|
||||
"AI-powered search results tailored to your queries",
|
||||
"Real-time information from the web",
|
||||
"Enhanced search capabilities for your projects",
|
||||
],
|
||||
BAIDU_SEARCH_API: [
|
||||
"Intelligent search tailored for Chinese web content",
|
||||
"Real-time information from Baidu's search index",
|
||||
"AI-powered summarization with source references",
|
||||
],
|
||||
SLACK_CONNECTOR: [
|
||||
"Search through all your Slack messages and conversations",
|
||||
"Access messages from public and private channels",
|
||||
"Connect your team's communications directly to your search space",
|
||||
"Keep your search results up-to-date with latest Slack content",
|
||||
"Index your Slack conversations for enhanced search capabilities",
|
||||
],
|
||||
DISCORD_CONNECTOR: [
|
||||
"Search through all your Discord messages and conversations",
|
||||
"Access messages from all accessible channels",
|
||||
"Connect your community's communications directly to your search space",
|
||||
"Keep your search results up-to-date with latest Discord content",
|
||||
"Index your Discord conversations for enhanced search capabilities",
|
||||
],
|
||||
NOTION_CONNECTOR: [
|
||||
"Search through all your Notion pages and databases",
|
||||
"Access page content, properties, and metadata",
|
||||
"Connect your knowledge base directly to your search space",
|
||||
"Keep your search results up-to-date with latest Notion content",
|
||||
"Index your Notion workspace for enhanced search capabilities",
|
||||
],
|
||||
CONFLUENCE_CONNECTOR: [
|
||||
"Search through all your Confluence pages and spaces",
|
||||
"Access page content, comments, and attachments",
|
||||
"Connect your team's documentation directly to your search space",
|
||||
"Keep your search results up-to-date with latest Confluence content",
|
||||
"Index your Confluence workspace for enhanced search capabilities",
|
||||
],
|
||||
BOOKSTACK_CONNECTOR: [
|
||||
"Search through all your BookStack pages and books",
|
||||
"Access page content, chapters, and documentation",
|
||||
"Connect your documentation directly to your search space",
|
||||
"Keep your search results up-to-date with latest BookStack content",
|
||||
"Index your BookStack instance for enhanced search capabilities",
|
||||
],
|
||||
GITHUB_CONNECTOR: [
|
||||
"Search through code, issues, and documentation from GitHub repositories",
|
||||
"Access repository content, pull requests, and discussions",
|
||||
"Connect your codebase directly to your search space",
|
||||
"Keep your search results up-to-date with latest GitHub content",
|
||||
"Index your GitHub repositories for enhanced search capabilities",
|
||||
],
|
||||
JIRA_CONNECTOR: [
|
||||
"Search through all your Jira issues and tickets",
|
||||
"Access issue descriptions, comments, and project data",
|
||||
"Connect your project management directly to your search space",
|
||||
"Keep your search results up-to-date with latest Jira content",
|
||||
"Index your Jira projects for enhanced search capabilities",
|
||||
],
|
||||
CLICKUP_CONNECTOR: [
|
||||
"Search through all your ClickUp tasks and projects",
|
||||
"Access task descriptions, comments, and project data",
|
||||
"Connect your task management directly to your search space",
|
||||
"Keep your search results up-to-date with latest ClickUp content",
|
||||
"Index your ClickUp workspace for enhanced search capabilities",
|
||||
],
|
||||
LUMA_CONNECTOR: [
|
||||
"Search through all your Luma events",
|
||||
"Access event details, descriptions, and attendee information",
|
||||
"Connect your events directly to your search space",
|
||||
"Keep your search results up-to-date with latest Luma content",
|
||||
"Index your Luma events for enhanced search capabilities",
|
||||
],
|
||||
CIRCLEBACK_CONNECTOR: [
|
||||
"Automatically receive meeting notes, transcripts, and action items",
|
||||
"Access meeting details, attendees, and insights",
|
||||
"Search through all your Circleback meeting records",
|
||||
"Real-time updates via webhook integration",
|
||||
"No manual indexing required - meetings are added automatically",
|
||||
],
|
||||
};
|
||||
|
||||
return benefits[connectorType] || null;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import type { FC } from "react";
|
||||
import { BaiduSearchApiConnectForm } from "./components/baidu-search-api-connect-form";
|
||||
import { BookStackConnectForm } from "./components/bookstack-connect-form";
|
||||
import { CirclebackConnectForm } from "./components/circleback-connect-form";
|
||||
import { ClickUpConnectForm } from "./components/clickup-connect-form";
|
||||
import { ConfluenceConnectForm } from "./components/confluence-connect-form";
|
||||
import { DiscordConnectForm } from "./components/discord-connect-form";
|
||||
import { ElasticsearchConnectForm } from "./components/elasticsearch-connect-form";
|
||||
import { GithubConnectForm } from "./components/github-connect-form";
|
||||
import { JiraConnectForm } from "./components/jira-connect-form";
|
||||
import { LinearConnectForm } from "./components/linear-connect-form";
|
||||
import { LinkupApiConnectForm } from "./components/linkup-api-connect-form";
|
||||
import { LumaConnectForm } from "./components/luma-connect-form";
|
||||
import { NotionConnectForm } from "./components/notion-connect-form";
|
||||
import { SearxngConnectForm } from "./components/searxng-connect-form";
|
||||
import { SlackConnectForm } from "./components/slack-connect-form";
|
||||
import { TavilyApiConnectForm } from "./components/tavily-api-connect-form";
|
||||
|
||||
export interface ConnectFormProps {
|
||||
onSubmit: (data: {
|
||||
name: string;
|
||||
connector_type: string;
|
||||
config: Record<string, unknown>;
|
||||
is_indexable: boolean;
|
||||
last_indexed_at: null;
|
||||
periodic_indexing_enabled: boolean;
|
||||
indexing_frequency_minutes: number | null;
|
||||
next_scheduled_at: null;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
periodicEnabled?: boolean;
|
||||
frequencyMinutes?: string;
|
||||
}) => Promise<void>;
|
||||
onBack: () => void;
|
||||
isSubmitting: boolean;
|
||||
onFormSubmit?: () => void;
|
||||
}
|
||||
|
||||
export type ConnectFormComponent = FC<ConnectFormProps>;
|
||||
|
||||
/**
|
||||
* Factory function to get the appropriate connect form component for a connector type
|
||||
*/
|
||||
export function getConnectFormComponent(
|
||||
connectorType: string
|
||||
): ConnectFormComponent | null {
|
||||
switch (connectorType) {
|
||||
case "TAVILY_API":
|
||||
return TavilyApiConnectForm;
|
||||
case "SEARXNG_API":
|
||||
return SearxngConnectForm;
|
||||
case "LINKUP_API":
|
||||
return LinkupApiConnectForm;
|
||||
case "BAIDU_SEARCH_API":
|
||||
return BaiduSearchApiConnectForm;
|
||||
case "LINEAR_CONNECTOR":
|
||||
return LinearConnectForm;
|
||||
case "ELASTICSEARCH_CONNECTOR":
|
||||
return ElasticsearchConnectForm;
|
||||
case "SLACK_CONNECTOR":
|
||||
return SlackConnectForm;
|
||||
case "DISCORD_CONNECTOR":
|
||||
return DiscordConnectForm;
|
||||
case "NOTION_CONNECTOR":
|
||||
return NotionConnectForm;
|
||||
case "CONFLUENCE_CONNECTOR":
|
||||
return ConfluenceConnectForm;
|
||||
case "BOOKSTACK_CONNECTOR":
|
||||
return BookStackConnectForm;
|
||||
case "GITHUB_CONNECTOR":
|
||||
return GithubConnectForm;
|
||||
case "JIRA_CONNECTOR":
|
||||
return JiraConnectForm;
|
||||
case "CLICKUP_CONNECTOR":
|
||||
return ClickUpConnectForm;
|
||||
case "LUMA_CONNECTOR":
|
||||
return LumaConnectForm;
|
||||
case "CIRCLEBACK_CONNECTOR":
|
||||
return CirclebackConnectForm;
|
||||
// Add other connector types here as needed
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import type { FC } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
export interface BaiduSearchApiConfigProps extends ConnectorConfigProps {
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const BaiduSearchApiConfig: FC<BaiduSearchApiConfigProps> = ({
|
||||
connector,
|
||||
onConfigChange,
|
||||
onNameChange,
|
||||
}) => {
|
||||
const [apiKey, setApiKey] = useState<string>(
|
||||
(connector.config?.BAIDU_API_KEY as string) || ""
|
||||
);
|
||||
const [name, setName] = useState<string>(connector.name || "");
|
||||
|
||||
// Update API key and name when connector changes
|
||||
useEffect(() => {
|
||||
const key = (connector.config?.BAIDU_API_KEY as string) || "";
|
||||
setApiKey(key);
|
||||
setName(connector.name || "");
|
||||
}, [connector.config, connector.name]);
|
||||
|
||||
const handleApiKeyChange = (value: string) => {
|
||||
setApiKey(value);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
BAIDU_API_KEY: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (onNameChange) {
|
||||
onNameChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Connector Name */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Connector Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My Baidu Search Connector"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
A friendly name to identify this connector.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base">Configuration</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Baidu AppBuilder API Key
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
placeholder="Enter your Baidu API key"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Update the Baidu API Key if needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
"use client";
|
||||
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import type { FC } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
export interface BookStackConfigProps extends ConnectorConfigProps {
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const BookStackConfig: FC<BookStackConfigProps> = ({
|
||||
connector,
|
||||
onConfigChange,
|
||||
onNameChange,
|
||||
}) => {
|
||||
const [baseUrl, setBaseUrl] = useState<string>(
|
||||
(connector.config?.BOOKSTACK_BASE_URL as string) || ""
|
||||
);
|
||||
const [tokenId, setTokenId] = useState<string>(
|
||||
(connector.config?.BOOKSTACK_TOKEN_ID as string) || ""
|
||||
);
|
||||
const [tokenSecret, setTokenSecret] = useState<string>(
|
||||
(connector.config?.BOOKSTACK_TOKEN_SECRET as string) || ""
|
||||
);
|
||||
const [name, setName] = useState<string>(connector.name || "");
|
||||
|
||||
// Update values when connector changes
|
||||
useEffect(() => {
|
||||
const url = (connector.config?.BOOKSTACK_BASE_URL as string) || "";
|
||||
const id = (connector.config?.BOOKSTACK_TOKEN_ID as string) || "";
|
||||
const secret = (connector.config?.BOOKSTACK_TOKEN_SECRET as string) || "";
|
||||
setBaseUrl(url);
|
||||
setTokenId(id);
|
||||
setTokenSecret(secret);
|
||||
setName(connector.name || "");
|
||||
}, [connector.config, connector.name]);
|
||||
|
||||
const handleBaseUrlChange = (value: string) => {
|
||||
setBaseUrl(value);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
BOOKSTACK_BASE_URL: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTokenIdChange = (value: string) => {
|
||||
setTokenId(value);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
BOOKSTACK_TOKEN_ID: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTokenSecretChange = (value: string) => {
|
||||
setTokenSecret(value);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
BOOKSTACK_TOKEN_SECRET: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (onNameChange) {
|
||||
onNameChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Connector Name */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Connector Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My BookStack Connector"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
A friendly name to identify this connector.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base">Configuration</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">BookStack Base URL</Label>
|
||||
<Input
|
||||
type="url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => handleBaseUrlChange(e.target.value)}
|
||||
placeholder="https://your-bookstack-instance.com"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
The base URL of your BookStack instance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Token ID</Label>
|
||||
<Input
|
||||
value={tokenId}
|
||||
onChange={(e) => handleTokenIdChange(e.target.value)}
|
||||
placeholder="Your Token ID"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Your BookStack API Token ID.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Token Secret
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={tokenSecret}
|
||||
onChange={(e) => handleTokenSecretChange(e.target.value)}
|
||||
placeholder="Your Token Secret"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Update your BookStack Token Secret if needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
"use client";
|
||||
|
||||
import { Copy, Webhook, Check } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import type { FC } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
export interface CirclebackConfigProps extends ConnectorConfigProps {
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const CirclebackConfig: FC<CirclebackConfigProps> = ({
|
||||
connector,
|
||||
onNameChange,
|
||||
}) => {
|
||||
const [name, setName] = useState<string>(connector.name || "");
|
||||
const [webhookUrl, setWebhookUrl] = useState<string>("");
|
||||
const [webhookInfo, setWebhookInfo] = useState<{ webhook_url: string; search_space_id: number; method: string; content_type: string; description: string; note: string } | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Update name when connector changes
|
||||
useEffect(() => {
|
||||
setName(connector.name || "");
|
||||
}, [connector.name]);
|
||||
|
||||
// Fetch webhook info
|
||||
useEffect(() => {
|
||||
const fetchWebhookInfo = async () => {
|
||||
if (!connector.search_space_id) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/webhooks/circleback/${connector.search_space_id}/info`
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setWebhookInfo(data);
|
||||
setWebhookUrl(data.webhook_url || "");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch webhook info:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchWebhookInfo();
|
||||
}, [connector.search_space_id]);
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (onNameChange) {
|
||||
onNameChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyWebhookUrl = async () => {
|
||||
if (webhookUrl) {
|
||||
await navigator.clipboard.writeText(webhookUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Connector Name */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Connector Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My Circleback Connector"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
A friendly name to identify this connector.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Webhook Configuration */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base flex items-center gap-2">
|
||||
<Webhook className="h-4 w-4" />
|
||||
Webhook Configuration
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Loading webhook information...
|
||||
</p>
|
||||
) : webhookUrl ? (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Webhook URL</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={webhookUrl}
|
||||
readOnly
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopyWebhookUrl}
|
||||
className="shrink-0"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Use this URL in your Circleback automation settings to send meeting data to SurfSense.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Unable to load webhook URL. Please try refreshing the page.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{webhookInfo && (
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Webhook className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-xs sm:text-sm">Configuration Instructions</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0 mt-1">
|
||||
Configure this URL in Circleback Settings → Automations → Create automation → Send webhook request.
|
||||
The webhook will automatically send meeting notes, transcripts, and action items to this search space.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import type { FC } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
export interface ClickUpConfigProps extends ConnectorConfigProps {
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const ClickUpConfig: FC<ClickUpConfigProps> = ({
|
||||
connector,
|
||||
onConfigChange,
|
||||
onNameChange,
|
||||
}) => {
|
||||
const [apiToken, setApiToken] = useState<string>(
|
||||
(connector.config?.CLICKUP_API_TOKEN as string) || ""
|
||||
);
|
||||
const [name, setName] = useState<string>(connector.name || "");
|
||||
|
||||
// Update API token and name when connector changes
|
||||
useEffect(() => {
|
||||
const token = (connector.config?.CLICKUP_API_TOKEN as string) || "";
|
||||
setApiToken(token);
|
||||
setName(connector.name || "");
|
||||
}, [connector.config, connector.name]);
|
||||
|
||||
const handleApiTokenChange = (value: string) => {
|
||||
setApiToken(value);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
CLICKUP_API_TOKEN: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (onNameChange) {
|
||||
onNameChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Connector Name */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Connector Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My ClickUp Connector"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
A friendly name to identify this connector.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base">Configuration</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
ClickUp API Token
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={apiToken}
|
||||
onChange={(e) => handleApiTokenChange(e.target.value)}
|
||||
placeholder="pk_..."
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Update your ClickUp API Token if needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
"use client";
|
||||
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import type { FC } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
export interface ConfluenceConfigProps extends ConnectorConfigProps {
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const ConfluenceConfig: FC<ConfluenceConfigProps> = ({
|
||||
connector,
|
||||
onConfigChange,
|
||||
onNameChange,
|
||||
}) => {
|
||||
const [baseUrl, setBaseUrl] = useState<string>(
|
||||
(connector.config?.CONFLUENCE_BASE_URL as string) || ""
|
||||
);
|
||||
const [email, setEmail] = useState<string>(
|
||||
(connector.config?.CONFLUENCE_EMAIL as string) || ""
|
||||
);
|
||||
const [apiToken, setApiToken] = useState<string>(
|
||||
(connector.config?.CONFLUENCE_API_TOKEN as string) || ""
|
||||
);
|
||||
const [name, setName] = useState<string>(connector.name || "");
|
||||
|
||||
// Update values when connector changes
|
||||
useEffect(() => {
|
||||
const url = (connector.config?.CONFLUENCE_BASE_URL as string) || "";
|
||||
const emailVal = (connector.config?.CONFLUENCE_EMAIL as string) || "";
|
||||
const token = (connector.config?.CONFLUENCE_API_TOKEN as string) || "";
|
||||
setBaseUrl(url);
|
||||
setEmail(emailVal);
|
||||
setApiToken(token);
|
||||
setName(connector.name || "");
|
||||
}, [connector.config, connector.name]);
|
||||
|
||||
const handleBaseUrlChange = (value: string) => {
|
||||
setBaseUrl(value);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
CONFLUENCE_BASE_URL: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailChange = (value: string) => {
|
||||
setEmail(value);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
CONFLUENCE_EMAIL: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiTokenChange = (value: string) => {
|
||||
setApiToken(value);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
CONFLUENCE_API_TOKEN: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (onNameChange) {
|
||||
onNameChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Connector Name */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Connector Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My Confluence Connector"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
A friendly name to identify this connector.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base">Configuration</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Confluence Base URL</Label>
|
||||
<Input
|
||||
type="url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => handleBaseUrlChange(e.target.value)}
|
||||
placeholder="https://your-domain.atlassian.net"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
The base URL of your Confluence instance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Email Address</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => handleEmailChange(e.target.value)}
|
||||
placeholder="your-email@example.com"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
The email address associated with your Atlassian account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
API Token
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={apiToken}
|
||||
onChange={(e) => handleApiTokenChange(e.target.value)}
|
||||
placeholder="Your API Token"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Update your Confluence API Token if needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import type { FC } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
export interface DiscordConfigProps extends ConnectorConfigProps {
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const DiscordConfig: FC<DiscordConfigProps> = ({
|
||||
connector,
|
||||
onConfigChange,
|
||||
onNameChange,
|
||||
}) => {
|
||||
const [botToken, setBotToken] = useState<string>(
|
||||
(connector.config?.DISCORD_BOT_TOKEN as string) || ""
|
||||
);
|
||||
const [name, setName] = useState<string>(connector.name || "");
|
||||
|
||||
// Update bot token and name when connector changes
|
||||
useEffect(() => {
|
||||
const token = (connector.config?.DISCORD_BOT_TOKEN as string) || "";
|
||||
setBotToken(token);
|
||||
setName(connector.name || "");
|
||||
}, [connector.config, connector.name]);
|
||||
|
||||
const handleBotTokenChange = (value: string) => {
|
||||
setBotToken(value);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
DISCORD_BOT_TOKEN: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (onNameChange) {
|
||||
onNameChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Connector Name */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Connector Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My Discord Connector"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
A friendly name to identify this connector.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base">Configuration</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Discord Bot Token
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={botToken}
|
||||
onChange={(e) => handleBotTokenChange(e.target.value)}
|
||||
placeholder="Your Bot Token"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Update your Discord Bot Token if needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,438 @@
|
|||
"use client";
|
||||
|
||||
import { KeyRound, Server } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import type { FC } from "react";
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { useId } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
export interface ElasticsearchConfigProps extends ConnectorConfigProps {
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const ElasticsearchConfig: FC<ElasticsearchConfigProps> = ({
|
||||
connector,
|
||||
onConfigChange,
|
||||
onNameChange,
|
||||
}) => {
|
||||
const authBasicId = useId();
|
||||
const authApiKeyId = useId();
|
||||
|
||||
const [name, setName] = useState<string>(connector.name || "");
|
||||
const [endpointUrl, setEndpointUrl] = useState<string>(
|
||||
(connector.config?.ELASTICSEARCH_URL as string) || ""
|
||||
);
|
||||
const [authMethod, setAuthMethod] = useState<"basic" | "api_key">(
|
||||
(connector.config?.ELASTICSEARCH_API_KEY ? "api_key" : "basic") as "basic" | "api_key"
|
||||
);
|
||||
const [username, setUsername] = useState<string>(
|
||||
(connector.config?.ELASTICSEARCH_USERNAME as string) || ""
|
||||
);
|
||||
const [password, setPassword] = useState<string>(
|
||||
(connector.config?.ELASTICSEARCH_PASSWORD as string) || ""
|
||||
);
|
||||
const [apiKey, setApiKey] = useState<string>(
|
||||
(connector.config?.ELASTICSEARCH_API_KEY as string) || ""
|
||||
);
|
||||
const [indices, setIndices] = useState<string>(
|
||||
Array.isArray(connector.config?.ELASTICSEARCH_INDEX)
|
||||
? (connector.config?.ELASTICSEARCH_INDEX as string[]).join(", ")
|
||||
: (connector.config?.ELASTICSEARCH_INDEX as string) || ""
|
||||
);
|
||||
const [query, setQuery] = useState<string>(
|
||||
(connector.config?.ELASTICSEARCH_QUERY as string) || "*"
|
||||
);
|
||||
const [searchFields, setSearchFields] = useState<string>(
|
||||
Array.isArray(connector.config?.ELASTICSEARCH_FIELDS)
|
||||
? (connector.config?.ELASTICSEARCH_FIELDS as string[]).join(", ")
|
||||
: ""
|
||||
);
|
||||
const [maxDocuments, setMaxDocuments] = useState<string>(
|
||||
connector.config?.ELASTICSEARCH_MAX_DOCUMENTS
|
||||
? String(connector.config.ELASTICSEARCH_MAX_DOCUMENTS)
|
||||
: ""
|
||||
);
|
||||
|
||||
// Update values when connector changes
|
||||
useEffect(() => {
|
||||
setName(connector.name || "");
|
||||
setEndpointUrl((connector.config?.ELASTICSEARCH_URL as string) || "");
|
||||
setAuthMethod(
|
||||
(connector.config?.ELASTICSEARCH_API_KEY ? "api_key" : "basic") as "basic" | "api_key"
|
||||
);
|
||||
setUsername((connector.config?.ELASTICSEARCH_USERNAME as string) || "");
|
||||
setPassword((connector.config?.ELASTICSEARCH_PASSWORD as string) || "");
|
||||
setApiKey((connector.config?.ELASTICSEARCH_API_KEY as string) || "");
|
||||
setIndices(
|
||||
Array.isArray(connector.config?.ELASTICSEARCH_INDEX)
|
||||
? (connector.config?.ELASTICSEARCH_INDEX as string[]).join(", ")
|
||||
: (connector.config?.ELASTICSEARCH_INDEX as string) || ""
|
||||
);
|
||||
setQuery((connector.config?.ELASTICSEARCH_QUERY as string) || "*");
|
||||
setSearchFields(
|
||||
Array.isArray(connector.config?.ELASTICSEARCH_FIELDS)
|
||||
? (connector.config?.ELASTICSEARCH_FIELDS as string[]).join(", ")
|
||||
: ""
|
||||
);
|
||||
setMaxDocuments(
|
||||
connector.config?.ELASTICSEARCH_MAX_DOCUMENTS
|
||||
? String(connector.config.ELASTICSEARCH_MAX_DOCUMENTS)
|
||||
: ""
|
||||
);
|
||||
}, [connector.config, connector.name]);
|
||||
|
||||
const stringToArray = (str: string): string[] => {
|
||||
const items = str
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
return Array.from(new Set(items));
|
||||
};
|
||||
|
||||
const updateConfig = (updates: Record<string, unknown>) => {
|
||||
if (onConfigChange) {
|
||||
// Filter out undefined values to remove keys
|
||||
const filteredUpdates = Object.fromEntries(
|
||||
Object.entries(updates).filter(([_, value]) => value !== undefined)
|
||||
);
|
||||
const newConfig = {
|
||||
...connector.config,
|
||||
...filteredUpdates,
|
||||
};
|
||||
// Remove keys that were set to undefined
|
||||
Object.keys(updates).forEach((key) => {
|
||||
if (updates[key] === undefined) {
|
||||
delete newConfig[key];
|
||||
}
|
||||
});
|
||||
onConfigChange(newConfig);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (onNameChange) {
|
||||
onNameChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndpointUrlChange = (value: string) => {
|
||||
setEndpointUrl(value);
|
||||
updateConfig({ ELASTICSEARCH_URL: value });
|
||||
};
|
||||
|
||||
const handleAuthMethodChange = (value: "basic" | "api_key") => {
|
||||
setAuthMethod(value);
|
||||
if (value === "basic") {
|
||||
updateConfig({
|
||||
ELASTICSEARCH_API_KEY: undefined,
|
||||
});
|
||||
} else {
|
||||
updateConfig({
|
||||
ELASTICSEARCH_USERNAME: undefined,
|
||||
ELASTICSEARCH_PASSWORD: undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUsernameChange = (value: string) => {
|
||||
setUsername(value);
|
||||
updateConfig({ ELASTICSEARCH_USERNAME: value });
|
||||
};
|
||||
|
||||
const handlePasswordChange = (value: string) => {
|
||||
setPassword(value);
|
||||
updateConfig({ ELASTICSEARCH_PASSWORD: value });
|
||||
};
|
||||
|
||||
const handleApiKeyChange = (value: string) => {
|
||||
setApiKey(value);
|
||||
updateConfig({ ELASTICSEARCH_API_KEY: value });
|
||||
};
|
||||
|
||||
const handleIndicesChange = (value: string) => {
|
||||
setIndices(value);
|
||||
const indicesArr = stringToArray(value);
|
||||
const indexValue =
|
||||
indicesArr.length === 0 ? "*" : indicesArr.length === 1 ? indicesArr[0] : indicesArr;
|
||||
updateConfig({ ELASTICSEARCH_INDEX: indexValue });
|
||||
};
|
||||
|
||||
const handleQueryChange = (value: string) => {
|
||||
setQuery(value);
|
||||
if (value && value !== "*") {
|
||||
updateConfig({ ELASTICSEARCH_QUERY: value });
|
||||
} else {
|
||||
// Remove the key by setting it to undefined
|
||||
updateConfig({ ELASTICSEARCH_QUERY: undefined });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchFieldsChange = (value: string) => {
|
||||
setSearchFields(value);
|
||||
if (value.trim()) {
|
||||
const fields = stringToArray(value);
|
||||
updateConfig({
|
||||
ELASTICSEARCH_FIELDS: fields,
|
||||
ELASTICSEARCH_CONTENT_FIELDS: fields,
|
||||
ELASTICSEARCH_TITLE_FIELD: fields.includes("title") ? "title" : undefined,
|
||||
});
|
||||
} else {
|
||||
// Remove the keys by setting them to undefined
|
||||
updateConfig({
|
||||
ELASTICSEARCH_FIELDS: undefined,
|
||||
ELASTICSEARCH_CONTENT_FIELDS: undefined,
|
||||
ELASTICSEARCH_TITLE_FIELD: undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMaxDocumentsChange = (value: string) => {
|
||||
setMaxDocuments(value);
|
||||
if (value && value.trim()) {
|
||||
const num = parseInt(value, 10);
|
||||
if (!isNaN(num) && num > 0) {
|
||||
updateConfig({ ELASTICSEARCH_MAX_DOCUMENTS: num });
|
||||
}
|
||||
} else {
|
||||
// Remove the key by setting it to undefined
|
||||
updateConfig({ ELASTICSEARCH_MAX_DOCUMENTS: undefined });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Connector Name */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Connector Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My Elasticsearch Connector"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
A friendly name to identify this connector.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Details */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
Connection Details
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Elasticsearch Endpoint URL</Label>
|
||||
<Input
|
||||
type="url"
|
||||
value={endpointUrl}
|
||||
onChange={(e) => handleEndpointUrlChange(e.target.value)}
|
||||
placeholder="https://your-cluster.es.region.aws.com:443"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Update the Elasticsearch endpoint URL if needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Authentication */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base flex items-center gap-2">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Authentication
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<RadioGroup.Root
|
||||
value={authMethod}
|
||||
onValueChange={(value) => handleAuthMethodChange(value as "basic" | "api_key")}
|
||||
className="flex flex-col space-y-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroup.Item
|
||||
value="api_key"
|
||||
id={authApiKeyId}
|
||||
className="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 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
|
||||
>
|
||||
<RadioGroup.Indicator className="flex items-center justify-center">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-current" />
|
||||
</RadioGroup.Indicator>
|
||||
</RadioGroup.Item>
|
||||
<Label htmlFor={authApiKeyId} className="text-xs sm:text-sm">API Key</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroup.Item
|
||||
value="basic"
|
||||
id={authBasicId}
|
||||
className="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 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
|
||||
>
|
||||
<RadioGroup.Indicator className="flex items-center justify-center">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-current" />
|
||||
</RadioGroup.Indicator>
|
||||
</RadioGroup.Item>
|
||||
<Label htmlFor={authBasicId} className="text-xs sm:text-sm">Username & Password</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
|
||||
{authMethod === "basic" && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Username</Label>
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => handleUsernameChange(e.target.value)}
|
||||
placeholder="elastic"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Password</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => handlePasswordChange(e.target.value)}
|
||||
placeholder="Password"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authMethod === "api_key" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">API Key</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
placeholder="Your API Key Here"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Update the Elasticsearch API key if needed.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Index Selection */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base">Index Selection</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Indices</Label>
|
||||
<Input
|
||||
value={indices}
|
||||
onChange={(e) => handleIndicesChange(e.target.value)}
|
||||
placeholder="logs-*, documents-*, app-logs"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Comma-separated indices to search (e.g., "logs-*, documents-*").
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{indices.trim() && (
|
||||
<div className="rounded-lg border border-border bg-muted/50 p-3">
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Selected Indices:</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stringToArray(indices).map((index) => (
|
||||
<Badge key={index} variant="secondary" className="text-[10px]">
|
||||
{index}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced Configuration */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base">Advanced Configuration</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">
|
||||
Default Search Query <span className="text-muted-foreground">(Optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => handleQueryChange(e.target.value)}
|
||||
placeholder="*"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Default Elasticsearch query to use for searches. Use "*" to match all documents.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">
|
||||
Search Fields <span className="text-muted-foreground">(Optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={searchFields}
|
||||
onChange={(e) => handleSearchFieldsChange(e.target.value)}
|
||||
placeholder="title, content, description"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Comma-separated list of specific fields to search in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{searchFields.trim() && (
|
||||
<div className="rounded-lg border border-border bg-muted/50 p-3">
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Search Fields:</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stringToArray(searchFields).map((field) => (
|
||||
<Badge key={field} variant="outline" className="text-[10px]">
|
||||
{field}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">
|
||||
Maximum Documents <span className="text-muted-foreground">(Optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={maxDocuments}
|
||||
onChange={(e) => handleMaxDocumentsChange(e.target.value)}
|
||||
placeholder="1000"
|
||||
min="1"
|
||||
max="10000"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Maximum number of documents to retrieve per search (1-10,000).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
"use client";
|
||||
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import type { FC } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
export interface GithubConfigProps extends ConnectorConfigProps {
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const GithubConfig: FC<GithubConfigProps> = ({
|
||||
connector,
|
||||
onConfigChange,
|
||||
onNameChange,
|
||||
}) => {
|
||||
const stringToArray = (arr: string[] | string | undefined): string[] => {
|
||||
if (Array.isArray(arr)) return arr;
|
||||
if (typeof arr === "string") {
|
||||
return arr.split(",").map((item) => item.trim()).filter((item) => item.length > 0);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const arrayToString = (arr: string[]): string => {
|
||||
return arr.join(", ");
|
||||
};
|
||||
|
||||
const [githubPat, setGithubPat] = useState<string>(
|
||||
(connector.config?.GITHUB_PAT as string) || ""
|
||||
);
|
||||
const [repoFullNames, setRepoFullNames] = useState<string>(
|
||||
arrayToString(stringToArray(connector.config?.repo_full_names))
|
||||
);
|
||||
const [name, setName] = useState<string>(connector.name || "");
|
||||
|
||||
// Update values when connector changes
|
||||
useEffect(() => {
|
||||
const pat = (connector.config?.GITHUB_PAT as string) || "";
|
||||
const repos = arrayToString(stringToArray(connector.config?.repo_full_names));
|
||||
setGithubPat(pat);
|
||||
setRepoFullNames(repos);
|
||||
setName(connector.name || "");
|
||||
}, [connector.config, connector.name]);
|
||||
|
||||
const handleGithubPatChange = (value: string) => {
|
||||
setGithubPat(value);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
GITHUB_PAT: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRepoFullNamesChange = (value: string) => {
|
||||
setRepoFullNames(value);
|
||||
const repoList = stringToArray(value);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
repo_full_names: repoList,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (onNameChange) {
|
||||
onNameChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Connector Name */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Connector Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My GitHub Connector"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
A friendly name to identify this connector.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base">Configuration</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
GitHub Personal Access Token
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={githubPat}
|
||||
onChange={(e) => handleGithubPatChange(e.target.value)}
|
||||
placeholder="ghp_..."
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Update your GitHub PAT if needed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Repository Names</Label>
|
||||
<Input
|
||||
value={repoFullNames}
|
||||
onChange={(e) => handleRepoFullNamesChange(e.target.value)}
|
||||
placeholder="owner/repo1, owner/repo2"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Comma-separated list of repository full names.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Show parsed repositories as badges */}
|
||||
{repoFullNames.trim() && (
|
||||
<div className="rounded-lg border border-border bg-muted/50 p-3">
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Repositories:</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stringToArray(repoFullNames).map((repo) => (
|
||||
<Badge key={repo} variant="secondary" className="text-[10px]">
|
||||
{repo}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
"use client";
|
||||
|
||||
import { Info } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import type { FC } from "react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GoogleDriveFolderTree } from "@/components/connectors/google-drive-folder-tree";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
interface SelectedFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({
|
||||
connector,
|
||||
onConfigChange,
|
||||
}) => {
|
||||
// Initialize with existing selected folders from connector config
|
||||
const existingFolders = (connector.config?.selected_folders as SelectedFolder[] | undefined) || [];
|
||||
const [selectedFolders, setSelectedFolders] = useState<SelectedFolder[]>(existingFolders);
|
||||
const [showFolderSelector, setShowFolderSelector] = useState(false);
|
||||
|
||||
// Update selected folders when connector config changes
|
||||
useEffect(() => {
|
||||
const folders = (connector.config?.selected_folders as SelectedFolder[] | undefined) || [];
|
||||
setSelectedFolders(folders);
|
||||
}, [connector.config]);
|
||||
|
||||
const handleSelectFolders = (folders: SelectedFolder[]) => {
|
||||
setSelectedFolders(folders);
|
||||
if (onConfigChange) {
|
||||
// Store folder IDs and names in config for indexing
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
selected_folders: folders,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base">Folder Selection</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Select specific folders to index. Only files directly in each folder will be processed—subfolders must be selected separately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedFolders.length > 0 && (
|
||||
<div className="p-2 sm:p-3 bg-muted rounded-lg text-xs sm:text-sm space-y-1 sm:space-y-2">
|
||||
<p className="font-medium">
|
||||
Selected {selectedFolders.length} folder{selectedFolders.length > 1 ? "s" : ""}:
|
||||
</p>
|
||||
<div className="max-h-20 sm:max-h-24 overflow-y-auto">
|
||||
{selectedFolders.map((folder) => (
|
||||
<p key={folder.id} className="text-xs sm:text-sm text-muted-foreground truncate" title={folder.name}>
|
||||
• {folder.name}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFolderSelector ? (
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
<GoogleDriveFolderTree
|
||||
connectorId={connector.id}
|
||||
selectedFolders={selectedFolders}
|
||||
onSelectFolders={handleSelectFolders}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowFolderSelector(false)}
|
||||
className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-white/10 text-xs sm:text-sm h-8 sm:h-9"
|
||||
>
|
||||
Done Selecting
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowFolderSelector(true)}
|
||||
className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-white/10 text-xs sm:text-sm h-8 sm:h-9"
|
||||
>
|
||||
{selectedFolders.length > 0 ? "Change Folder Selection" : "Select Folders"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center gap-2 [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0" />
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
Folder selection is used when indexing. You can change this selection when you start indexing.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
"use client";
|
||||
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import type { FC } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
export interface JiraConfigProps extends ConnectorConfigProps {
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const JiraConfig: FC<JiraConfigProps> = ({
|
||||
connector,
|
||||
onConfigChange,
|
||||
onNameChange,
|
||||
}) => {
|
||||
const [baseUrl, setBaseUrl] = useState<string>(
|
||||
(connector.config?.JIRA_BASE_URL as string) || ""
|
||||
);
|
||||
const [email, setEmail] = useState<string>(
|
||||
(connector.config?.JIRA_EMAIL as string) || ""
|
||||
);
|
||||
const [apiToken, setApiToken] = useState<string>(
|
||||
(connector.config?.JIRA_API_TOKEN as string) || ""
|
||||
);
|
||||
const [name, setName] = useState<string>(connector.name || "");
|
||||
|
||||
// Update values when connector changes
|
||||
useEffect(() => {
|
||||
const url = (connector.config?.JIRA_BASE_URL as string) || "";
|
||||
const emailVal = (connector.config?.JIRA_EMAIL as string) || "";
|
||||
const token = (connector.config?.JIRA_API_TOKEN as string) || "";
|
||||
setBaseUrl(url);
|
||||
setEmail(emailVal);
|
||||
setApiToken(token);
|
||||
setName(connector.name || "");
|
||||
}, [connector.config, connector.name]);
|
||||
|
||||
const handleBaseUrlChange = (value: string) => {
|
||||
setBaseUrl(value);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
JIRA_BASE_URL: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailChange = (value: string) => {
|
||||
setEmail(value);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
JIRA_EMAIL: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiTokenChange = (value: string) => {
|
||||
setApiToken(value);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
JIRA_API_TOKEN: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (onNameChange) {
|
||||
onNameChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Connector Name */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Connector Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My Jira Connector"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
A friendly name to identify this connector.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base">Configuration</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Jira Base URL</Label>
|
||||
<Input
|
||||
type="url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => handleBaseUrlChange(e.target.value)}
|
||||
placeholder="https://your-domain.atlassian.net"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
The base URL of your Jira instance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Email Address</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => handleEmailChange(e.target.value)}
|
||||
placeholder="your-email@example.com"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
The email address associated with your Atlassian account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
API Token
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={apiToken}
|
||||
onChange={(e) => handleApiTokenChange(e.target.value)}
|
||||
placeholder="Your API Token"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Update your Jira API Token if needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import type { FC } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
export interface LinearConfigProps extends ConnectorConfigProps {
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const LinearConfig: FC<LinearConfigProps> = ({
|
||||
connector,
|
||||
onConfigChange,
|
||||
onNameChange,
|
||||
}) => {
|
||||
const [apiKey, setApiKey] = useState<string>(
|
||||
(connector.config?.LINEAR_API_KEY as string) || ""
|
||||
);
|
||||
const [name, setName] = useState<string>(connector.name || "");
|
||||
|
||||
// Update API key and name when connector changes
|
||||
useEffect(() => {
|
||||
const key = (connector.config?.LINEAR_API_KEY as string) || "";
|
||||
setApiKey(key);
|
||||
setName(connector.name || "");
|
||||
}, [connector.config, connector.name]);
|
||||
|
||||
const handleApiKeyChange = (value: string) => {
|
||||
setApiKey(value);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
LINEAR_API_KEY: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (onNameChange) {
|
||||
onNameChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Connector Name */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Connector Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My Linear Connector"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
A friendly name to identify this connector.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base">Configuration</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Linear API Key
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
placeholder="Begins with lin_api_..."
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Update your Linear API Key if needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import type { FC } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
export interface LinkupApiConfigProps extends ConnectorConfigProps {
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const LinkupApiConfig: FC<LinkupApiConfigProps> = ({
|
||||
connector,
|
||||
onConfigChange,
|
||||
onNameChange,
|
||||
}) => {
|
||||
const [apiKey, setApiKey] = useState<string>(
|
||||
(connector.config?.LINKUP_API_KEY as string) || ""
|
||||
);
|
||||
const [name, setName] = useState<string>(connector.name || "");
|
||||
|
||||
// Update API key and name when connector changes
|
||||
useEffect(() => {
|
||||
const key = (connector.config?.LINKUP_API_KEY as string) || "";
|
||||
setApiKey(key);
|
||||
setName(connector.name || "");
|
||||
}, [connector.config, connector.name]);
|
||||
|
||||
const handleApiKeyChange = (value: string) => {
|
||||
setApiKey(value);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
LINKUP_API_KEY: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (onNameChange) {
|
||||
onNameChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Connector Name */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Connector Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My Linkup API Connector"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
A friendly name to identify this connector.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base">Configuration</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Linkup API Key
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
placeholder="Enter your Linkup API key"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Update the Linkup API Key if needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import type { FC } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
export interface LumaConfigProps extends ConnectorConfigProps {
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const LumaConfig: FC<LumaConfigProps> = ({
|
||||
connector,
|
||||
onConfigChange,
|
||||
onNameChange,
|
||||
}) => {
|
||||
const [apiKey, setApiKey] = useState<string>(
|
||||
(connector.config?.LUMA_API_KEY as string) || ""
|
||||
);
|
||||
const [name, setName] = useState<string>(connector.name || "");
|
||||
|
||||
// Update API key and name when connector changes
|
||||
useEffect(() => {
|
||||
const key = (connector.config?.LUMA_API_KEY as string) || "";
|
||||
setApiKey(key);
|
||||
setName(connector.name || "");
|
||||
}, [connector.config, connector.name]);
|
||||
|
||||
const handleApiKeyChange = (value: string) => {
|
||||
setApiKey(value);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
LUMA_API_KEY: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (onNameChange) {
|
||||
onNameChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Connector Name */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Connector Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My Luma Connector"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
A friendly name to identify this connector.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base">Configuration</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Luma API Key
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
placeholder="Your API Key"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Update your Luma API Key if needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import type { FC } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
export interface NotionConfigProps extends ConnectorConfigProps {
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const NotionConfig: FC<NotionConfigProps> = ({
|
||||
connector,
|
||||
onConfigChange,
|
||||
onNameChange,
|
||||
}) => {
|
||||
const [integrationToken, setIntegrationToken] = useState<string>(
|
||||
(connector.config?.NOTION_INTEGRATION_TOKEN as string) || ""
|
||||
);
|
||||
const [name, setName] = useState<string>(connector.name || "");
|
||||
|
||||
// Update integration token and name when connector changes
|
||||
useEffect(() => {
|
||||
const token = (connector.config?.NOTION_INTEGRATION_TOKEN as string) || "";
|
||||
setIntegrationToken(token);
|
||||
setName(connector.name || "");
|
||||
}, [connector.config, connector.name]);
|
||||
|
||||
const handleIntegrationTokenChange = (value: string) => {
|
||||
setIntegrationToken(value);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
NOTION_INTEGRATION_TOKEN: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (onNameChange) {
|
||||
onNameChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Connector Name */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Connector Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My Notion Connector"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
A friendly name to identify this connector.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base">Configuration</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Notion Integration Token
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={integrationToken}
|
||||
onChange={(e) => handleIntegrationTokenChange(e.target.value)}
|
||||
placeholder="Begins with secret_..."
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Update your Notion Integration Token if needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
"use client";
|
||||
|
||||
import { KeyRound, Globe } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import type { FC } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
export interface SearxngConfigProps extends ConnectorConfigProps {
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
const arrayToString = (arr: unknown): string => {
|
||||
if (!arr) return "";
|
||||
if (Array.isArray(arr)) {
|
||||
return arr.join(", ");
|
||||
}
|
||||
return String(arr);
|
||||
};
|
||||
|
||||
const stringToArray = (value: string): string[] | undefined => {
|
||||
if (!value) return undefined;
|
||||
const items = value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
return items.length > 0 ? items : undefined;
|
||||
};
|
||||
|
||||
export const SearxngConfig: FC<SearxngConfigProps> = ({
|
||||
connector,
|
||||
onConfigChange,
|
||||
onNameChange,
|
||||
}) => {
|
||||
const [host, setHost] = useState<string>(
|
||||
(connector.config?.SEARXNG_HOST as string) || ""
|
||||
);
|
||||
const [apiKey, setApiKey] = useState<string>(
|
||||
(connector.config?.SEARXNG_API_KEY as string) || ""
|
||||
);
|
||||
const [engines, setEngines] = useState<string>(
|
||||
arrayToString(connector.config?.SEARXNG_ENGINES)
|
||||
);
|
||||
const [categories, setCategories] = useState<string>(
|
||||
arrayToString(connector.config?.SEARXNG_CATEGORIES)
|
||||
);
|
||||
const [language, setLanguage] = useState<string>(
|
||||
(connector.config?.SEARXNG_LANGUAGE as string) || ""
|
||||
);
|
||||
const [safesearch, setSafesearch] = useState<string>(
|
||||
connector.config?.SEARXNG_SAFESEARCH !== undefined
|
||||
? String(connector.config.SEARXNG_SAFESEARCH)
|
||||
: ""
|
||||
);
|
||||
const [verifySsl, setVerifySsl] = useState<boolean>(
|
||||
connector.config?.SEARXNG_VERIFY_SSL !== undefined
|
||||
? (connector.config.SEARXNG_VERIFY_SSL as boolean)
|
||||
: true
|
||||
);
|
||||
const [name, setName] = useState<string>(connector.name || "");
|
||||
|
||||
// Update all fields when connector changes
|
||||
useEffect(() => {
|
||||
const hostValue = (connector.config?.SEARXNG_HOST as string) || "";
|
||||
const apiKeyValue = (connector.config?.SEARXNG_API_KEY as string) || "";
|
||||
const enginesValue = arrayToString(connector.config?.SEARXNG_ENGINES);
|
||||
const categoriesValue = arrayToString(connector.config?.SEARXNG_CATEGORIES);
|
||||
const languageValue = (connector.config?.SEARXNG_LANGUAGE as string) || "";
|
||||
const safesearchValue =
|
||||
connector.config?.SEARXNG_SAFESEARCH !== undefined
|
||||
? String(connector.config.SEARXNG_SAFESEARCH)
|
||||
: "";
|
||||
const verifySslValue =
|
||||
connector.config?.SEARXNG_VERIFY_SSL !== undefined
|
||||
? (connector.config.SEARXNG_VERIFY_SSL as boolean)
|
||||
: true;
|
||||
|
||||
setHost(hostValue);
|
||||
setApiKey(apiKeyValue);
|
||||
setEngines(enginesValue);
|
||||
setCategories(categoriesValue);
|
||||
setLanguage(languageValue);
|
||||
setSafesearch(safesearchValue);
|
||||
setVerifySsl(verifySslValue);
|
||||
setName(connector.name || "");
|
||||
}, [connector.config, connector.name]);
|
||||
|
||||
const updateConfig = (updates: Record<string, unknown>) => {
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
...updates,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleHostChange = (value: string) => {
|
||||
setHost(value);
|
||||
updateConfig({ SEARXNG_HOST: value });
|
||||
};
|
||||
|
||||
const handleApiKeyChange = (value: string) => {
|
||||
setApiKey(value);
|
||||
if (value) {
|
||||
updateConfig({ SEARXNG_API_KEY: value });
|
||||
} else {
|
||||
const newConfig = { ...connector.config };
|
||||
delete newConfig.SEARXNG_API_KEY;
|
||||
if (onConfigChange) {
|
||||
onConfigChange(newConfig);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnginesChange = (value: string) => {
|
||||
setEngines(value);
|
||||
const enginesArray = stringToArray(value);
|
||||
if (enginesArray) {
|
||||
updateConfig({ SEARXNG_ENGINES: enginesArray });
|
||||
} else {
|
||||
const newConfig = { ...connector.config };
|
||||
delete newConfig.SEARXNG_ENGINES;
|
||||
if (onConfigChange) {
|
||||
onConfigChange(newConfig);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCategoriesChange = (value: string) => {
|
||||
setCategories(value);
|
||||
const categoriesArray = stringToArray(value);
|
||||
if (categoriesArray) {
|
||||
updateConfig({ SEARXNG_CATEGORIES: categoriesArray });
|
||||
} else {
|
||||
const newConfig = { ...connector.config };
|
||||
delete newConfig.SEARXNG_CATEGORIES;
|
||||
if (onConfigChange) {
|
||||
onConfigChange(newConfig);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLanguageChange = (value: string) => {
|
||||
setLanguage(value);
|
||||
if (value) {
|
||||
updateConfig({ SEARXNG_LANGUAGE: value });
|
||||
} else {
|
||||
const newConfig = { ...connector.config };
|
||||
delete newConfig.SEARXNG_LANGUAGE;
|
||||
if (onConfigChange) {
|
||||
onConfigChange(newConfig);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSafesearchChange = (value: string) => {
|
||||
setSafesearch(value);
|
||||
if (value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
updateConfig({ SEARXNG_SAFESEARCH: parsed });
|
||||
}
|
||||
} else {
|
||||
const newConfig = { ...connector.config };
|
||||
delete newConfig.SEARXNG_SAFESEARCH;
|
||||
if (onConfigChange) {
|
||||
onConfigChange(newConfig);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifySslChange = (value: boolean) => {
|
||||
setVerifySsl(value);
|
||||
if (value === false) {
|
||||
updateConfig({ SEARXNG_VERIFY_SSL: false });
|
||||
} else {
|
||||
const newConfig = { ...connector.config };
|
||||
delete newConfig.SEARXNG_VERIFY_SSL;
|
||||
if (onConfigChange) {
|
||||
onConfigChange(newConfig);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (onNameChange) {
|
||||
onNameChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Connector Name */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Connector Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My SearxNG Connector"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
A friendly name to identify this connector.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base">Configuration</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<Globe className="h-4 w-4" />
|
||||
SearxNG Host
|
||||
</Label>
|
||||
<Input
|
||||
value={host}
|
||||
onChange={(e) => handleHostChange(e.target.value)}
|
||||
placeholder="https://searxng.example.org"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Update the SearxNG Host if needed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
API Key (optional)
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
placeholder="Enter API key if your instance requires one"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Leave empty if your SearxNG instance does not enforce API keys.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Engines (optional)</Label>
|
||||
<Input
|
||||
value={engines}
|
||||
onChange={(e) => handleEnginesChange(e.target.value)}
|
||||
placeholder="google,bing,duckduckgo"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Comma-separated list to target specific engines.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Categories (optional)</Label>
|
||||
<Input
|
||||
value={categories}
|
||||
onChange={(e) => handleCategoriesChange(e.target.value)}
|
||||
placeholder="general,it,science"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Comma-separated list of SearxNG categories.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Preferred Language (optional)</Label>
|
||||
<Input
|
||||
value={language}
|
||||
onChange={(e) => handleLanguageChange(e.target.value)}
|
||||
placeholder="en-US"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
IETF language tag (e.g. en, en-US). Leave blank to inherit defaults.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">SafeSearch Level (optional)</Label>
|
||||
<Input
|
||||
value={safesearch}
|
||||
onChange={(e) => handleSafesearchChange(e.target.value)}
|
||||
placeholder="0 (off), 1 (moderate), 2 (strict)"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Set 0, 1, or 2 to adjust SafeSearch filtering. Leave blank to use the instance default.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-slate-400/20 p-3 sm:p-4">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">Verify SSL Certificates</Label>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Disable only when connecting to instances with self-signed certificates.
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={verifySsl} onCheckedChange={handleVerifySslChange} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import type { FC } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
export interface SlackConfigProps extends ConnectorConfigProps {
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const SlackConfig: FC<SlackConfigProps> = ({
|
||||
connector,
|
||||
onConfigChange,
|
||||
onNameChange,
|
||||
}) => {
|
||||
const [botToken, setBotToken] = useState<string>(
|
||||
(connector.config?.SLACK_BOT_TOKEN as string) || ""
|
||||
);
|
||||
const [name, setName] = useState<string>(connector.name || "");
|
||||
|
||||
// Update bot token and name when connector changes
|
||||
useEffect(() => {
|
||||
const token = (connector.config?.SLACK_BOT_TOKEN as string) || "";
|
||||
setBotToken(token);
|
||||
setName(connector.name || "");
|
||||
}, [connector.config, connector.name]);
|
||||
|
||||
const handleBotTokenChange = (value: string) => {
|
||||
setBotToken(value);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
SLACK_BOT_TOKEN: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (onNameChange) {
|
||||
onNameChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Connector Name */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Connector Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My Slack Connector"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
A friendly name to identify this connector.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base">Configuration</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Slack Bot User OAuth Token
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={botToken}
|
||||
onChange={(e) => handleBotTokenChange(e.target.value)}
|
||||
placeholder="Begins with xoxb-..."
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Update your Bot User OAuth Token if needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import type { FC } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
export interface TavilyApiConfigProps extends ConnectorConfigProps {
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const TavilyApiConfig: FC<TavilyApiConfigProps> = ({
|
||||
connector,
|
||||
onConfigChange,
|
||||
onNameChange,
|
||||
}) => {
|
||||
const [apiKey, setApiKey] = useState<string>(
|
||||
(connector.config?.TAVILY_API_KEY as string) || ""
|
||||
);
|
||||
const [name, setName] = useState<string>(connector.name || "");
|
||||
|
||||
// Update API key and name when connector changes
|
||||
useEffect(() => {
|
||||
const key = (connector.config?.TAVILY_API_KEY as string) || "";
|
||||
setApiKey(key);
|
||||
setName(connector.name || "");
|
||||
}, [connector.config, connector.name]);
|
||||
|
||||
const handleApiKeyChange = (value: string) => {
|
||||
setApiKey(value);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
TAVILY_API_KEY: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (onNameChange) {
|
||||
onNameChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Connector Name */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Connector Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My Tavily API Connector"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
A friendly name to identify this connector.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base">Configuration</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Tavily API Key
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
placeholder="Enter your Tavily API key"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Update the Tavily API Key if needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
"use client";
|
||||
|
||||
import { Info } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import type { FC } from "react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
export const WebcrawlerConfig: FC<ConnectorConfigProps> = ({
|
||||
connector,
|
||||
onConfigChange,
|
||||
}) => {
|
||||
// Initialize with existing config values
|
||||
const existingApiKey = (connector.config?.FIRECRAWL_API_KEY as string | undefined) || "";
|
||||
const existingUrls = (connector.config?.INITIAL_URLS as string | undefined) || "";
|
||||
|
||||
const [apiKey, setApiKey] = useState(existingApiKey);
|
||||
const [initialUrls, setInitialUrls] = useState(existingUrls);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
|
||||
// Update state when connector config changes
|
||||
useEffect(() => {
|
||||
const apiKeyValue = (connector.config?.FIRECRAWL_API_KEY as string | undefined) || "";
|
||||
const urlsValue = (connector.config?.INITIAL_URLS as string | undefined) || "";
|
||||
setApiKey(apiKeyValue);
|
||||
setInitialUrls(urlsValue);
|
||||
}, [connector.config]);
|
||||
|
||||
const handleApiKeyChange = (value: string) => {
|
||||
setApiKey(value);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
FIRECRAWL_API_KEY: value.trim() || undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUrlsChange = (value: string) => {
|
||||
setInitialUrls(value);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
INITIAL_URLS: value.trim() || undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-4 sm:space-y-6">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base">Web Crawler Configuration</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Configure your web crawler settings. You can add a Firecrawl API key for enhanced crawling or use the free fallback option.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* API Key Field */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-key" className="text-xs sm:text-sm">
|
||||
Firecrawl API Key (Optional)
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="api-key"
|
||||
type={showApiKey ? "text" : "password"}
|
||||
placeholder="fc-xxxxxxxxxxxxx"
|
||||
value={apiKey}
|
||||
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||
className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 text-xs sm:text-sm pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showApiKey ? "Hide" : "Show"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Get your API key from{" "}
|
||||
<a
|
||||
href="https://firecrawl.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
firecrawl.dev
|
||||
</a>
|
||||
. If not provided, will use AsyncChromiumLoader as fallback.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Initial URLs Field */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="initial-urls" className="text-xs sm:text-sm">
|
||||
Initial URLs (Optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="initial-urls"
|
||||
placeholder="https://example.com https://docs.example.com https://blog.example.com"
|
||||
value={initialUrls}
|
||||
onChange={(e) => handleUrlsChange(e.target.value)}
|
||||
className="min-h-[100px] font-mono text-xs sm:text-sm bg-slate-400/5 dark:bg-white/5 border-slate-400/20 resize-none"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Enter URLs to crawl (one per line). You can add more URLs later.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info Alert */}
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center gap-2 [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0" />
|
||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||
Configuration is saved when you start indexing. You can update these settings anytime from the connector management page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { BaiduSearchApiConfig } from "./components/baidu-search-api-config";
|
||||
import { BookStackConfig } from "./components/bookstack-config";
|
||||
import { CirclebackConfig } from "./components/circleback-config";
|
||||
import { ClickUpConfig } from "./components/clickup-config";
|
||||
import { ConfluenceConfig } from "./components/confluence-config";
|
||||
import { DiscordConfig } from "./components/discord-config";
|
||||
import { ElasticsearchConfig } from "./components/elasticsearch-config";
|
||||
import { GithubConfig } from "./components/github-config";
|
||||
import { GoogleDriveConfig } from "./components/google-drive-config";
|
||||
import { JiraConfig } from "./components/jira-config";
|
||||
import { LinearConfig } from "./components/linear-config";
|
||||
import { LinkupApiConfig } from "./components/linkup-api-config";
|
||||
import { LumaConfig } from "./components/luma-config";
|
||||
import { NotionConfig } from "./components/notion-config";
|
||||
import { SearxngConfig } from "./components/searxng-config";
|
||||
import { SlackConfig } from "./components/slack-config";
|
||||
import { TavilyApiConfig } from "./components/tavily-api-config";
|
||||
import { WebcrawlerConfig } from "./components/webcrawler-config";
|
||||
|
||||
export interface ConnectorConfigProps {
|
||||
connector: SearchSourceConnector;
|
||||
onConfigChange?: (config: Record<string, unknown>) => void;
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export type ConnectorConfigComponent = FC<ConnectorConfigProps>;
|
||||
|
||||
/**
|
||||
* Factory function to get the appropriate config component for a connector type
|
||||
*/
|
||||
export function getConnectorConfigComponent(
|
||||
connectorType: string
|
||||
): ConnectorConfigComponent | null {
|
||||
switch (connectorType) {
|
||||
case "GOOGLE_DRIVE_CONNECTOR":
|
||||
return GoogleDriveConfig;
|
||||
case "TAVILY_API":
|
||||
return TavilyApiConfig;
|
||||
case "SEARXNG_API":
|
||||
return SearxngConfig;
|
||||
case "LINKUP_API":
|
||||
return LinkupApiConfig;
|
||||
case "BAIDU_SEARCH_API":
|
||||
return BaiduSearchApiConfig;
|
||||
case "LINEAR_CONNECTOR":
|
||||
return LinearConfig;
|
||||
case "WEBCRAWLER_CONNECTOR":
|
||||
return WebcrawlerConfig;
|
||||
case "ELASTICSEARCH_CONNECTOR":
|
||||
return ElasticsearchConfig;
|
||||
case "SLACK_CONNECTOR":
|
||||
return SlackConfig;
|
||||
case "DISCORD_CONNECTOR":
|
||||
return DiscordConfig;
|
||||
case "NOTION_CONNECTOR":
|
||||
return NotionConfig;
|
||||
case "CONFLUENCE_CONNECTOR":
|
||||
return ConfluenceConfig;
|
||||
case "BOOKSTACK_CONNECTOR":
|
||||
return BookStackConfig;
|
||||
case "GITHUB_CONNECTOR":
|
||||
return GithubConfig;
|
||||
case "JIRA_CONNECTOR":
|
||||
return JiraConfig;
|
||||
case "CLICKUP_CONNECTOR":
|
||||
return ClickUpConfig;
|
||||
case "LUMA_CONNECTOR":
|
||||
return LumaConfig;
|
||||
case "CIRCLEBACK_CONNECTOR":
|
||||
return CirclebackConfig;
|
||||
// OAuth connectors (Gmail, Calendar, Airtable) and others don't need special config UI
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import { type FC, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
|
||||
import { getConnectFormComponent } from "../../connect-forms";
|
||||
|
||||
interface ConnectorConnectViewProps {
|
||||
connectorType: string;
|
||||
onSubmit: (data: {
|
||||
name: string;
|
||||
connector_type: string;
|
||||
config: Record<string, unknown>;
|
||||
is_indexable: boolean;
|
||||
last_indexed_at: null;
|
||||
periodic_indexing_enabled: boolean;
|
||||
indexing_frequency_minutes: number | null;
|
||||
next_scheduled_at: null;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
periodicEnabled?: boolean;
|
||||
frequencyMinutes?: string;
|
||||
}) => Promise<void>;
|
||||
onBack: () => void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
|
||||
connectorType,
|
||||
onSubmit,
|
||||
onBack,
|
||||
isSubmitting,
|
||||
}) => {
|
||||
// Get connector-specific form component
|
||||
const ConnectFormComponent = useMemo(
|
||||
() => getConnectFormComponent(connectorType),
|
||||
[connectorType]
|
||||
);
|
||||
|
||||
const handleFormSubmit = () => {
|
||||
// Prevent multiple submissions
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
// Map connector types to their form IDs
|
||||
const formIdMap: Record<string, string> = {
|
||||
TAVILY_API: "tavily-connect-form",
|
||||
SEARXNG_API: "searxng-connect-form",
|
||||
LINKUP_API: "linkup-api-connect-form",
|
||||
BAIDU_SEARCH_API: "baidu-search-api-connect-form",
|
||||
LINEAR_CONNECTOR: "linear-connect-form",
|
||||
ELASTICSEARCH_CONNECTOR: "elasticsearch-connect-form",
|
||||
SLACK_CONNECTOR: "slack-connect-form",
|
||||
DISCORD_CONNECTOR: "discord-connect-form",
|
||||
NOTION_CONNECTOR: "notion-connect-form",
|
||||
CONFLUENCE_CONNECTOR: "confluence-connect-form",
|
||||
BOOKSTACK_CONNECTOR: "bookstack-connect-form",
|
||||
GITHUB_CONNECTOR: "github-connect-form",
|
||||
JIRA_CONNECTOR: "jira-connect-form",
|
||||
CLICKUP_CONNECTOR: "clickup-connect-form",
|
||||
LUMA_CONNECTOR: "luma-connect-form",
|
||||
CIRCLEBACK_CONNECTOR: "circleback-connect-form",
|
||||
};
|
||||
const formId = formIdMap[connectorType];
|
||||
if (formId) {
|
||||
const form = document.getElementById(formId) as HTMLFormElement;
|
||||
if (form) {
|
||||
form.requestSubmit();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!ConnectFormComponent) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden p-6">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Connector form not found for type: {connectorType}
|
||||
</p>
|
||||
<Button onClick={onBack} variant="ghost">
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground mb-6 w-fit"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to connectors
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl border border-slate-400/30">
|
||||
{getConnectorIcon(connectorType as EnumConnectorName, "h-7 w-7")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight">
|
||||
Connect {getConnectorTypeDisplay(connectorType)}
|
||||
</h2>
|
||||
<p className="text-xs sm:text-base text-muted-foreground mt-1">
|
||||
Enter your connection details
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Content - Scrollable */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 sm:px-12">
|
||||
<ConnectFormComponent
|
||||
onSubmit={onSubmit}
|
||||
onBack={onBack}
|
||||
isSubmitting={isSubmitting}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fixed Footer - Action buttons */}
|
||||
<div className="flex-shrink-0 flex items-center justify-between px-6 sm:px-12 py-6 bg-muted border-t border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
disabled={isSubmitting}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFormSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="text-xs sm:text-sm min-w-[140px] disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Connect {getConnectorTypeDisplay(connectorType)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, Info, Loader2, Trash2 } from "lucide-react";
|
||||
import { type FC, useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
||||
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
|
||||
import { getConnectorConfigComponent } from "../index";
|
||||
|
||||
interface ConnectorEditViewProps {
|
||||
connector: SearchSourceConnector;
|
||||
startDate: Date | undefined;
|
||||
endDate: Date | undefined;
|
||||
periodicEnabled: boolean;
|
||||
frequencyMinutes: string;
|
||||
isSaving: boolean;
|
||||
isDisconnecting: boolean;
|
||||
onStartDateChange: (date: Date | undefined) => void;
|
||||
onEndDateChange: (date: Date | undefined) => void;
|
||||
onPeriodicEnabledChange: (enabled: boolean) => void;
|
||||
onFrequencyChange: (frequency: string) => void;
|
||||
onSave: () => void;
|
||||
onDisconnect: () => void;
|
||||
onBack: () => void;
|
||||
onConfigChange?: (config: Record<string, any>) => void;
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
||||
connector,
|
||||
startDate,
|
||||
endDate,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
isSaving,
|
||||
isDisconnecting,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
onPeriodicEnabledChange,
|
||||
onFrequencyChange,
|
||||
onSave,
|
||||
onDisconnect,
|
||||
onBack,
|
||||
onConfigChange,
|
||||
onNameChange,
|
||||
}) => {
|
||||
// Get connector-specific config component
|
||||
const ConnectorConfigComponent = useMemo(
|
||||
() => getConnectorConfigComponent(connector.connector_type),
|
||||
[connector.connector_type]
|
||||
);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [hasMoreContent, setHasMoreContent] = useState(false);
|
||||
const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const checkScrollState = useCallback(() => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
|
||||
const target = scrollContainerRef.current;
|
||||
const scrolled = target.scrollTop > 0;
|
||||
const hasMore = target.scrollHeight > target.clientHeight &&
|
||||
target.scrollTop + target.clientHeight < target.scrollHeight - 10;
|
||||
|
||||
setIsScrolled(scrolled);
|
||||
setHasMoreContent(hasMore);
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
checkScrollState();
|
||||
}, [checkScrollState]);
|
||||
|
||||
// Check initial scroll state and on resize
|
||||
useEffect(() => {
|
||||
checkScrollState();
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
checkScrollState();
|
||||
});
|
||||
|
||||
if (scrollContainerRef.current) {
|
||||
resizeObserver.observe(scrollContainerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [checkScrollState]);
|
||||
|
||||
const handleDisconnectClick = () => {
|
||||
setShowDisconnectConfirm(true);
|
||||
};
|
||||
|
||||
const handleDisconnectConfirm = () => {
|
||||
setShowDisconnectConfirm(false);
|
||||
onDisconnect();
|
||||
};
|
||||
|
||||
const handleDisconnectCancel = () => {
|
||||
setShowDisconnectConfirm(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Fixed Header */}
|
||||
<div className={cn(
|
||||
"flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10 transition-shadow duration-200 relative z-10",
|
||||
isScrolled && "shadow-sm"
|
||||
)}>
|
||||
{/* Back button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground mb-6 w-fit"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to connectors
|
||||
</button>
|
||||
|
||||
{/* Connector header */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10 border border-primary/20">
|
||||
{getConnectorIcon(connector.connector_type, "size-7")}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight">
|
||||
{connector.name}
|
||||
</h2>
|
||||
<p className="text-xs sm:text-base text-muted-foreground mt-1">
|
||||
Manage your connector settings and sync configuration
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="h-full overflow-y-auto px-6 sm:px-12"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div className="space-y-6 pb-6 pt-2">
|
||||
{/* Connector-specific configuration */}
|
||||
{ConnectorConfigComponent && (
|
||||
<ConnectorConfigComponent
|
||||
connector={connector}
|
||||
onConfigChange={onConfigChange}
|
||||
onNameChange={onNameChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Date range selector and periodic sync - only shown for indexable connectors */}
|
||||
{connector.is_indexable && (
|
||||
<>
|
||||
{/* Date range selector - not shown for Google Drive (uses folder selection) or Webcrawler (uses config) */}
|
||||
{connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" && connector.connector_type !== "WEBCRAWLER_CONNECTOR" && (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={onStartDateChange}
|
||||
onEndDateChange={onEndDateChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PeriodicSyncConfig
|
||||
enabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
onEnabledChange={onPeriodicEnabledChange}
|
||||
onFrequencyChange={onFrequencyChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Info box - only shown for indexable connectors */}
|
||||
{connector.is_indexable && (
|
||||
<div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
|
||||
<Info className="size-4" />
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm">
|
||||
<p className="font-medium text-xs sm:text-sm">Re-indexing runs in the background</p>
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
|
||||
You can continue using SurfSense while we sync your data. Check the Active tab to see progress.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Top fade shadow - appears when scrolled */}
|
||||
{isScrolled && (
|
||||
<div className="absolute top-0 left-0 right-0 h-6 bg-gradient-to-b from-muted/50 to-transparent pointer-events-none z-10" />
|
||||
)}
|
||||
{/* Bottom fade shadow - appears when there's more content */}
|
||||
{hasMoreContent && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-3 bg-gradient-to-t from-muted/50 to-transparent pointer-events-none z-10" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fixed Footer - Action buttons */}
|
||||
<div className="flex-shrink-0 flex items-center justify-between px-6 sm:px-12 py-6 bg-muted border-t border-border">
|
||||
{showDisconnectConfirm ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs sm:text-sm text-muted-foreground">Are you sure?</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDisconnectConfirm}
|
||||
disabled={isDisconnecting}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
{isDisconnecting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Disconnecting...
|
||||
</>
|
||||
) : (
|
||||
"Confirm Disconnect"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDisconnectCancel}
|
||||
disabled={isDisconnecting}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDisconnectClick}
|
||||
disabled={isSaving || isDisconnecting}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onSave} disabled={isSaving || isDisconnecting} className="text-xs sm:text-sm">
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Save Changes"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { type FC, useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { IndexingConfigState } from "../../constants/connector-constants";
|
||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
||||
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
|
||||
import { getConnectorConfigComponent } from "../index";
|
||||
|
||||
interface IndexingConfigurationViewProps {
|
||||
config: IndexingConfigState;
|
||||
connector?: SearchSourceConnector;
|
||||
startDate: Date | undefined;
|
||||
endDate: Date | undefined;
|
||||
periodicEnabled: boolean;
|
||||
frequencyMinutes: string;
|
||||
isStartingIndexing: boolean;
|
||||
onStartDateChange: (date: Date | undefined) => void;
|
||||
onEndDateChange: (date: Date | undefined) => void;
|
||||
onPeriodicEnabledChange: (enabled: boolean) => void;
|
||||
onFrequencyChange: (frequency: string) => void;
|
||||
onConfigChange?: (config: Record<string, unknown>) => void;
|
||||
onStartIndexing: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
||||
config,
|
||||
connector,
|
||||
startDate,
|
||||
endDate,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
isStartingIndexing,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
onPeriodicEnabledChange,
|
||||
onFrequencyChange,
|
||||
onConfigChange,
|
||||
onStartIndexing,
|
||||
onSkip,
|
||||
}) => {
|
||||
// Get connector-specific config component
|
||||
const ConnectorConfigComponent = useMemo(
|
||||
() => connector ? getConnectorConfigComponent(connector.connector_type) : null,
|
||||
[connector]
|
||||
);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [hasMoreContent, setHasMoreContent] = useState(false);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const checkScrollState = useCallback(() => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
|
||||
const target = scrollContainerRef.current;
|
||||
const scrolled = target.scrollTop > 0;
|
||||
const hasMore = target.scrollHeight > target.clientHeight &&
|
||||
target.scrollTop + target.clientHeight < target.scrollHeight - 10;
|
||||
|
||||
setIsScrolled(scrolled);
|
||||
setHasMoreContent(hasMore);
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
checkScrollState();
|
||||
}, [checkScrollState]);
|
||||
|
||||
// Check initial scroll state and on resize
|
||||
useEffect(() => {
|
||||
checkScrollState();
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
checkScrollState();
|
||||
});
|
||||
|
||||
if (scrollContainerRef.current) {
|
||||
resizeObserver.observe(scrollContainerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [checkScrollState]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Fixed Header */}
|
||||
<div className={cn(
|
||||
"flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10 transition-shadow duration-200 relative z-10",
|
||||
isScrolled && "shadow-sm"
|
||||
)}>
|
||||
{/* Back button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip}
|
||||
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground mb-6 w-fit"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to connectors
|
||||
</button>
|
||||
|
||||
{/* Success header */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-green-500/10 border border-green-500/20">
|
||||
<Check className="size-7 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight">
|
||||
{config.connectorTitle} Connected!
|
||||
</h2>
|
||||
<p className="text-xs sm:text-base text-muted-foreground mt-1">
|
||||
Configure when to start syncing your data
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="h-full overflow-y-auto px-6 sm:px-12"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div className="space-y-6 pb-6 pt-2">
|
||||
{/* Connector-specific configuration */}
|
||||
{ConnectorConfigComponent && connector && (
|
||||
<ConnectorConfigComponent
|
||||
connector={connector}
|
||||
onConfigChange={onConfigChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Date range selector and periodic sync - only shown for indexable connectors */}
|
||||
{connector?.is_indexable && (
|
||||
<>
|
||||
{/* Date range selector - not shown for Google Drive (uses folder selection) or Webcrawler (uses config) */}
|
||||
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" && config.connectorType !== "WEBCRAWLER_CONNECTOR" && (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={onStartDateChange}
|
||||
onEndDateChange={onEndDateChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PeriodicSyncConfig
|
||||
enabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
onEnabledChange={onPeriodicEnabledChange}
|
||||
onFrequencyChange={onFrequencyChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Info box - only shown for indexable connectors */}
|
||||
{connector?.is_indexable && (
|
||||
<div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
|
||||
<Info className="size-4" />
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm">
|
||||
<p className="font-medium text-xs sm:text-sm">Indexing runs in the background</p>
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
|
||||
You can continue using SurfSense while we sync your data. Check the Active tab to see progress.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Top fade shadow - appears when scrolled */}
|
||||
{isScrolled && (
|
||||
<div className="absolute top-0 left-0 right-0 h-6 bg-gradient-to-b from-muted/50 to-transparent pointer-events-none z-10" />
|
||||
)}
|
||||
{/* Bottom fade shadow - appears when there's more content */}
|
||||
{hasMoreContent && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-3 bg-gradient-to-t from-muted/50 to-transparent pointer-events-none z-10" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fixed Footer - Action buttons */}
|
||||
<div className="flex-shrink-0 flex items-center justify-between px-6 sm:px-12 py-6 bg-muted">
|
||||
<Button variant="ghost" onClick={onSkip} disabled={isStartingIndexing} className="text-xs sm:text-sm">
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button onClick={onStartIndexing} disabled={isStartingIndexing} className="text-xs sm:text-sm">
|
||||
{isStartingIndexing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Starting...
|
||||
</>
|
||||
) : (
|
||||
"Start Indexing"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
|
||||
// OAuth Connectors (Quick Connect)
|
||||
export const OAUTH_CONNECTORS = [
|
||||
{
|
||||
id: "google-drive-connector",
|
||||
title: "Google Drive",
|
||||
description: "Search your Drive files",
|
||||
connectorType: EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
|
||||
authEndpoint: "/api/v1/auth/google/drive/connector/add/",
|
||||
},
|
||||
{
|
||||
id: "google-gmail-connector",
|
||||
title: "Gmail",
|
||||
description: "Search through your emails",
|
||||
connectorType: EnumConnectorName.GOOGLE_GMAIL_CONNECTOR,
|
||||
authEndpoint: "/api/v1/auth/google/gmail/connector/add/",
|
||||
},
|
||||
{
|
||||
id: "google-calendar-connector",
|
||||
title: "Google Calendar",
|
||||
description: "Search through your events",
|
||||
connectorType: EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR,
|
||||
authEndpoint: "/api/v1/auth/google/calendar/connector/add/",
|
||||
},
|
||||
{
|
||||
id: "airtable-connector",
|
||||
title: "Airtable",
|
||||
description: "Search your Airtable bases",
|
||||
connectorType: EnumConnectorName.AIRTABLE_CONNECTOR,
|
||||
authEndpoint: "/api/v1/auth/airtable/connector/add/",
|
||||
},
|
||||
] as const;
|
||||
|
||||
// Content Sources (tools that extract and import content from external sources)
|
||||
export const CRAWLERS = [
|
||||
{
|
||||
id: "youtube-crawler",
|
||||
title: "YouTube",
|
||||
description: "Crawl YouTube channels and playlists",
|
||||
connectorType: EnumConnectorName.YOUTUBE_CONNECTOR,
|
||||
},
|
||||
{
|
||||
id: "webcrawler-connector",
|
||||
title: "Web Pages",
|
||||
description: "Crawl web content",
|
||||
connectorType: EnumConnectorName.WEBCRAWLER_CONNECTOR,
|
||||
},
|
||||
] as const;
|
||||
|
||||
// Non-OAuth Connectors (redirect to old connector config pages)
|
||||
export const OTHER_CONNECTORS = [
|
||||
{
|
||||
id: "slack-connector",
|
||||
title: "Slack",
|
||||
description: "Search Slack messages",
|
||||
connectorType: EnumConnectorName.SLACK_CONNECTOR,
|
||||
},
|
||||
{
|
||||
id: "discord-connector",
|
||||
title: "Discord",
|
||||
description: "Search Discord messages",
|
||||
connectorType: EnumConnectorName.DISCORD_CONNECTOR,
|
||||
},
|
||||
{
|
||||
id: "notion-connector",
|
||||
title: "Notion",
|
||||
description: "Search Notion pages",
|
||||
connectorType: EnumConnectorName.NOTION_CONNECTOR,
|
||||
},
|
||||
{
|
||||
id: "confluence-connector",
|
||||
title: "Confluence",
|
||||
description: "Search documentation",
|
||||
connectorType: EnumConnectorName.CONFLUENCE_CONNECTOR,
|
||||
},
|
||||
{
|
||||
id: "bookstack-connector",
|
||||
title: "BookStack",
|
||||
description: "Search BookStack docs",
|
||||
connectorType: EnumConnectorName.BOOKSTACK_CONNECTOR,
|
||||
},
|
||||
{
|
||||
id: "github-connector",
|
||||
title: "GitHub",
|
||||
description: "Search repositories",
|
||||
connectorType: EnumConnectorName.GITHUB_CONNECTOR,
|
||||
},
|
||||
{
|
||||
id: "linear-connector",
|
||||
title: "Linear",
|
||||
description: "Search issues & projects",
|
||||
connectorType: EnumConnectorName.LINEAR_CONNECTOR,
|
||||
},
|
||||
{
|
||||
id: "jira-connector",
|
||||
title: "Jira",
|
||||
description: "Search Jira issues",
|
||||
connectorType: EnumConnectorName.JIRA_CONNECTOR,
|
||||
},
|
||||
{
|
||||
id: "clickup-connector",
|
||||
title: "ClickUp",
|
||||
description: "Search ClickUp tasks",
|
||||
connectorType: EnumConnectorName.CLICKUP_CONNECTOR,
|
||||
},
|
||||
{
|
||||
id: "luma-connector",
|
||||
title: "Luma",
|
||||
description: "Search Luma events",
|
||||
connectorType: EnumConnectorName.LUMA_CONNECTOR,
|
||||
},
|
||||
{
|
||||
id: "elasticsearch-connector",
|
||||
title: "Elasticsearch",
|
||||
description: "Search ES indexes",
|
||||
connectorType: EnumConnectorName.ELASTICSEARCH_CONNECTOR,
|
||||
},
|
||||
{
|
||||
id: "tavily-api",
|
||||
title: "Tavily AI",
|
||||
description: "Search with Tavily",
|
||||
connectorType: EnumConnectorName.TAVILY_API,
|
||||
},
|
||||
{
|
||||
id: "searxng",
|
||||
title: "SearxNG",
|
||||
description: "Search with SearxNG",
|
||||
connectorType: EnumConnectorName.SEARXNG_API,
|
||||
},
|
||||
{
|
||||
id: "linkup-api",
|
||||
title: "Linkup API",
|
||||
description: "Search with Linkup",
|
||||
connectorType: EnumConnectorName.LINKUP_API,
|
||||
},
|
||||
{
|
||||
id: "baidu-search-api",
|
||||
title: "Baidu Search",
|
||||
description: "Search with Baidu",
|
||||
connectorType: EnumConnectorName.BAIDU_SEARCH_API,
|
||||
},
|
||||
{
|
||||
id: "circleback-connector",
|
||||
title: "Circleback",
|
||||
description: "Receive meeting notes via webhook",
|
||||
connectorType: EnumConnectorName.CIRCLEBACK_CONNECTOR,
|
||||
},
|
||||
] as const;
|
||||
|
||||
// Re-export IndexingConfigState from schemas for backward compatibility
|
||||
export type { IndexingConfigState } from "./connector-popup.schemas";
|
||||
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import { z } from "zod";
|
||||
import { searchSourceConnectorTypeEnum } from "@/contracts/types/connector.types";
|
||||
|
||||
/**
|
||||
* Schema for URL query parameters used by the connector popup
|
||||
*/
|
||||
export const connectorPopupQueryParamsSchema = z.object({
|
||||
modal: z.enum(["connectors"]).optional(),
|
||||
tab: z.enum(["all", "active"]).optional(),
|
||||
view: z.enum(["configure", "edit", "connect", "youtube"]).optional(),
|
||||
connector: z.string().optional(),
|
||||
connectorId: z.string().optional(),
|
||||
connectorType: z.string().optional(),
|
||||
success: z.enum(["true", "false"]).optional(),
|
||||
});
|
||||
|
||||
export type ConnectorPopupQueryParams = z.infer<typeof connectorPopupQueryParamsSchema>;
|
||||
|
||||
/**
|
||||
* Schema for OAuth API response (auth_url)
|
||||
*/
|
||||
export const oauthAuthResponseSchema = z.object({
|
||||
auth_url: z.string().url("Invalid auth URL format"),
|
||||
});
|
||||
|
||||
export type OAuthAuthResponse = z.infer<typeof oauthAuthResponseSchema>;
|
||||
|
||||
/**
|
||||
* Schema for IndexingConfigState
|
||||
*/
|
||||
export const indexingConfigStateSchema = z.object({
|
||||
connectorType: searchSourceConnectorTypeEnum,
|
||||
connectorId: z.number().int().positive("Connector ID must be a positive integer"),
|
||||
connectorTitle: z.string().min(1, "Connector title is required"),
|
||||
});
|
||||
|
||||
export type IndexingConfigState = z.infer<typeof indexingConfigStateSchema>;
|
||||
|
||||
/**
|
||||
* Schema for frequency minutes (must be one of the allowed values)
|
||||
*/
|
||||
export const frequencyMinutesSchema = z.enum(["15", "60", "360", "720", "1440", "10080"], {
|
||||
message: "Invalid frequency value",
|
||||
});
|
||||
|
||||
export type FrequencyMinutes = z.infer<typeof frequencyMinutesSchema>;
|
||||
|
||||
/**
|
||||
* Schema for date range validation
|
||||
*/
|
||||
export const dateRangeSchema = z
|
||||
.object({
|
||||
startDate: z.date().optional(),
|
||||
endDate: z.date().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.startDate && data.endDate) {
|
||||
return data.startDate <= data.endDate;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Start date must be before or equal to end date",
|
||||
path: ["endDate"],
|
||||
}
|
||||
);
|
||||
|
||||
export type DateRange = z.infer<typeof dateRangeSchema>;
|
||||
|
||||
/**
|
||||
* Schema for connector ID validation (used in URL params)
|
||||
*/
|
||||
export const connectorIdSchema = z.string().min(1, "Connector ID is required");
|
||||
|
||||
/**
|
||||
* Helper function to safely parse query params
|
||||
*/
|
||||
export function parseConnectorPopupQueryParams(
|
||||
params: URLSearchParams | Record<string, string | null>
|
||||
): ConnectorPopupQueryParams {
|
||||
const obj: Record<string, string | undefined> = {};
|
||||
|
||||
if (params instanceof URLSearchParams) {
|
||||
params.forEach((value, key) => {
|
||||
obj[key] = value || undefined;
|
||||
});
|
||||
} else {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
obj[key] = value || undefined;
|
||||
});
|
||||
}
|
||||
|
||||
return connectorPopupQueryParamsSchema.parse(obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to safely parse OAuth response
|
||||
*/
|
||||
export function parseOAuthAuthResponse(data: unknown): OAuthAuthResponse {
|
||||
return oauthAuthResponseSchema.parse(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to validate indexing config state
|
||||
*/
|
||||
export function validateIndexingConfigState(data: unknown): IndexingConfigState {
|
||||
return indexingConfigStateSchema.parse(data);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,995 @@
|
|||
import { useAtomValue } from "jotai";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { createConnectorMutationAtom, deleteConnectorMutationAtom, indexConnectorMutationAtom, updateConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { queryClient } from "@/lib/query-client/client";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { format } from "date-fns";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { searchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { OAUTH_CONNECTORS, OTHER_CONNECTORS } from "../constants/connector-constants";
|
||||
import type { IndexingConfigState } from "../constants/connector-constants";
|
||||
import {
|
||||
parseConnectorPopupQueryParams,
|
||||
parseOAuthAuthResponse,
|
||||
validateIndexingConfigState,
|
||||
frequencyMinutesSchema,
|
||||
dateRangeSchema,
|
||||
} from "../constants/connector-popup.schemas";
|
||||
|
||||
export const useConnectorDialog = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const { data: allConnectors, refetch: refetchAllConnectors } = useAtomValue(connectorsAtom);
|
||||
const { mutateAsync: indexConnector } = useAtomValue(indexConnectorMutationAtom);
|
||||
const { mutateAsync: updateConnector } = useAtomValue(updateConnectorMutationAtom);
|
||||
const { mutateAsync: deleteConnector } = useAtomValue(deleteConnectorMutationAtom);
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("all");
|
||||
const [connectingId, setConnectingId] = useState<string | null>(null);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [indexingConfig, setIndexingConfig] = useState<IndexingConfigState | null>(null);
|
||||
const [indexingConnector, setIndexingConnector] = useState<SearchSourceConnector | null>(null);
|
||||
const [indexingConnectorConfig, setIndexingConnectorConfig] = useState<Record<string, unknown> | null>(null);
|
||||
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
|
||||
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
|
||||
const [isStartingIndexing, setIsStartingIndexing] = useState(false);
|
||||
const [periodicEnabled, setPeriodicEnabled] = useState(false);
|
||||
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
|
||||
|
||||
// Edit mode state
|
||||
const [editingConnector, setEditingConnector] = useState<SearchSourceConnector | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
||||
const [connectorConfig, setConnectorConfig] = useState<Record<string, unknown> | null>(null);
|
||||
const [connectorName, setConnectorName] = useState<string | null>(null);
|
||||
|
||||
// Connect mode state (for non-OAuth connectors)
|
||||
const [connectingConnectorType, setConnectingConnectorType] = useState<string | null>(null);
|
||||
const [isCreatingConnector, setIsCreatingConnector] = useState(false);
|
||||
const isCreatingConnectorRef = useRef(false);
|
||||
|
||||
// Helper function to get frequency label
|
||||
const getFrequencyLabel = useCallback((minutes: string): string => {
|
||||
switch (minutes) {
|
||||
case "15": return "15 minutes";
|
||||
case "60": return "hour";
|
||||
case "360": return "6 hours";
|
||||
case "720": return "12 hours";
|
||||
case "1440": return "day";
|
||||
case "10080": return "week";
|
||||
default: return `${minutes} minutes`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Synchronize state with URL query params
|
||||
useEffect(() => {
|
||||
try {
|
||||
const params = parseConnectorPopupQueryParams(searchParams);
|
||||
|
||||
if (params.modal === "connectors") {
|
||||
setIsOpen(true);
|
||||
|
||||
if (params.tab === "active" || params.tab === "all") {
|
||||
setActiveTab(params.tab);
|
||||
}
|
||||
|
||||
// Clear indexing config if view is not "configure" anymore
|
||||
if (params.view !== "configure" && indexingConfig) {
|
||||
setIndexingConfig(null);
|
||||
}
|
||||
|
||||
// Clear editing connector if view is not "edit" anymore
|
||||
if (params.view !== "edit" && editingConnector) {
|
||||
setEditingConnector(null);
|
||||
setConnectorName(null);
|
||||
}
|
||||
|
||||
// Clear connecting connector type if view is not "connect" anymore
|
||||
if (params.view !== "connect" && connectingConnectorType) {
|
||||
setConnectingConnectorType(null);
|
||||
}
|
||||
|
||||
// Handle connect view
|
||||
if (params.view === "connect" && params.connectorType && !connectingConnectorType) {
|
||||
setConnectingConnectorType(params.connectorType);
|
||||
}
|
||||
|
||||
// Handle YouTube view
|
||||
if (params.view === "youtube") {
|
||||
// YouTube view is active - no additional state needed
|
||||
}
|
||||
|
||||
if (params.view === "configure" && params.connector && !indexingConfig) {
|
||||
const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === params.connector);
|
||||
if (oauthConnector && allConnectors) {
|
||||
const existingConnector = allConnectors.find(
|
||||
(c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType
|
||||
);
|
||||
if (existingConnector) {
|
||||
// Validate connector data before setting state
|
||||
const connectorValidation = searchSourceConnector.safeParse(existingConnector);
|
||||
if (connectorValidation.success) {
|
||||
const config = validateIndexingConfigState({
|
||||
connectorType: oauthConnector.connectorType,
|
||||
connectorId: existingConnector.id,
|
||||
connectorTitle: oauthConnector.title,
|
||||
});
|
||||
setIndexingConfig(config);
|
||||
setIndexingConnector(existingConnector);
|
||||
setIndexingConnectorConfig(existingConnector.config);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle edit view
|
||||
if (params.view === "edit" && params.connectorId && allConnectors && !editingConnector) {
|
||||
const connectorId = parseInt(params.connectorId, 10);
|
||||
const connector = allConnectors.find((c: SearchSourceConnector) => c.id === connectorId);
|
||||
if (connector) {
|
||||
const connectorValidation = searchSourceConnector.safeParse(connector);
|
||||
if (connectorValidation.success) {
|
||||
setEditingConnector(connector);
|
||||
setConnectorConfig(connector.config);
|
||||
// Load existing periodic sync settings
|
||||
setPeriodicEnabled(connector.periodic_indexing_enabled);
|
||||
setFrequencyMinutes(
|
||||
connector.indexing_frequency_minutes?.toString() || "1440"
|
||||
);
|
||||
// Reset dates - user can set new ones for re-indexing
|
||||
setStartDate(undefined);
|
||||
setEndDate(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setIsOpen(false);
|
||||
// Clear indexing config when modal is closed
|
||||
if (indexingConfig) {
|
||||
setIndexingConfig(null);
|
||||
setIndexingConnector(null);
|
||||
setIndexingConnectorConfig(null);
|
||||
setStartDate(undefined);
|
||||
setEndDate(undefined);
|
||||
setPeriodicEnabled(false);
|
||||
setFrequencyMinutes("1440");
|
||||
setIsScrolled(false);
|
||||
setSearchQuery("");
|
||||
}
|
||||
// Clear editing connector when modal is closed
|
||||
if (editingConnector) {
|
||||
setEditingConnector(null);
|
||||
setConnectorName(null);
|
||||
setConnectorConfig(null);
|
||||
setStartDate(undefined);
|
||||
setEndDate(undefined);
|
||||
setPeriodicEnabled(false);
|
||||
setFrequencyMinutes("1440");
|
||||
setIsScrolled(false);
|
||||
setSearchQuery("");
|
||||
}
|
||||
// Clear connecting connector type when modal is closed
|
||||
if (connectingConnectorType) {
|
||||
setConnectingConnectorType(null);
|
||||
}
|
||||
// Clear YouTube view when modal is closed (handled by view param check)
|
||||
}
|
||||
} catch (error) {
|
||||
// Invalid query params - log but don't crash
|
||||
console.warn("Invalid connector popup query params:", error);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams, allConnectors, editingConnector, indexingConfig, connectingConnectorType]);
|
||||
|
||||
// Detect OAuth success and transition to config view
|
||||
useEffect(() => {
|
||||
try {
|
||||
const params = parseConnectorPopupQueryParams(searchParams);
|
||||
|
||||
if (params.success === "true" && params.connector && searchSpaceId && params.modal === "connectors") {
|
||||
const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === params.connector);
|
||||
if (oauthConnector) {
|
||||
refetchAllConnectors().then((result) => {
|
||||
if (!result.data) return;
|
||||
|
||||
const newConnector = result.data.find(
|
||||
(c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType
|
||||
);
|
||||
if (newConnector) {
|
||||
// Validate connector data before setting state
|
||||
const connectorValidation = searchSourceConnector.safeParse(newConnector);
|
||||
if (connectorValidation.success) {
|
||||
const config = validateIndexingConfigState({
|
||||
connectorType: oauthConnector.connectorType,
|
||||
connectorId: newConnector.id,
|
||||
connectorTitle: oauthConnector.title,
|
||||
});
|
||||
setIndexingConfig(config);
|
||||
setIndexingConnector(newConnector);
|
||||
setIndexingConnectorConfig(newConnector.config);
|
||||
setIsOpen(true);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("success");
|
||||
url.searchParams.set("view", "configure");
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
} else {
|
||||
console.warn("Invalid connector data after OAuth:", connectorValidation.error);
|
||||
toast.error("Failed to validate connector data");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Invalid query params - log but don't crash
|
||||
console.warn("Invalid connector popup query params in OAuth success handler:", error);
|
||||
}
|
||||
}, [searchParams, searchSpaceId, refetchAllConnectors]);
|
||||
|
||||
// Handle OAuth connection
|
||||
const handleConnectOAuth = useCallback(
|
||||
async (connector: (typeof OAUTH_CONNECTORS)[number]) => {
|
||||
if (!searchSpaceId || !connector.authEndpoint) return;
|
||||
|
||||
// Set connecting state immediately to disable button and show spinner
|
||||
setConnectingId(connector.id);
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${connector.authEndpoint}?space_id=${searchSpaceId}`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to initiate ${connector.title} OAuth`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Validate OAuth response with Zod
|
||||
const validatedData = parseOAuthAuthResponse(data);
|
||||
|
||||
// Don't clear connectingId here - let the redirect happen with button still disabled
|
||||
// The component will unmount on redirect anyway
|
||||
window.location.href = validatedData.auth_url;
|
||||
} catch (error) {
|
||||
console.error(`Error connecting to ${connector.title}:`, error);
|
||||
if (error instanceof Error && error.message.includes("Invalid auth URL")) {
|
||||
toast.error(`Invalid response from ${connector.title} OAuth endpoint`);
|
||||
} else {
|
||||
toast.error(`Failed to connect to ${connector.title}`);
|
||||
}
|
||||
// Only clear connectingId on error so user can retry
|
||||
setConnectingId(null);
|
||||
}
|
||||
},
|
||||
[searchSpaceId]
|
||||
);
|
||||
|
||||
// Handle creating YouTube crawler (not a connector, shows view in popup)
|
||||
const handleCreateYouTubeCrawler = useCallback(() => {
|
||||
if (!searchSpaceId) return;
|
||||
|
||||
// Update URL to show YouTube view
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("view", "youtube");
|
||||
window.history.pushState({ modal: true }, "", url.toString());
|
||||
}, [searchSpaceId]);
|
||||
|
||||
// Handle creating webcrawler connector
|
||||
const handleCreateWebcrawler = useCallback(async () => {
|
||||
if (!searchSpaceId) return;
|
||||
|
||||
setConnectingId("webcrawler-connector");
|
||||
try {
|
||||
const newConnector = await createConnector({
|
||||
data: {
|
||||
name: "Web Pages",
|
||||
connector_type: EnumConnectorName.WEBCRAWLER_CONNECTOR,
|
||||
config: {},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
// Refetch connectors to get the new one
|
||||
const result = await refetchAllConnectors();
|
||||
if (result.data) {
|
||||
const connector = result.data.find(
|
||||
(c: SearchSourceConnector) => c.connector_type === EnumConnectorName.WEBCRAWLER_CONNECTOR
|
||||
);
|
||||
if (connector) {
|
||||
const connectorValidation = searchSourceConnector.safeParse(connector);
|
||||
if (connectorValidation.success) {
|
||||
const config = validateIndexingConfigState({
|
||||
connectorType: EnumConnectorName.WEBCRAWLER_CONNECTOR,
|
||||
connectorId: connector.id,
|
||||
connectorTitle: "Web Pages",
|
||||
});
|
||||
setIndexingConfig(config);
|
||||
setIndexingConnector(connector);
|
||||
setIndexingConnectorConfig(connector.config || {});
|
||||
setIsOpen(true);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("view", "configure");
|
||||
window.history.pushState({ modal: true }, "", url.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating webcrawler connector:", error);
|
||||
toast.error("Failed to create web crawler connector");
|
||||
} finally {
|
||||
setConnectingId(null);
|
||||
}
|
||||
}, [searchSpaceId, createConnector, refetchAllConnectors]);
|
||||
|
||||
// Handle connecting non-OAuth connectors (like Tavily API)
|
||||
const handleConnectNonOAuth = useCallback((connectorType: string) => {
|
||||
if (!searchSpaceId) return;
|
||||
|
||||
// Set connecting state
|
||||
setConnectingConnectorType(connectorType);
|
||||
|
||||
// Update URL to show connect view
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("view", "connect");
|
||||
url.searchParams.set("connectorType", connectorType);
|
||||
window.history.pushState({ modal: true }, "", url.toString());
|
||||
}, [searchSpaceId]);
|
||||
|
||||
// Handle submitting connect form
|
||||
const handleSubmitConnectForm = useCallback(async (
|
||||
formData: {
|
||||
name: string;
|
||||
connector_type: string;
|
||||
config: Record<string, unknown>;
|
||||
is_indexable: boolean;
|
||||
last_indexed_at: null;
|
||||
periodic_indexing_enabled: boolean;
|
||||
indexing_frequency_minutes: number | null;
|
||||
next_scheduled_at: null;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
periodicEnabled?: boolean;
|
||||
frequencyMinutes?: string;
|
||||
}
|
||||
) => {
|
||||
if (!searchSpaceId || !connectingConnectorType) return;
|
||||
|
||||
// Prevent multiple submissions using ref for immediate check
|
||||
if (isCreatingConnectorRef.current) return;
|
||||
isCreatingConnectorRef.current = true;
|
||||
|
||||
setIsCreatingConnector(true);
|
||||
try {
|
||||
// Extract UI-only fields before sending to backend
|
||||
const { startDate, endDate, periodicEnabled, frequencyMinutes, ...connectorData } = formData;
|
||||
|
||||
// Create connector - ensure types match the schema
|
||||
const newConnector = await createConnector({
|
||||
data: {
|
||||
...connectorData,
|
||||
connector_type: connectorData.connector_type as EnumConnectorName,
|
||||
next_scheduled_at: connectorData.next_scheduled_at as string | null,
|
||||
},
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
// Refetch connectors to get the new one
|
||||
const result = await refetchAllConnectors();
|
||||
if (result.data) {
|
||||
const connector = result.data.find(
|
||||
(c: SearchSourceConnector) => c.id === newConnector.id
|
||||
);
|
||||
if (connector) {
|
||||
// Validate connector data
|
||||
const connectorValidation = searchSourceConnector.safeParse(connector);
|
||||
if (connectorValidation.success) {
|
||||
// Store connectingConnectorType before clearing it
|
||||
const currentConnectorType = connectingConnectorType;
|
||||
|
||||
// Find connector title from constants
|
||||
const connectorInfo = OTHER_CONNECTORS.find(
|
||||
c => c.connectorType === currentConnectorType
|
||||
);
|
||||
const connectorTitle = connectorInfo?.title || connector.name;
|
||||
|
||||
// Set up indexing config
|
||||
const config = validateIndexingConfigState({
|
||||
connectorType: currentConnectorType as EnumConnectorName,
|
||||
connectorId: connector.id,
|
||||
connectorTitle,
|
||||
});
|
||||
|
||||
// Clear connecting state to allow view transition
|
||||
setConnectingConnectorType(null);
|
||||
|
||||
// Set indexing config state
|
||||
setIndexingConfig(config);
|
||||
setIndexingConnector(connector);
|
||||
setIndexingConnectorConfig(connector.config || {});
|
||||
|
||||
// Pre-populate indexing configuration with values from form if provided
|
||||
if (formData.startDate !== undefined) {
|
||||
setStartDate(formData.startDate);
|
||||
}
|
||||
if (formData.endDate !== undefined) {
|
||||
setEndDate(formData.endDate);
|
||||
}
|
||||
if (formData.periodicEnabled !== undefined) {
|
||||
setPeriodicEnabled(formData.periodicEnabled);
|
||||
}
|
||||
if (formData.frequencyMinutes !== undefined) {
|
||||
setFrequencyMinutes(formData.frequencyMinutes);
|
||||
}
|
||||
|
||||
// Auto-start indexing for non-OAuth reindexable connectors
|
||||
// This only applies to non-OAuth reindexable connectors (e.g., Elasticsearch, Linear)
|
||||
// Non-reindexable connectors (e.g., Tavily) have is_indexable: false, so they won't trigger this
|
||||
// Backend will use default date ranges (365 days ago to today) if dates are not provided
|
||||
if (connector.is_indexable) {
|
||||
// Get indexing configuration from form (or use defaults)
|
||||
const startDateForIndexing = formData.startDate;
|
||||
const endDateForIndexing = formData.endDate;
|
||||
const periodicEnabledForIndexing = formData.periodicEnabled || false;
|
||||
const frequencyMinutesForIndexing = formData.frequencyMinutes || "1440";
|
||||
|
||||
// Update connector with periodic sync settings if enabled
|
||||
if (periodicEnabledForIndexing) {
|
||||
const frequency = parseInt(frequencyMinutesForIndexing, 10);
|
||||
await updateConnector({
|
||||
id: connector.id,
|
||||
data: {
|
||||
periodic_indexing_enabled: true,
|
||||
indexing_frequency_minutes: frequency,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Start indexing (backend will use defaults if dates are undefined)
|
||||
const startDateStr = startDateForIndexing ? format(startDateForIndexing, "yyyy-MM-dd") : undefined;
|
||||
const endDateStr = endDateForIndexing ? format(endDateForIndexing, "yyyy-MM-dd") : undefined;
|
||||
|
||||
await indexConnector({
|
||||
connector_id: connector.id,
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
start_date: startDateStr,
|
||||
end_date: endDateStr,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success(`${connectorTitle} connected and indexing started!`, {
|
||||
description: periodicEnabledForIndexing
|
||||
? `Periodic sync enabled every ${getFrequencyLabel(frequencyMinutesForIndexing)}.`
|
||||
: "You can continue working while we sync your data.",
|
||||
});
|
||||
|
||||
// Close modal and return to main view
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("modal");
|
||||
url.searchParams.delete("tab");
|
||||
url.searchParams.delete("view");
|
||||
url.searchParams.delete("connectorType");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
|
||||
// Clear indexing config state since we're not showing the view
|
||||
setIndexingConfig(null);
|
||||
setIndexingConnector(null);
|
||||
setIndexingConnectorConfig(null);
|
||||
|
||||
// Invalidate queries to refresh data
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
|
||||
});
|
||||
|
||||
// Refresh connectors list
|
||||
await refetchAllConnectors();
|
||||
} else {
|
||||
// Non-indexable connector - just show success message
|
||||
toast.success(`${connectorTitle} connected successfully!`);
|
||||
|
||||
// Close modal and return to main view
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("modal");
|
||||
url.searchParams.delete("tab");
|
||||
url.searchParams.delete("view");
|
||||
url.searchParams.delete("connectorType");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
isCreatingConnectorRef.current = false;
|
||||
setIsCreatingConnector(false);
|
||||
// Don't clear connectingConnectorType here - it's cleared above when transitioning to config view
|
||||
}
|
||||
}, [connectingConnectorType, searchSpaceId, createConnector, refetchAllConnectors, updateConnector, indexConnector, router, getFrequencyLabel, queryClient]);
|
||||
|
||||
// Handle going back from connect view
|
||||
const handleBackFromConnect = useCallback(() => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("tab", "all");
|
||||
url.searchParams.delete("view");
|
||||
url.searchParams.delete("connectorType");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
}, [router]);
|
||||
|
||||
// Handle going back from YouTube view
|
||||
const handleBackFromYouTube = useCallback(() => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("tab", "all");
|
||||
url.searchParams.delete("view");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
}, [router]);
|
||||
|
||||
// Handle starting indexing
|
||||
const handleStartIndexing = useCallback(async (refreshConnectors: () => void) => {
|
||||
if (!indexingConfig || !searchSpaceId) return;
|
||||
|
||||
// Validate date range (skip for Google Drive and Webcrawler)
|
||||
if (indexingConfig.connectorType !== "GOOGLE_DRIVE_CONNECTOR" && indexingConfig.connectorType !== "WEBCRAWLER_CONNECTOR") {
|
||||
const dateRangeValidation = dateRangeSchema.safeParse({ startDate, endDate });
|
||||
if (!dateRangeValidation.success) {
|
||||
const firstIssueMsg =
|
||||
dateRangeValidation.error.issues && dateRangeValidation.error.issues.length > 0
|
||||
? dateRangeValidation.error.issues[0].message
|
||||
: "Invalid date range";
|
||||
toast.error(firstIssueMsg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate frequency minutes if periodic is enabled
|
||||
if (periodicEnabled) {
|
||||
const frequencyValidation = frequencyMinutesSchema.safeParse(frequencyMinutes);
|
||||
if (!frequencyValidation.success) {
|
||||
toast.error("Invalid frequency value");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsStartingIndexing(true);
|
||||
try {
|
||||
const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined;
|
||||
const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined;
|
||||
|
||||
// Update connector with periodic sync settings and config changes
|
||||
if (periodicEnabled || indexingConnectorConfig) {
|
||||
const frequency = periodicEnabled ? parseInt(frequencyMinutes, 10) : undefined;
|
||||
await updateConnector({
|
||||
id: indexingConfig.connectorId,
|
||||
data: {
|
||||
...(periodicEnabled && {
|
||||
periodic_indexing_enabled: true,
|
||||
indexing_frequency_minutes: frequency,
|
||||
}),
|
||||
...(indexingConnectorConfig && {
|
||||
config: indexingConnectorConfig,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Handle Google Drive folder selection
|
||||
if (indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" && indexingConnectorConfig) {
|
||||
const selectedFolders = indexingConnectorConfig.selected_folders as Array<{ id: string; name: string }> | undefined;
|
||||
if (selectedFolders && selectedFolders.length > 0) {
|
||||
// Index with folder selection
|
||||
const folderIds = selectedFolders.map((f) => f.id).join(",");
|
||||
const folderNames = selectedFolders.map((f) => f.name).join(", ");
|
||||
await indexConnector({
|
||||
connector_id: indexingConfig.connectorId,
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
folder_ids: folderIds,
|
||||
folder_names: folderNames,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Google Drive requires folder selection - show error if none selected
|
||||
toast.error("Please select at least one folder to index");
|
||||
setIsStartingIndexing(false);
|
||||
return;
|
||||
}
|
||||
} else if (indexingConfig.connectorType === "WEBCRAWLER_CONNECTOR") {
|
||||
// Webcrawler doesn't use date ranges, just uses config (API key and URLs)
|
||||
await indexConnector({
|
||||
connector_id: indexingConfig.connectorId,
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await indexConnector({
|
||||
connector_id: indexingConfig.connectorId,
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
start_date: startDateStr,
|
||||
end_date: endDateStr,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toast.success(`${indexingConfig.connectorTitle} indexing started`, {
|
||||
description: periodicEnabled
|
||||
? `Periodic sync enabled every ${getFrequencyLabel(frequencyMinutes)}.`
|
||||
: "You can continue working while we sync your data.",
|
||||
});
|
||||
|
||||
// Update URL - the effect will handle closing the modal and clearing state
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("modal");
|
||||
url.searchParams.delete("tab");
|
||||
url.searchParams.delete("success");
|
||||
url.searchParams.delete("connector");
|
||||
url.searchParams.delete("view");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
|
||||
refreshConnectors();
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error starting indexing:", error);
|
||||
toast.error("Failed to start indexing");
|
||||
} finally {
|
||||
setIsStartingIndexing(false);
|
||||
}
|
||||
}, [indexingConfig, searchSpaceId, startDate, endDate, indexConnector, updateConnector, periodicEnabled, frequencyMinutes, getFrequencyLabel, router, indexingConnectorConfig]);
|
||||
|
||||
// Handle skipping indexing
|
||||
const handleSkipIndexing = useCallback(() => {
|
||||
// Update URL - the effect will handle closing the modal and clearing state
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("modal");
|
||||
url.searchParams.delete("tab");
|
||||
url.searchParams.delete("success");
|
||||
url.searchParams.delete("connector");
|
||||
url.searchParams.delete("view");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
}, [router]);
|
||||
|
||||
// Handle starting edit mode
|
||||
const handleStartEdit = useCallback((connector: SearchSourceConnector) => {
|
||||
if (!searchSpaceId) return;
|
||||
|
||||
// Check if this is an OAuth connector
|
||||
const isOAuthConnector = OAUTH_CONNECTORS.some(
|
||||
(oauthConnector) => oauthConnector.connectorType === connector.connector_type
|
||||
);
|
||||
|
||||
// Check if this is webcrawler, Tavily API, SearxNG, Linkup, Baidu, Linear, Elasticsearch, Slack, Discord, or Notion (can be managed in popup)
|
||||
const isWebcrawler = connector.connector_type === EnumConnectorName.WEBCRAWLER_CONNECTOR;
|
||||
const isTavilyApi = connector.connector_type === EnumConnectorName.TAVILY_API;
|
||||
const isSearxng = connector.connector_type === EnumConnectorName.SEARXNG_API;
|
||||
const isLinkup = connector.connector_type === EnumConnectorName.LINKUP_API;
|
||||
const isBaidu = connector.connector_type === EnumConnectorName.BAIDU_SEARCH_API;
|
||||
const isLinear = connector.connector_type === EnumConnectorName.LINEAR_CONNECTOR;
|
||||
const isElasticsearch = connector.connector_type === EnumConnectorName.ELASTICSEARCH_CONNECTOR;
|
||||
const isSlack = connector.connector_type === EnumConnectorName.SLACK_CONNECTOR;
|
||||
const isDiscord = connector.connector_type === EnumConnectorName.DISCORD_CONNECTOR;
|
||||
const isNotion = connector.connector_type === EnumConnectorName.NOTION_CONNECTOR;
|
||||
|
||||
// If not OAuth, not webcrawler, not Tavily API, not SearxNG, not Linkup, not Baidu, not Linear, not Elasticsearch, not Slack, not Discord, and not Notion, redirect to old connector edit page
|
||||
if (!isOAuthConnector && !isWebcrawler && !isTavilyApi && !isSearxng && !isLinkup && !isBaidu && !isLinear && !isElasticsearch && !isSlack && !isDiscord && !isNotion) {
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors/${connector.id}/edit`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate connector data
|
||||
const connectorValidation = searchSourceConnector.safeParse(connector);
|
||||
if (!connectorValidation.success) {
|
||||
toast.error("Invalid connector data");
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingConnector(connector);
|
||||
setConnectorName(connector.name);
|
||||
// Load existing periodic sync settings
|
||||
setPeriodicEnabled(connector.periodic_indexing_enabled);
|
||||
setFrequencyMinutes(connector.indexing_frequency_minutes?.toString() || "1440");
|
||||
// Reset dates - user can set new ones for re-indexing
|
||||
setStartDate(undefined);
|
||||
setEndDate(undefined);
|
||||
|
||||
// Update URL
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("view", "edit");
|
||||
url.searchParams.set("connectorId", connector.id.toString());
|
||||
window.history.pushState({ modal: true }, "", url.toString());
|
||||
}, [searchSpaceId, router]);
|
||||
|
||||
// Handle saving connector changes
|
||||
const handleSaveConnector = useCallback(async (refreshConnectors: () => void) => {
|
||||
if (!editingConnector || !searchSpaceId) return;
|
||||
|
||||
// Validate date range (skip for Google Drive which uses folder selection, Webcrawler which uses config, and non-indexable connectors)
|
||||
if (editingConnector.is_indexable && editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" && editingConnector.connector_type !== "WEBCRAWLER_CONNECTOR") {
|
||||
const dateRangeValidation = dateRangeSchema.safeParse({ startDate, endDate });
|
||||
if (!dateRangeValidation.success) {
|
||||
toast.error(dateRangeValidation.error.issues[0]?.message || "Invalid date range");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent periodic indexing for non-indexable connectors
|
||||
if (periodicEnabled && !editingConnector.is_indexable) {
|
||||
toast.error("Periodic indexing is not available for this connector type");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate frequency minutes if periodic is enabled (only for indexable connectors)
|
||||
if (periodicEnabled && editingConnector.is_indexable) {
|
||||
const frequencyValidation = frequencyMinutesSchema.safeParse(frequencyMinutes);
|
||||
if (!frequencyValidation.success) {
|
||||
toast.error("Invalid frequency value");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined;
|
||||
const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined;
|
||||
|
||||
// Update connector with periodic sync settings, config changes, and name
|
||||
const frequency = periodicEnabled ? parseInt(frequencyMinutes, 10) : null;
|
||||
await updateConnector({
|
||||
id: editingConnector.id,
|
||||
data: {
|
||||
name: connectorName || editingConnector.name,
|
||||
periodic_indexing_enabled: periodicEnabled,
|
||||
indexing_frequency_minutes: frequency,
|
||||
config: connectorConfig || editingConnector.config,
|
||||
},
|
||||
});
|
||||
|
||||
// Re-index based on connector type (only for indexable connectors)
|
||||
let indexingDescription = "Settings saved.";
|
||||
if (!editingConnector.is_indexable) {
|
||||
// Non-indexable connectors (like Tavily API) don't need re-indexing
|
||||
indexingDescription = "Settings saved.";
|
||||
} else if (editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR") {
|
||||
// Google Drive uses folder selection from config, not date ranges
|
||||
const selectedFolders = (connectorConfig || editingConnector.config)?.selected_folders as Array<{ id: string; name: string }> | undefined;
|
||||
if (selectedFolders && selectedFolders.length > 0) {
|
||||
const folderIds = selectedFolders.map((f) => f.id).join(",");
|
||||
const folderNames = selectedFolders.map((f) => f.name).join(", ");
|
||||
await indexConnector({
|
||||
connector_id: editingConnector.id,
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
folder_ids: folderIds,
|
||||
folder_names: folderNames,
|
||||
},
|
||||
});
|
||||
indexingDescription = `Re-indexing started for ${selectedFolders.length} folder(s).`;
|
||||
}
|
||||
} else if (editingConnector.connector_type === "WEBCRAWLER_CONNECTOR") {
|
||||
// Webcrawler uses config (API key and URLs), not date ranges
|
||||
await indexConnector({
|
||||
connector_id: editingConnector.id,
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
indexingDescription = "Re-indexing started with updated configuration.";
|
||||
} else if (startDateStr || endDateStr) {
|
||||
// Other connectors use date ranges
|
||||
await indexConnector({
|
||||
connector_id: editingConnector.id,
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
start_date: startDateStr,
|
||||
end_date: endDateStr,
|
||||
},
|
||||
});
|
||||
indexingDescription = "Re-indexing started with new date range.";
|
||||
}
|
||||
|
||||
toast.success(`${editingConnector.name} updated successfully`, {
|
||||
description: periodicEnabled
|
||||
? `Periodic sync ${frequency ? `enabled every ${getFrequencyLabel(frequencyMinutes)}` : "enabled"}. ${indexingDescription}`
|
||||
: indexingDescription,
|
||||
});
|
||||
|
||||
// Update URL - the effect will handle closing the modal and clearing state
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("modal");
|
||||
url.searchParams.delete("tab");
|
||||
url.searchParams.delete("view");
|
||||
url.searchParams.delete("connectorId");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
|
||||
refreshConnectors();
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error saving connector:", error);
|
||||
toast.error("Failed to save connector changes");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [editingConnector, searchSpaceId, startDate, endDate, indexConnector, updateConnector, periodicEnabled, frequencyMinutes, getFrequencyLabel, router, connectorConfig, connectorName]);
|
||||
|
||||
// Handle disconnecting connector
|
||||
const handleDisconnectConnector = useCallback(async (refreshConnectors: () => void) => {
|
||||
if (!editingConnector || !searchSpaceId) return;
|
||||
|
||||
setIsDisconnecting(true);
|
||||
try {
|
||||
await deleteConnector({
|
||||
id: editingConnector.id,
|
||||
});
|
||||
|
||||
toast.success(`${editingConnector.name} disconnected successfully`);
|
||||
|
||||
// Update URL - the effect will handle closing the modal and clearing state
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("modal");
|
||||
url.searchParams.delete("tab");
|
||||
url.searchParams.delete("view");
|
||||
url.searchParams.delete("connectorId");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
|
||||
refreshConnectors();
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error disconnecting connector:", error);
|
||||
toast.error("Failed to disconnect connector");
|
||||
} finally {
|
||||
setIsDisconnecting(false);
|
||||
}
|
||||
}, [editingConnector, searchSpaceId, deleteConnector, router]);
|
||||
|
||||
// Handle going back from edit view
|
||||
const handleBackFromEdit = useCallback(() => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("tab", "all");
|
||||
url.searchParams.delete("view");
|
||||
url.searchParams.delete("connectorId");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
}, [router]);
|
||||
|
||||
// Handle dialog open/close
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setIsOpen(open);
|
||||
|
||||
if (open) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("tab", activeTab);
|
||||
window.history.pushState({ modal: true }, "", url.toString());
|
||||
} else {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("modal");
|
||||
url.searchParams.delete("tab");
|
||||
url.searchParams.delete("success");
|
||||
url.searchParams.delete("connector");
|
||||
url.searchParams.delete("view");
|
||||
window.history.pushState({ modal: false }, "", url.toString());
|
||||
setIsScrolled(false);
|
||||
setSearchQuery("");
|
||||
if (!isStartingIndexing && !isSaving && !isDisconnecting && !isCreatingConnector) {
|
||||
setIndexingConfig(null);
|
||||
setIndexingConnector(null);
|
||||
setIndexingConnectorConfig(null);
|
||||
setEditingConnector(null);
|
||||
setConnectorName(null);
|
||||
setConnectorConfig(null);
|
||||
setConnectingConnectorType(null);
|
||||
setStartDate(undefined);
|
||||
setEndDate(undefined);
|
||||
setPeriodicEnabled(false);
|
||||
setFrequencyMinutes("1440");
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeTab, isStartingIndexing, isDisconnecting, isSaving, isCreatingConnector]
|
||||
);
|
||||
|
||||
// Handle tab change
|
||||
const handleTabChange = useCallback(
|
||||
(value: string) => {
|
||||
setActiveTab(value);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", value);
|
||||
window.history.replaceState({ modal: true }, "", url.toString());
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Handle scroll
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
setIsScrolled(e.currentTarget.scrollTop > 0);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
isOpen,
|
||||
activeTab,
|
||||
connectingId,
|
||||
isScrolled,
|
||||
searchQuery,
|
||||
indexingConfig,
|
||||
indexingConnector,
|
||||
indexingConnectorConfig,
|
||||
editingConnector,
|
||||
connectingConnectorType,
|
||||
isCreatingConnector,
|
||||
startDate,
|
||||
endDate,
|
||||
isStartingIndexing,
|
||||
isSaving,
|
||||
isDisconnecting,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
searchSpaceId,
|
||||
allConnectors,
|
||||
|
||||
// Setters
|
||||
setSearchQuery,
|
||||
setStartDate,
|
||||
setEndDate,
|
||||
setPeriodicEnabled,
|
||||
setFrequencyMinutes,
|
||||
setConnectorName,
|
||||
|
||||
// Handlers
|
||||
handleOpenChange,
|
||||
handleTabChange,
|
||||
handleScroll,
|
||||
handleConnectOAuth,
|
||||
handleConnectNonOAuth,
|
||||
handleCreateWebcrawler,
|
||||
handleCreateYouTubeCrawler,
|
||||
handleSubmitConnectForm,
|
||||
handleStartIndexing,
|
||||
handleSkipIndexing,
|
||||
handleStartEdit,
|
||||
handleSaveConnector,
|
||||
handleDisconnectConnector,
|
||||
handleBackFromEdit,
|
||||
handleBackFromConnect,
|
||||
handleBackFromYouTube,
|
||||
connectorConfig,
|
||||
setConnectorConfig,
|
||||
setIndexingConnectorConfig,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
// Main component export
|
||||
export { ConnectorIndicator } from "../connector-popup";
|
||||
|
||||
// Sub-components (if needed for external use)
|
||||
export { ConnectorCard } from "./components/connector-card";
|
||||
export { DateRangeSelector } from "./components/date-range-selector";
|
||||
export { PeriodicSyncConfig } from "./components/periodic-sync-config";
|
||||
export { IndexingConfigurationView } from "./connector-configs/views/indexing-configuration-view";
|
||||
export { ConnectorEditView } from "./connector-configs/views/connector-edit-view";
|
||||
export { ConnectorDialogHeader } from "./components/connector-dialog-header";
|
||||
export { AllConnectorsTab } from "./tabs/all-connectors-tab";
|
||||
export { ActiveConnectorsTab } from "./tabs/active-connectors-tab";
|
||||
|
||||
// Constants and types
|
||||
export { OAUTH_CONNECTORS, CRAWLERS, OTHER_CONNECTORS } from "./constants/connector-constants";
|
||||
export type { IndexingConfigState } from "./constants/connector-constants";
|
||||
|
||||
// Schemas and validation
|
||||
export {
|
||||
connectorPopupQueryParamsSchema,
|
||||
oauthAuthResponseSchema,
|
||||
indexingConfigStateSchema,
|
||||
frequencyMinutesSchema,
|
||||
dateRangeSchema,
|
||||
parseConnectorPopupQueryParams,
|
||||
parseOAuthAuthResponse,
|
||||
validateIndexingConfigState,
|
||||
} from "./constants/connector-popup.schemas";
|
||||
export type {
|
||||
ConnectorPopupQueryParams,
|
||||
OAuthAuthResponse,
|
||||
FrequencyMinutes,
|
||||
DateRange,
|
||||
} from "./constants/connector-popup.schemas";
|
||||
|
||||
// Hooks
|
||||
export { useConnectorDialog } from "./hooks/use-connector-dialog";
|
||||
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { Cable, FileText, Loader2 } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import type { LogSummary, LogActiveTask } from "@/contracts/types/log.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
TabsContent,
|
||||
} from "@/components/ui/tabs";
|
||||
|
||||
interface ActiveConnectorsTabProps {
|
||||
hasSources: boolean;
|
||||
totalSourceCount: number;
|
||||
activeDocumentTypes: Array<[string, number]>;
|
||||
connectors: SearchSourceConnector[];
|
||||
indexingConnectorIds: Set<number>;
|
||||
logsSummary: LogSummary | undefined;
|
||||
searchSpaceId: string;
|
||||
onTabChange: (value: string) => void;
|
||||
onManage?: (connector: SearchSourceConnector) => void;
|
||||
}
|
||||
|
||||
export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
||||
hasSources,
|
||||
activeDocumentTypes,
|
||||
connectors,
|
||||
indexingConnectorIds,
|
||||
logsSummary,
|
||||
onTabChange,
|
||||
onManage,
|
||||
}) => {
|
||||
|
||||
return (
|
||||
<TabsContent value="active" className="m-0">
|
||||
{hasSources ? (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
Currently Active
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{activeDocumentTypes.map(([docType, count]) => (
|
||||
<div
|
||||
key={docType}
|
||||
className="flex items-center gap-4 p-4 rounded-xl bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10 border border-border transition-all"
|
||||
>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-slate-400/5 dark:bg-white/5 border border-slate-400/5 dark:border-white/5">
|
||||
{getConnectorIcon(docType, "size-6")}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[14px] font-semibold leading-tight">
|
||||
{getDocumentTypeLabel(docType)}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground mt-1 inline-flex items-center gap-1.5">
|
||||
<FileText className="size-3 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">
|
||||
{(count as number).toLocaleString()} document{count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{connectors.map((connector) => {
|
||||
const isIndexing = indexingConnectorIds.has(connector.id);
|
||||
const activeTask = logsSummary?.active_tasks?.find(
|
||||
(task: LogActiveTask) => task.connector_id === connector.id
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`connector-${connector.id}`}
|
||||
className={cn(
|
||||
"flex items-center gap-4 p-4 rounded-xl border border-border transition-all",
|
||||
isIndexing
|
||||
? "bg-primary/5 border-primary/20"
|
||||
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-12 w-12 items-center justify-center rounded-lg border",
|
||||
isIndexing
|
||||
? "bg-primary/10 border-primary/20"
|
||||
: "bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5"
|
||||
)}
|
||||
>
|
||||
{getConnectorIcon(connector.connector_type, "size-6")}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[14px] font-semibold leading-tight truncate">
|
||||
{connector.name}
|
||||
</p>
|
||||
{isIndexing ? (
|
||||
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Indexing...
|
||||
{activeTask?.message && (
|
||||
<span className="text-muted-foreground truncate max-w-[150px]">
|
||||
• {activeTask.message}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
{connector.last_indexed_at
|
||||
? `Last indexed: ${format(new Date(connector.last_indexed_at), "MMM d, yyyy")}`
|
||||
: "Never indexed"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80"
|
||||
onClick={onManage ? () => onManage(connector) : undefined}
|
||||
disabled={isIndexing}
|
||||
>
|
||||
{isIndexing ? "Syncing..." : "Manage"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
|
||||
<Cable className="size-8 text-muted-foreground/50" />
|
||||
</div>
|
||||
<h4 className="text-lg font-semibold">No active sources</h4>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-[280px]">
|
||||
Connect your first service to start searching across all your data.
|
||||
</p>
|
||||
<Button
|
||||
variant="link"
|
||||
className="mt-6 text-primary hover:underline"
|
||||
onClick={() => onTabChange("all")}
|
||||
>
|
||||
Browse available connectors
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type FC } from "react";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
|
||||
import { OAUTH_CONNECTORS, CRAWLERS, OTHER_CONNECTORS } from "../constants/connector-constants";
|
||||
import { ConnectorCard } from "../components/connector-card";
|
||||
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
|
||||
|
||||
interface AllConnectorsTabProps {
|
||||
searchQuery: string;
|
||||
searchSpaceId: string;
|
||||
connectedTypes: Set<string>;
|
||||
connectingId: string | null;
|
||||
allConnectors: SearchSourceConnector[] | undefined;
|
||||
documentTypeCounts?: Record<string, number>;
|
||||
indexingConnectorIds?: Set<number>;
|
||||
logsSummary?: LogSummary;
|
||||
onConnectOAuth: (connector: (typeof OAUTH_CONNECTORS)[number]) => void;
|
||||
onConnectNonOAuth?: (connectorType: string) => void;
|
||||
onCreateWebcrawler?: () => void;
|
||||
onCreateYouTubeCrawler?: () => void;
|
||||
onManage?: (connector: SearchSourceConnector) => void;
|
||||
}
|
||||
|
||||
export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||
searchQuery,
|
||||
searchSpaceId,
|
||||
connectedTypes,
|
||||
connectingId,
|
||||
allConnectors,
|
||||
documentTypeCounts,
|
||||
indexingConnectorIds,
|
||||
logsSummary,
|
||||
onConnectOAuth,
|
||||
onConnectNonOAuth,
|
||||
onCreateWebcrawler,
|
||||
onCreateYouTubeCrawler,
|
||||
onManage,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
// Helper to find active task for a connector
|
||||
const getActiveTaskForConnector = (connectorId: number): LogActiveTask | undefined => {
|
||||
if (!logsSummary?.active_tasks) return undefined;
|
||||
return logsSummary.active_tasks.find(
|
||||
(task: LogActiveTask) => task.connector_id === connectorId
|
||||
);
|
||||
};
|
||||
|
||||
// Filter connectors based on search
|
||||
const filteredOAuth = OAUTH_CONNECTORS.filter(
|
||||
(c) =>
|
||||
c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
c.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const filteredCrawlers = CRAWLERS.filter(
|
||||
(c) =>
|
||||
c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
c.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const filteredOther = OTHER_CONNECTORS.filter(
|
||||
(c) =>
|
||||
c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
c.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Quick Connect */}
|
||||
{filteredOAuth.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
Quick Connect
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{filteredOAuth.map((connector) => {
|
||||
const isConnected = connectedTypes.has(connector.connectorType);
|
||||
const isConnecting = connectingId === connector.id;
|
||||
// Find the actual connector object if connected
|
||||
const actualConnector = isConnected && allConnectors
|
||||
? allConnectors.find((c: SearchSourceConnector) => c.connector_type === connector.connectorType)
|
||||
: undefined;
|
||||
|
||||
const documentCount = getDocumentCountForConnector(connector.connectorType, documentTypeCounts);
|
||||
const isIndexing = actualConnector && indexingConnectorIds?.has(actualConnector.id);
|
||||
const activeTask = actualConnector ? getActiveTaskForConnector(actualConnector.id) : undefined;
|
||||
|
||||
return (
|
||||
<ConnectorCard
|
||||
key={connector.id}
|
||||
id={connector.id}
|
||||
title={connector.title}
|
||||
description={connector.description}
|
||||
connectorType={connector.connectorType}
|
||||
isConnected={isConnected}
|
||||
isConnecting={isConnecting}
|
||||
documentCount={documentCount}
|
||||
isIndexing={isIndexing}
|
||||
activeTask={activeTask}
|
||||
onConnect={() => onConnectOAuth(connector)}
|
||||
onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Content Sources */}
|
||||
{filteredCrawlers.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
Content Sources
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{filteredCrawlers.map((crawler) => {
|
||||
const isYouTube = crawler.id === "youtube-crawler";
|
||||
const isWebcrawler = crawler.id === "webcrawler-connector";
|
||||
|
||||
// For crawlers that are actual connectors, check connection status
|
||||
const isConnected = crawler.connectorType
|
||||
? connectedTypes.has(crawler.connectorType)
|
||||
: false;
|
||||
const isConnecting = connectingId === crawler.id;
|
||||
|
||||
// Find the actual connector object if connected
|
||||
const actualConnector = isConnected && crawler.connectorType && allConnectors
|
||||
? allConnectors.find((c: SearchSourceConnector) => c.connector_type === crawler.connectorType)
|
||||
: undefined;
|
||||
|
||||
const documentCount = crawler.connectorType
|
||||
? getDocumentCountForConnector(crawler.connectorType, documentTypeCounts)
|
||||
: undefined;
|
||||
const isIndexing = actualConnector && indexingConnectorIds?.has(actualConnector.id);
|
||||
const activeTask = actualConnector ? getActiveTaskForConnector(actualConnector.id) : undefined;
|
||||
|
||||
const handleConnect = isYouTube && onCreateYouTubeCrawler
|
||||
? onCreateYouTubeCrawler
|
||||
: isWebcrawler && onCreateWebcrawler
|
||||
? onCreateWebcrawler
|
||||
: crawler.connectorType && onConnectNonOAuth
|
||||
? () => onConnectNonOAuth(crawler.connectorType!)
|
||||
: crawler.connectorType
|
||||
? () => router.push(`/dashboard/${searchSpaceId}/connectors/add/${crawler.id}`)
|
||||
: () => {}; // Fallback for non-connector crawlers
|
||||
|
||||
return (
|
||||
<ConnectorCard
|
||||
key={crawler.id}
|
||||
id={crawler.id}
|
||||
title={crawler.title}
|
||||
description={crawler.description}
|
||||
connectorType={crawler.connectorType || undefined}
|
||||
isConnected={isConnected}
|
||||
isConnecting={isConnecting}
|
||||
documentCount={documentCount}
|
||||
isIndexing={isIndexing}
|
||||
activeTask={activeTask}
|
||||
onConnect={handleConnect}
|
||||
onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* More Integrations */}
|
||||
{filteredOther.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
More Integrations
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{filteredOther.map((connector) => {
|
||||
// Special handling for connectors that can be created in popup
|
||||
const isWebcrawler = connector.id === "webcrawler-connector";
|
||||
const isTavily = connector.id === "tavily-api";
|
||||
const isSearxng = connector.id === "searxng";
|
||||
const isLinkup = connector.id === "linkup-api";
|
||||
const isBaidu = connector.id === "baidu-search-api";
|
||||
const isLinear = connector.id === "linear-connector";
|
||||
const isElasticsearch = connector.id === "elasticsearch-connector";
|
||||
const isSlack = connector.id === "slack-connector";
|
||||
const isDiscord = connector.id === "discord-connector";
|
||||
const isNotion = connector.id === "notion-connector";
|
||||
const isConfluence = connector.id === "confluence-connector";
|
||||
const isBookStack = connector.id === "bookstack-connector";
|
||||
const isGithub = connector.id === "github-connector";
|
||||
const isJira = connector.id === "jira-connector";
|
||||
const isClickUp = connector.id === "clickup-connector";
|
||||
const isLuma = connector.id === "luma-connector";
|
||||
const isCircleback = connector.id === "circleback-connector";
|
||||
|
||||
const isConnected = connectedTypes.has(connector.connectorType);
|
||||
const isConnecting = connectingId === connector.id;
|
||||
|
||||
// Find the actual connector object if connected
|
||||
const actualConnector = isConnected && allConnectors
|
||||
? allConnectors.find((c: SearchSourceConnector) => c.connector_type === connector.connectorType)
|
||||
: undefined;
|
||||
|
||||
const documentCount = getDocumentCountForConnector(connector.connectorType, documentTypeCounts);
|
||||
const isIndexing = actualConnector && indexingConnectorIds?.has(actualConnector.id);
|
||||
const activeTask = actualConnector ? getActiveTaskForConnector(actualConnector.id) : undefined;
|
||||
|
||||
const handleConnect = isWebcrawler && onCreateWebcrawler
|
||||
? onCreateWebcrawler
|
||||
: (isTavily || isSearxng || isLinkup || isBaidu || isLinear || isElasticsearch || isSlack || isDiscord || isNotion || isConfluence || isBookStack || isGithub || isJira || isClickUp || isLuma || isCircleback) && onConnectNonOAuth
|
||||
? () => onConnectNonOAuth(connector.connectorType)
|
||||
: () => router.push(`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`);
|
||||
|
||||
return (
|
||||
<ConnectorCard
|
||||
key={connector.id}
|
||||
id={connector.id}
|
||||
title={connector.title}
|
||||
description={connector.description}
|
||||
connectorType={connector.connectorType}
|
||||
isConnected={isConnected}
|
||||
isConnecting={isConnecting}
|
||||
documentCount={documentCount}
|
||||
isIndexing={isIndexing}
|
||||
activeTask={activeTask}
|
||||
onConnect={handleConnect}
|
||||
onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Maps SearchSourceConnectorType to DocumentType for fetching document counts
|
||||
*
|
||||
* Note: Some connectors don't have a direct 1:1 mapping to document types:
|
||||
* - Search API connectors (TAVILY_API, SEARXNG_API, etc.) don't index documents
|
||||
* - WEBCRAWLER_CONNECTOR maps to CRAWLED_URL document type
|
||||
* - GOOGLE_DRIVE_CONNECTOR maps to GOOGLE_DRIVE_FILE document type
|
||||
*/
|
||||
export const CONNECTOR_TO_DOCUMENT_TYPE: Record<string, string> = {
|
||||
// Direct mappings (connector type matches document type)
|
||||
SLACK_CONNECTOR: "SLACK_CONNECTOR",
|
||||
NOTION_CONNECTOR: "NOTION_CONNECTOR",
|
||||
GITHUB_CONNECTOR: "GITHUB_CONNECTOR",
|
||||
LINEAR_CONNECTOR: "LINEAR_CONNECTOR",
|
||||
DISCORD_CONNECTOR: "DISCORD_CONNECTOR",
|
||||
JIRA_CONNECTOR: "JIRA_CONNECTOR",
|
||||
CONFLUENCE_CONNECTOR: "CONFLUENCE_CONNECTOR",
|
||||
CLICKUP_CONNECTOR: "CLICKUP_CONNECTOR",
|
||||
GOOGLE_CALENDAR_CONNECTOR: "GOOGLE_CALENDAR_CONNECTOR",
|
||||
GOOGLE_GMAIL_CONNECTOR: "GOOGLE_GMAIL_CONNECTOR",
|
||||
AIRTABLE_CONNECTOR: "AIRTABLE_CONNECTOR",
|
||||
LUMA_CONNECTOR: "LUMA_CONNECTOR",
|
||||
ELASTICSEARCH_CONNECTOR: "ELASTICSEARCH_CONNECTOR",
|
||||
BOOKSTACK_CONNECTOR: "BOOKSTACK_CONNECTOR",
|
||||
CIRCLEBACK_CONNECTOR: "CIRCLEBACK",
|
||||
|
||||
// Special mappings (connector type differs from document type)
|
||||
GOOGLE_DRIVE_CONNECTOR: "GOOGLE_DRIVE_FILE",
|
||||
WEBCRAWLER_CONNECTOR: "CRAWLED_URL",
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the document type for a given connector type
|
||||
* Returns undefined if the connector doesn't index documents (e.g., search APIs)
|
||||
*/
|
||||
export function getDocumentTypeForConnector(
|
||||
connectorType: string
|
||||
): string | undefined {
|
||||
return CONNECTOR_TO_DOCUMENT_TYPE[connectorType];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get document count for a specific connector type from document type counts
|
||||
*/
|
||||
export function getDocumentCountForConnector(
|
||||
connectorType: string,
|
||||
documentTypeCounts: Record<string, number> | undefined
|
||||
): number | undefined {
|
||||
if (!documentTypeCounts) return undefined;
|
||||
|
||||
const documentType = getDocumentTypeForConnector(connectorType);
|
||||
if (!documentType) return undefined;
|
||||
|
||||
return documentTypeCounts[documentType];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a connector type is indexable (produces documents)
|
||||
*/
|
||||
export function isIndexableConnectorType(connectorType: string): boolean {
|
||||
return connectorType in CONNECTOR_TO_DOCUMENT_TYPE;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,247 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { TagInput, type Tag as TagType } from "emblor";
|
||||
import { useAtom } from "jotai";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { createDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
|
||||
const youtubeRegex =
|
||||
/^(https:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})$/;
|
||||
|
||||
interface YouTubeCrawlerViewProps {
|
||||
searchSpaceId: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({
|
||||
searchSpaceId,
|
||||
onBack,
|
||||
}) => {
|
||||
const t = useTranslations("add_youtube");
|
||||
const router = useRouter();
|
||||
const [videoTags, setVideoTags] = useState<TagType[]>([]);
|
||||
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Use the createDocumentMutationAtom
|
||||
const [createDocumentMutation] = useAtom(createDocumentMutationAtom);
|
||||
const { mutate: createYouTubeDocument, isPending: isSubmitting } = createDocumentMutation;
|
||||
|
||||
const isValidYoutubeUrl = (url: string): boolean => {
|
||||
return youtubeRegex.test(url);
|
||||
};
|
||||
|
||||
const extractVideoId = (url: string): string | null => {
|
||||
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (videoTags.length === 0) {
|
||||
setError(t("error_no_video"));
|
||||
return;
|
||||
}
|
||||
|
||||
const invalidUrls = videoTags.filter((tag) => !isValidYoutubeUrl(tag.text));
|
||||
if (invalidUrls.length > 0) {
|
||||
setError(t("error_invalid_urls", { urls: invalidUrls.map((tag) => tag.text).join(", ") }));
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
toast(t("processing_toast"), {
|
||||
description: t("processing_toast_desc"),
|
||||
});
|
||||
|
||||
const videoUrls = videoTags.map((tag) => tag.text);
|
||||
|
||||
// Use the mutation to create YouTube documents
|
||||
createYouTubeDocument(
|
||||
{
|
||||
document_type: "YOUTUBE_VIDEO",
|
||||
content: videoUrls,
|
||||
search_space_id: parseInt(searchSpaceId, 10),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast(t("success_toast"), {
|
||||
description: t("success_toast_desc"),
|
||||
});
|
||||
// Close the popup and navigate to documents
|
||||
onBack();
|
||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const errorMessage = error instanceof Error ? error.message : t("error_generic");
|
||||
setError(errorMessage);
|
||||
toast(t("error_toast"), {
|
||||
description: `${t("error_toast_desc")}: ${errorMessage}`,
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddTag = (text: string) => {
|
||||
if (!isValidYoutubeUrl(text)) {
|
||||
toast(t("invalid_url_toast"), {
|
||||
description: t("invalid_url_toast_desc"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoTags.some((tag) => tag.text === text)) {
|
||||
toast(t("duplicate_url_toast"), {
|
||||
description: t("duplicate_url_toast_desc"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newTag: TagType = {
|
||||
id: Date.now().toString(),
|
||||
text: text,
|
||||
};
|
||||
|
||||
setVideoTags([...videoTags, newTag]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground mb-6 w-fit"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to connectors
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl border border-slate-400/30">
|
||||
{getConnectorIcon(EnumConnectorName.YOUTUBE_CONNECTOR, "h-7 w-7")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight">
|
||||
{t("title")}
|
||||
</h2>
|
||||
<p className="text-xs sm:text-base text-muted-foreground mt-1">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Content - Scrollable */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 sm:px-12">
|
||||
<div className="space-y-4 pb-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="video-input" className="text-sm sm:text-base">
|
||||
{t("label")}
|
||||
</Label>
|
||||
<TagInput
|
||||
id="video-input"
|
||||
tags={videoTags}
|
||||
setTags={setVideoTags}
|
||||
placeholder={t("placeholder")}
|
||||
onAddTag={handleAddTag}
|
||||
styleClasses={{
|
||||
inlineTagsContainer:
|
||||
"border border-slate-400/20 rounded-lg bg-muted/50 shadow-sm shadow-black/5 transition-shadow focus-within:border-slate-400/40 focus-within:outline-none focus-within:ring-[3px] focus-within:ring-ring/20 p-1 gap-1",
|
||||
input: "w-full min-w-[80px] focus-visible:outline-none shadow-none px-2 h-7 text-foreground/90 placeholder:text-muted-foreground bg-transparent",
|
||||
tag: {
|
||||
body: "h-7 relative bg-background border border-input hover:bg-background rounded-md font-medium text-xs ps-2 pe-7 flex",
|
||||
closeButton:
|
||||
"absolute -inset-y-px -end-px p-0 rounded-e-lg flex size-7 transition-colors outline-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 text-muted-foreground/80 hover:text-foreground",
|
||||
},
|
||||
}}
|
||||
activeTagIndex={activeTagIndex}
|
||||
setActiveTagIndex={setActiveTagIndex}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("hint")}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-500 mt-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-4 text-sm">
|
||||
<h4 className="font-medium mb-2">{t("tips_title")}</h4>
|
||||
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
|
||||
<li>{t("tip_1")}</li>
|
||||
<li>{t("tip_2")}</li>
|
||||
<li>{t("tip_3")}</li>
|
||||
<li>{t("tip_4")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{videoTags.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<h4 className="font-medium">{t("preview")}:</h4>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{videoTags.map((tag, _index) => {
|
||||
const videoId = extractVideoId(tag.text);
|
||||
return videoId ? (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="relative aspect-video rounded-lg overflow-hidden border"
|
||||
>
|
||||
<iframe
|
||||
width="100%"
|
||||
height="100%"
|
||||
src={`https://www.youtube.com/embed/${videoId}`}
|
||||
title="YouTube video player"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fixed Footer - Action buttons */}
|
||||
<div className="flex-shrink-0 flex items-center justify-between px-6 sm:px-12 py-6 bg-muted border-t border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
disabled={isSubmitting}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || videoTags.length === 0}
|
||||
className="text-xs sm:text-sm min-w-[140px] disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("processing")}
|
||||
</>
|
||||
) : (
|
||||
t("submit")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -1,13 +1,77 @@
|
|||
import { AssistantIf, ThreadPrimitive } from "@assistant-ui/react";
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
ActionBarPrimitive,
|
||||
AssistantIf,
|
||||
BranchPickerPrimitive,
|
||||
ComposerPrimitive,
|
||||
ErrorPrimitive,
|
||||
MessagePrimitive,
|
||||
ThreadPrimitive,
|
||||
useAssistantState,
|
||||
useComposerRuntime,
|
||||
} from "@assistant-ui/react";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
FileText,
|
||||
Loader2,
|
||||
PencilIcon,
|
||||
RefreshCwIcon,
|
||||
SquareIcon,
|
||||
} from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
type FC,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
mentionedDocumentIdsAtom,
|
||||
mentionedDocumentsAtom,
|
||||
messageDocumentsMapAtom,
|
||||
} from "@/atoms/chat/mentioned-documents.atom";
|
||||
import {
|
||||
globalNewLLMConfigsAtom,
|
||||
llmPreferencesAtom,
|
||||
newLLMConfigsAtom,
|
||||
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import {
|
||||
ComposerAddAttachment,
|
||||
ComposerAttachments,
|
||||
UserMessageAttachments,
|
||||
} from "@/components/assistant-ui/attachment";
|
||||
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
|
||||
import {
|
||||
InlineMentionEditor,
|
||||
type InlineMentionEditorRef,
|
||||
} from "@/components/assistant-ui/inline-mention-editor";
|
||||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import {
|
||||
DocumentMentionPicker,
|
||||
type DocumentMentionPickerRef,
|
||||
} from "@/components/new-chat/document-mention-picker";
|
||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||
import { ThinkingStepsContext } from "@/components/assistant-ui/thinking-steps";
|
||||
import { ThreadWelcome } from "@/components/assistant-ui/thread-welcome";
|
||||
import { Composer } from "@/components/assistant-ui/composer";
|
||||
import { ThreadScrollToBottom } from "@/components/assistant-ui/thread-scroll-to-bottom";
|
||||
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
|
||||
import { UserMessage } from "@/components/assistant-ui/user-message";
|
||||
import { EditComposer } from "@/components/assistant-ui/edit-composer";
|
||||
import {
|
||||
ThinkingStepsContext,
|
||||
ThinkingStepsDisplay,
|
||||
} from "@/components/assistant-ui/thinking-steps";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Document } from "@/contracts/types/document.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Props for the Thread component
|
||||
|
|
@ -59,3 +123,619 @@ export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map(), head
|
|||
</ThinkingStepsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const ThreadScrollToBottom: FC = () => {
|
||||
return (
|
||||
<ThreadPrimitive.ScrollToBottom asChild>
|
||||
<TooltipIconButton
|
||||
tooltip="Scroll to bottom"
|
||||
variant="outline"
|
||||
className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full p-4 disabled:invisible dark:bg-background dark:hover:bg-accent"
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</TooltipIconButton>
|
||||
</ThreadPrimitive.ScrollToBottom>
|
||||
);
|
||||
};
|
||||
|
||||
const getTimeBasedGreeting = (userEmail?: string): string => {
|
||||
const hour = new Date().getHours();
|
||||
|
||||
// Extract first name from email if available
|
||||
const firstName = userEmail
|
||||
? userEmail.split("@")[0].split(".")[0].charAt(0).toUpperCase() +
|
||||
userEmail.split("@")[0].split(".")[0].slice(1)
|
||||
: null;
|
||||
|
||||
// Array of greeting variations for each time period
|
||||
const morningGreetings = ["Good morning", "Fresh start today", "Morning", "Hey there"];
|
||||
|
||||
const afternoonGreetings = ["Good afternoon", "Afternoon", "Hey there", "Hi there"];
|
||||
|
||||
const eveningGreetings = ["Good evening", "Evening", "Hey there", "Hi there"];
|
||||
|
||||
const nightGreetings = ["Good night", "Evening", "Hey there", "Winding down"];
|
||||
|
||||
const lateNightGreetings = ["Still up", "Night owl mode", "Up past bedtime", "Hi there"];
|
||||
|
||||
// Select a random greeting based on time
|
||||
let greeting: string;
|
||||
if (hour < 5) {
|
||||
// Late night: midnight to 5 AM
|
||||
greeting = lateNightGreetings[Math.floor(Math.random() * lateNightGreetings.length)];
|
||||
} else if (hour < 12) {
|
||||
greeting = morningGreetings[Math.floor(Math.random() * morningGreetings.length)];
|
||||
} else if (hour < 18) {
|
||||
greeting = afternoonGreetings[Math.floor(Math.random() * afternoonGreetings.length)];
|
||||
} else if (hour < 22) {
|
||||
greeting = eveningGreetings[Math.floor(Math.random() * eveningGreetings.length)];
|
||||
} else {
|
||||
// Night: 10 PM to midnight
|
||||
greeting = nightGreetings[Math.floor(Math.random() * nightGreetings.length)];
|
||||
}
|
||||
|
||||
// Add personalization with first name if available
|
||||
if (firstName) {
|
||||
return `${greeting}, ${firstName}!`;
|
||||
}
|
||||
|
||||
return `${greeting}!`;
|
||||
};
|
||||
|
||||
const ThreadWelcome: FC = () => {
|
||||
const { data: user } = useAtomValue(currentUserAtom);
|
||||
|
||||
// Memoize greeting so it doesn't change on re-renders (only on user change)
|
||||
const greeting = useMemo(() => getTimeBasedGreeting(user?.email), [user?.email]);
|
||||
|
||||
return (
|
||||
<div className="aui-thread-welcome-root mx-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center px-4 relative">
|
||||
{/* Greeting positioned above the composer - fixed position */}
|
||||
<div className="aui-thread-welcome-message absolute bottom-[calc(50%+5rem)] left-0 right-0 flex flex-col items-center text-center">
|
||||
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-2 animate-in text-3xl md:text-5xl delay-100 duration-500 ease-out fill-mode-both">
|
||||
{greeting}
|
||||
</h1>
|
||||
</div>
|
||||
{/* Composer - top edge fixed, expands downward only */}
|
||||
<div className="fade-in slide-in-from-bottom-3 animate-in delay-200 duration-500 ease-out fill-mode-both w-full flex items-start justify-center absolute top-[calc(50%-3.5rem)] left-0 right-0">
|
||||
<Composer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Composer: FC = () => {
|
||||
// ---- State for document mentions (using atoms to persist across remounts) ----
|
||||
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
|
||||
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState("");
|
||||
const editorRef = useRef<InlineMentionEditorRef>(null);
|
||||
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
||||
const { search_space_id } = useParams();
|
||||
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
|
||||
const composerRuntime = useComposerRuntime();
|
||||
const hasAutoFocusedRef = useRef(false);
|
||||
|
||||
// Check if thread is empty (new chat)
|
||||
const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty);
|
||||
|
||||
// Check if thread is currently running (streaming response)
|
||||
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
|
||||
|
||||
// Auto-focus editor when on new chat page
|
||||
useEffect(() => {
|
||||
if (isThreadEmpty && !hasAutoFocusedRef.current && editorRef.current) {
|
||||
// Small delay to ensure the editor is fully mounted
|
||||
const timeoutId = setTimeout(() => {
|
||||
editorRef.current?.focus();
|
||||
hasAutoFocusedRef.current = true;
|
||||
}, 100);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [isThreadEmpty]);
|
||||
|
||||
// Sync mentioned document IDs to atom for use in chat request
|
||||
useEffect(() => {
|
||||
setMentionedDocumentIds(mentionedDocuments.map((doc) => doc.id));
|
||||
}, [mentionedDocuments, setMentionedDocumentIds]);
|
||||
|
||||
// Handle text change from inline editor - sync with assistant-ui composer
|
||||
const handleEditorChange = useCallback(
|
||||
(text: string) => {
|
||||
composerRuntime.setText(text);
|
||||
},
|
||||
[composerRuntime]
|
||||
);
|
||||
|
||||
// Handle @ mention trigger from inline editor
|
||||
const handleMentionTrigger = useCallback((query: string) => {
|
||||
setShowDocumentPopover(true);
|
||||
setMentionQuery(query);
|
||||
}, []);
|
||||
|
||||
// Handle mention close
|
||||
const handleMentionClose = useCallback(() => {
|
||||
if (showDocumentPopover) {
|
||||
setShowDocumentPopover(false);
|
||||
setMentionQuery("");
|
||||
}
|
||||
}, [showDocumentPopover]);
|
||||
|
||||
// Handle keyboard navigation when popover is open
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (showDocumentPopover) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
documentPickerRef.current?.moveDown();
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
documentPickerRef.current?.moveUp();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
documentPickerRef.current?.selectHighlighted();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setShowDocumentPopover(false);
|
||||
setMentionQuery("");
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
[showDocumentPopover]
|
||||
);
|
||||
|
||||
// Handle submit from inline editor (Enter key)
|
||||
const handleSubmit = useCallback(() => {
|
||||
// Prevent sending while a response is still streaming
|
||||
if (isThreadRunning) {
|
||||
return;
|
||||
}
|
||||
if (!showDocumentPopover) {
|
||||
composerRuntime.send();
|
||||
// Clear the editor after sending
|
||||
editorRef.current?.clear();
|
||||
setMentionedDocuments([]);
|
||||
setMentionedDocumentIds([]);
|
||||
}
|
||||
}, [
|
||||
showDocumentPopover,
|
||||
isThreadRunning,
|
||||
composerRuntime,
|
||||
setMentionedDocuments,
|
||||
setMentionedDocumentIds,
|
||||
]);
|
||||
|
||||
// Handle document removal from inline editor
|
||||
const handleDocumentRemove = useCallback(
|
||||
(docId: number) => {
|
||||
setMentionedDocuments((prev) => {
|
||||
const updated = prev.filter((doc) => doc.id !== docId);
|
||||
// Immediately sync document IDs to avoid race conditions
|
||||
setMentionedDocumentIds(updated.map((doc) => doc.id));
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[setMentionedDocuments, setMentionedDocumentIds]
|
||||
);
|
||||
|
||||
// Handle document selection from picker
|
||||
const handleDocumentsMention = useCallback(
|
||||
(documents: Document[]) => {
|
||||
// Insert chips into the inline editor for each new document
|
||||
const existingIds = new Set(mentionedDocuments.map((d) => d.id));
|
||||
const newDocs = documents.filter((doc) => !existingIds.has(doc.id));
|
||||
|
||||
for (const doc of newDocs) {
|
||||
editorRef.current?.insertDocumentChip(doc);
|
||||
}
|
||||
|
||||
// Update mentioned documents state
|
||||
setMentionedDocuments((prev) => {
|
||||
const existingIdSet = new Set(prev.map((d) => d.id));
|
||||
const uniqueNewDocs = documents.filter((doc) => !existingIdSet.has(doc.id));
|
||||
const updated = [...prev, ...uniqueNewDocs];
|
||||
// Immediately sync document IDs to avoid race conditions
|
||||
setMentionedDocumentIds(updated.map((doc) => doc.id));
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Reset mention query but keep popover open for more selections
|
||||
setMentionQuery("");
|
||||
},
|
||||
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]
|
||||
);
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
|
||||
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50">
|
||||
<ComposerAttachments />
|
||||
{/* -------- Inline Mention Editor -------- */}
|
||||
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-3 pt-3 pb-6">
|
||||
<InlineMentionEditor
|
||||
ref={editorRef}
|
||||
placeholder="Ask SurfSense or @mention docs"
|
||||
onMentionTrigger={handleMentionTrigger}
|
||||
onMentionClose={handleMentionClose}
|
||||
onChange={handleEditorChange}
|
||||
onDocumentRemove={handleDocumentRemove}
|
||||
onSubmit={handleSubmit}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="min-h-[24px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* -------- Document mention popover (rendered via portal) -------- */}
|
||||
{showDocumentPopover &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<button
|
||||
type="button"
|
||||
className="fixed inset-0 cursor-default"
|
||||
style={{ zIndex: 9998 }}
|
||||
onClick={() => setShowDocumentPopover(false)}
|
||||
aria-label="Close document picker"
|
||||
/>
|
||||
{/* Popover positioned above input */}
|
||||
<div
|
||||
className="fixed shadow-2xl rounded-lg border border-border overflow-hidden bg-popover"
|
||||
style={{
|
||||
zIndex: 9999,
|
||||
bottom: editorContainerRef.current
|
||||
? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px`
|
||||
: "200px",
|
||||
left: editorContainerRef.current
|
||||
? `${editorContainerRef.current.getBoundingClientRect().left}px`
|
||||
: "50%",
|
||||
}}
|
||||
>
|
||||
<DocumentMentionPicker
|
||||
ref={documentPickerRef}
|
||||
searchSpaceId={Number(search_space_id)}
|
||||
onSelectionChange={handleDocumentsMention}
|
||||
onDone={() => {
|
||||
setShowDocumentPopover(false);
|
||||
setMentionQuery("");
|
||||
}}
|
||||
initialSelectedDocuments={mentionedDocuments}
|
||||
externalSearch={mentionQuery}
|
||||
/>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
<ComposerAction />
|
||||
</ComposerPrimitive.AttachmentDropzone>
|
||||
</ComposerPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const ComposerAction: FC = () => {
|
||||
// Check if any attachments are still being processed (running AND progress < 100)
|
||||
// When progress is 100, processing is done but waiting for send()
|
||||
const hasProcessingAttachments = useAssistantState(({ composer }) =>
|
||||
composer.attachments?.some((att) => {
|
||||
const status = att.status;
|
||||
if (status?.type !== "running") return false;
|
||||
const progress = (status as { type: "running"; progress?: number }).progress;
|
||||
return progress === undefined || progress < 100;
|
||||
})
|
||||
);
|
||||
|
||||
// Check if composer text is empty
|
||||
const isComposerEmpty = useAssistantState(({ composer }) => {
|
||||
const text = composer.text?.trim() || "";
|
||||
return text.length === 0;
|
||||
});
|
||||
|
||||
// Check if a model is configured
|
||||
const { data: userConfigs } = useAtomValue(newLLMConfigsAtom);
|
||||
const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
|
||||
const { data: preferences } = useAtomValue(llmPreferencesAtom);
|
||||
|
||||
const hasModelConfigured = useMemo(() => {
|
||||
if (!preferences) return false;
|
||||
const agentLlmId = preferences.agent_llm_id;
|
||||
if (agentLlmId === null || agentLlmId === undefined) return false;
|
||||
|
||||
// Check if the configured model actually exists
|
||||
if (agentLlmId < 0) {
|
||||
return globalConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
||||
}
|
||||
return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
||||
}, [preferences, globalConfigs, userConfigs]);
|
||||
|
||||
const isSendDisabled = hasProcessingAttachments || isComposerEmpty || !hasModelConfigured;
|
||||
|
||||
return (
|
||||
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<ComposerAddAttachment />
|
||||
<ConnectorIndicator />
|
||||
</div>
|
||||
|
||||
{/* Show processing indicator when attachments are being processed */}
|
||||
{hasProcessingAttachments && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
<span>Processing...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show warning when no model is configured */}
|
||||
{!hasModelConfigured && !hasProcessingAttachments && (
|
||||
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400 text-xs">
|
||||
<AlertCircle className="size-3" />
|
||||
<span>Select a model</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AssistantIf condition={({ thread }) => !thread.isRunning}>
|
||||
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
|
||||
<TooltipIconButton
|
||||
tooltip={
|
||||
!hasModelConfigured
|
||||
? "Please select a model from the header to start chatting"
|
||||
: hasProcessingAttachments
|
||||
? "Wait for attachments to process"
|
||||
: isComposerEmpty
|
||||
? "Enter a message to send"
|
||||
: "Send message"
|
||||
}
|
||||
side="bottom"
|
||||
type="submit"
|
||||
variant="default"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"aui-composer-send size-8 rounded-full",
|
||||
isSendDisabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
aria-label="Send message"
|
||||
disabled={isSendDisabled}
|
||||
>
|
||||
<ArrowUpIcon className="aui-composer-send-icon size-4" />
|
||||
</TooltipIconButton>
|
||||
</ComposerPrimitive.Send>
|
||||
</AssistantIf>
|
||||
|
||||
<AssistantIf condition={({ thread }) => thread.isRunning}>
|
||||
<ComposerPrimitive.Cancel asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="icon"
|
||||
className="aui-composer-cancel size-8 rounded-full"
|
||||
aria-label="Stop generating"
|
||||
>
|
||||
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
|
||||
</Button>
|
||||
</ComposerPrimitive.Cancel>
|
||||
</AssistantIf>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MessageError: FC = () => {
|
||||
return (
|
||||
<MessagePrimitive.Error>
|
||||
<ErrorPrimitive.Root className="aui-message-error-root mt-2 rounded-md border border-destructive bg-destructive/10 p-3 text-destructive text-sm dark:bg-destructive/5 dark:text-red-200">
|
||||
<ErrorPrimitive.Message className="aui-message-error-message line-clamp-2" />
|
||||
</ErrorPrimitive.Root>
|
||||
</MessagePrimitive.Error>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom component to render thinking steps from Context
|
||||
*/
|
||||
const ThinkingStepsPart: FC = () => {
|
||||
const thinkingStepsMap = useContext(ThinkingStepsContext);
|
||||
|
||||
// Get the current message ID to look up thinking steps
|
||||
const messageId = useAssistantState(({ message }) => message?.id);
|
||||
const thinkingSteps = thinkingStepsMap.get(messageId) || [];
|
||||
|
||||
// Check if this specific message is currently streaming
|
||||
// A message is streaming if: thread is running AND this is the last assistant message
|
||||
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
|
||||
const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false);
|
||||
const isMessageStreaming = isThreadRunning && isLastMessage;
|
||||
|
||||
if (thinkingSteps.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<ThinkingStepsDisplay steps={thinkingSteps} isThreadRunning={isMessageStreaming} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AssistantMessageInner: FC = () => {
|
||||
return (
|
||||
<>
|
||||
{/* Render thinking steps from message content - this ensures proper scroll tracking */}
|
||||
<ThinkingStepsPart />
|
||||
|
||||
<div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed">
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
Text: MarkdownText,
|
||||
tools: { Fallback: ToolFallback },
|
||||
}}
|
||||
/>
|
||||
<MessageError />
|
||||
</div>
|
||||
|
||||
<div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex">
|
||||
<BranchPicker />
|
||||
<AssistantActionBar />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AssistantMessage: FC = () => {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
|
||||
data-role="assistant"
|
||||
>
|
||||
<AssistantMessageInner />
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const AssistantActionBar: FC = () => {
|
||||
return (
|
||||
<ActionBarPrimitive.Root
|
||||
hideWhenRunning
|
||||
autohide="not-last"
|
||||
autohideFloat="single-branch"
|
||||
className="aui-assistant-action-bar-root -ml-1 col-start-3 row-start-2 flex gap-1 text-muted-foreground data-floating:absolute data-floating:rounded-md data-floating:border data-floating:bg-background data-floating:p-1 data-floating:shadow-sm"
|
||||
>
|
||||
<ActionBarPrimitive.Copy asChild>
|
||||
<TooltipIconButton tooltip="Copy">
|
||||
<AssistantIf condition={({ message }) => message.isCopied}>
|
||||
<CheckIcon />
|
||||
</AssistantIf>
|
||||
<AssistantIf condition={({ message }) => !message.isCopied}>
|
||||
<CopyIcon />
|
||||
</AssistantIf>
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Copy>
|
||||
<ActionBarPrimitive.ExportMarkdown asChild>
|
||||
<TooltipIconButton tooltip="Export as Markdown">
|
||||
<DownloadIcon />
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.ExportMarkdown>
|
||||
<ActionBarPrimitive.Reload asChild>
|
||||
<TooltipIconButton tooltip="Refresh">
|
||||
<RefreshCwIcon />
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Reload>
|
||||
</ActionBarPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const UserMessage: FC = () => {
|
||||
const messageId = useAssistantState(({ message }) => message?.id);
|
||||
const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom);
|
||||
const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined;
|
||||
const hasAttachments = useAssistantState(
|
||||
({ message }) => message?.attachments && message.attachments.length > 0
|
||||
);
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="aui-user-message-root fade-in slide-in-from-bottom-1 mx-auto grid w-full max-w-(--thread-max-width) animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2"
|
||||
data-role="user"
|
||||
>
|
||||
<div className="aui-user-message-content-wrapper col-start-2 min-w-0">
|
||||
{/* Display attachments and mentioned documents */}
|
||||
{(hasAttachments || (mentionedDocs && mentionedDocs.length > 0)) && (
|
||||
<div className="flex flex-wrap items-end gap-2 mb-2 justify-end">
|
||||
{/* Attachments (images show as thumbnails, documents as chips) */}
|
||||
<UserMessageAttachments />
|
||||
{/* Mentioned documents as chips */}
|
||||
{mentionedDocs?.map((doc) => (
|
||||
<span
|
||||
key={doc.id}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-xs font-medium text-primary border border-primary/20"
|
||||
title={doc.title}
|
||||
>
|
||||
<FileText className="size-3" />
|
||||
<span className="max-w-[150px] truncate">{doc.title}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Message bubble with action bar positioned relative to it */}
|
||||
<div className="relative">
|
||||
<div className="aui-user-message-content wrap-break-word rounded-2xl bg-muted px-4 py-2.5 text-foreground">
|
||||
<MessagePrimitive.Parts />
|
||||
</div>
|
||||
<div className="aui-user-action-bar-wrapper absolute top-1/2 right-full -translate-y-1/2 pr-1">
|
||||
<UserActionBar />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BranchPicker className="aui-user-branch-picker -mr-1 col-span-full col-start-1 row-start-3 justify-end" />
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const UserActionBar: FC = () => {
|
||||
return (
|
||||
<ActionBarPrimitive.Root
|
||||
hideWhenRunning
|
||||
autohide="not-last"
|
||||
className="aui-user-action-bar-root flex flex-col items-end"
|
||||
>
|
||||
<ActionBarPrimitive.Edit asChild>
|
||||
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4">
|
||||
<PencilIcon />
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Edit>
|
||||
</ActionBarPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const EditComposer: FC = () => {
|
||||
return (
|
||||
<MessagePrimitive.Root className="aui-edit-composer-wrapper mx-auto flex w-full max-w-(--thread-max-width) flex-col px-2 py-3">
|
||||
<ComposerPrimitive.Root className="aui-edit-composer-root ml-auto flex w-full max-w-[85%] flex-col rounded-2xl bg-muted">
|
||||
<ComposerPrimitive.Input
|
||||
className="aui-edit-composer-input min-h-14 w-full resize-none bg-transparent p-4 text-foreground text-sm outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="aui-edit-composer-footer mx-3 mb-3 flex items-center gap-2 self-end">
|
||||
<ComposerPrimitive.Cancel asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
Cancel
|
||||
</Button>
|
||||
</ComposerPrimitive.Cancel>
|
||||
<ComposerPrimitive.Send asChild>
|
||||
<Button size="sm">Update</Button>
|
||||
</ComposerPrimitive.Send>
|
||||
</div>
|
||||
</ComposerPrimitive.Root>
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const BranchPicker: FC<BranchPickerPrimitive.Root.Props> = ({ className, ...rest }) => {
|
||||
return (
|
||||
<BranchPickerPrimitive.Root
|
||||
hideWhenSingleBranch
|
||||
className={cn(
|
||||
"aui-branch-picker-root -ml-2 mr-2 inline-flex items-center text-muted-foreground text-xs",
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<BranchPickerPrimitive.Previous asChild>
|
||||
<TooltipIconButton tooltip="Previous">
|
||||
<ChevronLeftIcon />
|
||||
</TooltipIconButton>
|
||||
</BranchPickerPrimitive.Previous>
|
||||
<span className="aui-branch-picker-state font-medium">
|
||||
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
|
||||
</span>
|
||||
<BranchPickerPrimitive.Next asChild>
|
||||
<TooltipIconButton tooltip="Next">
|
||||
<ChevronRightIcon />
|
||||
</TooltipIconButton>
|
||||
</BranchPickerPrimitive.Next>
|
||||
</BranchPickerPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -27,7 +27,12 @@ export const TooltipIconButton = forwardRef<HTMLButtonElement, TooltipIconButton
|
|||
<span className="aui-sr-only sr-only">{tooltip}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side}>{tooltip}</TooltipContent>
|
||||
<TooltipContent
|
||||
side={side}
|
||||
className="bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none"
|
||||
>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import {
|
|||
Presentation,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -223,32 +222,38 @@ export function GoogleDriveFolderTree({
|
|||
const childFolders = children?.filter((c) => c.isFolder) || [];
|
||||
const childFiles = children?.filter((c) => !c.isFolder) || [];
|
||||
|
||||
const indentSize = 0.75; // Smaller indent for mobile
|
||||
|
||||
return (
|
||||
<div key={item.id} className="w-full" style={{ marginLeft: `${level * 1.25}rem` }}>
|
||||
<div key={item.id} className="w-full sm:ml-[calc(var(--level)*1.25rem)]" style={{ marginLeft: `${level * indentSize}rem`, '--level': level } as React.CSSProperties & { '--level'?: number }}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center group gap-2 h-auto py-2 px-2 rounded-md hover:bg-accent cursor-pointer",
|
||||
"flex items-center group gap-1 sm:gap-2 h-auto py-1 sm:py-2 px-1 sm:px-2 rounded-md",
|
||||
isFolder && "hover:bg-accent cursor-pointer",
|
||||
!isFolder && "cursor-default opacity-60",
|
||||
isSelected && "bg-accent/50"
|
||||
)}
|
||||
>
|
||||
{isFolder ? (
|
||||
<span
|
||||
className="flex items-center justify-center w-4 h-4 shrink-0"
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center w-3 h-3 sm:w-4 sm:h-4 shrink-0 bg-transparent border-0 p-0 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFolder(item);
|
||||
}}
|
||||
aria-label={isExpanded ? `Collapse ${item.name}` : `Expand ${item.name}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<Loader2 className="h-2.5 w-2.5 sm:h-3 sm:w-3 animate-spin" />
|
||||
) : isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
<ChevronDown className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4 h-4 shrink-0" />
|
||||
<span className="w-3 h-3 sm:w-4 sm:h-4 shrink-0" />
|
||||
)}
|
||||
|
||||
<Checkbox
|
||||
|
|
@ -263,25 +268,40 @@ export function GoogleDriveFolderTree({
|
|||
className="shrink-0 z-20 group-hover:border-white group-hover:border"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{isFolder && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleFolderSelection(item.id, item.name)}
|
||||
className="shrink-0 h-3.5 w-3.5 sm:h-4 sm:w-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="shrink-0">
|
||||
{isFolder ? (
|
||||
isExpanded ? (
|
||||
<FolderOpen className="h-4 w-4 text-blue-500" />
|
||||
<FolderOpen className="h-3 w-3 sm:h-4 sm:w-4 text-blue-500" />
|
||||
) : (
|
||||
<Folder className="h-4 w-4 text-gray-500" />
|
||||
<Folder className="h-3 w-3 sm:h-4 sm:w-4 text-gray-500" />
|
||||
)
|
||||
) : (
|
||||
getFileIcon(item.mimeType, "h-4 w-4")
|
||||
getFileIcon(item.mimeType, "h-3 w-3 sm:h-4 sm:w-4")
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span
|
||||
className="truncate flex-1 text-left text-sm min-w-0"
|
||||
onClick={() => isFolder && toggleFolder(item)}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
{isFolder ? (
|
||||
<button
|
||||
type="button"
|
||||
className="truncate flex-1 text-left text-xs sm:text-sm min-w-0 bg-transparent border-0 p-0 cursor-pointer"
|
||||
onClick={() => toggleFolder(item)}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
) : (
|
||||
<span className="truncate flex-1 text-left text-xs sm:text-sm min-w-0">
|
||||
{item.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && isFolder && children && (
|
||||
|
|
@ -290,7 +310,7 @@ export function GoogleDriveFolderTree({
|
|||
{childFiles.map((child) => renderItem(child, level + 1))}
|
||||
|
||||
{children.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground py-2 pl-2">Empty folder</div>
|
||||
<div className="text-[10px] sm:text-xs text-muted-foreground py-1 sm:py-2 pl-1 sm:pl-2">Empty folder</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -300,25 +320,29 @@ export function GoogleDriveFolderTree({
|
|||
|
||||
return (
|
||||
<div className="border rounded-md w-full overflow-hidden">
|
||||
<ScrollArea className="h-[450px] w-full">
|
||||
<div className="p-2 pr-4 w-full overflow-x-hidden">
|
||||
<div className="mb-2 pb-2 border-b">
|
||||
<div className="flex items-center gap-2 h-auto py-2 px-2 rounded-md hover:bg-accent cursor-pointer">
|
||||
<ScrollArea className="h-[300px] sm:h-[450px] w-full">
|
||||
<div className="p-1 sm:p-2 pr-2 sm:pr-4 w-full overflow-x-hidden">
|
||||
<div className="mb-1 sm:mb-2 pb-1 sm:pb-2 border-b">
|
||||
<div className="flex items-center gap-1 sm:gap-2 h-auto py-1 sm:py-2 px-1 sm:px-2 rounded-md hover:bg-accent cursor-pointer">
|
||||
<Checkbox
|
||||
checked={isFolderSelected("root")}
|
||||
onCheckedChange={() => toggleFolderSelection("root", "My Drive")}
|
||||
className="shrink-0"
|
||||
className="shrink-0 h-3.5 w-3.5 sm:h-4 sm:w-4"
|
||||
/>
|
||||
<HardDrive className="h-4 w-4 text-primary shrink-0" />
|
||||
<span className="font-semibold truncate" onClick={() => toggleFolderSelection("root", "My Drive")}>
|
||||
<HardDrive className="h-3 w-3 sm:h-4 sm:w-4 text-primary shrink-0" />
|
||||
<button
|
||||
type="button"
|
||||
className="font-semibold truncate text-xs sm:text-sm cursor-pointer bg-transparent border-0 p-0 text-left"
|
||||
onClick={() => toggleFolderSelection("root", "My Drive")}
|
||||
>
|
||||
My Drive
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoadingRoot && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<div className="flex items-center justify-center py-4 sm:py-8">
|
||||
<Loader2 className="h-4 w-4 sm:h-6 sm:w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -327,7 +351,7 @@ export function GoogleDriveFolderTree({
|
|||
</div>
|
||||
|
||||
{!isLoadingRoot && rootItems.length === 0 && (
|
||||
<div className="text-center text-sm text-muted-foreground py-8">
|
||||
<div className="text-center text-xs sm:text-sm text-muted-foreground py-4 sm:py-8">
|
||||
No files or folders found in your Google Drive
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -98,7 +98,6 @@ export function DashboardBreadcrumb() {
|
|||
"new-chat": t("chat") || "Chat",
|
||||
documents: t("documents"),
|
||||
connectors: t("connectors"),
|
||||
sources: "Sources",
|
||||
logs: t("logs"),
|
||||
settings: t("settings"),
|
||||
editor: t("editor"),
|
||||
|
|
@ -132,26 +131,10 @@ export function DashboardBreadcrumb() {
|
|||
return breadcrumbs;
|
||||
}
|
||||
|
||||
// Handle sources sub-sections
|
||||
if (section === "sources") {
|
||||
const sourceLabels: Record<string, string> = {
|
||||
add: "Add Sources",
|
||||
};
|
||||
|
||||
const sourceLabel = sourceLabels[subSection] || subSection;
|
||||
breadcrumbs.push({
|
||||
label: "Sources",
|
||||
href: `/dashboard/${segments[1]}/sources`,
|
||||
});
|
||||
breadcrumbs.push({ label: sourceLabel });
|
||||
return breadcrumbs;
|
||||
}
|
||||
|
||||
// Handle documents sub-sections
|
||||
if (section === "documents") {
|
||||
const documentLabels: Record<string, string> = {
|
||||
upload: t("upload_documents"),
|
||||
youtube: t("add_youtube"),
|
||||
webpage: t("add_webpages"),
|
||||
};
|
||||
|
||||
|
|
@ -192,7 +175,6 @@ export function DashboardBreadcrumb() {
|
|||
"linkup-api": "LinkUp API",
|
||||
"luma-connector": "Luma",
|
||||
"elasticsearch-connector": "Elasticsearch",
|
||||
"webcrawler-connector": "Web Pages",
|
||||
};
|
||||
|
||||
const connectorLabel = connectorLabels[connectorType] || connectorType;
|
||||
|
|
@ -259,7 +241,7 @@ export function DashboardBreadcrumb() {
|
|||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{breadcrumbs.map((item, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<React.Fragment key={`${index}-${item.href || item.label}`}>
|
||||
<BreadcrumbItem>
|
||||
{index === breadcrumbs.length - 1 ? (
|
||||
<BreadcrumbPage>{item.label}</BreadcrumbPage>
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
className="w-[280px] md:w-[360px] p-0 rounded-lg md:rounded-xl shadow-lg border-border/30"
|
||||
className="w-[280px] md:w-[360px] p-0 rounded-lg md:rounded-xl shadow-lg border-border/60"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
|
|
@ -252,7 +252,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
{/* Global Configs Section */}
|
||||
{filteredGlobalConfigs.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground tracking-wider">
|
||||
<Globe className="size-3.5" />
|
||||
Global Models
|
||||
</div>
|
||||
|
|
@ -314,7 +314,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
{/* User Configs Section */}
|
||||
{filteredUserConfigs.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground tracking-wider">
|
||||
<User className="size-3.5" />
|
||||
Your Configurations
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -169,10 +169,6 @@ const defaultData = {
|
|||
url: "#",
|
||||
icon: "Database",
|
||||
items: [
|
||||
{
|
||||
title: "Add Sources",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Manage Documents",
|
||||
url: "#",
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ export function NavMain({ items, onSourcesExpandedChange }: NavMainProps) {
|
|||
Researcher: "researcher",
|
||||
"Manage LLMs": "manage_llms",
|
||||
Sources: "sources",
|
||||
"Add Sources": "add_sources",
|
||||
"Manage Documents": "manage_documents",
|
||||
"Manage Connectors": "manage_connectors",
|
||||
Podcasts: "podcasts",
|
||||
|
|
|
|||
|
|
@ -1,203 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { IconChevronDown, IconChevronRight } from "@tabler/icons-react";
|
||||
import { AnimatePresence, motion, type Variants } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { connectorCategories } from "./connector-data";
|
||||
|
||||
interface ConnectorsTabProps {
|
||||
searchSpaceId: string;
|
||||
}
|
||||
|
||||
export function ConnectorsTab({ searchSpaceId }: ConnectorsTabProps) {
|
||||
const t = useTranslations("add_connector");
|
||||
const [expandedCategories, setExpandedCategories] = useState<string[]>([
|
||||
"web-search",
|
||||
"messaging",
|
||||
"project-management",
|
||||
"documentation",
|
||||
"development",
|
||||
"databases",
|
||||
"productivity",
|
||||
"web-crawling",
|
||||
]);
|
||||
|
||||
const toggleCategory = (categoryId: string) => {
|
||||
setExpandedCategories((prev) =>
|
||||
prev.includes(categoryId) ? prev.filter((id) => id !== categoryId) : [...prev, categoryId]
|
||||
);
|
||||
};
|
||||
|
||||
const cardVariants: Variants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 260,
|
||||
damping: 20,
|
||||
},
|
||||
},
|
||||
hover: {
|
||||
scale: 1.02,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 10,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const staggerContainer = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{connectorCategories.map((category) => (
|
||||
<div key={category.id} className="rounded-lg border bg-card text-card-foreground shadow-sm">
|
||||
<Collapsible
|
||||
open={expandedCategories.includes(category.id)}
|
||||
onOpenChange={() => toggleCategory(category.id)}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex items-center justify-between space-x-4 p-4">
|
||||
<h3 className="text-lg sm:text-xl font-semibold">{t(category.title)}</h3>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="w-9 p-0 hover:bg-muted">
|
||||
<motion.div
|
||||
animate={{
|
||||
rotate: expandedCategories.includes(category.id) ? 180 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
>
|
||||
<IconChevronDown className="h-5 w-5" />
|
||||
</motion.div>
|
||||
<span className="sr-only">Toggle</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent>
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 p-4"
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
>
|
||||
{category.connectors.map((connector) => (
|
||||
<motion.div
|
||||
key={connector.id}
|
||||
variants={cardVariants}
|
||||
whileHover="hover"
|
||||
className="col-span-1"
|
||||
>
|
||||
<Card className="h-full flex flex-col overflow-hidden border-transparent transition-all duration-200 hover:border-primary/50">
|
||||
<CardHeader className="flex-row items-center gap-4 pb-2">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 dark:bg-primary/20">
|
||||
<motion.div
|
||||
whileHover={{ rotate: 5, scale: 1.1 }}
|
||||
className="text-primary"
|
||||
>
|
||||
{connector.icon}
|
||||
</motion.div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium">{connector.title}</h3>
|
||||
{connector.status === "coming-soon" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-amber-100 dark:bg-amber-950 text-amber-800 dark:text-amber-300 border-amber-200 dark:border-amber-800"
|
||||
>
|
||||
{t("coming_soon")}
|
||||
</Badge>
|
||||
)}
|
||||
{connector.status === "connected" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-green-100 dark:bg-green-950 text-green-800 dark:text-green-300 border-green-200 dark:border-green-800"
|
||||
>
|
||||
{t("connected")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pb-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(connector.description)}
|
||||
</p>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="mt-auto pt-2">
|
||||
{connector.status === "available" && (
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`}
|
||||
className="w-full"
|
||||
>
|
||||
<Button variant="default" className="w-full group">
|
||||
<span>{t("connect")}</span>
|
||||
<motion.div
|
||||
className="ml-1"
|
||||
initial={{ x: 0 }}
|
||||
whileHover={{ x: 3 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 10,
|
||||
}}
|
||||
>
|
||||
<IconChevronRight className="h-4 w-4" />
|
||||
</motion.div>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{connector.status === "coming-soon" && (
|
||||
<Button variant="outline" disabled className="w-full opacity-70">
|
||||
{t("coming_soon")}
|
||||
</Button>
|
||||
)}
|
||||
{connector.status === "connected" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-green-500 text-green-600 hover:bg-green-50 dark:hover:bg-green-950"
|
||||
>
|
||||
{t("manage")}
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,245 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { IconBrandYoutube } from "@tabler/icons-react";
|
||||
import { TagInput, type Tag as TagType } from "emblor";
|
||||
import { useAtom } from "jotai";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { createDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const youtubeRegex =
|
||||
/^(https:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})$/;
|
||||
|
||||
interface YouTubeTabProps {
|
||||
searchSpaceId: string;
|
||||
}
|
||||
|
||||
export function YouTubeTab({ searchSpaceId }: YouTubeTabProps) {
|
||||
const t = useTranslations("add_youtube");
|
||||
const router = useRouter();
|
||||
const [videoTags, setVideoTags] = useState<TagType[]>([]);
|
||||
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Use the createDocumentMutationAtom
|
||||
const [createDocumentMutation] = useAtom(createDocumentMutationAtom);
|
||||
const { mutate: createYouTubeDocument, isPending: isSubmitting } = createDocumentMutation;
|
||||
|
||||
const isValidYoutubeUrl = (url: string): boolean => {
|
||||
return youtubeRegex.test(url);
|
||||
};
|
||||
|
||||
const extractVideoId = (url: string): string | null => {
|
||||
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (videoTags.length === 0) {
|
||||
setError(t("error_no_video"));
|
||||
return;
|
||||
}
|
||||
|
||||
const invalidUrls = videoTags.filter((tag) => !isValidYoutubeUrl(tag.text));
|
||||
if (invalidUrls.length > 0) {
|
||||
setError(t("error_invalid_urls", { urls: invalidUrls.map((tag) => tag.text).join(", ") }));
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
toast(t("processing_toast"), {
|
||||
description: t("processing_toast_desc"),
|
||||
});
|
||||
|
||||
const videoUrls = videoTags.map((tag) => tag.text);
|
||||
|
||||
// Use the mutation to create YouTube documents
|
||||
createYouTubeDocument(
|
||||
{
|
||||
document_type: "YOUTUBE_VIDEO",
|
||||
content: videoUrls,
|
||||
search_space_id: parseInt(searchSpaceId),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast(t("success_toast"), {
|
||||
description: t("success_toast_desc"),
|
||||
});
|
||||
router.push(`/dashboard/${searchSpaceId}/documents`);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setError(error.message || t("error_generic"));
|
||||
toast(t("error_toast"), {
|
||||
description: `${t("error_toast_desc")}: ${error.message || "Failed to process YouTube videos"}`,
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddTag = (text: string) => {
|
||||
if (!isValidYoutubeUrl(text)) {
|
||||
toast(t("invalid_url_toast"), {
|
||||
description: t("invalid_url_toast_desc"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoTags.some((tag) => tag.text === text)) {
|
||||
toast(t("duplicate_url_toast"), {
|
||||
description: t("duplicate_url_toast_desc"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newTag: TagType = {
|
||||
id: Date.now().toString(),
|
||||
text: text,
|
||||
};
|
||||
|
||||
setVideoTags([...videoTags, newTag]);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="max-w-2xl mx-auto space-y-6"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg sm:text-2xl flex items-center gap-2">
|
||||
<IconBrandYoutube className="h-5 w-5" />
|
||||
{t("title")}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs sm:text-sm">{t("subtitle")}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="video-input" className="text-sm sm:text-base">
|
||||
{t("label")}
|
||||
</Label>
|
||||
<TagInput
|
||||
id="video-input"
|
||||
tags={videoTags}
|
||||
setTags={setVideoTags}
|
||||
placeholder={t("placeholder")}
|
||||
onAddTag={handleAddTag}
|
||||
styleClasses={{
|
||||
inlineTagsContainer:
|
||||
"border-input rounded-lg bg-background shadow-sm shadow-black/5 transition-shadow focus-within:border-ring focus-within:outline-none focus-within:ring-[3px] focus-within:ring-ring/20 p-1 gap-1",
|
||||
input: "w-full min-w-[80px] focus-visible:outline-none shadow-none px-2 h-7",
|
||||
tag: {
|
||||
body: "h-7 relative bg-background border border-input hover:bg-background rounded-md font-medium text-xs ps-2 pe-7 flex",
|
||||
closeButton:
|
||||
"absolute -inset-y-px -end-px p-0 rounded-e-lg flex size-7 transition-colors outline-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 text-muted-foreground/80 hover:text-foreground",
|
||||
},
|
||||
}}
|
||||
activeTagIndex={activeTagIndex}
|
||||
setActiveTagIndex={setActiveTagIndex}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("hint")}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<motion.div
|
||||
className="text-sm text-red-500 mt-2"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
>
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-4 text-sm">
|
||||
<h4 className="font-medium mb-2">{t("tips_title")}</h4>
|
||||
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
|
||||
<li>{t("tip_1")}</li>
|
||||
<li>{t("tip_2")}</li>
|
||||
<li>{t("tip_3")}</li>
|
||||
<li>{t("tip_4")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{videoTags.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<h4 className="font-medium">{t("preview")}:</h4>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{videoTags.map((tag, index) => {
|
||||
const videoId = extractVideoId(tag.text);
|
||||
return videoId ? (
|
||||
<motion.div
|
||||
key={tag.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="relative aspect-video rounded-lg overflow-hidden border"
|
||||
>
|
||||
<iframe
|
||||
width="100%"
|
||||
height="100%"
|
||||
src={`https://www.youtube.com/embed/${videoId}`}
|
||||
title="YouTube video player"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</motion.div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/documents`)}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || videoTags.length === 0}
|
||||
size="sm"
|
||||
className="relative overflow-hidden text-xs sm:text-sm"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("processing")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconBrandYoutube className="mr-2 h-4 w-4" />
|
||||
{t("submit")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue