Merge pull request #664 from AnishSarkar22/feat/slack-oauth

feat: Slack, Discord OAuth connector & 5 min periodic syncing
This commit is contained in:
Rohan Verma 2026-01-05 21:31:54 -08:00 committed by GitHub
commit afe63943f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
68 changed files with 4622 additions and 2532 deletions

View file

@ -189,7 +189,7 @@ export const ConnectorIndicator: FC = () => {
)}
</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">
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 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} />
@ -272,7 +272,7 @@ export const ConnectorIndicator: FC = () => {
{/* 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">
<div className="px-4 sm:px-12 py-4 sm:py-8 pb-12 sm:pb-16">
<TabsContent value="all" className="m-0">
<AllConnectorsTab
searchQuery={searchQuery}

View file

@ -1,7 +1,7 @@
"use client";
import { IconBrandYoutube } from "@tabler/icons-react";
import { format } from "date-fns";
import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
import { FileText, Loader2 } from "lucide-react";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
@ -49,6 +49,45 @@ function formatDocumentCount(count: number | undefined): string {
return `${m.replace(/\.0$/, "")}M docs`;
}
/**
* Format last indexed date with contextual messages
* Examples: "Just now", "10 minutes ago", "Today at 2:30 PM", "Yesterday at 3:45 PM", "3 days ago", "Jan 15, 2026"
*/
function formatLastIndexedDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const minutesAgo = differenceInMinutes(now, date);
const daysAgo = differenceInDays(now, date);
// Just now (within last minute)
if (minutesAgo < 1) {
return "Just now";
}
// X minutes ago (less than 1 hour)
if (minutesAgo < 60) {
return `${minutesAgo} ${minutesAgo === 1 ? "minute" : "minutes"} ago`;
}
// Today at [time]
if (isToday(date)) {
return `Today at ${format(date, "h:mm a")}`;
}
// Yesterday at [time]
if (isYesterday(date)) {
return `Yesterday at ${format(date, "h:mm a")}`;
}
// X days ago (less than 7 days)
if (daysAgo < 7) {
return `${daysAgo} ${daysAgo === 1 ? "day" : "days"} ago`;
}
// Full date for older entries
return format(date, "MMM d, yyyy");
}
export const ConnectorCard: FC<ConnectorCardProps> = ({
id,
title,
@ -86,13 +125,13 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
// Show last indexed date for connected connectors
if (lastIndexedAt) {
return (
<span className="whitespace-nowrap">
Last indexed: {format(new Date(lastIndexedAt), "MMM d, yyyy")}
<span className="whitespace-nowrap text-[10px]">
Last indexed: {formatLastIndexedDate(lastIndexedAt)}
</span>
);
}
// Fallback for connected but never indexed
return <span className="whitespace-nowrap">Never indexed</span>;
return <span className="whitespace-nowrap text-[10px]">Never indexed</span>;
}
return description;
@ -113,9 +152,9 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
<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 className="text-[10px] text-muted-foreground mt-1">{getStatusContent()}</div>
{isConnected && documentCount !== undefined && (
<p className="text-[11px] text-muted-foreground mt-0.5">
<p className="text-[10px] text-muted-foreground mt-0.5">
{formatDocumentCount(documentCount)}
</p>
)}
@ -130,12 +169,10 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
!isConnected && "shadow-xs"
)}
onClick={isConnected ? onManage : onConnect}
disabled={isConnecting || isIndexing}
disabled={isConnecting}
>
{isConnecting ? (
<Loader2 className="size-3 animate-spin" />
) : isIndexing ? (
"Syncing..."
) : isConnected ? (
"Manage"
) : id === "youtube-crawler" ? (

View file

@ -24,20 +24,20 @@ export const ConnectorDialogHeader: FC<ConnectorDialogHeaderProps> = ({
return (
<div
className={cn(
"flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10 transition-shadow duration-200 relative z-10",
"flex-shrink-0 px-4 sm:px-12 pt-5 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">
<DialogTitle className="text-xl 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">
<DialogDescription className="text-xs 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">
<div className="flex flex-col-reverse sm:flex-row sm:items-end justify-between gap-4 sm:gap-8 mt-4 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"
@ -63,7 +63,7 @@ export const ConnectorDialogHeader: FC<ConnectorDialogHeaderProps> = ({
<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" />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-gray-500 dark:text-gray-500" />
<input
type="text"
placeholder="Search"
@ -78,7 +78,7 @@ export const ConnectorDialogHeader: FC<ConnectorDialogHeaderProps> = ({
<button
type="button"
onClick={() => onSearchChange("")}
className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-gray-500 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
aria-label="Clear search"
>
<X className="size-4" />

View file

@ -50,6 +50,9 @@ export const PeriodicSyncConfig: FC<PeriodicSyncConfigProps> = ({
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="5" className="text-xs sm:text-sm">
Every 5 minutes
</SelectItem>
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>

View file

@ -256,6 +256,9 @@ export const BookStackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="5" className="text-xs sm:text-sm">
Every 5 minutes
</SelectItem>
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>

View file

@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Info, Webhook } from "lucide-react";
import { Webhook } from "lucide-react";
import type { FC } from "react";
import { useRef } from "react";
import { useForm } from "react-hook-form";

View file

@ -209,6 +209,9 @@ export const ClickUpConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmittin
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="5" className="text-xs sm:text-sm">
Every 5 minutes
</SelectItem>
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>

View file

@ -263,6 +263,9 @@ export const ConfluenceConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmit
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="5" className="text-xs sm:text-sm">
Every 5 minutes
</SelectItem>
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>

View file

@ -1,406 +0,0 @@
"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 {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { DateRangeSelector } from "../../components/date-range-selector";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
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>
);
};

View file

@ -616,6 +616,9 @@ export const ElasticsearchConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSub
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="5" className="text-xs sm:text-sm">
Every 5 minutes
</SelectItem>
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>

View file

@ -269,6 +269,9 @@ export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="5" className="text-xs sm:text-sm">
Every 5 minutes
</SelectItem>
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>

View file

@ -262,6 +262,9 @@ export const JiraConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="5" className="text-xs sm:text-sm">
Every 5 minutes
</SelectItem>
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>

View file

@ -1,396 +0,0 @@
"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 {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { DateRangeSelector } from "../../components/date-range-selector";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
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>
);
};

View file

@ -209,6 +209,9 @@ export const LumaConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="5" className="text-xs sm:text-sm">
Every 5 minutes
</SelectItem>
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>

View file

@ -1,396 +0,0 @@
"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 {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { DateRangeSelector } from "../../components/date-range-selector";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
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>
);
};

View file

@ -1,426 +0,0 @@
"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 {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { DateRangeSelector } from "../../components/date-range-selector";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
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>
);
};

View file

@ -4,16 +4,12 @@ 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 {
@ -51,16 +47,8 @@ export function getConnectFormComponent(connectorType: string): ConnectFormCompo
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":

View file

@ -1,88 +1,26 @@
"use client";
import { KeyRound } from "lucide-react";
import { Info } from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } 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);
}
};
export const DiscordConfig: FC<DiscordConfigProps> = () => {
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 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>
{/* 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.
<div className="text-xs sm:text-sm">
<p className="font-medium text-xs sm:text-sm">Add Bot to Servers</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
Before indexing, make sure the Discord bot has been added to the servers (guilds) you want to
index. The bot can only access messages from servers it's been added to. Use the OAuth
authorization flow to add the bot to your servers.
</p>
</div>
</div>

View file

@ -1,89 +0,0 @@
"use client";
import { KeyRound } from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } 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>
);
};

View file

@ -1,91 +0,0 @@
"use client";
import { KeyRound } from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } 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>
);
};

View file

@ -1,84 +1,27 @@
"use client";
import { KeyRound } from "lucide-react";
import { Info } from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } 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);
}
};
export const SlackConfig: FC<SlackConfigProps> = () => {
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 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>
{/* 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.
<div className="text-xs sm:text-sm">
<p className="font-medium text-xs sm:text-sm">Add Bot to Channels</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
Before indexing, add the SurfSense bot to each channel you want to index. The bot can
only access messages from channels it's been added to. Type{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[9px]">/invite @SurfSense</code> in
any channel to add it.
</p>
</div>
</div>

View file

@ -12,10 +12,8 @@ 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";
@ -46,8 +44,6 @@ export function getConnectorConfigComponent(
return LinkupApiConfig;
case "BAIDU_SEARCH_API":
return BaiduSearchApiConfig;
case "LINEAR_CONNECTOR":
return LinearConfig;
case "WEBCRAWLER_CONNECTOR":
return WebcrawlerConfig;
case "ELASTICSEARCH_CONNECTOR":
@ -56,8 +52,6 @@ export function getConnectorConfigComponent(
return SlackConfig;
case "DISCORD_CONNECTOR":
return DiscordConfig;
case "NOTION_CONNECTOR":
return NotionConfig;
case "CONFLUENCE_CONNECTOR":
return ConfluenceConfig;
case "BOOKSTACK_CONNECTOR":
@ -72,7 +66,7 @@ export function getConnectorConfigComponent(
return LumaConfig;
case "CIRCLEBACK_CONNECTOR":
return CirclebackConfig;
// OAuth connectors (Gmail, Calendar, Airtable) and others don't need special config UI
// OAuth connectors (Gmail, Calendar, Airtable, Notion) and others don't need special config UI
default:
return null;
}

View file

@ -51,11 +51,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
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",

View file

@ -59,6 +59,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
const [isScrolled, setIsScrolled] = useState(false);
const [hasMoreContent, setHasMoreContent] = useState(false);
const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);
const [isQuickIndexing, setIsQuickIndexing] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const checkScrollState = useCallback(() => {
@ -94,6 +95,13 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
};
}, [checkScrollState]);
// Reset local quick indexing state when indexing completes
useEffect(() => {
if (!isIndexing) {
setIsQuickIndexing(false);
}
}, [isIndexing]);
const handleDisconnectClick = () => {
setShowDisconnectConfirm(true);
};
@ -107,6 +115,13 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
setShowDisconnectConfirm(false);
};
const handleQuickIndex = useCallback(() => {
if (onQuickIndex) {
setIsQuickIndexing(true);
onQuickIndex();
}
}, [onQuickIndex]);
return (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Fixed Header */}
@ -146,11 +161,11 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
<Button
variant="secondary"
size="sm"
onClick={onQuickIndex}
disabled={isIndexing || isSaving || isDisconnecting}
onClick={handleQuickIndex}
disabled={isQuickIndexing || isIndexing || isSaving || isDisconnecting}
className="text-xs sm:text-sm bg-slate-400/10 dark:bg-white/10 hover:bg-slate-400/20 dark:hover:bg-white/20 border-slate-400/20 dark:border-white/20 w-full sm:w-auto"
>
{isIndexing ? (
{isQuickIndexing || isIndexing ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Indexing...

View file

@ -2,6 +2,7 @@
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { cn } from "@/lib/utils";
@ -43,6 +44,9 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
onStartIndexing,
onSkip,
}) => {
const searchParams = useSearchParams();
const isFromOAuth = searchParams.get("view") === "configure";
// Get connector-specific config component
const ConnectorConfigComponent = useMemo(
() => (connector ? getConnectorConfigComponent(connector.connector_type) : null),
@ -94,15 +98,17 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
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>
{/* Back button - only show if not from OAuth */}
{!isFromOAuth && (
<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">
@ -187,15 +193,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
</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>
<div className="flex-shrink-0 flex items-center justify-end px-6 sm:px-12 py-6 bg-muted">
<Button
onClick={onStartIndexing}
disabled={isStartingIndexing}

View file

@ -30,6 +30,34 @@ export const OAUTH_CONNECTORS = [
connectorType: EnumConnectorName.AIRTABLE_CONNECTOR,
authEndpoint: "/api/v1/auth/airtable/connector/add/",
},
{
id: "notion-connector",
title: "Notion",
description: "Search your Notion pages",
connectorType: EnumConnectorName.NOTION_CONNECTOR,
authEndpoint: "/api/v1/auth/notion/connector/add/",
},
{
id: "linear-connector",
title: "Linear",
description: "Search issues & projects",
connectorType: EnumConnectorName.LINEAR_CONNECTOR,
authEndpoint: "/api/v1/auth/linear/connector/add/",
},
{
id: "slack-connector",
title: "Slack",
description: "Search Slack messages",
connectorType: EnumConnectorName.SLACK_CONNECTOR,
authEndpoint: "/api/v1/auth/slack/connector/add/",
},
{
id: "discord-connector",
title: "Discord",
description: "Search Discord messages",
connectorType: EnumConnectorName.DISCORD_CONNECTOR,
authEndpoint: "/api/v1/auth/discord/connector/add/",
},
] as const;
// Content Sources (tools that extract and import content from external sources)
@ -50,24 +78,6 @@ export const CRAWLERS = [
// 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",
@ -86,12 +96,6 @@ export const OTHER_CONNECTORS = [
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",
@ -143,7 +147,7 @@ export const OTHER_CONNECTORS = [
{
id: "circleback-connector",
title: "Circleback",
description: "Receive meeting notes via webhook",
description: "Receive meeting notes, transcripts",
connectorType: EnumConnectorName.CIRCLEBACK_CONNECTOR,
},
] as const;

View file

@ -39,7 +39,7 @@ 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"], {
export const frequencyMinutesSchema = z.enum(["5", "15", "60", "360", "720", "1440", "10080"], {
message: "Invalid frequency value",
});

View file

@ -472,7 +472,7 @@ export const useConnectorDialog = () => {
}
// Auto-start indexing for non-OAuth reindexable connectors
// This only applies to non-OAuth reindexable connectors (e.g., Elasticsearch, Linear)
// This only applies to non-OAuth reindexable connectors (e.g., Elasticsearch)
// 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) {

View file

@ -1,6 +1,6 @@
"use client";
import { format } from "date-fns";
import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
import { ArrowRight, Cable, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import type { FC } from "react";
@ -64,6 +64,42 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
return `${m.replace(/\.0$/, "")}M docs`;
};
// Format last indexed date with contextual messages
const formatLastIndexedDate = (dateString: string): string => {
const date = new Date(dateString);
const now = new Date();
const minutesAgo = differenceInMinutes(now, date);
const daysAgo = differenceInDays(now, date);
// Just now (within last minute)
if (minutesAgo < 1) {
return "Just now";
}
// X minutes ago (less than 1 hour)
if (minutesAgo < 60) {
return `${minutesAgo} ${minutesAgo === 1 ? "minute" : "minutes"} ago`;
}
// Today at [time]
if (isToday(date)) {
return `Today at ${format(date, "h:mm a")}`;
}
// Yesterday at [time]
if (isYesterday(date)) {
return `Yesterday at ${format(date, "h:mm a")}`;
}
// X days ago (less than 7 days)
if (daysAgo < 7) {
return `${daysAgo} ${daysAgo === 1 ? "day" : "days"} ago`;
}
// Full date for older entries
return format(date, "MMM d, yyyy");
};
// Document types that should be shown as cards (not from connectors)
// These are: EXTENSION (browser extension), FILE (uploaded files), NOTE (editor notes),
// YOUTUBE_VIDEO (YouTube videos), and CRAWLED_URL (web pages - shown separately even though it can come from WEBCRAWLER_CONNECTOR)
@ -148,13 +184,13 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
)}
</p>
) : (
<p className="text-[11px] text-muted-foreground mt-1">
<p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap">
{connector.last_indexed_at
? `Last indexed: ${format(new Date(connector.last_indexed_at), "MMM d, yyyy")}`
? `Last indexed: ${formatLastIndexedDate(connector.last_indexed_at)}`
: "Never indexed"}
</p>
)}
<p className="text-[11px] text-muted-foreground mt-0.5">
<p className="text-[10px] text-muted-foreground mt-0.5">
{formatDocumentCount(documentCount)}
</p>
</div>
@ -163,9 +199,8 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
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"}
Manage
</Button>
</div>
);

View file

@ -1,5 +1,6 @@
"use client";
import { Upload } from "lucide-react";
import { useAtomValue } from "jotai";
import { useRouter } from "next/navigation";
import {
@ -85,6 +86,7 @@ const DocumentUploadPopupContent: FC<{
}> = ({ isOpen, onOpenChange }) => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const router = useRouter();
const [isAccordionExpanded, setIsAccordionExpanded] = useState(false);
if (!searchSpaceId) return null;
@ -95,16 +97,40 @@ const DocumentUploadPopupContent: FC<{
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl w-[95vw] sm:w-full h-[calc(100vh-2rem)] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-12 [&>button]:top-4 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5">
<DialogContent className="max-w-4xl w-[95vw] sm:w-full max-h-[calc(100vh-2rem)] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-12 [&>button]:top-4 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5">
<DialogTitle className="sr-only">Upload Document</DialogTitle>
<div className="flex-1 min-h-0 relative overflow-hidden">
<div className="h-full overflow-y-auto">
<div className="px-3 sm:px-12 pt-12 sm:pt-24 pb-6 sm:pb-16">
<DocumentUploadTab searchSpaceId={searchSpaceId} onSuccess={handleSuccess} />
{/* Fixed Header */}
<div className="flex-shrink-0 px-4 sm:px-12 pt-6 sm:pt-10 transition-shadow duration-200 relative z-10">
{/* Upload header */}
<div className="flex items-center gap-2 sm:gap-4 mb-2 sm:mb-6">
<div className="flex h-10 w-10 sm:h-14 sm:w-14 items-center justify-center rounded-lg sm:rounded-xl bg-primary/10 border border-primary/20 flex-shrink-0">
<Upload className="size-5 sm:size-7 text-primary" />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-lg sm:text-2xl font-semibold tracking-tight">Upload Documents</h2>
<p className="text-xs sm:text-base text-muted-foreground mt-0.5 sm:mt-1">
Upload and sync your documents to your search space
</p>
</div>
</div>
{/* Bottom fade shadow */}
<div className="absolute bottom-0 left-0 right-0 h-2 sm:h-7 bg-gradient-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
</div>
{/* Scrollable Content */}
<div className="flex-1 min-h-0 relative overflow-hidden">
<div className={`h-full ${isAccordionExpanded ? "overflow-y-auto" : ""}`}>
<div className="px-6 sm:px-12 pb-5 sm:pb-16">
<DocumentUploadTab
searchSpaceId={searchSpaceId}
onSuccess={handleSuccess}
onAccordionStateChange={setIsAccordionExpanded}
/>
</div>
</div>
{/* Bottom fade shadow - only show when scrolling */}
{isAccordionExpanded && (
<div className="absolute bottom-0 left-0 right-0 h-2 sm:h-7 bg-gradient-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
)}
</div>
</DialogContent>
</Dialog>

View file

@ -36,7 +36,6 @@ export const editConnectorSchema = z.object({
SEARXNG_LANGUAGE: z.string().optional(),
SEARXNG_SAFESEARCH: z.string().optional(),
SEARXNG_VERIFY_SSL: z.string().optional(),
LINEAR_API_KEY: z.string().optional(),
LINKUP_API_KEY: z.string().optional(),
DISCORD_BOT_TOKEN: z.string().optional(),
CONFLUENCE_BASE_URL: z.string().optional(),

View file

@ -12,7 +12,7 @@ import {
useState,
} from "react";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { Document } from "@/contracts/types/document.types";
import type { Document, GetDocumentsResponse } from "@/contracts/types/document.types";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils";
@ -31,6 +31,8 @@ interface DocumentMentionPickerProps {
externalSearch?: string;
}
const PAGE_SIZE = 20;
function useDebounced<T>(value: T, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
@ -52,12 +54,29 @@ export const DocumentMentionPicker = forwardRef<
const debouncedSearch = useDebounced(search, 150);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
const scrollContainerRef = useRef<HTMLDivElement>(null);
// State for pagination
const [accumulatedDocuments, setAccumulatedDocuments] = useState<Document[]>([]);
const [currentPage, setCurrentPage] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
// Reset pagination when search or search space changes
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally reset pagination when search/space changes
useEffect(() => {
setAccumulatedDocuments([]);
setCurrentPage(0);
setHasMore(false);
setHighlightedIndex(0);
}, [debouncedSearch, searchSpaceId]);
// Query params for initial fetch (page 0)
const fetchQueryParams = useMemo(
() => ({
search_space_id: searchSpaceId,
page: 0,
page_size: 20,
page_size: PAGE_SIZE,
}),
[searchSpaceId]
);
@ -66,31 +85,97 @@ export const DocumentMentionPicker = forwardRef<
return {
search_space_id: searchSpaceId,
page: 0,
page_size: 20,
page_size: PAGE_SIZE,
title: debouncedSearch,
};
}, [debouncedSearch, searchSpaceId]);
// Use query for fetching documents
// Use query for fetching first page of documents
const { data: documents, isLoading: isDocumentsLoading } = useQuery({
queryKey: cacheKeys.documents.withQueryParams(fetchQueryParams),
queryFn: () => documentsApiService.getDocuments({ queryParams: fetchQueryParams }),
staleTime: 3 * 60 * 1000,
enabled: !!searchSpaceId && !debouncedSearch.trim(),
enabled: !!searchSpaceId && !debouncedSearch.trim() && currentPage === 0,
});
// Searching
// Searching - first page
const { data: searchedDocuments, isLoading: isSearchedDocumentsLoading } = useQuery({
queryKey: cacheKeys.documents.withQueryParams(searchQueryParams),
queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }),
staleTime: 3 * 60 * 1000,
enabled: !!searchSpaceId && !!debouncedSearch.trim(),
enabled: !!searchSpaceId && !!debouncedSearch.trim() && currentPage === 0,
});
const actualDocuments = debouncedSearch.trim()
? searchedDocuments?.items || []
: documents?.items || [];
const actualLoading = debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading;
// Update accumulated documents when first page loads
useEffect(() => {
if (currentPage === 0) {
if (debouncedSearch.trim()) {
if (searchedDocuments) {
setAccumulatedDocuments(searchedDocuments.items);
setHasMore(searchedDocuments.has_more);
}
} else {
if (documents) {
setAccumulatedDocuments(documents.items);
setHasMore(documents.has_more);
}
}
}
}, [documents, searchedDocuments, debouncedSearch, currentPage]);
// Function to load next page
const loadNextPage = useCallback(async () => {
if (isLoadingMore || !hasMore) return;
const nextPage = currentPage + 1;
setIsLoadingMore(true);
try {
let response: GetDocumentsResponse;
if (debouncedSearch.trim()) {
const queryParams = {
search_space_id: searchSpaceId,
page: nextPage,
page_size: PAGE_SIZE,
title: debouncedSearch,
};
response = await documentsApiService.searchDocuments({ queryParams });
} else {
const queryParams = {
search_space_id: searchSpaceId,
page: nextPage,
page_size: PAGE_SIZE,
};
response = await documentsApiService.getDocuments({ queryParams });
}
setAccumulatedDocuments((prev) => [...prev, ...response.items]);
setHasMore(response.has_more);
setCurrentPage(nextPage);
} catch (error) {
console.error("Failed to load next page:", error);
} finally {
setIsLoadingMore(false);
}
}, [currentPage, hasMore, isLoadingMore, debouncedSearch, searchSpaceId]);
// Infinite scroll handler
const handleScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
// Load more when within 50px of bottom
if (scrollBottom < 50 && hasMore && !isLoadingMore) {
loadNextPage();
}
},
[hasMore, isLoadingMore, loadNextPage]
);
const actualDocuments = accumulatedDocuments;
const actualLoading =
(debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) && currentPage === 0;
// Track already selected document IDs
const selectedIds = useMemo(
@ -184,8 +269,12 @@ export const DocumentMentionPicker = forwardRef<
role="listbox"
tabIndex={-1}
>
{/* Document List - Shows max 3 items on mobile, 5 items on desktop */}
<div className="max-h-[108px] sm:max-h-[180px] overflow-y-auto">
{/* Document List - Shows max 5 items on mobile, 7-8 items on desktop */}
<div
ref={scrollContainerRef}
className="max-h-[180px] sm:max-h-[280px] overflow-y-auto"
onScroll={handleScroll}
>
{actualLoading ? (
<div className="flex items-center justify-center py-4">
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full" />
@ -235,6 +324,12 @@ export const DocumentMentionPicker = forwardRef<
</button>
);
})}
{/* Loading indicator for additional pages */}
{isLoadingMore && (
<div className="flex items-center justify-center py-2">
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full" />
</div>
)}
</div>
)}
</div>

View file

@ -31,6 +31,7 @@ import { GridPattern } from "./GridPattern";
interface DocumentUploadTabProps {
searchSpaceId: string;
onSuccess?: () => void;
onAccordionStateChange?: (isExpanded: boolean) => void;
}
const audioFileTypes = {
@ -109,11 +110,16 @@ const FILE_TYPE_CONFIG: Record<string, Record<string, string[]>> = {
const cardClass = "border border-border bg-slate-400/5 dark:bg-white/5";
export function DocumentUploadTab({ searchSpaceId, onSuccess }: DocumentUploadTabProps) {
export function DocumentUploadTab({
searchSpaceId,
onSuccess,
onAccordionStateChange,
}: DocumentUploadTabProps) {
const t = useTranslations("upload_documents");
const router = useRouter();
const [files, setFiles] = useState<File[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
const [accordionValue, setAccordionValue] = useState<string>("");
const [uploadDocumentMutation] = useAtom(uploadDocumentMutationAtom);
const { mutate: uploadDocuments, isPending: isUploading } = uploadDocumentMutation;
const fileInputRef = useRef<HTMLInputElement>(null);
@ -154,6 +160,15 @@ export function DocumentUploadTab({ searchSpaceId, onSuccess }: DocumentUploadTa
const totalFileSize = files.reduce((total, file) => total + file.size, 0);
// Track accordion state changes
const handleAccordionChange = useCallback(
(value: string) => {
setAccordionValue(value);
onAccordionStateChange?.(value === "supported-file-types");
},
[onAccordionStateChange]
);
const handleUpload = async () => {
setUploadProgress(0);
trackDocumentUploadStarted(Number(searchSpaceId), files.length, totalFileSize);
@ -190,11 +205,13 @@ export function DocumentUploadTab({ searchSpaceId, onSuccess }: DocumentUploadTa
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-3 sm:space-y-6 max-w-4xl mx-auto"
className="space-y-3 sm:space-y-6 max-w-4xl mx-auto pt-0"
>
<Alert className="border border-border bg-slate-400/5 dark:bg-white/5">
<Info className="h-4 w-4" />
<AlertDescription className="text-xs sm:text-sm">{t("file_size_limit")}</AlertDescription>
<Alert className="border border-border bg-slate-400/5 dark:bg-white/5 flex items-start gap-3 [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg~*]:pl-0">
<Info className="h-4 w-4 shrink-0 mt-0.5" />
<AlertDescription className="text-xs sm:text-sm leading-relaxed pt-0.5">
{t("file_size_limit")}
</AlertDescription>
</Alert>
<Card className={`relative overflow-hidden ${cardClass}`}>
@ -366,11 +383,13 @@ export function DocumentUploadTab({ searchSpaceId, onSuccess }: DocumentUploadTa
<Accordion
type="single"
collapsible
className={`w-full ${cardClass} border border-border rounded-lg`}
value={accordionValue}
onValueChange={handleAccordionChange}
className={`w-full ${cardClass} border border-border rounded-lg mb-0`}
>
<AccordionItem value="supported-file-types" className="border-0">
<AccordionTrigger className="px-3 sm:px-6 py-3 sm:py-4 hover:no-underline">
<div className="flex items-center gap-2">
<AccordionTrigger className="px-3 sm:px-6 py-3 sm:py-4 hover:no-underline !items-center [&>svg]:!translate-y-0">
<div className="flex items-center gap-2 flex-1">
<Tag className="h-4 w-4 sm:h-5 sm:w-5 shrink-0" />
<div className="text-left min-w-0">
<div className="font-semibold text-sm sm:text-base">