mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-22 23:31:12 +02:00
feat: monorepo
This commit is contained in:
parent
fe39077849
commit
a1474ca49e
144 changed files with 43821 additions and 1 deletions
19
surfsense_web/app/auth/callback/page.tsx
Normal file
19
surfsense_web/app/auth/callback/page.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { Suspense } from 'react';
|
||||
import TokenHandler from '@/components/TokenHandler';
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="text-2xl font-bold mb-4">Authentication Callback</h1>
|
||||
<Suspense fallback={<div className="flex items-center justify-center min-h-[200px]">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
</div>}>
|
||||
<TokenHandler
|
||||
redirectPath="/dashboard"
|
||||
tokenParamName="token"
|
||||
storageKey="surfsense_bearer_token"
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { IconCheck, IconCopy, IconKey } from "@tabler/icons-react"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { useApiKey } from "@/hooks/use-api-key"
|
||||
|
||||
const fadeIn = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1, transition: { duration: 0.4 } }
|
||||
}
|
||||
|
||||
const staggerContainer = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ApiKeyClient = () => {
|
||||
const {
|
||||
apiKey,
|
||||
isLoading,
|
||||
copied,
|
||||
copyToClipboard
|
||||
} = useApiKey()
|
||||
|
||||
return (
|
||||
<div className="flex justify-center w-full min-h-screen py-10 px-4">
|
||||
<motion.div
|
||||
className="w-full max-w-3xl"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={staggerContainer}
|
||||
>
|
||||
<motion.div className="mb-8 text-center" variants={fadeIn}>
|
||||
<h1 className="text-3xl font-bold tracking-tight">API Key</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Your API key for authenticating with the SurfSense API.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeIn}>
|
||||
<Alert className="mb-8">
|
||||
<IconKey className="h-4 w-4" />
|
||||
<AlertTitle>Important</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your API key grants full access to your account. Never share it publicly or with unauthorized users.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeIn}>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle>Your API Key</CardTitle>
|
||||
<CardDescription>
|
||||
Use this key to authenticate your API requests.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AnimatePresence mode="wait">
|
||||
{isLoading ? (
|
||||
<motion.div
|
||||
key="loading"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="h-10 w-full bg-muted animate-pulse rounded-md"
|
||||
/>
|
||||
) : apiKey ? (
|
||||
<motion.div
|
||||
key="api-key"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<div className="bg-muted p-3 rounded-md flex-1 font-mono text-sm overflow-x-auto whitespace-nowrap">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{apiKey}
|
||||
</motion.div>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={copyToClipboard}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<motion.div
|
||||
whileTap={{ scale: 0.9 }}
|
||||
animate={copied ? { scale: [1, 1.2, 1] } : {}}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{copied ? <IconCheck className="h-4 w-4" /> : <IconCopy className="h-4 w-4" />}
|
||||
</motion.div>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{copied ? "Copied!" : "Copy to clipboard"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="no-key"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="text-muted-foreground text-center"
|
||||
>
|
||||
No API key found.
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="mt-8"
|
||||
variants={fadeIn}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<h2 className="text-xl font-semibold mb-4 text-center">How to use your API key</h2>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<motion.div
|
||||
className="space-y-4"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={staggerContainer}
|
||||
>
|
||||
<motion.div variants={fadeIn}>
|
||||
<h3 className="font-medium mb-2 text-center">Authentication</h3>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Include your API key in the Authorization header of your requests:
|
||||
</p>
|
||||
<motion.pre
|
||||
className="bg-muted p-3 rounded-md mt-2 overflow-x-auto"
|
||||
whileHover={{ scale: 1.01 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 10 }}
|
||||
>
|
||||
<code className="text-xs">
|
||||
Authorization: Bearer {apiKey || 'YOUR_API_KEY'}
|
||||
</code>
|
||||
</motion.pre>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ApiKeyClient
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
'use client'
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
// Loading component with animation
|
||||
const LoadingComponent = () => (
|
||||
<div className="flex flex-col justify-center items-center min-h-screen">
|
||||
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin mb-4"></div>
|
||||
<p className="text-muted-foreground">Loading API Key Management...</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Dynamically import the ApiKeyClient component
|
||||
const ApiKeyClient = dynamic(() => import('./api-key-client'), {
|
||||
ssr: false,
|
||||
loading: () => <LoadingComponent />
|
||||
})
|
||||
|
||||
export default function ClientWrapper() {
|
||||
const [isMounted, setIsMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!isMounted) {
|
||||
return <LoadingComponent />
|
||||
}
|
||||
|
||||
return <ApiKeyClient />
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import React from 'react'
|
||||
import ClientWrapper from './client-wrapper'
|
||||
|
||||
export default function ApiKeyPage() {
|
||||
return <ClientWrapper />
|
||||
}
|
||||
|
|
@ -0,0 +1,510 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { MessageCircleMore, Search, Calendar, Tag, Trash2, ExternalLink, MoreHorizontal } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
// UI Components
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/pagination';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface Chat {
|
||||
created_at: string;
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
messages: ChatMessage[];
|
||||
search_space_id: number;
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
role: string;
|
||||
content: string;
|
||||
parts?: any;
|
||||
}
|
||||
|
||||
interface ChatsPageClientProps {
|
||||
searchSpaceId: string;
|
||||
}
|
||||
|
||||
const pageVariants = {
|
||||
initial: { opacity: 0 },
|
||||
enter: { opacity: 1, transition: { duration: 0.3, ease: 'easeInOut' } },
|
||||
exit: { opacity: 0, transition: { duration: 0.3, ease: 'easeInOut' } }
|
||||
};
|
||||
|
||||
const chatCardVariants = {
|
||||
initial: { y: 20, opacity: 0 },
|
||||
animate: { y: 0, opacity: 1 },
|
||||
exit: { y: -20, opacity: 0 }
|
||||
};
|
||||
|
||||
const MotionCard = motion(Card);
|
||||
|
||||
export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) {
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
const [filteredChats, setFilteredChats] = useState<Chat[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [selectedType, setSelectedType] = useState<string>('all');
|
||||
const [sortOrder, setSortOrder] = useState<string>('newest');
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [chatToDelete, setChatToDelete] = useState<{ id: number, title: string } | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const chatsPerPage = 9;
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Get initial page from URL params if it exists
|
||||
useEffect(() => {
|
||||
const pageParam = searchParams.get('page');
|
||||
if (pageParam) {
|
||||
const pageNumber = parseInt(pageParam, 10);
|
||||
if (!isNaN(pageNumber) && pageNumber > 0) {
|
||||
setCurrentPage(pageNumber);
|
||||
}
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Fetch chats from API
|
||||
useEffect(() => {
|
||||
const fetchChats = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Get token from localStorage
|
||||
const token = localStorage.getItem('surfsense_bearer_token');
|
||||
|
||||
if (!token) {
|
||||
setError('Authentication token not found. Please log in again.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch all chats for this search space
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/?search_space_id=${searchSpaceId}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(`Failed to fetch chats: ${response.status} ${errorData?.error || ''}`);
|
||||
}
|
||||
|
||||
const data: Chat[] = await response.json();
|
||||
setChats(data);
|
||||
setFilteredChats(data);
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
console.error('Error fetching chats:', error);
|
||||
setError(error instanceof Error ? error.message : 'Unknown error occurred');
|
||||
setChats([]);
|
||||
setFilteredChats([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchChats();
|
||||
}, [searchSpaceId]);
|
||||
|
||||
// Filter and sort chats based on search query, type, and sort order
|
||||
useEffect(() => {
|
||||
let result = [...chats];
|
||||
|
||||
// Filter by search term
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(chat =>
|
||||
chat.title.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by type
|
||||
if (selectedType !== 'all') {
|
||||
result = result.filter(chat => chat.type === selectedType);
|
||||
}
|
||||
|
||||
// Sort chats
|
||||
result.sort((a, b) => {
|
||||
const dateA = new Date(a.created_at).getTime();
|
||||
const dateB = new Date(b.created_at).getTime();
|
||||
|
||||
return sortOrder === 'newest' ? dateB - dateA : dateA - dateB;
|
||||
});
|
||||
|
||||
setFilteredChats(result);
|
||||
setTotalPages(Math.max(1, Math.ceil(result.length / chatsPerPage)));
|
||||
|
||||
// Reset to first page when filters change
|
||||
if (currentPage !== 1 && (searchQuery || selectedType !== 'all' || sortOrder !== 'newest')) {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
}, [chats, searchQuery, selectedType, sortOrder, currentPage]);
|
||||
|
||||
// Function to handle chat deletion
|
||||
const handleDeleteChat = async () => {
|
||||
if (!chatToDelete) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const token = localStorage.getItem('surfsense_bearer_token');
|
||||
if (!token) {
|
||||
setIsDeleting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${chatToDelete.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete chat: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Close dialog and refresh chats
|
||||
setDeleteDialogOpen(false);
|
||||
setChatToDelete(null);
|
||||
|
||||
// Update local state by removing the deleted chat
|
||||
setChats(prevChats => prevChats.filter(chat => chat.id !== chatToDelete.id));
|
||||
} catch (error) {
|
||||
console.error('Error deleting chat:', error);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate pagination
|
||||
const indexOfLastChat = currentPage * chatsPerPage;
|
||||
const indexOfFirstChat = indexOfLastChat - chatsPerPage;
|
||||
const currentChats = filteredChats.slice(indexOfFirstChat, indexOfLastChat);
|
||||
|
||||
// Get unique chat types for filter dropdown
|
||||
const chatTypes = ['all', ...Array.from(new Set(chats.map(chat => chat.type)))];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="container p-6 mx-auto"
|
||||
initial="initial"
|
||||
animate="enter"
|
||||
exit="exit"
|
||||
variants={pageVariants}
|
||||
>
|
||||
<div className="flex flex-col space-y-4 md:space-y-6">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">All Chats</h1>
|
||||
<p className="text-muted-foreground">View, search, and manage all your chats.</p>
|
||||
</div>
|
||||
|
||||
{/* Filter and Search Bar */}
|
||||
<div className="flex flex-col space-y-4 md:flex-row md:items-center md:justify-between md:space-y-0">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="relative w-full md:w-80">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search chats..."
|
||||
className="pl-8"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={selectedType} onValueChange={setSelectedType}>
|
||||
<SelectTrigger className="w-full md:w-40">
|
||||
<SelectValue placeholder="Filter by type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{chatTypes.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type === 'all' ? 'All Types' : type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Select value={sortOrder} onValueChange={setSortOrder}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Sort order" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="newest">Newest First</SelectItem>
|
||||
<SelectItem value="oldest">Oldest First</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Messages */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
<p className="text-sm text-muted-foreground">Loading chats...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !isLoading && (
|
||||
<div className="border border-destructive/50 text-destructive p-4 rounded-md">
|
||||
<h3 className="font-medium">Error loading chats</h3>
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && filteredChats.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-40 gap-2 text-center">
|
||||
<MessageCircleMore className="h-8 w-8 text-muted-foreground" />
|
||||
<h3 className="font-medium">No chats found</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{searchQuery || selectedType !== 'all'
|
||||
? 'Try adjusting your search filters'
|
||||
: 'Start a new chat to get started'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat Grid */}
|
||||
{!isLoading && !error && filteredChats.length > 0 && (
|
||||
<AnimatePresence mode="wait">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{currentChats.map((chat, index) => (
|
||||
<MotionCard
|
||||
key={chat.id}
|
||||
variants={chatCardVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
||||
className="overflow-hidden hover:shadow-md transition-shadow"
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="line-clamp-1">{chat.title || `Chat ${chat.id}`}</CardTitle>
|
||||
<CardDescription>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
<span>{format(new Date(chat.created_at), 'MMM d, yyyy')}</span>
|
||||
</span>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => window.location.href = `/dashboard/${chat.search_space_id}/researcher/${chat.id}`}>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
<span>View Chat</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => {
|
||||
setChatToDelete({ id: chat.id, title: chat.title || `Chat ${chat.id}` });
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>Delete Chat</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm text-muted-foreground line-clamp-3">
|
||||
{chat.messages && chat.messages.length > 0
|
||||
? typeof chat.messages[0] === 'string'
|
||||
? chat.messages[0]
|
||||
: chat.messages[0]?.content || 'No message content'
|
||||
: 'No messages in this chat.'}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between pt-2">
|
||||
<div className="flex items-center text-xs text-muted-foreground">
|
||||
<MessageCircleMore className="mr-1 h-3.5 w-3.5" />
|
||||
<span>{chat.messages?.length || 0} messages</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Tag className="mr-1 h-3 w-3" />
|
||||
{chat.type || 'Unknown'}
|
||||
</Badge>
|
||||
</CardFooter>
|
||||
</MotionCard>
|
||||
))}
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{!isLoading && !error && totalPages > 1 && (
|
||||
<Pagination className="mt-8">
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href={`?page=${Math.max(1, currentPage - 1)}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage > 1) setCurrentPage(currentPage - 1);
|
||||
}}
|
||||
className={currentPage <= 1 ? 'pointer-events-none opacity-50' : ''}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{Array.from({ length: totalPages }).map((_, index) => {
|
||||
const pageNumber = index + 1;
|
||||
const isVisible =
|
||||
pageNumber === 1 ||
|
||||
pageNumber === totalPages ||
|
||||
(pageNumber >= currentPage - 1 && pageNumber <= currentPage + 1);
|
||||
|
||||
if (!isVisible) {
|
||||
// Show ellipsis at appropriate positions
|
||||
if (pageNumber === 2 || pageNumber === totalPages - 1) {
|
||||
return (
|
||||
<PaginationItem key={pageNumber}>
|
||||
<span className="flex h-9 w-9 items-center justify-center">...</span>
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PaginationItem key={pageNumber}>
|
||||
<PaginationLink
|
||||
href={`?page=${pageNumber}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(pageNumber);
|
||||
}}
|
||||
isActive={pageNumber === currentPage}
|
||||
>
|
||||
{pageNumber}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
})}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href={`?page=${Math.min(totalPages, currentPage + 1)}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage < totalPages) setCurrentPage(currentPage + 1);
|
||||
}}
|
||||
className={currentPage >= totalPages ? 'pointer-events-none opacity-50' : ''}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Trash2 className="h-5 w-5 text-destructive" />
|
||||
<span>Delete Chat</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete <span className="font-medium">{chatToDelete?.title}</span>? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteChat}
|
||||
disabled={isDeleting}
|
||||
className="gap-2"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
18
surfsense_web/app/dashboard/[search_space_id]/chats/page.tsx
Normal file
18
surfsense_web/app/dashboard/[search_space_id]/chats/page.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Suspense } from 'react';
|
||||
import ChatsPageClient from './chats-client';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
search_space_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function ChatsPage({ params }: PageProps) {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex items-center justify-center h-[60vh]">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
</div>}>
|
||||
<ChatsPageClient searchSpaceId={params.search_space_id} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
'use client';
|
||||
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"
|
||||
import React from 'react'
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider"
|
||||
|
||||
export function DashboardClientLayout({
|
||||
children,
|
||||
searchSpaceId,
|
||||
navSecondary,
|
||||
navMain
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
searchSpaceId: string;
|
||||
navSecondary: any[];
|
||||
navMain: any[];
|
||||
}) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
{/* Use AppSidebarProvider which fetches user, search space, and recent chats */}
|
||||
<AppSidebarProvider
|
||||
searchSpaceId={searchSpaceId}
|
||||
navSecondary={navSecondary}
|
||||
navMain={navMain}
|
||||
/>
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
<ThemeTogglerComponent />
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { toast } from "sonner";
|
||||
import { Edit, Plus, Search, Trash2, ExternalLink, RefreshCw } from "lucide-react";
|
||||
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
// Helper function to get connector type display name
|
||||
const getConnectorTypeDisplay = (type: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
"SERPER_API": "Serper API",
|
||||
"TAVILY_API": "Tavily API",
|
||||
"SLACK_CONNECTOR": "Slack",
|
||||
"NOTION_CONNECTOR": "Notion",
|
||||
// Add other connector types here as needed
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
||||
// Helper function to format date with time
|
||||
const formatDateTime = (dateString: string | null): string => {
|
||||
if (!dateString) return "Never";
|
||||
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
export default function ConnectorsPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
|
||||
const { connectors, isLoading, error, deleteConnector, indexConnector } = useSearchSourceConnectors();
|
||||
const [connectorToDelete, setConnectorToDelete] = useState<number | null>(null);
|
||||
const [indexingConnectorId, setIndexingConnectorId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast.error("Failed to load connectors");
|
||||
console.error("Error fetching connectors:", error);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
// Handle connector deletion
|
||||
const handleDeleteConnector = async () => {
|
||||
if (connectorToDelete === null) return;
|
||||
|
||||
try {
|
||||
await deleteConnector(connectorToDelete);
|
||||
toast.success("Connector deleted successfully");
|
||||
} catch (error) {
|
||||
console.error("Error deleting connector:", error);
|
||||
toast.error("Failed to delete connector");
|
||||
} finally {
|
||||
setConnectorToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle connector indexing
|
||||
const handleIndexConnector = async (connectorId: number) => {
|
||||
setIndexingConnectorId(connectorId);
|
||||
try {
|
||||
await indexConnector(connectorId, searchSpaceId);
|
||||
toast.success("Connector content indexed successfully");
|
||||
} catch (error) {
|
||||
console.error("Error indexing connector content:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to index connector content");
|
||||
} finally {
|
||||
setIndexingConnectorId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-6xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mb-8 flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connectors</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Manage your connected services and data sources.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Connector
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>Your Connectors</CardTitle>
|
||||
<CardDescription>
|
||||
View and manage all your connected services.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-pulse text-center">
|
||||
<div className="h-6 w-32 bg-muted rounded mx-auto mb-2"></div>
|
||||
<div className="h-4 w-48 bg-muted rounded mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : connectors.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-medium mb-2">No connectors found</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
You haven't added any connectors yet. Add one to enhance your search capabilities.
|
||||
</p>
|
||||
<Button onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Your First Connector
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Last Indexed</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{connectors.map((connector) => (
|
||||
<TableRow key={connector.id}>
|
||||
<TableCell className="font-medium">{connector.name}</TableCell>
|
||||
<TableCell>{getConnectorTypeDisplay(connector.connector_type)}</TableCell>
|
||||
<TableCell>
|
||||
{connector.is_indexable
|
||||
? formatDateTime(connector.last_indexed_at)
|
||||
: "Not indexable"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{connector.is_indexable && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleIndexConnector(connector.id)}
|
||||
disabled={indexingConnectorId === connector.id}
|
||||
>
|
||||
{indexingConnectorId === connector.id ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">Index Content</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Index Content</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/${connector.id}`)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
<span className="sr-only">Edit</span>
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive-foreground hover:bg-destructive/10"
|
||||
onClick={() => setConnectorToDelete(connector.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">Delete</span>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Connector</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this connector? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setConnectorToDelete(null)}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={handleDeleteConnector}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
|
||||
import { useSearchSourceConnectors, SearchSourceConnector } from "@/hooks/useSearchSourceConnectors";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const apiConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().min(10, {
|
||||
message: "API key is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Helper function to get connector type display name
|
||||
const getConnectorTypeDisplay = (type: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
"SERPER_API": "Serper API",
|
||||
"TAVILY_API": "Tavily API",
|
||||
"SLACK_CONNECTOR": "Slack Connector",
|
||||
"NOTION_CONNECTOR": "Notion Connector",
|
||||
// Add other connector types here as needed
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
||||
// Define the type for the form values
|
||||
type ApiConnectorFormValues = z.infer<typeof apiConnectorFormSchema>;
|
||||
|
||||
export default function EditConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const connectorId = parseInt(params.connector_id as string, 10);
|
||||
|
||||
const { connectors, updateConnector } = useSearchSourceConnectors();
|
||||
const [connector, setConnector] = useState<SearchSourceConnector | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<ApiConnectorFormValues>({
|
||||
resolver: zodResolver(apiConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Get API key field name based on connector type
|
||||
const getApiKeyFieldName = (connectorType: string): string => {
|
||||
const fieldMap: Record<string, string> = {
|
||||
"SERPER_API": "SERPER_API_KEY",
|
||||
"TAVILY_API": "TAVILY_API_KEY",
|
||||
"SLACK_CONNECTOR": "SLACK_BOT_TOKEN",
|
||||
"NOTION_CONNECTOR": "NOTION_INTEGRATION_TOKEN"
|
||||
};
|
||||
return fieldMap[connectorType] || "";
|
||||
};
|
||||
|
||||
// Find connector in the list
|
||||
useEffect(() => {
|
||||
const currentConnector = connectors.find(c => c.id === connectorId);
|
||||
|
||||
if (currentConnector) {
|
||||
setConnector(currentConnector);
|
||||
|
||||
// Check if connector type is supported
|
||||
const apiKeyField = getApiKeyFieldName(currentConnector.connector_type);
|
||||
if (apiKeyField) {
|
||||
form.reset({
|
||||
name: currentConnector.name,
|
||||
api_key: currentConnector.config[apiKeyField] || "",
|
||||
});
|
||||
} else {
|
||||
// Redirect if not a supported connector type
|
||||
toast.error("This connector type is not supported for editing");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
} else if (!isLoading && connectors.length > 0) {
|
||||
// If connectors are loaded but this one isn't found
|
||||
toast.error("Connector not found");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
}
|
||||
}, [connectors, connectorId, form, router, searchSpaceId, isLoading]);
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: ApiConnectorFormValues) => {
|
||||
if (!connector) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const apiKeyField = getApiKeyFieldName(connector.connector_type);
|
||||
|
||||
// Only update the API key if a new one was provided
|
||||
const updatedConfig = { ...connector.config };
|
||||
if (values.api_key) {
|
||||
updatedConfig[apiKeyField] = values.api_key;
|
||||
}
|
||||
|
||||
await updateConnector(connectorId, {
|
||||
name: values.name,
|
||||
connector_type: connector.connector_type,
|
||||
config: updatedConfig,
|
||||
});
|
||||
|
||||
toast.success("Connector updated successfully!");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error updating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to update connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl flex justify-center items-center min-h-[60vh]">
|
||||
<div className="animate-pulse text-center">
|
||||
<div className="h-8 w-48 bg-muted rounded mx-auto mb-4"></div>
|
||||
<div className="h-4 w-64 bg-muted rounded mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">
|
||||
Edit {connector ? getConnectorTypeDisplay(connector.connector_type) : ""} Connector
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Update your connector settings.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>API Key Security</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your API key is stored securely. For security reasons, we don't display your existing API key.
|
||||
If you don't update the API key field, your existing key will be preserved.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My API Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{connector?.connector_type === "SLACK_CONNECTOR"
|
||||
? "Slack Bot Token"
|
||||
: connector?.connector_type === "NOTION_CONNECTOR"
|
||||
? "Notion Integration Token"
|
||||
: "API Key"}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={
|
||||
connector?.connector_type === "SLACK_CONNECTOR"
|
||||
? "Enter your Slack Bot Token"
|
||||
: connector?.connector_type === "NOTION_CONNECTOR"
|
||||
? "Enter your Notion Integration Token"
|
||||
: "Enter your API key"
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{connector?.connector_type === "SLACK_CONNECTOR"
|
||||
? "Enter a new Slack Bot Token or leave blank to keep your existing token."
|
||||
: connector?.connector_type === "NOTION_CONNECTOR"
|
||||
? "Enter a new Notion Integration Token or leave blank to keep your existing token."
|
||||
: "Enter a new API key or leave blank to keep your existing key."}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Updating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Update Connector
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,317 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const notionConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
integration_token: z.string().min(10, {
|
||||
message: "Notion Integration Token is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type NotionConnectorFormValues = z.infer<typeof notionConnectorFormSchema>;
|
||||
|
||||
export default function NotionConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<NotionConnectorFormValues>({
|
||||
resolver: zodResolver(notionConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Notion Connector",
|
||||
integration_token: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: NotionConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: "NOTION_CONNECTOR",
|
||||
config: {
|
||||
NOTION_INTEGRATION_TOKEN: values.integration_token,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
|
||||
toast.success("Notion connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="connect">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Notion Workspace</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Notion to search and retrieve information from your workspace pages and databases. This connector can index your Notion content for search.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Notion Integration Token Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Notion Integration Token to use this connector. You can create a Notion integration and get the token from{" "}
|
||||
<a
|
||||
href="https://www.notion.so/my-integrations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Notion Integrations Dashboard
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Notion Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="integration_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Notion Integration Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="ntn_.."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Notion Integration Token will be encrypted and stored securely. It typically starts with "ntn_".
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Notion
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Notion integration:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Search through your Notion pages and databases</li>
|
||||
<li>Access documents, wikis, and knowledge bases</li>
|
||||
<li>Connect your team's knowledge directly to your search space</li>
|
||||
<li>Keep your search results up-to-date with latest Notion content</li>
|
||||
<li>Index your Notion documents for enhanced search capabilities</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documentation">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Notion Connector Documentation</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to set up and use the Notion connector to index your workspace data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The Notion connector uses the Notion search API to fetch all pages that the connector has access to within a workspace.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||
<li>For follow up indexing runs, the connector only retrieves pages that have been updated since the last indexing attempt.</li>
|
||||
<li>Indexing is configured to run every <strong>10 minutes</strong>, so page updates should appear within 10 minutes.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="authorization">
|
||||
<AccordionTrigger className="text-lg font-medium">Authorization</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>No Admin Access Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
There's no requirement to be an Admin to share information with an integration. Any member can share pages and databases with it.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Step 1: Create an integration</h4>
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Visit <a href="https://www.notion.com/my-integrations" target="_blank" rel="noopener noreferrer" className="font-medium underline underline-offset-4">https://www.notion.com/my-integrations</a> in your browser.</li>
|
||||
<li>Click the <strong>+ New integration</strong> button.</li>
|
||||
<li>Name the integration (something like "Search Connector" could work).</li>
|
||||
<li>Select "Read content" as the only capability required.</li>
|
||||
<li>Click <strong>Submit</strong> to create the integration.</li>
|
||||
<li>On the next page, you'll find your Notion integration token. Make a copy of it as you'll need it to configure the connector.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Step 2: Share pages/databases with your integration</h4>
|
||||
<p className="text-muted-foreground mb-3">
|
||||
To keep your information secure, integrations don't have access to any pages or databases in the workspace at first.
|
||||
You must share specific pages with an integration in order for the connector to access those pages.
|
||||
</p>
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Go to the page/database in your workspace.</li>
|
||||
<li>Click the <code>•••</code> on the top right corner of the page.</li>
|
||||
<li>Scroll to the bottom of the pop-up and click <strong>Add connections</strong>.</li>
|
||||
<li>Search for and select the new integration in the <code>Search for connections...</code> menu.</li>
|
||||
<li>
|
||||
<strong>Important:</strong>
|
||||
<ul className="list-disc pl-5 mt-1">
|
||||
<li>If you've added a page, all child pages also become accessible.</li>
|
||||
<li>If you've added a database, all rows (and their children) become accessible.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="indexing">
|
||||
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Navigate to the Connector Dashboard and select the <strong>Notion</strong> Connector.</li>
|
||||
<li>Place the <strong>Integration Token</strong> under <strong>Step 1 Provide Credentials</strong>.</li>
|
||||
<li>Click <strong>Connect</strong> to establish the connection.</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Indexing Behavior</AlertTitle>
|
||||
<AlertDescription>
|
||||
The Notion connector currently indexes everything it has access to. If you want to limit specific content being indexed, simply unshare the database from Notion with the integration.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
"use client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
IconBrandGoogle,
|
||||
IconBrandSlack,
|
||||
IconBrandWindows,
|
||||
IconBrandDiscord,
|
||||
IconSearch,
|
||||
IconMessages,
|
||||
IconDatabase,
|
||||
IconCloud,
|
||||
IconBrandGithub,
|
||||
IconBrandNotion,
|
||||
IconMail,
|
||||
IconBrandZoom,
|
||||
IconChevronRight,
|
||||
} from "@tabler/icons-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
|
||||
// Define connector categories and their connectors
|
||||
const connectorCategories = [
|
||||
{
|
||||
id: "search-engines",
|
||||
title: "Search Engines",
|
||||
description: "Connect to search engines to enhance your research capabilities.",
|
||||
icon: <IconSearch className="h-5 w-5" />,
|
||||
connectors: [
|
||||
{
|
||||
id: "tavily-api",
|
||||
title: "Tavily Search API",
|
||||
description: "Connect to Tavily Search API to search the web.",
|
||||
icon: <IconSearch className="h-6 w-6" />,
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "serper-api",
|
||||
title: "Serper API",
|
||||
description: "Connect to Serper API to search the web.",
|
||||
icon: <IconBrandGoogle className="h-6 w-6" />,
|
||||
status: "coming-soon",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "team-chats",
|
||||
title: "Team Chats",
|
||||
description: "Connect to your team communication platforms.",
|
||||
icon: <IconMessages className="h-5 w-5" />,
|
||||
connectors: [
|
||||
{
|
||||
id: "slack-connector",
|
||||
title: "Slack",
|
||||
description: "Connect to your Slack workspace to access messages and channels.",
|
||||
icon: <IconBrandSlack className="h-6 w-6" />,
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "ms-teams",
|
||||
title: "Microsoft Teams",
|
||||
description: "Connect to Microsoft Teams to access your team's conversations.",
|
||||
icon: <IconBrandWindows className="h-6 w-6" />,
|
||||
status: "coming-soon",
|
||||
},
|
||||
{
|
||||
id: "discord",
|
||||
title: "Discord",
|
||||
description: "Connect to Discord servers to access messages and channels.",
|
||||
icon: <IconBrandDiscord className="h-6 w-6" />,
|
||||
status: "coming-soon",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "knowledge-bases",
|
||||
title: "Knowledge Bases",
|
||||
description: "Connect to your knowledge bases and documentation.",
|
||||
icon: <IconDatabase className="h-5 w-5" />,
|
||||
connectors: [
|
||||
{
|
||||
id: "notion-connector",
|
||||
title: "Notion",
|
||||
description: "Connect to your Notion workspace to access pages and databases.",
|
||||
icon: <IconBrandNotion className="h-6 w-6" />,
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
title: "GitHub",
|
||||
description: "Connect to GitHub repositories to access code and documentation.",
|
||||
icon: <IconBrandGithub className="h-6 w-6" />,
|
||||
status: "coming-soon",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "communication",
|
||||
title: "Communication",
|
||||
description: "Connect to your email and meeting platforms.",
|
||||
icon: <IconMail className="h-5 w-5" />,
|
||||
connectors: [
|
||||
{
|
||||
id: "gmail",
|
||||
title: "Gmail",
|
||||
description: "Connect to your Gmail account to access emails.",
|
||||
icon: <IconMail className="h-6 w-6" />,
|
||||
status: "coming-soon",
|
||||
},
|
||||
{
|
||||
id: "zoom",
|
||||
title: "Zoom",
|
||||
description: "Connect to Zoom to access meeting recordings and transcripts.",
|
||||
icon: <IconBrandZoom className="h-6 w-6" />,
|
||||
status: "coming-soon",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function ConnectorsPage() {
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [expandedCategories, setExpandedCategories] = useState<string[]>(["search-engines"]);
|
||||
|
||||
const toggleCategory = (categoryId: string) => {
|
||||
setExpandedCategories(prev =>
|
||||
prev.includes(categoryId)
|
||||
? prev.filter(id => id !== categoryId)
|
||||
: [...prev, categoryId]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-6xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mb-8 text-center"
|
||||
>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connect Your Tools</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Integrate with your favorite services to enhance your research capabilities.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{connectorCategories.map((category, categoryIndex) => (
|
||||
<Collapsible
|
||||
key={category.id}
|
||||
open={expandedCategories.includes(category.id)}
|
||||
onOpenChange={() => toggleCategory(category.id)}
|
||||
className="border rounded-lg overflow-hidden bg-card"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: categoryIndex * 0.1 }}
|
||||
className="p-4 flex items-center justify-between cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-md bg-primary/10 text-primary">
|
||||
{category.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{category.title}</h2>
|
||||
<p className="text-sm text-muted-foreground">{category.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<IconChevronRight
|
||||
className={cn(
|
||||
"h-5 w-5 text-muted-foreground transition-transform duration-200",
|
||||
expandedCategories.includes(category.id) && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<Separator />
|
||||
<div className="p-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<AnimatePresence>
|
||||
{category.connectors.map((connector, index) => (
|
||||
<motion.div
|
||||
key={connector.id}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
delay: index * 0.05,
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30
|
||||
}}
|
||||
className={cn(
|
||||
"relative group flex flex-col p-4 rounded-lg border",
|
||||
connector.status === "coming-soon" ? "opacity-70" : ""
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition duration-200 bg-gradient-to-t from-accent/50 to-transparent rounded-lg pointer-events-none" />
|
||||
|
||||
<div className="mb-4 relative z-10 text-primary">
|
||||
{connector.icon}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-lg font-semibold group-hover:translate-x-1 transition duration-200">
|
||||
{connector.title}
|
||||
</h3>
|
||||
{connector.status === "coming-soon" && (
|
||||
<span className="text-xs bg-muted px-2 py-1 rounded-full">Coming soon</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-4 flex-grow">
|
||||
{connector.description}
|
||||
</p>
|
||||
|
||||
{connector.status === "available" ? (
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`}
|
||||
className="w-full mt-auto"
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full"
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mt-auto"
|
||||
disabled
|
||||
>
|
||||
Notify Me
|
||||
</Button>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const serperApiFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().min(10, {
|
||||
message: "API key is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type SerperApiFormValues = z.infer<typeof serperApiFormSchema>;
|
||||
|
||||
export default function SerperApiPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<SerperApiFormValues>({
|
||||
resolver: zodResolver(serperApiFormSchema),
|
||||
defaultValues: {
|
||||
name: "Serper API Connector",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: SerperApiFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: "SERPER_API",
|
||||
config: {
|
||||
SERPER_API_KEY: values.api_key,
|
||||
},
|
||||
is_indexable: false,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
|
||||
toast.success("Serper API connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Serper API</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Serper API to enhance your search capabilities with Google search results.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>API Key Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Serper API key to use this connector. You can get one by signing up at{" "}
|
||||
<a
|
||||
href="https://serper.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
serper.dev
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Serper API Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Serper API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your Serper API key"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your API key will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Serper API
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Serper API:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Access to Google search results directly in your research</li>
|
||||
<li>Real-time information from the web</li>
|
||||
<li>Enhanced search capabilities for your projects</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,351 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const slackConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
bot_token: z.string().min(10, {
|
||||
message: "Bot User OAuth Token is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type SlackConnectorFormValues = z.infer<typeof slackConnectorFormSchema>;
|
||||
|
||||
export default function SlackConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<SlackConnectorFormValues>({
|
||||
resolver: zodResolver(slackConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Slack Connector",
|
||||
bot_token: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: SlackConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: "SLACK_CONNECTOR",
|
||||
config: {
|
||||
SLACK_BOT_TOKEN: values.bot_token,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
|
||||
toast.success("Slack connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="connect">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Slack Workspace</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Slack to search and retrieve information from your workspace channels and conversations. This connector can index your Slack messages for search.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Bot User OAuth Token Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Slack Bot User OAuth Token to use this connector. You can create a Slack app and get the token from{" "}
|
||||
<a
|
||||
href="https://api.slack.com/apps"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Slack API Dashboard
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Slack Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bot_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slack Bot User OAuth Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="xoxb-..."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Bot User OAuth Token will be encrypted and stored securely. It typically starts with "xoxb-".
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Slack
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Slack integration:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Search through your Slack channels and conversations</li>
|
||||
<li>Access historical messages and shared files</li>
|
||||
<li>Connect your team's knowledge directly to your search space</li>
|
||||
<li>Keep your search results up-to-date with latest communications</li>
|
||||
<li>Index your Slack messages for enhanced search capabilities</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documentation">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Slack Connector Documentation</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to set up and use the Slack connector to index your workspace data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The Slack connector indexes all public channels for a given workspace.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||
<li>Upcoming: Support for private channels by tagging/adding the Slack Bot to private channels.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="authorization">
|
||||
<AccordionTrigger className="text-lg font-medium">Authorization</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Admin Access Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You must be an admin of the Slack workspace to set up the connector.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Navigate and sign in to <a href="https://api.slack.com/apps" target="_blank" rel="noopener noreferrer" className="font-medium underline underline-offset-4">https://api.slack.com/apps</a>.</li>
|
||||
<li>
|
||||
Create a new Slack app:
|
||||
<ul className="list-disc pl-5 mt-1">
|
||||
<li>Click the <strong>Create New App</strong> button in the top right.</li>
|
||||
<li>Select <strong>From an app manifest</strong> option.</li>
|
||||
<li>Select the relevant workspace from the dropdown and click <strong>Next</strong>.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
Select the "YAML" tab, paste the following manifest into the text box, and click <strong>Next</strong>:
|
||||
<div className="bg-muted p-4 rounded-md mt-2 overflow-x-auto">
|
||||
<pre className="text-xs">
|
||||
{`display_information:
|
||||
name: SlackConnector
|
||||
description: ReadOnly Connector for indexing
|
||||
features:
|
||||
bot_user:
|
||||
display_name: SlackConnector
|
||||
always_online: false
|
||||
oauth_config:
|
||||
scopes:
|
||||
bot:
|
||||
- channels:history
|
||||
- channels:read
|
||||
- groups:history
|
||||
- groups:read
|
||||
- channels:join
|
||||
- im:history
|
||||
- users:read
|
||||
- users:read.email
|
||||
- usergroups:read
|
||||
settings:
|
||||
org_deploy_enabled: false
|
||||
socket_mode_enabled: false
|
||||
token_rotation_enabled: false`}
|
||||
</pre>
|
||||
</div>
|
||||
</li>
|
||||
<li>Click the <strong>Create</strong> button.</li>
|
||||
<li>In the app page, navigate to the <strong>OAuth & Permissions</strong> tab under the <strong>Features</strong> header.</li>
|
||||
<li>Copy the <strong>Bot User OAuth Token</strong>, this will be used to access Slack.</li>
|
||||
</ol>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="indexing">
|
||||
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Navigate to the Connector Dashboard and select the <strong>Slack</strong> Connector.</li>
|
||||
<li>Place the <strong>Bot User OAuth Token</strong> under <strong>Step 1 Provide Credentials</strong>.</li>
|
||||
<li>Click <strong>Connect</strong> to establish the connection.</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Important: Invite Bot to Channels</AlertTitle>
|
||||
<AlertDescription>
|
||||
After connecting, you must invite the bot to each channel you want to index. In each Slack channel, type:
|
||||
<pre className="mt-2 bg-background p-2 rounded-md text-xs">/invite @YourBotName</pre>
|
||||
<p className="mt-2">Without this step, you'll get a "not_in_channel" error when the connector tries to access channel messages.</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Alert className="bg-muted mt-4">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>First Indexing</AlertTitle>
|
||||
<AlertDescription>
|
||||
The first indexing pulls all of the public channels and takes longer than future updates. Only channels where the bot has been invited will be fully indexed.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="mt-4">
|
||||
<h4 className="font-medium mb-2">Troubleshooting:</h4>
|
||||
<ul className="list-disc pl-5 space-y-2 text-muted-foreground">
|
||||
<li>
|
||||
<strong>not_in_channel error:</strong> If you see this error in logs, it means the bot hasn't been invited to a channel it's trying to access. Use the <code>/invite @YourBotName</code> command in that channel.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Alternative approach:</strong> You can add the <code>chat:write.public</code> scope to your Slack app to allow it to access public channels without an explicit invitation.
|
||||
</li>
|
||||
<li>
|
||||
<strong>For private channels:</strong> The bot must always be invited using the <code>/invite</code> command.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const tavilyApiFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().min(10, {
|
||||
message: "API key is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type TavilyApiFormValues = z.infer<typeof tavilyApiFormSchema>;
|
||||
|
||||
export default function TavilyApiPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<TavilyApiFormValues>({
|
||||
resolver: zodResolver(tavilyApiFormSchema),
|
||||
defaultValues: {
|
||||
name: "Tavily API Connector",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: TavilyApiFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: "TAVILY_API",
|
||||
config: {
|
||||
TAVILY_API_KEY: values.api_key,
|
||||
},
|
||||
is_indexable: false,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
|
||||
toast.success("Tavily API connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Tavily API</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Tavily API to enhance your search capabilities with AI-powered search results.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>API Key Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Tavily API key to use this connector. You can get one by signing up at{" "}
|
||||
<a
|
||||
href="https://tavily.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
tavily.com
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Tavily API Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tavily API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your Tavily API key"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your API key will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Tavily API
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Tavily API:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>AI-powered search results tailored to your queries</li>
|
||||
<li>Real-time information from the web</li>
|
||||
<li>Enhanced search capabilities for your projects</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,463 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useCallback, useRef } from "react"
|
||||
import { useDropzone } from "react-dropzone"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { toast } from "sonner"
|
||||
import { X, Upload, FileIcon, Tag, AlertCircle, CheckCircle2, Calendar, FileType } from "lucide-react"
|
||||
import { useRouter, useParams } from "next/navigation"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
|
||||
// Grid pattern component inspired by Aceternity UI
|
||||
function GridPattern() {
|
||||
const columns = 41;
|
||||
const rows = 11;
|
||||
return (
|
||||
<div className="flex bg-gray-100 dark:bg-neutral-900 flex-shrink-0 flex-wrap justify-center items-center gap-x-px gap-y-px scale-105">
|
||||
{Array.from({ length: rows }).map((_, row) =>
|
||||
Array.from({ length: columns }).map((_, col) => {
|
||||
const index = row * columns + col;
|
||||
return (
|
||||
<div
|
||||
key={`${col}-${row}`}
|
||||
className={`w-10 h-10 flex flex-shrink-0 rounded-[2px] ${index % 2 === 0
|
||||
? "bg-gray-50 dark:bg-neutral-950"
|
||||
: "bg-gray-50 dark:bg-neutral-950 shadow-[0px_0px_1px_3px_rgba(255,255,255,1)_inset] dark:shadow-[0px_0px_1px_3px_rgba(0,0,0,1)_inset]"
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FileUploader() {
|
||||
// Use the useParams hook to get the params
|
||||
const params = useParams();
|
||||
const search_space_id = params.search_space_id as string;
|
||||
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const router = useRouter();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const acceptedFileTypes = {
|
||||
'image/bmp': ['.bmp'],
|
||||
'text/csv': ['.csv'],
|
||||
'application/msword': ['.doc'],
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
|
||||
'message/rfc822': ['.eml'],
|
||||
'application/epub+zip': ['.epub'],
|
||||
'image/heic': ['.heic'],
|
||||
'text/html': ['.html'],
|
||||
'image/jpeg': ['.jpeg', '.jpg'],
|
||||
'image/png': ['.png'],
|
||||
'text/markdown': ['.md'],
|
||||
'application/vnd.ms-outlook': ['.msg'],
|
||||
'application/vnd.oasis.opendocument.text': ['.odt'],
|
||||
'text/x-org': ['.org'],
|
||||
'application/pkcs7-signature': ['.p7s'],
|
||||
'application/pdf': ['.pdf'],
|
||||
'application/vnd.ms-powerpoint': ['.ppt'],
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'],
|
||||
'text/x-rst': ['.rst'],
|
||||
'application/rtf': ['.rtf'],
|
||||
'image/tiff': ['.tiff'],
|
||||
'text/plain': ['.txt'],
|
||||
'text/tab-separated-values': ['.tsv'],
|
||||
'application/vnd.ms-excel': ['.xls'],
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
||||
'application/xml': ['.xml'],
|
||||
}
|
||||
|
||||
const supportedExtensions = Array.from(new Set(Object.values(acceptedFileTypes).flat())).sort()
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
setFiles((prevFiles) => [...prevFiles, ...acceptedFiles])
|
||||
}, [])
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: acceptedFileTypes,
|
||||
maxSize: 50 * 1024 * 1024, // 50MB
|
||||
})
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles((prevFiles) => prevFiles.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return "0 Bytes"
|
||||
const k = 1024
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"]
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
setIsUploading(true)
|
||||
|
||||
const formData = new FormData()
|
||||
files.forEach((file) => {
|
||||
formData.append("files", file)
|
||||
})
|
||||
|
||||
formData.append('search_space_id', search_space_id)
|
||||
|
||||
try {
|
||||
toast("File Upload", {
|
||||
description: "Files Uploading Initiated",
|
||||
})
|
||||
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL!}/api/v1/documents/fileupload`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Authorization': `Bearer ${window.localStorage.getItem("surfsense_bearer_token")}`
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Upload failed")
|
||||
}
|
||||
|
||||
await response.json()
|
||||
|
||||
toast("Upload Successful", {
|
||||
description: "Files Uploaded Successfully",
|
||||
})
|
||||
|
||||
router.push(`/dashboard/${search_space_id}/documents`);
|
||||
} catch (error: any) {
|
||||
setIsUploading(false)
|
||||
toast("Upload Error", {
|
||||
description: `Error uploading files: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const mainVariant = {
|
||||
initial: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
animate: {
|
||||
x: 20,
|
||||
y: -20,
|
||||
opacity: 0.9,
|
||||
},
|
||||
};
|
||||
|
||||
const secondaryVariant = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
when: "beforeChildren",
|
||||
staggerChildren: 0.1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 10 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.3 } }
|
||||
};
|
||||
|
||||
const fileItemVariants = {
|
||||
hidden: { opacity: 0, x: -20 },
|
||||
visible: { opacity: 1, x: 0, transition: { duration: 0.3 } },
|
||||
exit: { opacity: 0, x: 20, transition: { duration: 0.2 } }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grow flex items-center justify-center p-4 md:p-8">
|
||||
<motion.div
|
||||
className="w-full max-w-3xl mx-auto"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
>
|
||||
|
||||
<motion.div
|
||||
className="bg-background rounded-xl shadow-lg overflow-hidden border border-border"
|
||||
variants={itemVariants}
|
||||
>
|
||||
<motion.div
|
||||
className="p-10 group/file block rounded-lg cursor-pointer w-full relative overflow-hidden"
|
||||
whileHover="animate"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Grid background pattern */}
|
||||
<div className="absolute inset-0 [mask-image:radial-gradient(ellipse_at_center,white,transparent)]">
|
||||
<GridPattern />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
{/* Dropzone area */}
|
||||
<div {...getRootProps()} className="flex flex-col items-center justify-center">
|
||||
<input
|
||||
{...getInputProps()}
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<p className="relative z-20 font-sans font-bold text-neutral-700 dark:text-neutral-300 text-xl">
|
||||
Upload files
|
||||
</p>
|
||||
<p className="relative z-20 font-sans font-normal text-neutral-400 dark:text-neutral-400 text-base mt-2">
|
||||
Drag or drop your files here or click to upload
|
||||
</p>
|
||||
|
||||
<div className="relative w-full mt-10 max-w-xl mx-auto">
|
||||
{!files.length && (
|
||||
<motion.div
|
||||
layoutId="file-upload"
|
||||
variants={mainVariant}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 20,
|
||||
}}
|
||||
className="relative group-hover/file:shadow-2xl z-40 bg-white dark:bg-neutral-900 flex items-center justify-center h-32 mt-4 w-full max-w-[8rem] mx-auto rounded-md shadow-[0px_10px_50px_rgba(0,0,0,0.1)]"
|
||||
key="upload-icon"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
{isDragActive ? (
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-neutral-600 flex flex-col items-center"
|
||||
>
|
||||
Drop it
|
||||
<Upload className="h-4 w-4 text-neutral-600 dark:text-neutral-400 mt-2" />
|
||||
</motion.p>
|
||||
) : (
|
||||
<Upload className="h-8 w-8 text-neutral-600 dark:text-neutral-300" />
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{!files.length && (
|
||||
<motion.div
|
||||
variants={secondaryVariant}
|
||||
className="absolute opacity-0 border border-dashed border-primary inset-0 z-30 bg-transparent flex items-center justify-center h-32 mt-4 w-full max-w-[8rem] mx-auto rounded-md"
|
||||
key="upload-border"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
></motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* File list section */}
|
||||
<AnimatePresence mode="wait">
|
||||
{files.length > 0 && (
|
||||
<motion.div
|
||||
className="px-8 pb-8"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="font-medium">Selected Files ({files.length})</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Use AnimatePresence to properly handle the transition
|
||||
// This will ensure the file icon reappears properly
|
||||
setFiles([]);
|
||||
|
||||
// Force a re-render after animation completes
|
||||
setTimeout(() => {
|
||||
const event = new Event('resize');
|
||||
window.dispatchEvent(event);
|
||||
}, 350);
|
||||
}}
|
||||
disabled={isUploading}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
|
||||
<AnimatePresence>
|
||||
{files.map((file, index) => (
|
||||
<motion.div
|
||||
key={`${file.name}-${index}`}
|
||||
layoutId={index === 0 ? "file-upload" : `file-upload-${index}`}
|
||||
className="relative overflow-hidden z-40 bg-white dark:bg-neutral-900 flex flex-col items-start justify-start p-4 w-full mx-auto rounded-md shadow-sm border border-border"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
variants={fileItemVariants}
|
||||
>
|
||||
<div className="flex justify-between w-full items-center gap-4">
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
layout
|
||||
className="text-base text-neutral-700 dark:text-neutral-300 truncate max-w-xs font-medium"
|
||||
>
|
||||
{file.name}
|
||||
</motion.p>
|
||||
<div className="flex items-center gap-2">
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
layout
|
||||
className="rounded-lg px-2 py-1 w-fit flex-shrink-0 text-sm text-neutral-600 dark:bg-neutral-800 dark:text-white bg-neutral-100"
|
||||
>
|
||||
{formatFileSize(file.size)}
|
||||
</motion.p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeFile(index)}
|
||||
disabled={isUploading}
|
||||
className="h-8 w-8"
|
||||
aria-label={`Remove ${file.name}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex text-sm md:flex-row flex-col items-start md:items-center w-full mt-2 justify-between text-neutral-600 dark:text-neutral-400">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
layout
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md bg-gray-100 dark:bg-neutral-800"
|
||||
>
|
||||
<FileType className="h-3 w-3" />
|
||||
<span>{file.type || 'Unknown type'}</span>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
layout
|
||||
className="flex items-center gap-1 mt-2 md:mt-0"
|
||||
>
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>modified {new Date(file.lastModified).toLocaleDateString()}</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="mt-6"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<Button
|
||||
className="w-full py-6 text-base font-medium"
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading || files.length === 0}
|
||||
>
|
||||
{isUploading ? (
|
||||
<motion.div
|
||||
className="flex items-center gap-2"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
>
|
||||
<Upload className="h-5 w-5" />
|
||||
</motion.div>
|
||||
<span>Uploading...</span>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
className="flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
<span>Upload {files.length} {files.length === 1 ? "file" : "files"}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* File type information */}
|
||||
<motion.div
|
||||
className="px-8 pb-8"
|
||||
variants={itemVariants}
|
||||
>
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Tag className="h-4 w-4 text-primary" />
|
||||
<p className="text-sm font-medium">Supported file types:</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{supportedExtensions.map((ext) => (
|
||||
<motion.span
|
||||
key={ext}
|
||||
className="px-2 py-1 bg-primary/10 text-primary text-xs rounded-full"
|
||||
whileHover={{ scale: 1.05, backgroundColor: "rgba(var(--primary), 0.2)" }}
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 1 }}
|
||||
layout
|
||||
>
|
||||
{ext}
|
||||
</motion.span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<style jsx global>{`
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(var(--muted-foreground), 0.3);
|
||||
border-radius: 20px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(var(--muted-foreground), 0.5);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { Tag, TagInput } from "emblor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { toast } from "sonner";
|
||||
import { Globe, Loader2 } from "lucide-react";
|
||||
|
||||
// URL validation regex
|
||||
const urlRegex = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/;
|
||||
|
||||
export default function WebpageCrawler() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const search_space_id = params.search_space_id as string;
|
||||
|
||||
const [urlTags, setUrlTags] = useState<Tag[]>([]);
|
||||
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Function to validate a URL
|
||||
const isValidUrl = (url: string): boolean => {
|
||||
return urlRegex.test(url);
|
||||
};
|
||||
|
||||
// Function to handle URL submission
|
||||
const handleSubmit = async () => {
|
||||
// Validate that we have at least one URL
|
||||
if (urlTags.length === 0) {
|
||||
setError("Please add at least one URL");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate all URLs
|
||||
const invalidUrls = urlTags.filter(tag => !isValidUrl(tag.text));
|
||||
if (invalidUrls.length > 0) {
|
||||
setError(`Invalid URLs detected: ${invalidUrls.map(tag => tag.text).join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
toast("URL Crawling", {
|
||||
description: "Starting URL crawling process...",
|
||||
});
|
||||
|
||||
// Extract URLs from tags
|
||||
const urls = urlTags.map(tag => tag.text);
|
||||
|
||||
// Make API call to backend
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem("surfsense_bearer_token")}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
"document_type": "CRAWLED_URL",
|
||||
"content": urls,
|
||||
"search_space_id": parseInt(search_space_id)
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to crawl URLs");
|
||||
}
|
||||
|
||||
await response.json();
|
||||
|
||||
toast("Crawling Successful", {
|
||||
description: "URLs have been submitted for crawling",
|
||||
});
|
||||
|
||||
// Redirect to documents page
|
||||
router.push(`/dashboard/${search_space_id}/documents`);
|
||||
} catch (error: any) {
|
||||
setError(error.message || "An error occurred while crawling URLs");
|
||||
toast("Crawling Error", {
|
||||
description: `Error crawling URLs: ${error.message}`,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to add a new URL tag
|
||||
const handleAddTag = (text: string) => {
|
||||
// Basic URL validation
|
||||
if (!isValidUrl(text)) {
|
||||
toast("Invalid URL", {
|
||||
description: "Please enter a valid URL",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
if (urlTags.some(tag => tag.text === text)) {
|
||||
toast("Duplicate URL", {
|
||||
description: "This URL has already been added",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the new tag
|
||||
const newTag: Tag = {
|
||||
id: Date.now().toString(),
|
||||
text: text,
|
||||
};
|
||||
|
||||
setUrlTags([...urlTags, newTag]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<Card className="max-w-2xl mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5" />
|
||||
Add Webpages for Crawling
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enter URLs to crawl and add to your document collection
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="url-input">Enter URLs to crawl</Label>
|
||||
<TagInput
|
||||
id="url-input"
|
||||
tags={urlTags}
|
||||
setTags={setUrlTags}
|
||||
placeholder="Enter a URL and press Enter"
|
||||
onAddTag={handleAddTag}
|
||||
styleClasses={{
|
||||
inlineTagsContainer:
|
||||
"border-input rounded-lg bg-background shadow-sm shadow-black/5 transition-shadow focus-within:border-ring focus-within:outline-none focus-within:ring-[3px] focus-within:ring-ring/20 p-1 gap-1",
|
||||
input: "w-full min-w-[80px] focus-visible:outline-none shadow-none px-2 h-7",
|
||||
tag: {
|
||||
body: "h-7 relative bg-background border border-input hover:bg-background rounded-md font-medium text-xs ps-2 pe-7 flex",
|
||||
closeButton:
|
||||
"absolute -inset-y-px -end-px p-0 rounded-e-lg flex size-7 transition-colors outline-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 text-muted-foreground/80 hover:text-foreground",
|
||||
},
|
||||
}}
|
||||
activeTagIndex={activeTagIndex}
|
||||
setActiveTagIndex={setActiveTagIndex}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Add multiple URLs by pressing Enter after each one
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-500 mt-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-4 text-sm">
|
||||
<h4 className="font-medium mb-2">Tips for URL crawling:</h4>
|
||||
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
|
||||
<li>Enter complete URLs including http:// or https://</li>
|
||||
<li>Make sure the websites allow crawling</li>
|
||||
<li>Public webpages work best</li>
|
||||
<li>Crawling may take some time depending on the website size</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/dashboard/${search_space_id}/documents`)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || urlTags.length === 0}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
'Submit URLs for Crawling'
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
surfsense_web/app/dashboard/[search_space_id]/layout.tsx
Normal file
99
surfsense_web/app/dashboard/[search_space_id]/layout.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// Server component
|
||||
import React, { use } from 'react'
|
||||
import { DashboardClientLayout } from './client-layout'
|
||||
|
||||
export default function DashboardLayout({
|
||||
params,
|
||||
children
|
||||
}: {
|
||||
params: Promise<{ search_space_id: string }>,
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
// Use React.use to unwrap the params Promise
|
||||
const { search_space_id } = use(params);
|
||||
|
||||
// TODO: Get search space name from our FastAPI backend
|
||||
const customNavSecondary = [
|
||||
{
|
||||
title: `All Search Spaces`,
|
||||
url: `#`,
|
||||
icon: "Info",
|
||||
},
|
||||
{
|
||||
title: `All Search Spaces`,
|
||||
url: "/dashboard",
|
||||
icon: "Undo2",
|
||||
},
|
||||
]
|
||||
|
||||
const customNavMain = [
|
||||
{
|
||||
title: "Researcher",
|
||||
url: `/dashboard/${search_space_id}/researcher`,
|
||||
icon: "SquareTerminal",
|
||||
isActive: true,
|
||||
items: [],
|
||||
},
|
||||
|
||||
{
|
||||
title: "Documents",
|
||||
url: "#",
|
||||
icon: "FileStack",
|
||||
items: [
|
||||
{
|
||||
title: "Upload Documents",
|
||||
url: `/dashboard/${search_space_id}/documents/upload`,
|
||||
},
|
||||
{
|
||||
title: "Add Webpages",
|
||||
url: `/dashboard/${search_space_id}/documents/webpage`,
|
||||
},
|
||||
{
|
||||
title: "Manage Documents",
|
||||
url: `/dashboard/${search_space_id}/documents`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Connectors",
|
||||
url: `#`,
|
||||
icon: "Cable",
|
||||
items: [
|
||||
{
|
||||
title: "Add Connector",
|
||||
url: `/dashboard/${search_space_id}/connectors/add`,
|
||||
},
|
||||
{
|
||||
title: "Manage Connectors",
|
||||
url: `/dashboard/${search_space_id}/connectors`,
|
||||
},
|
||||
],
|
||||
},
|
||||
// TODO: Add research synthesizer's
|
||||
// {
|
||||
// title: "Research Synthesizer's",
|
||||
// url: `#`,
|
||||
// icon: "SquareLibrary",
|
||||
// items: [
|
||||
// {
|
||||
// title: "Podcast Creator",
|
||||
// url: `/dashboard/${search_space_id}/synthesizer/podcast`,
|
||||
// },
|
||||
// {
|
||||
// title: "Presentation Creator",
|
||||
// url: `/dashboard/${search_space_id}/synthesizer/presentation`,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
]
|
||||
|
||||
return (
|
||||
<DashboardClientLayout
|
||||
searchSpaceId={search_space_id}
|
||||
navSecondary={customNavSecondary}
|
||||
navMain={customNavMain}
|
||||
>
|
||||
{children}
|
||||
</DashboardClientLayout>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,80 @@
|
|||
"use client";
|
||||
import React, { useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
const ResearcherPage = () => {
|
||||
const router = useRouter();
|
||||
const { search_space_id } = useParams();
|
||||
const [isCreating, setIsCreating] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const createChat = async () => {
|
||||
try {
|
||||
// Get token from localStorage
|
||||
const token = localStorage.getItem('surfsense_bearer_token');
|
||||
|
||||
if (!token) {
|
||||
setError('Authentication token not found');
|
||||
setIsCreating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new chat
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: "GENERAL",
|
||||
title: "Untitled Chat", // Empty title initially
|
||||
initial_connectors: ["CRAWLED_URL"], // Default connector
|
||||
messages: [],
|
||||
search_space_id: Number(search_space_id)
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create chat: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Redirect to the new chat page
|
||||
router.push(`/dashboard/${search_space_id}/researcher/${data.id}`);
|
||||
} catch (err) {
|
||||
console.error('Error creating chat:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to create chat');
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
createChat();
|
||||
}, [search_space_id, router]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-4rem)]">
|
||||
<div className="text-red-500 mb-4">Error: {error}</div>
|
||||
<button
|
||||
onClick={() => location.reload()}
|
||||
className="px-4 py-2 bg-primary text-white rounded-md"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-4rem)]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary mb-4" />
|
||||
<p className="text-muted-foreground">Creating new research chat...</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearcherPage;
|
||||
353
surfsense_web/app/dashboard/page.tsx
Normal file
353
surfsense_web/app/dashboard/page.tsx
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
"use client";
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Plus, Search, Trash2, AlertCircle, Loader2 } from 'lucide-react'
|
||||
import { Tilt } from '@/components/ui/tilt'
|
||||
import { Spotlight } from '@/components/ui/spotlight'
|
||||
import { Logo } from '@/components/Logo';
|
||||
import { ThemeTogglerComponent } from '@/components/theme/theme-toggle';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useSearchSpaces } from '@/hooks/use-search-spaces';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* Formats a date string into a readable format
|
||||
* @param dateString - The date string to format
|
||||
* @returns Formatted date string (e.g., "Jan 1, 2023")
|
||||
*/
|
||||
const formatDate = (dateString: string): string => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Loading screen component with animation
|
||||
*/
|
||||
const LoadingScreen = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xl font-medium">Loading</CardTitle>
|
||||
<CardDescription>Fetching your search spaces...</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center py-6">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, ease: "linear" }}
|
||||
>
|
||||
<Loader2 className="h-12 w-12 text-primary" />
|
||||
</motion.div>
|
||||
</CardContent>
|
||||
<CardFooter className="border-t pt-4 text-sm text-muted-foreground">
|
||||
This may take a moment
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Error screen component with animation
|
||||
*/
|
||||
const ErrorScreen = ({ message }: { message: string }) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="w-[400px] bg-background/60 backdrop-blur-sm border-destructive/20">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
<CardTitle className="text-xl font-medium">Error</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Something went wrong</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert variant="destructive" className="bg-destructive/10 border-destructive/30">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error Details</AlertTitle>
|
||||
<AlertDescription className="mt-2">
|
||||
{message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end gap-2 border-t pt-4">
|
||||
<Button variant="outline" onClick={() => router.refresh()}>
|
||||
Try Again
|
||||
</Button>
|
||||
<Button onClick={() => router.push('/')}>
|
||||
Go Home
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DashboardPage = () => {
|
||||
// Animation variants
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { y: 20, opacity: 0 },
|
||||
visible: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 24,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { searchSpaces, loading, error, refreshSearchSpaces } = useSearchSpaces();
|
||||
|
||||
if (loading) return <LoadingScreen />;
|
||||
if (error) return <ErrorScreen message={error} />;
|
||||
|
||||
const handleDeleteSearchSpace = async (id: number) => {
|
||||
// Send DELETE request to the API
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('surfsense_bearer_token')}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error("Failed to delete search space");
|
||||
throw new Error("Failed to delete search space");
|
||||
}
|
||||
|
||||
// Refresh the search spaces list after successful deletion
|
||||
refreshSearchSpaces();
|
||||
} catch (error) {
|
||||
console.error('Error deleting search space:', error);
|
||||
toast.error("An error occurred while deleting the search space");
|
||||
return;
|
||||
}
|
||||
toast.success("Search space deleted successfully");
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="container mx-auto py-10"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
>
|
||||
<motion.div className="flex flex-col space-y-6" variants={itemVariants}>
|
||||
<div className="flex flex-row space-x-4 justify-between">
|
||||
<div className="flex flex-row space-x-4">
|
||||
<Logo className="w-10 h-10 rounded-md" />
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h1 className="text-4xl font-bold">SurfSense Dashboard</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Welcome to your SurfSense dashboard.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ThemeTogglerComponent />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-6 mt-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-semibold">Your Search Spaces</h2>
|
||||
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
||||
<Link href="/dashboard/searchspaces">
|
||||
<Button className="h-10">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Search Space
|
||||
</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{searchSpaces && searchSpaces.map((space) => (
|
||||
<motion.div
|
||||
key={space.id}
|
||||
variants={itemVariants}
|
||||
className="aspect-[4/3]"
|
||||
>
|
||||
|
||||
<Tilt
|
||||
rotationFactor={6}
|
||||
isRevese
|
||||
springOptions={{
|
||||
stiffness: 26.7,
|
||||
damping: 4.1,
|
||||
mass: 0.2,
|
||||
}}
|
||||
className="group relative rounded-lg h-full"
|
||||
>
|
||||
<Spotlight
|
||||
className="z-10 from-blue-500/20 via-blue-300/10 to-blue-200/5 blur-2xl"
|
||||
size={248}
|
||||
springOptions={{
|
||||
stiffness: 26.7,
|
||||
damping: 4.1,
|
||||
mass: 0.2,
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col h-full overflow-hidden rounded-xl border bg-muted/30 backdrop-blur-sm transition-all hover:border-primary/50">
|
||||
<div className="relative h-32 w-full overflow-hidden">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1519389950473-47ba0277781c?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1740&q=80"
|
||||
alt={space.name}
|
||||
className="h-full w-full object-cover grayscale duration-700 group-hover:grayscale-0"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background/80 to-transparent" />
|
||||
<div className="absolute bottom-2 left-3 flex items-center gap-2">
|
||||
<Link href={`/dashboard/${space.id}/documents`}>
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100/80 dark:bg-blue-950/80">
|
||||
<Search className="h-4 w-4 text-blue-500" />
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="absolute top-2 right-2">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full bg-background/50 backdrop-blur-sm hover:bg-destructive/90 hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Search Space</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{space.name}"? This action cannot be undone.
|
||||
All documents, chats, and podcasts in this search space will be permanently deleted.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDeleteSearchSpace(space.id)}
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col justify-between p-4">
|
||||
<div>
|
||||
<h3 className="font-medium text-lg">{space.name}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{space.description}</p>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-between text-xs text-muted-foreground">
|
||||
{/* <span>{space.title}</span> */}
|
||||
<span>Created {formatDate(space.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tilt>
|
||||
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{searchSpaces.length === 0 && (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="col-span-full flex flex-col items-center justify-center p-12 text-center"
|
||||
>
|
||||
<div className="rounded-full bg-muted/50 p-4 mb-4">
|
||||
<Search className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-2">No search spaces found</h3>
|
||||
<p className="text-muted-foreground mb-6">Create your first search space to get started</p>
|
||||
<Link href="/dashboard/searchspaces">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Search Space
|
||||
</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{searchSpaces.length > 0 && (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="aspect-[4/3]"
|
||||
>
|
||||
<Tilt
|
||||
rotationFactor={6}
|
||||
isRevese
|
||||
springOptions={{
|
||||
stiffness: 26.7,
|
||||
damping: 4.1,
|
||||
mass: 0.2,
|
||||
}}
|
||||
className="group relative rounded-lg h-full"
|
||||
>
|
||||
<Link href="/dashboard/searchspaces" className="flex h-full">
|
||||
<div className="flex flex-col items-center justify-center h-full w-full rounded-xl border border-dashed bg-muted/10 hover:border-primary/50 transition-colors">
|
||||
<Plus className="h-10 w-10 mb-3 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Add New Search Space</span>
|
||||
</div>
|
||||
</Link>
|
||||
</Tilt>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DashboardPage
|
||||
52
surfsense_web/app/dashboard/searchspaces/page.tsx
Normal file
52
surfsense_web/app/dashboard/searchspaces/page.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { SearchSpaceForm } from "@/components/search-space-form";
|
||||
import { motion } from "framer-motion";
|
||||
import { useRouter } from "next/navigation";
|
||||
export default function SearchSpacesPage() {
|
||||
const router = useRouter();
|
||||
const handleCreateSearchSpace = async (data: { name: string; description: string }) => {
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('surfsense_bearer_token')}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error("Failed to create search space");
|
||||
throw new Error("Failed to create search space");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
toast.success("Search space created successfully", {
|
||||
description: `"${data.name}" has been created.`,
|
||||
});
|
||||
|
||||
router.push(`/dashboard`);
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
console.error('Error creating search space:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="container mx-auto py-10"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<SearchSpaceForm onSubmit={handleCreateSearchSpace} />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
BIN
surfsense_web/app/favicon.ico
Normal file
BIN
surfsense_web/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
150
surfsense_web/app/globals.css
Normal file
150
surfsense_web/app/globals.css
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.145 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.145 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.985 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.396 0.141 25.723);
|
||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--border: oklch(0.269 0 0);
|
||||
--input: oklch(0.269 0 0);
|
||||
--ring: oklch(0.439 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.269 0 0);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
:root {
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
}
|
||||
73
surfsense_web/app/layout.tsx
Normal file
73
surfsense_web/app/layout.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Roboto } from "next/font/google";
|
||||
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { ThemeProvider } from "@/components/theme/theme-provider";
|
||||
|
||||
const roboto = Roboto({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "700"],
|
||||
display: 'swap',
|
||||
variable: '--font-roboto',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "SurfSense - A Personal NotebookLM and Perplexity-like AI Assistant for Everyone.",
|
||||
description:
|
||||
"Have your own private NotebookLM and Perplexity with better integrations.",
|
||||
openGraph: {
|
||||
images: [
|
||||
{
|
||||
url: "https://surfsense.net/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "SurfSense - A Personal NotebookLM and Perplexity-like AI Assistant for Everyone.",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
site: "https://surfsense.net",
|
||||
creator: "https://surfsense.net",
|
||||
title: "SurfSense - A Personal NotebookLM and Perplexity-like AI Assistant for Everyone.",
|
||||
description:
|
||||
"Have your own private NotebookLM and Perplexity with better integrations.",
|
||||
images: [
|
||||
{
|
||||
url: "https://surfsense.net/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "SurfSense - A Personal NotebookLM and Perplexity-like AI Assistant for Everyone.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={cn(
|
||||
roboto.className,
|
||||
"bg-white dark:bg-black antialiased h-full w-full"
|
||||
)}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
defaultTheme="light"
|
||||
>
|
||||
{children}
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
98
surfsense_web/app/login/GoogleLoginButton.tsx
Normal file
98
surfsense_web/app/login/GoogleLoginButton.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"use client";
|
||||
import React from "react";
|
||||
import { IconBrandGoogleFilled } from "@tabler/icons-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Logo } from "@/components/Logo";
|
||||
|
||||
export function GoogleLoginButton() {
|
||||
const handleGoogleLogin = () => {
|
||||
// Redirect to Google OAuth authorization URL
|
||||
fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/google/authorize`)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get authorization URL');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.authorization_url) {
|
||||
window.location.href = data.authorization_url;
|
||||
} else {
|
||||
console.error('No authorization URL received');
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error during Google login:', error);
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden">
|
||||
<AmbientBackground />
|
||||
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
||||
<Logo className="rounded-md" />
|
||||
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
|
||||
Welcome Back
|
||||
</h1>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="group/btn relative flex w-full items-center justify-center space-x-2 rounded-lg bg-white px-6 py-4 text-neutral-700 shadow-lg transition-all duration-200 hover:shadow-xl dark:bg-neutral-800 dark:text-neutral-200"
|
||||
onClick={handleGoogleLogin}
|
||||
>
|
||||
<div className="absolute inset-0 h-full w-full transform opacity-0 transition duration-200 group-hover/btn:opacity-100">
|
||||
<div className="absolute -left-px -top-px h-4 w-4 rounded-tl-lg border-l-2 border-t-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-left-2 group-hover/btn:-top-2"></div>
|
||||
<div className="absolute -right-px -top-px h-4 w-4 rounded-tr-lg border-r-2 border-t-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-right-2 group-hover/btn:-top-2"></div>
|
||||
<div className="absolute -bottom-px -left-px h-4 w-4 rounded-bl-lg border-b-2 border-l-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-bottom-2 group-hover/btn:-left-2"></div>
|
||||
<div className="absolute -bottom-px -right-px h-4 w-4 rounded-br-lg border-b-2 border-r-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-bottom-2 group-hover/btn:-right-2"></div>
|
||||
</div>
|
||||
<IconBrandGoogleFilled className="h-5 w-5 text-neutral-700 dark:text-neutral-200" />
|
||||
<span className="text-base font-medium">Continue with Google</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
const AmbientBackground = () => {
|
||||
return (
|
||||
<div className="pointer-events-none absolute left-0 top-0 z-0 h-screen w-screen">
|
||||
<div
|
||||
style={{
|
||||
transform: "translateY(-350px) rotate(-45deg)",
|
||||
width: "560px",
|
||||
height: "1380px",
|
||||
background:
|
||||
"radial-gradient(68.54% 68.72% at 55.02% 31.46%, rgba(59, 130, 246, 0.08) 0%, rgba(59, 130, 246, 0.02) 50%, rgba(59, 130, 246, 0) 100%)",
|
||||
}}
|
||||
className="absolute left-0 top-0"
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
transform: "rotate(-45deg) translate(5%, -50%)",
|
||||
transformOrigin: "top left",
|
||||
width: "240px",
|
||||
height: "1380px",
|
||||
background:
|
||||
"radial-gradient(50% 50% at 50% 50%, rgba(59, 130, 246, 0.06) 0%, rgba(59, 130, 246, 0.02) 80%, transparent 100%)",
|
||||
}}
|
||||
className="absolute left-0 top-0"
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
borderRadius: "20px",
|
||||
transform: "rotate(-45deg) translate(-180%, -70%)",
|
||||
transformOrigin: "top left",
|
||||
width: "240px",
|
||||
height: "1380px",
|
||||
background:
|
||||
"radial-gradient(50% 50% at 50% 50%, rgba(59, 130, 246, 0.04) 0%, rgba(59, 130, 246, 0.02) 80%, transparent 100%)",
|
||||
}}
|
||||
className="absolute left-0 top-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
5
surfsense_web/app/login/page.tsx
Normal file
5
surfsense_web/app/login/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { GoogleLoginButton } from "./GoogleLoginButton";
|
||||
|
||||
export default function LoginPage() {
|
||||
return <GoogleLoginButton />;
|
||||
}
|
||||
16
surfsense_web/app/page.tsx
Normal file
16
surfsense_web/app/page.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"use client";
|
||||
import React from "react";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { motion } from "framer-motion";
|
||||
import { ModernHeroWithGradients } from "@/components/ModernHeroWithGradients";
|
||||
import { Footer } from "@/components/Footer";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white">
|
||||
<Navbar />
|
||||
<ModernHeroWithGradients />
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue