diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index fbe137287..12fd7144f 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -1,26 +1,170 @@ +"use client"; + import { useAtomValue } from "jotai"; import { - ChevronRightIcon, + ChevronRight, Loader2, Plug2, - Plus, + Search, } from "lucide-react"; import Link from "next/link"; -import { - type FC, - useCallback, - useRef, - useState, -} from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { type FC, useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon"; +import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { EnumConnectorName } from "@/contracts/enums/connector"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; +import { authenticatedFetch } from "@/lib/auth-utils"; +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { cn } from "@/lib/utils"; +// OAuth Connectors (Quick Connect) +const OAUTH_CONNECTORS = [ + { + id: "google-gmail-connector", + title: "Gmail", + description: "Search through your emails", + connectorType: EnumConnectorName.GOOGLE_GMAIL_CONNECTOR, + authEndpoint: "/api/v1/auth/google/gmail/connector/add/", + }, + { + id: "google-calendar-connector", + title: "Google Calendar", + description: "Search through your events", + connectorType: EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR, + authEndpoint: "/api/v1/auth/google/calendar/connector/add/", + }, + { + id: "airtable-connector", + title: "Airtable", + description: "Search your Airtable bases", + connectorType: EnumConnectorName.AIRTABLE_CONNECTOR, + authEndpoint: "/api/v1/auth/airtable/connector/add/", + }, +]; + +// Non-OAuth Connectors +const OTHER_CONNECTORS = [ + { + id: "slack-connector", + title: "Slack", + description: "Search Slack messages", + connectorType: EnumConnectorName.SLACK_CONNECTOR, + }, + { + id: "discord-connector", + title: "Discord", + description: "Search Discord messages", + connectorType: EnumConnectorName.DISCORD_CONNECTOR, + }, + { + id: "notion-connector", + title: "Notion", + description: "Search Notion pages", + connectorType: EnumConnectorName.NOTION_CONNECTOR, + }, + { + id: "confluence-connector", + title: "Confluence", + description: "Search documentation", + connectorType: EnumConnectorName.CONFLUENCE_CONNECTOR, + }, + { + id: "bookstack-connector", + title: "BookStack", + description: "Search BookStack docs", + connectorType: EnumConnectorName.BOOKSTACK_CONNECTOR, + }, + { + id: "github-connector", + title: "GitHub", + description: "Search repositories", + connectorType: EnumConnectorName.GITHUB_CONNECTOR, + }, + { + id: "linear-connector", + title: "Linear", + description: "Search issues & projects", + connectorType: EnumConnectorName.LINEAR_CONNECTOR, + }, + { + id: "jira-connector", + title: "Jira", + description: "Search Jira issues", + connectorType: EnumConnectorName.JIRA_CONNECTOR, + }, + { + id: "clickup-connector", + title: "ClickUp", + description: "Search ClickUp tasks", + connectorType: EnumConnectorName.CLICKUP_CONNECTOR, + }, + { + id: "luma-connector", + title: "Luma", + description: "Search Luma events", + connectorType: EnumConnectorName.LUMA_CONNECTOR, + }, + { + id: "elasticsearch-connector", + title: "Elasticsearch", + description: "Search ES indexes", + connectorType: EnumConnectorName.ELASTICSEARCH_CONNECTOR, + }, + { + id: "webcrawler-connector", + title: "Web Pages", + description: "Crawl web content", + connectorType: EnumConnectorName.WEBCRAWLER_CONNECTOR, + }, + { + id: "tavily-api", + title: "Tavily AI", + description: "Search with Tavily", + connectorType: EnumConnectorName.TAVILY_API, + }, + { + id: "searxng", + title: "SearxNG", + description: "Search with SearxNG", + connectorType: EnumConnectorName.SEARXNG_API, + }, + { + id: "linkup-api", + title: "Linkup API", + description: "Search with Linkup", + connectorType: EnumConnectorName.LINKUP_API, + }, + { + id: "baidu-search-api", + title: "Baidu Search", + description: "Search with Baidu", + connectorType: EnumConnectorName.BAIDU_SEARCH_API, + }, +]; + +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs"; + export const ConnectorIndicator: FC = () => { + const router = useRouter(); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const { connectors, isLoading: connectorsLoading } = useSearchSourceConnectors( false, @@ -28,11 +172,34 @@ export const ConnectorIndicator: FC = () => { ); const { data: documentTypeCounts, isLoading: documentTypesLoading } = useAtomValue(documentTypeCountsAtom); + const { data: allConnectors } = useAtomValue(connectorsAtom); + const pathname = usePathname(); const [isOpen, setIsOpen] = useState(false); - const closeTimeoutRef = useRef(null); + const [activeTab, setActiveTab] = useState("all"); + const [connectingId, setConnectingId] = useState(null); + const [isScrolled, setIsScrolled] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); const isLoading = connectorsLoading || documentTypesLoading; + // Synchronize state with URL path + useEffect(() => { + const pathParts = window.location.pathname.split("/"); + const connectorsIdx = pathParts.indexOf("connectors"); + + if (connectorsIdx !== -1) { + if (!isOpen) setIsOpen(true); + + // Detect tab from URL: .../connectors/active or .../connectors/all + const tabFromUrl = pathParts[connectorsIdx + 1]; + if (tabFromUrl === "active" || tabFromUrl === "all") { + if (activeTab !== tabFromUrl) setActiveTab(tabFromUrl); + } + } else { + if (isOpen) setIsOpen(false); + } + }, [pathname, isOpen, activeTab]); + // Get document types that have documents in the search space const activeDocumentTypes = documentTypeCounts ? Object.entries(documentTypeCounts).filter(([_, count]) => count > 0) @@ -42,122 +209,335 @@ export const ConnectorIndicator: FC = () => { const hasSources = hasConnectors || activeDocumentTypes.length > 0; const totalSourceCount = connectors.length + activeDocumentTypes.length; - const handleMouseEnter = useCallback(() => { - // Clear any pending close timeout - if (closeTimeoutRef.current) { - clearTimeout(closeTimeoutRef.current); - closeTimeoutRef.current = null; - } - setIsOpen(true); - }, []); + // Check which connectors are already connected + const connectedTypes = new Set( + (allConnectors || []).map((c: SearchSourceConnector) => c.connector_type) + ); - const handleMouseLeave = useCallback(() => { - // Delay closing by 150ms for better UX - closeTimeoutRef.current = setTimeout(() => { - setIsOpen(false); - }, 150); + // Filter connectors based on search + const filteredOAuth = OAUTH_CONNECTORS.filter(c => + c.title.toLowerCase().includes(searchQuery.toLowerCase()) || + c.description.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const filteredOther = OTHER_CONNECTORS.filter(c => + c.title.toLowerCase().includes(searchQuery.toLowerCase()) || + c.description.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + // Handle OAuth connection + const handleConnectOAuth = useCallback( + async (connector: (typeof OAUTH_CONNECTORS)[0]) => { + if (!searchSpaceId || !connector.authEndpoint) return; + + try { + setConnectingId(connector.id); + const response = await authenticatedFetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${connector.authEndpoint}?space_id=${searchSpaceId}`, + { method: "GET" } + ); + + if (!response.ok) { + throw new Error(`Failed to initiate ${connector.title} OAuth`); + } + + const data = await response.json(); + window.location.href = data.auth_url; + } catch (error) { + console.error(`Error connecting to ${connector.title}:`, error); + toast.error(`Failed to connect to ${connector.title}`); + } finally { + setConnectingId(null); + } + }, + [searchSpaceId] + ); + + const handleOpenChange = useCallback( + (open: boolean) => { + setIsOpen(open); + + const currentPath = window.location.pathname; + const basePath = currentPath.split("/connectors")[0].replace(/\/$/, ""); + + if (open) { + // Base state is /connectors/all + const newUrl = `${basePath}/connectors/${activeTab}`; + window.history.pushState({ modal: true }, "", newUrl); + } else { + // Return to base chat path + window.history.pushState({ modal: false }, "", basePath || "/"); + setIsScrolled(false); + setSearchQuery(""); + } + }, + [activeTab] + ); + + const handleTabChange = useCallback( + (value: string) => { + setActiveTab(value); + const currentPath = window.location.pathname; + const basePath = currentPath.split("/connectors")[0].replace(/\/$/, ""); + + // Update URL to reflect the new tab state + const newUrl = `${basePath}/connectors/${value}`; + window.history.replaceState({ modal: true }, "", newUrl); + }, + [] + ); + + const handleScroll = useCallback((e: React.UIEvent) => { + setIsScrolled(e.currentTarget.scrollTop > 0); }, []); if (!searchSpaceId) return null; return ( - - - - - + - {hasSources ? ( -
-
-

Connected Sources

- - {totalSourceCount} - -
-
- {/* Document types from the search space */} - {activeDocumentTypes.map(([docType]) => ( -
- {getConnectorIcon(docType, "size-3.5")} - {getDocumentTypeLabel(docType)} -
- ))} - {/* Search source connectors */} - {connectors.map((connector) => ( -
- {getConnectorIcon(connector.connector_type, "size-3.5")} - {connector.name} -
- ))} -
-
- - - Add more sources - - -
-
- ) : ( -
-

No sources yet

-

- Add documents or connect data sources to enhance search results. -

- - - Add Connector - -
+ className={cn( + "size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative", + "hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30", + "outline-none focus:outline-none focus-visible:outline-none font-semibold text-xs", + "border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none" )} -
-
+ aria-label={ + hasSources ? `View ${totalSourceCount} connected sources` : "Add your first connector" + } + onClick={() => handleOpenChange(true)} + > + {isLoading ? ( + + ) : ( + <> + + {totalSourceCount > 0 && ( + + {totalSourceCount > 99 ? "99+" : totalSourceCount} + + )} + + )} + + + + + {/* Header */} +
+ + Connectors + + Search across all your apps and data in one place. + + + +
+ + + All Connectors + + + Active + {totalSourceCount > 0 && ( + + {totalSourceCount} + + )} + + +
+ + setSearchQuery(e.target.value)} + /> +
+
+
+ + {/* Content */} +
+
+ + {/* Quick Connect */} + {filteredOAuth.length > 0 && ( +
+
+

Quick Connect

+
+
+ {filteredOAuth.map((connector) => { + const isConnected = connectedTypes.has(connector.connectorType); + const isConnecting = connectingId === connector.id; + + return ( +
+
+ {getConnectorIcon(connector.connectorType, "size-6")} +
+
+
+ {connector.title} + {isConnected && ( + + )} +
+

+ {isConnected ? "Connected" : connector.description} +

+
+ +
+ ); + })} +
+
+ )} + + {/* More Integrations */} + {filteredOther.length > 0 && ( +
+
+

More Integrations

+
+
+ {filteredOther.map((connector) => { + const isConnected = connectedTypes.has(connector.connectorType); + + return ( + +
+ {getConnectorIcon(connector.connectorType, "size-6")} +
+
+
+ {connector.title} + {isConnected && ( + + )} +
+

+ {connector.description} +

+
+ + + ); + })} +
+
+ )} +
+ + + {hasSources ? ( +
+
+

Currently Active

+
+
+ {activeDocumentTypes.map(([docType, count]) => ( +
+
+ {getConnectorIcon(docType, "size-6")} +
+
+

+ {getDocumentTypeLabel(docType)} +

+

+ {count as number} documents indexed +

+
+
+ ))} + {connectors.map((connector) => ( +
+
+ {getConnectorIcon(connector.connector_type, "size-6")} +
+
+

+ {connector.name} +

+

Status: Active

+
+ +
+ ))} +
+
+ ) : ( +
+
+ +
+

No active sources

+

+ Connect your first service to start searching across all your data. +

+ + Browse available connectors + +
+ )} +
+
+
+
+
+ ); }; - diff --git a/surfsense_web/components/ui/dialog.tsx b/surfsense_web/components/ui/dialog.tsx index 47fcfeece..d04d76520 100644 --- a/surfsense_web/components/ui/dialog.tsx +++ b/surfsense_web/components/ui/dialog.tsx @@ -44,7 +44,7 @@ const DialogContent = React.forwardRef< {...props} > {children} - + Close