mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-30 21:59:46 +02:00
feat: monorepo
This commit is contained in:
parent
fe39077849
commit
a1474ca49e
144 changed files with 43821 additions and 1 deletions
1
surfsense_web/.cursorrules
Normal file
1
surfsense_web/.cursorrules
Normal file
|
|
@ -0,0 +1 @@
|
|||
use pnpm as default package manager
|
||||
15
surfsense_web/.dockerignore
Normal file
15
surfsense_web/.dockerignore
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
.git
|
||||
.gitignore
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
41
surfsense_web/.gitignore
vendored
Normal file
41
surfsense_web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
31
surfsense_web/.vscode/launch.json
vendored
Normal file
31
surfsense_web/.vscode/launch.json
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Next.js: debug client-side",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:3000",
|
||||
"webRoot": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug server-side",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm run debug:server",
|
||||
"skipFiles": ["<node_internals>/**"]
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug full stack",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm run debug",
|
||||
"serverReadyAction": {
|
||||
"pattern": "- Local:.+(https?://.+)",
|
||||
"uriFormat": "%s",
|
||||
"action": "debugWithChrome"
|
||||
},
|
||||
"skipFiles": ["<node_internals>/**"]
|
||||
}
|
||||
]
|
||||
}
|
||||
25
surfsense_web/Dockerfile
Normal file
25
surfsense_web/Dockerfile
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build app for production
|
||||
# For development, we'll mount the source code as a volume
|
||||
# so the build step will be skipped in development mode
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Start Next.js in development mode by default
|
||||
# This will be faster for development since we're mounting the code as a volume
|
||||
CMD ["pnpm", "dev"]
|
||||
21
surfsense_web/LICENSE
Normal file
21
surfsense_web/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 Rohan Verma
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
96
surfsense_web/README.md
Normal file
96
surfsense_web/README.md
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# Next.js Token Handler Component
|
||||
|
||||
This project includes a reusable client component for Next.js that handles token storage from URL parameters.
|
||||
|
||||
## TokenHandler Component
|
||||
|
||||
The `TokenHandler` component is designed to:
|
||||
|
||||
1. Extract a token from URL parameters
|
||||
2. Store the token in localStorage
|
||||
3. Redirect the user to a specified path
|
||||
|
||||
### Usage
|
||||
|
||||
```tsx
|
||||
import TokenHandler from '@/components/TokenHandler';
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Authentication Callback</h1>
|
||||
<TokenHandler
|
||||
redirectPath="/dashboard"
|
||||
tokenParamName="token"
|
||||
storageKey="auth_token"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
The component accepts the following props:
|
||||
|
||||
- `redirectPath` (optional): Path to redirect after storing token (default: '/')
|
||||
- `tokenParamName` (optional): Name of the URL parameter containing the token (default: 'token')
|
||||
- `storageKey` (optional): Key to use when storing in localStorage (default: 'auth_token')
|
||||
|
||||
### Example URL
|
||||
|
||||
After authentication, redirect users to:
|
||||
```
|
||||
https://your-domain.com/auth/callback?token=your-auth-token
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
- Uses Next.js's `useSearchParams` hook to access URL parameters
|
||||
- Uses `useRouter` for client-side navigation after token storage
|
||||
- Includes error handling for localStorage operations
|
||||
- Displays a loading message while processing
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- This implementation assumes the token is passed securely
|
||||
- Consider using HTTPS to prevent token interception
|
||||
- For enhanced security, consider using HTTP-only cookies instead of localStorage
|
||||
- The token in the URL might be visible in browser history and server logs
|
||||
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
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>
|
||||
);
|
||||
}
|
||||
21
surfsense_web/components.json
Normal file
21
surfsense_web/components.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
102
surfsense_web/components/Footer.tsx
Normal file
102
surfsense_web/components/Footer.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"use client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
IconBrandGithub,
|
||||
IconBrandLinkedin,
|
||||
IconBrandTwitter,
|
||||
} from "@tabler/icons-react";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
export function Footer() {
|
||||
const pages = [
|
||||
{
|
||||
title: "Privacy",
|
||||
href: "#",
|
||||
},
|
||||
{
|
||||
title: "Terms",
|
||||
href: "#",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="border-t border-neutral-100 dark:border-white/[0.1] px-8 py-20 bg-white dark:bg-neutral-950 w-full relative overflow-hidden">
|
||||
<div className="max-w-7xl mx-auto text-sm text-neutral-500 justify-between items-start md:px-8">
|
||||
<div className="flex flex-col items-center justify-center w-full relative">
|
||||
<div className="mr-0 md:mr-4 md:flex mb-4">
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium text-black dark:text-white ml-2">SurfSense</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="transition-colors flex sm:flex-row flex-col hover:text-text-neutral-800 text-neutral-600 dark:text-neutral-300 list-none gap-4">
|
||||
{pages.map((page, idx) => (
|
||||
<li key={"pages" + idx} className="list-none">
|
||||
<Link
|
||||
className="transition-colors hover:text-text-neutral-800"
|
||||
href={page.href}
|
||||
>
|
||||
{page.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<GridLineHorizontal className="max-w-7xl mx-auto mt-8" />
|
||||
</div>
|
||||
<div className="flex sm:flex-row flex-col justify-between mt-8 items-center w-full">
|
||||
<p className="text-neutral-500 dark:text-neutral-400 mb-8 sm:mb-0">
|
||||
© SurfSense 2025
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<Link href="https://x.com/mod_setter">
|
||||
<IconBrandTwitter className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
|
||||
</Link>
|
||||
<Link href="https://www.linkedin.com/in/rohan-verma-sde/">
|
||||
<IconBrandLinkedin className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
|
||||
</Link>
|
||||
<Link href="https://github.com/MODSetter">
|
||||
<IconBrandGithub className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const GridLineHorizontal = ({
|
||||
className,
|
||||
offset,
|
||||
}: {
|
||||
className?: string;
|
||||
offset?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--background": "#ffffff",
|
||||
"--color": "rgba(0, 0, 0, 0.2)",
|
||||
"--height": "1px",
|
||||
"--width": "5px",
|
||||
"--fade-stop": "90%",
|
||||
"--offset": offset || "200px", //-100px if you want to keep the line inside
|
||||
"--color-dark": "rgba(255, 255, 255, 0.2)",
|
||||
maskComposite: "exclude",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"w-[calc(100%+var(--offset))] h-[var(--height)]",
|
||||
"bg-[linear-gradient(to_right,var(--color),var(--color)_50%,transparent_0,transparent)]",
|
||||
"[background-size:var(--width)_var(--height)]",
|
||||
"[mask:linear-gradient(to_left,var(--background)_var(--fade-stop),transparent),_linear-gradient(to_right,var(--background)_var(--fade-stop),transparent),_linear-gradient(black,black)]",
|
||||
"[mask-composite:exclude]",
|
||||
"z-30",
|
||||
"dark:bg-[linear-gradient(to_right,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
|
||||
className
|
||||
)}
|
||||
></div>
|
||||
);
|
||||
};
|
||||
22
surfsense_web/components/Logo.tsx
Normal file
22
surfsense_web/components/Logo.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"use client";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Logo = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<Link
|
||||
href="/"
|
||||
>
|
||||
<Image
|
||||
src="/icon-128.png"
|
||||
className={cn(className)}
|
||||
alt="logo"
|
||||
width={128}
|
||||
height={128}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
526
surfsense_web/components/ModernHeroWithGradients.tsx
Normal file
526
surfsense_web/components/ModernHeroWithGradients.tsx
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
"use client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IconArrowRight, IconBrandGithub } from "@tabler/icons-react";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Logo } from "./Logo";
|
||||
|
||||
export function ModernHeroWithGradients() {
|
||||
return (
|
||||
<div className="relative h-full min-h-[50rem] w-full bg-gray-50 dark:bg-black">
|
||||
<div className="relative z-20 mx-auto w-full px-4 py-6 md:px-8 lg:px-4">
|
||||
<div className="relative my-12 overflow-hidden rounded-3xl bg-white py-16 shadow-sm dark:bg-gray-900/80 dark:shadow-lg dark:shadow-purple-900/10 md:py-48 mx-auto w-full max-w-[95%] xl:max-w-[98%]">
|
||||
<TopLines />
|
||||
<BottomLines />
|
||||
<SideLines />
|
||||
<TopGradient />
|
||||
<BottomGradient />
|
||||
<DarkModeGradient />
|
||||
|
||||
<div className="relative z-20 flex flex-col items-center justify-center overflow-hidden rounded-3xl p-4 md:p-12 lg:p-16">
|
||||
<Link
|
||||
href="https://github.com/MODSetter/SurfSense"
|
||||
className="flex items-center gap-1 rounded-full border border-gray-200 bg-gradient-to-b from-gray-50 to-gray-100 px-4 py-1 text-center text-sm text-gray-800 shadow-sm dark:border-[#404040] dark:bg-gradient-to-b dark:from-[#5B5B5D] dark:to-[#262627] dark:text-white dark:shadow-inner dark:shadow-purple-500/10"
|
||||
>
|
||||
<span>SurfSense v0.0.6 Released</span>
|
||||
<IconArrowRight className="h-4 w-4 text-gray-800 dark:text-white" />
|
||||
</Link>
|
||||
{/* Import the Logo component or define it in this file */}
|
||||
<div className="flex items-center justify-center gap-4 mt-10 mb-2">
|
||||
<div className="h-16 w-16">
|
||||
<Logo className="rounded-md" />
|
||||
</div>
|
||||
<h1 className="bg-gradient-to-b from-gray-800 to-gray-600 bg-clip-text py-4 text-center text-3xl text-transparent dark:from-white dark:to-purple-300 md:text-5xl lg:text-8xl">
|
||||
SurfSense
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mx-auto max-w-3xl py-6 text-center text-base text-gray-600 dark:text-neutral-300 md:text-lg lg:text-xl">
|
||||
A Customizable AI Research Agent just like NotebookLM or Perplexity, but connected to external sources such as search engines (Tavily), Slack, Notion, and more.
|
||||
</p>
|
||||
<div className="flex flex-col items-center gap-6 py-6 sm:flex-row">
|
||||
<Link
|
||||
href="/login"
|
||||
className="w-48 gap-1 rounded-full border border-gray-200 bg-gradient-to-b from-gray-50 to-gray-100 px-5 py-3 text-center text-sm font-medium text-gray-800 shadow-sm dark:border-[#404040] dark:bg-gradient-to-b dark:from-[#5B5B5D] dark:to-[#262627] dark:text-white dark:shadow-inner dark:shadow-purple-500/10"
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
<Link
|
||||
href="https://github.com/MODSetter/SurfSense"
|
||||
className="w-48 gap-1 rounded-full border border-transparent bg-gray-800 px-5 py-3 text-center text-sm font-medium text-white shadow-sm hover:bg-gray-700 dark:bg-gradient-to-r dark:from-purple-700 dark:to-indigo-800 dark:text-white dark:hover:from-purple-600 dark:hover:to-indigo-700 flex items-center justify-center"
|
||||
>
|
||||
<IconBrandGithub className="h-5 w-5 mr-2" />
|
||||
<span>GitHub</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TopLines = () => {
|
||||
return (
|
||||
<svg
|
||||
width="166"
|
||||
height="298"
|
||||
viewBox="0 0 166 298"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="aspect-square pointer-events-none absolute inset-x-0 top-0 h-[100px] w-full md:h-[200px]"
|
||||
>
|
||||
<line
|
||||
y1="-0.5"
|
||||
x2="406"
|
||||
y2="-0.5"
|
||||
transform="matrix(0 1 1 0 1 -108)"
|
||||
stroke="url(#paint0_linear_254_143)"
|
||||
/>
|
||||
<line
|
||||
y1="-0.5"
|
||||
x2="406"
|
||||
y2="-0.5"
|
||||
transform="matrix(0 1 1 0 34 -108)"
|
||||
stroke="url(#paint1_linear_254_143)"
|
||||
/>
|
||||
<line
|
||||
y1="-0.5"
|
||||
x2="406"
|
||||
y2="-0.5"
|
||||
transform="matrix(0 1 1 0 67 -108)"
|
||||
stroke="url(#paint2_linear_254_143)"
|
||||
/>
|
||||
<line
|
||||
y1="-0.5"
|
||||
x2="406"
|
||||
y2="-0.5"
|
||||
transform="matrix(0 1 1 0 100 -108)"
|
||||
stroke="url(#paint3_linear_254_143)"
|
||||
/>
|
||||
<line
|
||||
y1="-0.5"
|
||||
x2="406"
|
||||
y2="-0.5"
|
||||
transform="matrix(0 1 1 0 133 -108)"
|
||||
stroke="url(#paint4_linear_254_143)"
|
||||
/>
|
||||
<line
|
||||
y1="-0.5"
|
||||
x2="406"
|
||||
y2="-0.5"
|
||||
transform="matrix(0 1 1 0 166 -108)"
|
||||
stroke="url(#paint5_linear_254_143)"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_254_143"
|
||||
x1="-7.42412e-06"
|
||||
y1="0.500009"
|
||||
x2="405"
|
||||
y2="0.500009"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_254_143"
|
||||
x1="-7.42412e-06"
|
||||
y1="0.500009"
|
||||
x2="405"
|
||||
y2="0.500009"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_254_143"
|
||||
x1="-7.42412e-06"
|
||||
y1="0.500009"
|
||||
x2="405"
|
||||
y2="0.500009"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint3_linear_254_143"
|
||||
x1="-7.42412e-06"
|
||||
y1="0.500009"
|
||||
x2="405"
|
||||
y2="0.500009"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint4_linear_254_143"
|
||||
x1="-7.42412e-06"
|
||||
y1="0.500009"
|
||||
x2="405"
|
||||
y2="0.500009"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint5_linear_254_143"
|
||||
x1="-7.42412e-06"
|
||||
y1="0.500009"
|
||||
x2="405"
|
||||
y2="0.500009"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const BottomLines = () => {
|
||||
return (
|
||||
<svg
|
||||
width="445"
|
||||
height="418"
|
||||
viewBox="0 0 445 418"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="aspect-square pointer-events-none absolute inset-x-0 -bottom-20 z-20 h-[150px] w-full md:h-[300px]"
|
||||
>
|
||||
<line
|
||||
x1="139.5"
|
||||
y1="418"
|
||||
x2="139.5"
|
||||
y2="12"
|
||||
stroke="url(#paint0_linear_0_1)"
|
||||
/>
|
||||
<line
|
||||
x1="172.5"
|
||||
y1="418"
|
||||
x2="172.5"
|
||||
y2="12"
|
||||
stroke="url(#paint1_linear_0_1)"
|
||||
/>
|
||||
<line
|
||||
x1="205.5"
|
||||
y1="418"
|
||||
x2="205.5"
|
||||
y2="12"
|
||||
stroke="url(#paint2_linear_0_1)"
|
||||
/>
|
||||
<line
|
||||
x1="238.5"
|
||||
y1="418"
|
||||
x2="238.5"
|
||||
y2="12"
|
||||
stroke="url(#paint3_linear_0_1)"
|
||||
/>
|
||||
<line
|
||||
x1="271.5"
|
||||
y1="418"
|
||||
x2="271.5"
|
||||
y2="12"
|
||||
stroke="url(#paint4_linear_0_1)"
|
||||
/>
|
||||
<line
|
||||
x1="304.5"
|
||||
y1="418"
|
||||
x2="304.5"
|
||||
y2="12"
|
||||
stroke="url(#paint5_linear_0_1)"
|
||||
/>
|
||||
<path
|
||||
d="M1 149L109.028 235.894C112.804 238.931 115 243.515 115 248.361V417"
|
||||
stroke="url(#paint6_linear_0_1)"
|
||||
strokeOpacity="0.1"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M444 149L335.972 235.894C332.196 238.931 330 243.515 330 248.361V417"
|
||||
stroke="url(#paint7_linear_0_1)"
|
||||
strokeOpacity="0.1"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_0_1"
|
||||
x1="140.5"
|
||||
y1="418"
|
||||
x2="140.5"
|
||||
y2="13"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_0_1"
|
||||
x1="173.5"
|
||||
y1="418"
|
||||
x2="173.5"
|
||||
y2="13"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_0_1"
|
||||
x1="206.5"
|
||||
y1="418"
|
||||
x2="206.5"
|
||||
y2="13"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint3_linear_0_1"
|
||||
x1="239.5"
|
||||
y1="418"
|
||||
x2="239.5"
|
||||
y2="13"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint4_linear_0_1"
|
||||
x1="272.5"
|
||||
y1="418"
|
||||
x2="272.5"
|
||||
y2="13"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint5_linear_0_1"
|
||||
x1="305.5"
|
||||
y1="418"
|
||||
x2="305.5"
|
||||
y2="13"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint6_linear_0_1"
|
||||
x1="115"
|
||||
y1="390.591"
|
||||
x2="-59.1703"
|
||||
y2="205.673"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.481613" stopColor="#E8E8E8" className="dark:stop-color-[#F8F8F8]" />
|
||||
<stop offset="1" stopColor="#E8E8E8" stopOpacity="0" className="dark:stop-color-[#F8F8F8]" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint7_linear_0_1"
|
||||
x1="330"
|
||||
y1="390.591"
|
||||
x2="504.17"
|
||||
y2="205.673"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.481613" stopColor="#E8E8E8" className="dark:stop-color-[#F8F8F8]" />
|
||||
<stop offset="1" stopColor="#E8E8E8" stopOpacity="0" className="dark:stop-color-[#F8F8F8]" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const SideLines = () => {
|
||||
return (
|
||||
<svg
|
||||
width="1382"
|
||||
height="370"
|
||||
viewBox="0 0 1382 370"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="pointer-events-none absolute inset-0 z-30 h-full w-full"
|
||||
>
|
||||
<path
|
||||
d="M268 115L181.106 6.97176C178.069 3.19599 173.485 1 168.639 1H0"
|
||||
stroke="url(#paint0_linear_337_46)"
|
||||
strokeOpacity="0.1"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M1114 115L1200.89 6.97176C1203.93 3.19599 1208.52 1 1213.36 1H1382"
|
||||
stroke="url(#paint1_linear_337_46)"
|
||||
strokeOpacity="0.1"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M268 255L181.106 363.028C178.069 366.804 173.485 369 168.639 369H0"
|
||||
stroke="url(#paint2_linear_337_46)"
|
||||
strokeOpacity="0.1"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M1114 255L1200.89 363.028C1203.93 366.804 1208.52 369 1213.36 369H1382"
|
||||
stroke="url(#paint3_linear_337_46)"
|
||||
strokeOpacity="0.1"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_337_46"
|
||||
x1="26.4087"
|
||||
y1="1.00001"
|
||||
x2="211.327"
|
||||
y2="175.17"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.481613" stopColor="#E8E8E8" className="dark:stop-color-[#F8F8F8]" />
|
||||
<stop offset="1" stopColor="#E8E8E8" stopOpacity="0" className="dark:stop-color-[#F8F8F8]" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_337_46"
|
||||
x1="1355.59"
|
||||
y1="1.00001"
|
||||
x2="1170.67"
|
||||
y2="175.17"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.481613" stopColor="#E8E8E8" className="dark:stop-color-[#F8F8F8]" />
|
||||
<stop offset="1" stopColor="#E8E8E8" stopOpacity="0" className="dark:stop-color-[#F8F8F8]" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_337_46"
|
||||
x1="26.4087"
|
||||
y1="369"
|
||||
x2="211.327"
|
||||
y2="194.83"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.481613" stopColor="#E8E8E8" className="dark:stop-color-[#F8F8F8]" />
|
||||
<stop offset="1" stopColor="#E8E8E8" stopOpacity="0" className="dark:stop-color-[#F8F8F8]" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint3_linear_337_46"
|
||||
x1="1355.59"
|
||||
y1="369"
|
||||
x2="1170.67"
|
||||
y2="194.83"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.481613" stopColor="#E8E8E8" className="dark:stop-color-[#F8F8F8]" />
|
||||
<stop offset="1" stopColor="#E8E8E8" stopOpacity="0" className="dark:stop-color-[#F8F8F8]" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const BottomGradient = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="851"
|
||||
height="595"
|
||||
viewBox="0 0 851 595"
|
||||
fill="none"
|
||||
className={cn(
|
||||
"pointer-events-none absolute -right-80 bottom-0 h-full w-full opacity-30 dark:opacity-100 dark:hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<path
|
||||
d="M118.499 0H532.468L635.375 38.6161L665 194.625L562.093 346H0L24.9473 121.254L118.499 0Z"
|
||||
fill="url(#paint0_radial_254_132)"
|
||||
/>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="paint0_radial_254_132"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(412.5 346) rotate(-91.153) scale(397.581 423.744)"
|
||||
>
|
||||
<stop stopColor="#AAD3E9" />
|
||||
<stop offset="0.25" stopColor="#7FB8D4" />
|
||||
<stop offset="0.573634" stopColor="#5A9BB8" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const TopGradient = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1007"
|
||||
height="997"
|
||||
viewBox="0 0 1007 997"
|
||||
fill="none"
|
||||
className={cn(
|
||||
"pointer-events-none absolute -left-96 top-0 h-full w-full opacity-30 dark:opacity-100 dark:hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<path
|
||||
d="M807 110.119L699.5 -117.546L8.5 -154L-141 246.994L-7 952L127 782.111L279 652.114L513 453.337L807 110.119Z"
|
||||
fill="url(#paint0_radial_254_135)"
|
||||
/>
|
||||
<path
|
||||
d="M807 110.119L699.5 -117.546L8.5 -154L-141 246.994L-7 952L127 782.111L279 652.114L513 453.337L807 110.119Z"
|
||||
fill="url(#paint1_radial_254_135)"
|
||||
/>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="paint0_radial_254_135"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(77.0001 15.8894) rotate(90.3625) scale(869.41 413.353)"
|
||||
>
|
||||
<stop stopColor="#AAD3E9" />
|
||||
<stop offset="0.25" stopColor="#7FB8D4" />
|
||||
<stop offset="0.573634" stopColor="#5A9BB8" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id="paint1_radial_254_135"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(127.5 -31) rotate(1.98106) scale(679.906 715.987)"
|
||||
>
|
||||
<stop stopColor="#AAD3E9" />
|
||||
<stop offset="0.283363" stopColor="#7FB8D4" />
|
||||
<stop offset="0.573634" stopColor="#5A9BB8" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const DarkModeGradient = ({ className }: { className?: string } = {}) => {
|
||||
return (
|
||||
<div className="hidden dark:block">
|
||||
<div className="absolute -left-48 -top-48 h-[800px] w-[800px] rounded-full bg-purple-900/20 blur-[180px]"></div>
|
||||
<div className="absolute -right-48 -bottom-48 h-[800px] w-[800px] rounded-full bg-indigo-900/20 blur-[180px]"></div>
|
||||
<div className="absolute left-1/2 top-1/2 h-[400px] w-[400px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-purple-800/10 blur-[120px]"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
307
surfsense_web/components/Navbar.tsx
Normal file
307
surfsense_web/components/Navbar.tsx
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
"use client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IconMenu2, IconX, IconBrandGoogleFilled } from "@tabler/icons-react";
|
||||
import {
|
||||
motion,
|
||||
AnimatePresence,
|
||||
useScroll,
|
||||
useMotionValueEvent,
|
||||
} from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Logo } from "./Logo";
|
||||
import { ThemeTogglerComponent } from "./theme/theme-toggle";
|
||||
|
||||
interface NavbarProps {
|
||||
navItems: {
|
||||
name: string;
|
||||
link: string;
|
||||
}[];
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export const Navbar = () => {
|
||||
const navItems = [
|
||||
{
|
||||
name: "",
|
||||
link: "/",
|
||||
},
|
||||
// {
|
||||
// name: "Product",
|
||||
// link: "/#product",
|
||||
// },
|
||||
// {
|
||||
// name: "Pricing",
|
||||
// link: "/#pricing",
|
||||
// },
|
||||
];
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { scrollY } = useScroll({
|
||||
target: ref,
|
||||
offset: ["start start", "end start"],
|
||||
});
|
||||
const [visible, setVisible] = useState<boolean>(false);
|
||||
|
||||
useMotionValueEvent(scrollY, "change", (latest) => {
|
||||
if (latest > 100) {
|
||||
setVisible(true);
|
||||
} else {
|
||||
setVisible(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<motion.div ref={ref} className="w-full fixed top-2 inset-x-0 z-50">
|
||||
<DesktopNav visible={visible} navItems={navItems} />
|
||||
<MobileNav visible={visible} navItems={navItems} />
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const DesktopNav = ({ navItems, visible }: NavbarProps) => {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
|
||||
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 (
|
||||
<motion.div
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
animate={{
|
||||
backdropFilter: "blur(16px)",
|
||||
background: visible
|
||||
? "rgba(var(--background-rgb), 0.8)"
|
||||
: "rgba(var(--background-rgb), 0.6)",
|
||||
width: visible ? "38%" : "80%",
|
||||
height: visible ? "48px" : "64px",
|
||||
y: visible ? 8 : 0,
|
||||
}}
|
||||
initial={{
|
||||
width: "80%",
|
||||
height: "64px",
|
||||
background: "rgba(var(--background-rgb), 0.6)",
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
className={cn(
|
||||
"hidden lg:flex flex-row self-center items-center justify-between py-2 mx-auto px-6 rounded-full relative z-[60] backdrop-saturate-[1.8]",
|
||||
visible ? "border dark:border-white/10 border-gray-300/30" : "border-0"
|
||||
)}
|
||||
style={{
|
||||
"--background-rgb": "var(--tw-dark) ? '0, 0, 0' : '255, 255, 255'",
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Logo className="h-8 w-8 rounded-md" />
|
||||
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
|
||||
</div>
|
||||
<motion.div
|
||||
className="lg:flex flex-row flex-1 items-center justify-center space-x-1 text-sm"
|
||||
animate={{
|
||||
scale: visible ? 0.9 : 1,
|
||||
justifyContent: visible ? "flex-end" : "center",
|
||||
}}
|
||||
>
|
||||
{navItems.map((navItem, idx) => (
|
||||
<motion.div
|
||||
key={`nav-item-${idx}`}
|
||||
onHoverStart={() => setHoveredIndex(idx)}
|
||||
className="relative"
|
||||
>
|
||||
<Link
|
||||
className="dark:text-white/90 text-gray-800 relative px-3 py-1.5 transition-colors"
|
||||
href={navItem.link}
|
||||
>
|
||||
<span className="relative z-10">{navItem.name}</span>
|
||||
{hoveredIndex === idx && (
|
||||
<motion.div
|
||||
layoutId="menu-hover"
|
||||
className="absolute inset-0 rounded-full dark:bg-gradient-to-r dark:from-white/10 dark:to-white/20 bg-gradient-to-r from-gray-200 to-gray-300"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1.1,
|
||||
background: "var(--tw-dark) ? radial-gradient(circle at center, rgba(255,255,255,0.2) 0%, rgba(255,255,255,0.1) 50%, transparent 100%) : radial-gradient(circle at center, rgba(0,0,0,0.05) 0%, rgba(0,0,0,0.03) 50%, transparent 100%)",
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
},
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
bounce: 0.4,
|
||||
duration: 0.4,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeTogglerComponent />
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{!visible && (
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 25,
|
||||
},
|
||||
}}
|
||||
exit={{
|
||||
scale: 0.8,
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={handleGoogleLogin}
|
||||
variant="outline"
|
||||
className="hidden md:flex items-center gap-2 rounded-full dark:bg-white/20 dark:hover:bg-white/30 dark:text-white bg-gray-100 hover:bg-gray-200 text-gray-800 border-0"
|
||||
>
|
||||
<IconBrandGoogleFilled className="h-4 w-4" />
|
||||
<span>Sign in with Google</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileNav = ({ navItems, visible }: NavbarProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
// Redirect to the login page
|
||||
window.location.href = "./login";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
animate={{
|
||||
backdropFilter: "blur(16px)",
|
||||
background: visible
|
||||
? "rgba(var(--background-rgb), 0.8)"
|
||||
: "rgba(var(--background-rgb), 0.6)",
|
||||
width: visible ? "80%" : "90%",
|
||||
y: visible ? 0 : 8,
|
||||
borderRadius: open ? "24px" : "full",
|
||||
padding: "8px 16px",
|
||||
}}
|
||||
initial={{
|
||||
width: "80%",
|
||||
background: "rgba(var(--background-rgb), 0.6)",
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
className={cn(
|
||||
"flex relative flex-col lg:hidden w-full justify-between items-center max-w-[calc(100vw-2rem)] mx-auto z-50 backdrop-saturate-[1.8] rounded-full",
|
||||
visible ? "border border-solid dark:border-white/40 border-gray-300/30" : "border-0"
|
||||
)}
|
||||
style={{
|
||||
"--background-rgb": "var(--tw-dark) ? '0, 0, 0' : '255, 255, 255'",
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<div className="flex flex-row justify-between items-center w-full">
|
||||
<Logo className="h-8 w-8 rounded-md" />
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeTogglerComponent />
|
||||
{open ? (
|
||||
<IconX className="dark:text-white/90 text-gray-800" onClick={() => setOpen(!open)} />
|
||||
) : (
|
||||
<IconMenu2
|
||||
className="dark:text-white/90 text-gray-800"
|
||||
onClick={() => setOpen(!open)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
className="flex rounded-3xl absolute top-16 dark:bg-black/80 bg-white/90 backdrop-blur-xl backdrop-saturate-[1.8] inset-x-0 z-50 flex-col items-start justify-start gap-4 w-full px-6 py-8"
|
||||
>
|
||||
{navItems.map(
|
||||
(navItem: { link: string; name: string }, idx: number) => (
|
||||
<Link
|
||||
key={`link=${idx}`}
|
||||
href={navItem.link}
|
||||
onClick={() => setOpen(false)}
|
||||
className="relative dark:text-white/90 text-gray-800 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<motion.span className="block">{navItem.name}</motion.span>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
onClick={handleGoogleLogin}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 mt-4 w-full justify-center rounded-full dark:bg-white/20 dark:hover:bg-white/30 dark:text-white bg-gray-100 hover:bg-gray-200 text-gray-800 border-0"
|
||||
>
|
||||
<IconBrandGoogleFilled className="h-4 w-4" />
|
||||
<span>Sign in with Google</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
55
surfsense_web/components/TokenHandler.tsx
Normal file
55
surfsense_web/components/TokenHandler.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
interface TokenHandlerProps {
|
||||
redirectPath?: string; // Path to redirect after storing token
|
||||
tokenParamName?: string; // Name of the URL parameter containing the token
|
||||
storageKey?: string; // Key to use when storing in localStorage
|
||||
}
|
||||
|
||||
/**
|
||||
* Client component that extracts a token from URL parameters and stores it in localStorage
|
||||
*
|
||||
* @param redirectPath - Path to redirect after storing token (default: '/')
|
||||
* @param tokenParamName - Name of the URL parameter containing the token (default: 'token')
|
||||
* @param storageKey - Key to use when storing in localStorage (default: 'auth_token')
|
||||
*/
|
||||
const TokenHandler = ({
|
||||
redirectPath = '/',
|
||||
tokenParamName = 'token',
|
||||
storageKey = 'surfsense_bearer_token'
|
||||
}: TokenHandlerProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
// Only run on client-side
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// Get token from URL parameters
|
||||
const token = searchParams.get(tokenParamName);
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
// Store token in localStorage
|
||||
localStorage.setItem(storageKey, token);
|
||||
console.log(`Token stored in localStorage with key: ${storageKey}`);
|
||||
|
||||
// Redirect to specified path
|
||||
router.push(redirectPath);
|
||||
} catch (error) {
|
||||
console.error('Error storing token in localStorage:', error);
|
||||
}
|
||||
}
|
||||
}, [searchParams, tokenParamName, storageKey, redirectPath, router]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[200px]">
|
||||
<p className="text-gray-500">Processing authentication...</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TokenHandler;
|
||||
112
surfsense_web/components/chat/Citation.tsx
Normal file
112
surfsense_web/components/chat/Citation.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import React, { useState } from 'react';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { getConnectorIcon } from './ConnectorComponents';
|
||||
import { Source } from './types';
|
||||
|
||||
type CitationProps = {
|
||||
citationId: number;
|
||||
citationText: string;
|
||||
position: number;
|
||||
source: Source | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Citation component to handle individual citations
|
||||
*/
|
||||
export const Citation = ({ citationId, citationText, position, source }: CitationProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const citationKey = `citation-${citationId}-${position}`;
|
||||
|
||||
if (!source) return <>{citationText}</>;
|
||||
|
||||
return (
|
||||
<span key={citationKey} className="relative inline-flex items-center">
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<sup>
|
||||
<span
|
||||
className="inline-flex items-center justify-center text-primary cursor-pointer bg-primary/10 hover:bg-primary/15 w-4 h-4 rounded-full text-[10px] font-medium ml-0.5 transition-colors border border-primary/20 shadow-sm"
|
||||
>
|
||||
{citationId}
|
||||
</span>
|
||||
</sup>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-80 p-0">
|
||||
<Card className="border-0 shadow-none">
|
||||
<div className="p-3 flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-7 h-7 flex items-center justify-center bg-muted rounded-full">
|
||||
{getConnectorIcon(source.connectorType || '')}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium text-sm text-card-foreground">{source.title}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{source.description}</p>
|
||||
<div className="mt-2 flex items-center text-xs text-muted-foreground">
|
||||
<span className="truncate max-w-[200px]">{source.url}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-full"
|
||||
onClick={() => window.open(source.url, '_blank')}
|
||||
title="Open in new tab"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to render text with citations
|
||||
*/
|
||||
export const renderTextWithCitations = (text: string, getCitationSource: (id: number) => Source | null) => {
|
||||
// Regular expression to find citation patterns like [1], [2], etc.
|
||||
const citationRegex = /\[(\d+)\]/g;
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
let position = 0;
|
||||
|
||||
while ((match = citationRegex.exec(text)) !== null) {
|
||||
// Add text before the citation
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.substring(lastIndex, match.index));
|
||||
}
|
||||
|
||||
// Add the citation component
|
||||
const citationId = parseInt(match[1], 10);
|
||||
parts.push(
|
||||
<Citation
|
||||
key={`citation-${citationId}-${position}`}
|
||||
citationId={citationId}
|
||||
citationText={match[0]}
|
||||
position={position}
|
||||
source={getCitationSource(citationId)}
|
||||
/>
|
||||
);
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
position++;
|
||||
}
|
||||
|
||||
// Add any remaining text after the last citation
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.substring(lastIndex));
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
162
surfsense_web/components/chat/ConnectorComponents.tsx
Normal file
162
surfsense_web/components/chat/ConnectorComponents.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Search,
|
||||
Globe,
|
||||
BookOpen,
|
||||
Sparkles,
|
||||
Microscope,
|
||||
Telescope,
|
||||
File,
|
||||
Link,
|
||||
Slack,
|
||||
Webhook
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Connector, ResearchMode } from './types';
|
||||
|
||||
// Helper function to get connector icon
|
||||
export const getConnectorIcon = (connectorType: string) => {
|
||||
const iconProps = { className: "h-4 w-4" };
|
||||
|
||||
switch(connectorType) {
|
||||
case 'CRAWLED_URL':
|
||||
return <Globe {...iconProps} />;
|
||||
case 'FILE':
|
||||
return <File {...iconProps} />;
|
||||
case 'EXTENSION':
|
||||
return <Webhook {...iconProps} />;
|
||||
case 'SERPER_API':
|
||||
case 'TAVILY_API':
|
||||
return <Link {...iconProps} />;
|
||||
case 'SLACK_CONNECTOR':
|
||||
return <Slack {...iconProps} />;
|
||||
case 'NOTION_CONNECTOR':
|
||||
return <BookOpen {...iconProps} />;
|
||||
case 'DEEP':
|
||||
return <Sparkles {...iconProps} />;
|
||||
case 'DEEPER':
|
||||
return <Microscope {...iconProps} />;
|
||||
case 'DEEPEST':
|
||||
return <Telescope {...iconProps} />;
|
||||
default:
|
||||
return <Search {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
export const researcherOptions: { value: ResearchMode; label: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
value: 'GENERAL',
|
||||
label: 'General',
|
||||
icon: getConnectorIcon('GENERAL')
|
||||
},
|
||||
{
|
||||
value: 'DEEP',
|
||||
label: 'Deep',
|
||||
icon: getConnectorIcon('DEEP')
|
||||
},
|
||||
{
|
||||
value: 'DEEPER',
|
||||
label: 'Deeper',
|
||||
icon: getConnectorIcon('DEEPER')
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Displays a small icon for a connector type
|
||||
*/
|
||||
export const ConnectorIcon = ({ type, index = 0 }: { type: string; index?: number }) => (
|
||||
<div
|
||||
className="w-4 h-4 rounded-full flex items-center justify-center bg-muted border border-background"
|
||||
style={{ zIndex: 10 - index }}
|
||||
>
|
||||
{getConnectorIcon(type)}
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Displays a count indicator for additional connectors
|
||||
*/
|
||||
export const ConnectorCountBadge = ({ count }: { count: number }) => (
|
||||
<div className="w-4 h-4 rounded-full flex items-center justify-center bg-primary text-primary-foreground text-[8px] font-medium border border-background z-0">
|
||||
+{count}
|
||||
</div>
|
||||
);
|
||||
|
||||
type ConnectorButtonProps = {
|
||||
selectedConnectors: string[];
|
||||
onClick: () => void;
|
||||
connectorSources: Connector[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Button that displays selected connectors and opens connector selection dialog
|
||||
*/
|
||||
export const ConnectorButton = ({ selectedConnectors, onClick, connectorSources }: ConnectorButtonProps) => {
|
||||
const totalConnectors = connectorSources.length;
|
||||
const selectedCount = selectedConnectors.length;
|
||||
const progressPercentage = (selectedCount / totalConnectors) * 100;
|
||||
|
||||
// Get the name of a single selected connector
|
||||
const getSingleConnectorName = () => {
|
||||
const connector = connectorSources.find(c => c.type === selectedConnectors[0]);
|
||||
return connector?.name || '';
|
||||
};
|
||||
|
||||
// Get display text based on selection count
|
||||
const getDisplayText = () => {
|
||||
if (selectedCount === totalConnectors) return "All Connectors";
|
||||
if (selectedCount === 1) return getSingleConnectorName();
|
||||
return `${selectedCount} Connectors`;
|
||||
};
|
||||
|
||||
// Render the empty state (no connectors selected)
|
||||
const renderEmptyState = () => (
|
||||
<>
|
||||
<Plus className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Select Connectors</span>
|
||||
</>
|
||||
);
|
||||
|
||||
// Render the selected connectors preview
|
||||
const renderSelectedConnectors = () => (
|
||||
<>
|
||||
<div className="flex -space-x-1.5 mr-1">
|
||||
{/* Show up to 3 connector icons */}
|
||||
{selectedConnectors.slice(0, 3).map((type, index) => (
|
||||
<ConnectorIcon key={type} type={type} index={index} />
|
||||
))}
|
||||
|
||||
{/* Show count indicator if more than 3 connectors are selected */}
|
||||
{selectedCount > 3 && <ConnectorCountBadge count={selectedCount - 3} />}
|
||||
</div>
|
||||
|
||||
{/* Display text */}
|
||||
<span className="font-medium">{getDisplayText()}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-7 px-2 text-xs font-medium rounded-md border-border relative overflow-hidden group scale-90 origin-left"
|
||||
onClick={onClick}
|
||||
aria-label={selectedCount === 0 ? "Select Connectors" : `${selectedCount} connectors selected`}
|
||||
>
|
||||
{/* Progress indicator */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 h-1 bg-primary"
|
||||
style={{
|
||||
width: `${progressPercentage}%`,
|
||||
transition: 'width 0.3s ease'
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1.5 z-10 relative">
|
||||
{selectedCount === 0 ? renderEmptyState() : renderSelectedConnectors()}
|
||||
<ChevronDown className="h-3 w-3 ml-0.5 text-muted-foreground opacity-70" />
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
80
surfsense_web/components/chat/ScrollUtils.tsx
Normal file
80
surfsense_web/components/chat/ScrollUtils.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import React, { RefObject, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Function to scroll to the bottom of a container
|
||||
*/
|
||||
export const scrollToBottom = (ref: RefObject<HTMLDivElement>) => {
|
||||
ref.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to scroll to bottom when messages change
|
||||
*/
|
||||
export const useScrollToBottom = (ref: RefObject<HTMLDivElement>, dependencies: any[]) => {
|
||||
useEffect(() => {
|
||||
scrollToBottom(ref);
|
||||
}, dependencies);
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to check scroll position and update indicators
|
||||
*/
|
||||
export const updateScrollIndicators = (
|
||||
tabsListRef: RefObject<HTMLDivElement>,
|
||||
setCanScrollLeft: (value: boolean) => void,
|
||||
setCanScrollRight: (value: boolean) => void
|
||||
) => {
|
||||
if (tabsListRef.current) {
|
||||
const { scrollLeft, scrollWidth, clientWidth } = tabsListRef.current;
|
||||
setCanScrollLeft(scrollLeft > 0);
|
||||
setCanScrollRight(scrollLeft + clientWidth < scrollWidth - 10); // 10px buffer
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to initialize scroll indicators and add resize listener
|
||||
*/
|
||||
export const useScrollIndicators = (
|
||||
tabsListRef: RefObject<HTMLDivElement>,
|
||||
setCanScrollLeft: (value: boolean) => void,
|
||||
setCanScrollRight: (value: boolean) => void
|
||||
) => {
|
||||
const updateIndicators = () => updateScrollIndicators(tabsListRef, setCanScrollLeft, setCanScrollRight);
|
||||
|
||||
useEffect(() => {
|
||||
updateIndicators();
|
||||
// Add resize listener to update indicators when window size changes
|
||||
window.addEventListener('resize', updateIndicators);
|
||||
return () => window.removeEventListener('resize', updateIndicators);
|
||||
}, []);
|
||||
|
||||
return updateIndicators;
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to scroll tabs list left
|
||||
*/
|
||||
export const scrollTabsLeft = (
|
||||
tabsListRef: RefObject<HTMLDivElement>,
|
||||
updateIndicators: () => void
|
||||
) => {
|
||||
if (tabsListRef.current) {
|
||||
tabsListRef.current.scrollBy({ left: -200, behavior: 'smooth' });
|
||||
// Update indicators after scrolling
|
||||
setTimeout(updateIndicators, 300);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to scroll tabs list right
|
||||
*/
|
||||
export const scrollTabsRight = (
|
||||
tabsListRef: RefObject<HTMLDivElement>,
|
||||
updateIndicators: () => void
|
||||
) => {
|
||||
if (tabsListRef.current) {
|
||||
tabsListRef.current.scrollBy({ left: 200, behavior: 'smooth' });
|
||||
// Update indicators after scrolling
|
||||
setTimeout(updateIndicators, 300);
|
||||
}
|
||||
};
|
||||
38
surfsense_web/components/chat/SegmentedControl.tsx
Normal file
38
surfsense_web/components/chat/SegmentedControl.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import React from 'react';
|
||||
|
||||
type SegmentedControlProps<T extends string> = {
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
options: Array<{
|
||||
value: T;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* A segmented control component for selecting between different options
|
||||
*/
|
||||
function SegmentedControl<T extends string>({ value, onChange, options }: SegmentedControlProps<T>) {
|
||||
return (
|
||||
<div className="flex rounded-md border border-border overflow-hidden scale-90 origin-left">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
className={`flex items-center gap-1 px-2 py-1 text-xs transition-colors ${
|
||||
value === option.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted'
|
||||
}`}
|
||||
onClick={() => onChange(option.value)}
|
||||
aria-pressed={value === option.value}
|
||||
>
|
||||
{option.icon}
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SegmentedControl;
|
||||
69
surfsense_web/components/chat/SourceUtils.tsx
Normal file
69
surfsense_web/components/chat/SourceUtils.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import React from 'react';
|
||||
import { Source, Connector } from './types';
|
||||
|
||||
/**
|
||||
* Function to get sources for the main view
|
||||
*/
|
||||
export const getMainViewSources = (connector: Connector, initialSourcesDisplay: number) => {
|
||||
return connector.sources?.slice(0, initialSourcesDisplay);
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to get filtered sources for the dialog
|
||||
*/
|
||||
export const getFilteredSources = (connector: Connector, sourceFilter: string) => {
|
||||
if (!sourceFilter.trim()) {
|
||||
return connector.sources;
|
||||
}
|
||||
|
||||
const filter = sourceFilter.toLowerCase().trim();
|
||||
return connector.sources?.filter(source =>
|
||||
source.title.toLowerCase().includes(filter) ||
|
||||
source.description.toLowerCase().includes(filter)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to get paginated and filtered sources for the dialog
|
||||
*/
|
||||
export const getPaginatedDialogSources = (
|
||||
connector: Connector,
|
||||
sourceFilter: string,
|
||||
expandedSources: boolean,
|
||||
sourcesPage: number,
|
||||
sourcesPerPage: number
|
||||
) => {
|
||||
const filteredSources = getFilteredSources(connector, sourceFilter);
|
||||
|
||||
if (expandedSources) {
|
||||
return filteredSources;
|
||||
}
|
||||
return filteredSources?.slice(0, sourcesPage * sourcesPerPage);
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to get the count of sources for a connector type
|
||||
*/
|
||||
export const getSourcesCount = (connectorSources: Connector[], connectorType: string) => {
|
||||
const connector = connectorSources.find(c => c.type === connectorType);
|
||||
return connector?.sources?.length || 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to get a citation source by ID
|
||||
*/
|
||||
export const getCitationSource = (
|
||||
citationId: number,
|
||||
connectorSources: Connector[]
|
||||
): Source | null => {
|
||||
for (const connector of connectorSources) {
|
||||
const source = connector.sources?.find(s => s.id === citationId);
|
||||
if (source) {
|
||||
return {
|
||||
...source,
|
||||
connectorType: connector.type
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
18
surfsense_web/components/chat/connector-sources.ts
Normal file
18
surfsense_web/components/chat/connector-sources.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Connector sources
|
||||
export const connectorSourcesMenu = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Crawled URL",
|
||||
type: "CRAWLED_URL",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "File",
|
||||
type: "FILE",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Extension",
|
||||
type: "EXTENSION",
|
||||
},
|
||||
];
|
||||
7
surfsense_web/components/chat/index.ts
Normal file
7
surfsense_web/components/chat/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// Export all components and utilities from the chat folder
|
||||
export { default as SegmentedControl } from './SegmentedControl';
|
||||
export * from './ConnectorComponents';
|
||||
export * from './Citation';
|
||||
export * from './SourceUtils';
|
||||
export * from './ScrollUtils';
|
||||
export * from './types';
|
||||
51
surfsense_web/components/chat/types.ts
Normal file
51
surfsense_web/components/chat/types.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Types for chat components
|
||||
*/
|
||||
|
||||
export type Source = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
connectorType?: string;
|
||||
};
|
||||
|
||||
export type Connector = {
|
||||
id: number;
|
||||
type: string;
|
||||
name: string;
|
||||
sources?: Source[];
|
||||
};
|
||||
|
||||
export type StatusMessage = {
|
||||
id: number;
|
||||
message: string;
|
||||
type: 'info' | 'success' | 'error' | 'warning';
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
// Define message types to match useChat() structure
|
||||
export type MessageRole = 'user' | 'assistant' | 'system' | 'data';
|
||||
|
||||
export interface ToolInvocation {
|
||||
state: 'call' | 'result';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: any;
|
||||
result?: any;
|
||||
}
|
||||
|
||||
export interface ToolInvocationUIPart {
|
||||
type: 'tool-invocation';
|
||||
toolInvocation: ToolInvocation;
|
||||
}
|
||||
|
||||
|
||||
export type ResearchMode = 'GENERAL' | 'DEEP' | 'DEEPER' | 'DEEPEST';
|
||||
34
surfsense_web/components/document-viewer.tsx
Normal file
34
surfsense_web/components/document-viewer.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import React from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { FileText } from "lucide-react";
|
||||
|
||||
interface DocumentViewerProps {
|
||||
title: string;
|
||||
content: string;
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DocumentViewer({ title, content, trigger }: DocumentViewerProps) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="ghost" size="sm" className="flex items-center gap-1">
|
||||
<FileText size={16} />
|
||||
<span>View Content</span>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-4">
|
||||
<MarkdownViewer content={content} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
55
surfsense_web/components/json-metadata-viewer.tsx
Normal file
55
surfsense_web/components/json-metadata-viewer.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import React from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FileJson } from "lucide-react";
|
||||
import { JsonView, defaultStyles } from "react-json-view-lite";
|
||||
import "react-json-view-lite/dist/index.css";
|
||||
|
||||
interface JsonMetadataViewerProps {
|
||||
title: string;
|
||||
metadata: any;
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function JsonMetadataViewer({ title, metadata, trigger }: JsonMetadataViewerProps) {
|
||||
// Ensure metadata is a valid object
|
||||
const jsonData = React.useMemo(() => {
|
||||
if (!metadata) return {};
|
||||
|
||||
try {
|
||||
// If metadata is a string, try to parse it
|
||||
if (typeof metadata === "string") {
|
||||
return JSON.parse(metadata);
|
||||
}
|
||||
// Otherwise, use it as is
|
||||
return metadata;
|
||||
} catch (error) {
|
||||
console.error("Error parsing JSON metadata:", error);
|
||||
return { error: "Invalid JSON metadata" };
|
||||
}
|
||||
}, [metadata]);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="ghost" size="sm" className="flex items-center gap-1">
|
||||
<FileJson size={16} />
|
||||
<span>View Metadata</span>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title} - Metadata</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 p-4 bg-muted/30 rounded-md">
|
||||
<JsonView
|
||||
data={jsonData}
|
||||
style={defaultStyles}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
154
surfsense_web/components/markdown-viewer.tsx
Normal file
154
surfsense_web/components/markdown-viewer.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import React from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize from "rehype-sanitize";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Citation } from "./chat/Citation";
|
||||
import { Source } from "./chat/types";
|
||||
|
||||
interface MarkdownViewerProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
getCitationSource?: (id: number) => Source | null;
|
||||
}
|
||||
|
||||
export function MarkdownViewer({ content, className, getCitationSource }: MarkdownViewerProps) {
|
||||
return (
|
||||
<div className={cn("prose prose-sm dark:prose-invert max-w-none", className)}>
|
||||
<ReactMarkdown
|
||||
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
// Define custom components for markdown elements
|
||||
p: ({node, children, ...props}) => {
|
||||
// If there's no getCitationSource function, just render normally
|
||||
if (!getCitationSource) {
|
||||
return <p className="my-2" {...props}>{children}</p>;
|
||||
}
|
||||
|
||||
// Process citations within paragraph content
|
||||
return <p className="my-2" {...props}>{processCitationsInReactChildren(children, getCitationSource)}</p>;
|
||||
},
|
||||
a: ({node, children, ...props}) => {
|
||||
// Process citations within link content if needed
|
||||
const processedChildren = getCitationSource
|
||||
? processCitationsInReactChildren(children, getCitationSource)
|
||||
: children;
|
||||
return <a className="text-primary hover:underline" {...props}>{processedChildren}</a>;
|
||||
},
|
||||
ul: ({node, ...props}) => <ul className="list-disc pl-5 my-2" {...props} />,
|
||||
ol: ({node, ...props}) => <ol className="list-decimal pl-5 my-2" {...props} />,
|
||||
h1: ({node, children, ...props}) => {
|
||||
const processedChildren = getCitationSource
|
||||
? processCitationsInReactChildren(children, getCitationSource)
|
||||
: children;
|
||||
return <h1 className="text-2xl font-bold mt-6 mb-2" {...props}>{processedChildren}</h1>;
|
||||
},
|
||||
h2: ({node, children, ...props}) => {
|
||||
const processedChildren = getCitationSource
|
||||
? processCitationsInReactChildren(children, getCitationSource)
|
||||
: children;
|
||||
return <h2 className="text-xl font-bold mt-5 mb-2" {...props}>{processedChildren}</h2>;
|
||||
},
|
||||
h3: ({node, children, ...props}) => {
|
||||
const processedChildren = getCitationSource
|
||||
? processCitationsInReactChildren(children, getCitationSource)
|
||||
: children;
|
||||
return <h3 className="text-lg font-bold mt-4 mb-2" {...props}>{processedChildren}</h3>;
|
||||
},
|
||||
h4: ({node, children, ...props}) => {
|
||||
const processedChildren = getCitationSource
|
||||
? processCitationsInReactChildren(children, getCitationSource)
|
||||
: children;
|
||||
return <h4 className="text-base font-bold mt-3 mb-1" {...props}>{processedChildren}</h4>;
|
||||
},
|
||||
blockquote: ({node, ...props}) => <blockquote className="border-l-4 border-muted pl-4 italic my-2" {...props} />,
|
||||
hr: ({node, ...props}) => <hr className="my-4 border-muted" {...props} />,
|
||||
img: ({node, ...props}) => <img className="max-w-full h-auto my-4 rounded" {...props} />,
|
||||
table: ({node, ...props}) => <div className="overflow-x-auto my-4"><table className="min-w-full divide-y divide-border" {...props} /></div>,
|
||||
th: ({node, ...props}) => <th className="px-3 py-2 text-left font-medium bg-muted" {...props} />,
|
||||
td: ({node, ...props}) => <td className="px-3 py-2 border-t border-border" {...props} />,
|
||||
code: ({node, className, children, ...props}: any) => {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const isInline = !match;
|
||||
return isInline
|
||||
? <code className="bg-muted px-1 py-0.5 rounded text-xs" {...props}>{children}</code>
|
||||
: (
|
||||
<div className="relative my-4">
|
||||
<pre className="bg-muted p-4 rounded-md overflow-x-auto">
|
||||
<code className="text-xs" {...props}>{children}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to process citations within React children
|
||||
function processCitationsInReactChildren(children: React.ReactNode, getCitationSource: (id: number) => Source | null): React.ReactNode {
|
||||
// If children is not an array or string, just return it
|
||||
if (!children || (typeof children !== 'string' && !Array.isArray(children))) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// Handle string content directly - this is where we process citation references
|
||||
if (typeof children === 'string') {
|
||||
return processCitationsInText(children, getCitationSource);
|
||||
}
|
||||
|
||||
// Handle arrays of children recursively
|
||||
if (Array.isArray(children)) {
|
||||
return React.Children.map(children, child => {
|
||||
if (typeof child === 'string') {
|
||||
return processCitationsInText(child, getCitationSource);
|
||||
}
|
||||
return child;
|
||||
});
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
// Process citation references in text content
|
||||
function processCitationsInText(text: string, getCitationSource: (id: number) => Source | null): React.ReactNode[] {
|
||||
const citationRegex = /\[(\d+)\]/g;
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
let position = 0;
|
||||
|
||||
while ((match = citationRegex.exec(text)) !== null) {
|
||||
// Add text before the citation
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.substring(lastIndex, match.index));
|
||||
}
|
||||
|
||||
// Add the citation component
|
||||
const citationId = parseInt(match[1], 10);
|
||||
parts.push(
|
||||
<Citation
|
||||
key={`citation-${citationId}-${position}`}
|
||||
citationId={citationId}
|
||||
citationText={match[0]}
|
||||
position={position}
|
||||
source={getCitationSource(citationId)}
|
||||
/>
|
||||
);
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
position++;
|
||||
}
|
||||
|
||||
// Add any remaining text after the last citation
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.substring(lastIndex));
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
247
surfsense_web/components/search-space-form.tsx
Normal file
247
surfsense_web/components/search-space-form.tsx
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Plus, Search, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tilt } from "@/components/ui/tilt";
|
||||
import { Spotlight } from "@/components/ui/spotlight";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const searchSpaceFormSchema = z.object({
|
||||
name: z.string().min(3, "Name is required"),
|
||||
description: z.string().min(10, "Description is required"),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type SearchSpaceFormValues = z.infer<typeof searchSpaceFormSchema>;
|
||||
|
||||
interface SearchSpaceFormProps {
|
||||
onSubmit?: (data: { name: string; description: string }) => void;
|
||||
onDelete?: () => void;
|
||||
className?: string;
|
||||
isEditing?: boolean;
|
||||
initialData?: { name: string; description: string };
|
||||
}
|
||||
|
||||
export function SearchSpaceForm({
|
||||
onSubmit,
|
||||
onDelete,
|
||||
className,
|
||||
isEditing = false,
|
||||
initialData = { name: "", description: "" }
|
||||
}: SearchSpaceFormProps) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
// Initialize the form with React Hook Form and Zod validation
|
||||
const form = useForm<SearchSpaceFormValues>({
|
||||
resolver: zodResolver(searchSpaceFormSchema),
|
||||
defaultValues: {
|
||||
name: initialData.name,
|
||||
description: initialData.description,
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const handleFormSubmit = (values: SearchSpaceFormValues) => {
|
||||
if (onSubmit) {
|
||||
onSubmit(values);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete confirmation
|
||||
const handleDelete = () => {
|
||||
if (onDelete) {
|
||||
onDelete();
|
||||
}
|
||||
setShowDeleteDialog(false);
|
||||
};
|
||||
|
||||
// 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={cn("space-y-8", className)}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
>
|
||||
<motion.div className="flex flex-col space-y-2" variants={itemVariants}>
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
{isEditing ? "Edit Search Space" : "Create Search Space"}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{isEditing
|
||||
? "Update your search space details"
|
||||
: "Create a new search space to organize your documents, chats, and podcasts."}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="w-full"
|
||||
variants={itemVariants}
|
||||
>
|
||||
<Tilt
|
||||
rotationFactor={6}
|
||||
isRevese
|
||||
springOptions={{
|
||||
stiffness: 26.7,
|
||||
damping: 4.1,
|
||||
mass: 0.2,
|
||||
}}
|
||||
className="group relative rounded-lg"
|
||||
>
|
||||
<Spotlight
|
||||
className="z-10 from-blue-500/20 via-blue-300/10 to-blue-200/5 blur-2xl"
|
||||
size={300}
|
||||
springOptions={{
|
||||
stiffness: 26.7,
|
||||
damping: 4.1,
|
||||
mass: 0.2,
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col p-8 rounded-xl border-2 bg-muted/30 backdrop-blur-sm transition-all hover:border-primary/50 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="p-3 rounded-full bg-blue-100 dark:bg-blue-950/50">
|
||||
<Search className="size-6 text-blue-500" />
|
||||
</span>
|
||||
<h3 className="text-xl font-semibold">Search Space</h3>
|
||||
</div>
|
||||
{isEditing && onDelete && (
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full hover:bg-destructive/90 hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete your search space.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
A search space allows you to organize and search through your documents,
|
||||
generate podcasts, and have AI-powered conversations about your content.
|
||||
</p>
|
||||
</div>
|
||||
</Tilt>
|
||||
</motion.div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleFormSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter search space name" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A unique name for your search space.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter search space description" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A brief description of what this search space will be used for.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{isEditing ? "Update" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchSpaceForm;
|
||||
309
surfsense_web/components/sidebar/AppSidebarProvider.tsx
Normal file
309
surfsense_web/components/sidebar/AppSidebarProvider.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AppSidebar } from '@/components/sidebar/app-sidebar';
|
||||
import { iconMap } from '@/components/sidebar/app-sidebar';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { apiClient } from '@/lib/api'; // Import the API client
|
||||
|
||||
interface Chat {
|
||||
created_at: string;
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
messages: string[];
|
||||
search_space_id: number;
|
||||
}
|
||||
|
||||
interface SearchSpace {
|
||||
created_at: string;
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
is_superuser: boolean;
|
||||
is_verified: boolean;
|
||||
}
|
||||
|
||||
interface AppSidebarProviderProps {
|
||||
searchSpaceId: string;
|
||||
navSecondary: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon: string;
|
||||
}[];
|
||||
navMain: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon: string;
|
||||
isActive?: boolean;
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export function AppSidebarProvider({
|
||||
searchSpaceId,
|
||||
navSecondary,
|
||||
navMain
|
||||
}: AppSidebarProviderProps) {
|
||||
const [recentChats, setRecentChats] = useState<{ name: string; url: string; icon: string; id: number; search_space_id: number; actions: { name: string; icon: string; onClick: () => void }[] }[]>([]);
|
||||
const [searchSpace, setSearchSpace] = useState<SearchSpace | null>(null);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoadingChats, setIsLoadingChats] = useState(true);
|
||||
const [isLoadingSearchSpace, setIsLoadingSearchSpace] = useState(true);
|
||||
const [isLoadingUser, setIsLoadingUser] = useState(true);
|
||||
const [chatError, setChatError] = useState<string | null>(null);
|
||||
const [searchSpaceError, setSearchSpaceError] = useState<string | null>(null);
|
||||
const [userError, setUserError] = useState<string | null>(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [chatToDelete, setChatToDelete] = useState<{ id: number, name: string } | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
// Set isClient to true when component mounts on the client
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
// Fetch user details
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
// Only run on client-side
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
// Use the API client instead of direct fetch
|
||||
const userData = await apiClient.get<User>('users/me');
|
||||
setUser(userData);
|
||||
setUserError(null);
|
||||
} catch (error) {
|
||||
console.error('Error fetching user:', error);
|
||||
setUserError(error instanceof Error ? error.message : 'Unknown error occurred');
|
||||
} finally {
|
||||
setIsLoadingUser(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in fetchUser:', error);
|
||||
setIsLoadingUser(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUser();
|
||||
}, []);
|
||||
|
||||
// Fetch recent chats
|
||||
useEffect(() => {
|
||||
const fetchRecentChats = async () => {
|
||||
try {
|
||||
// Only run on client-side
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
// Use the API client instead of direct fetch
|
||||
const chats: Chat[] = await apiClient.get<Chat[]>('api/v1/chats/?limit=5&skip=0');
|
||||
|
||||
// Transform API response to the format expected by AppSidebar
|
||||
const formattedChats = chats.map(chat => ({
|
||||
name: chat.title || `Chat ${chat.id}`, // Fallback if title is empty
|
||||
url: `/dashboard/${chat.search_space_id}/researcher/${chat.id}`,
|
||||
icon: 'MessageCircleMore',
|
||||
id: chat.id,
|
||||
search_space_id: chat.search_space_id,
|
||||
actions: [
|
||||
{
|
||||
name: 'View Details',
|
||||
icon: 'ExternalLink',
|
||||
onClick: () => {
|
||||
window.location.href = `/dashboard/${chat.search_space_id}/researcher/${chat.id}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
icon: 'Trash2',
|
||||
onClick: () => {
|
||||
setChatToDelete({ id: chat.id, name: chat.title || `Chat ${chat.id}` });
|
||||
setShowDeleteDialog(true);
|
||||
}
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
setRecentChats(formattedChats);
|
||||
setChatError(null);
|
||||
} catch (error) {
|
||||
console.error('Error fetching chats:', error);
|
||||
setChatError(error instanceof Error ? error.message : 'Unknown error occurred');
|
||||
// Provide empty array to ensure UI still renders
|
||||
setRecentChats([]);
|
||||
} finally {
|
||||
setIsLoadingChats(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in fetchRecentChats:', error);
|
||||
setIsLoadingChats(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRecentChats();
|
||||
|
||||
// Set up a refresh interval (every 5 minutes)
|
||||
const intervalId = setInterval(fetchRecentChats, 5 * 60 * 1000);
|
||||
|
||||
// Clean up interval on component unmount
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
// Handle delete chat
|
||||
const handleDeleteChat = async () => {
|
||||
if (!chatToDelete) return;
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
|
||||
// Use the API client instead of direct fetch
|
||||
await apiClient.delete(`api/v1/chats/${chatToDelete.id}`);
|
||||
|
||||
// Close dialog and refresh chats
|
||||
setRecentChats(recentChats.filter(chat => chat.id !== chatToDelete.id));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting chat:', error);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setShowDeleteDialog(false);
|
||||
setChatToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch search space details
|
||||
useEffect(() => {
|
||||
const fetchSearchSpace = async () => {
|
||||
try {
|
||||
// Only run on client-side
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
// Use the API client instead of direct fetch
|
||||
const data: SearchSpace = await apiClient.get<SearchSpace>(`api/v1/searchspaces/${searchSpaceId}`);
|
||||
setSearchSpace(data);
|
||||
setSearchSpaceError(null);
|
||||
} catch (error) {
|
||||
console.error('Error fetching search space:', error);
|
||||
setSearchSpaceError(error instanceof Error ? error.message : 'Unknown error occurred');
|
||||
} finally {
|
||||
setIsLoadingSearchSpace(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in fetchSearchSpace:', error);
|
||||
setIsLoadingSearchSpace(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSearchSpace();
|
||||
}, [searchSpaceId]);
|
||||
|
||||
// Create a fallback chat if there's an error or no chats
|
||||
const fallbackChats = chatError || (!isLoadingChats && recentChats.length === 0)
|
||||
? [{
|
||||
name: chatError ? "Error loading chats" : "No recent chats",
|
||||
url: "#",
|
||||
icon: chatError ? "AlertCircle" : "MessageCircleMore",
|
||||
id: 0,
|
||||
search_space_id: Number(searchSpaceId),
|
||||
actions: []
|
||||
}]
|
||||
: [];
|
||||
|
||||
// Use fallback chats if there's an error or no chats
|
||||
const displayChats = recentChats.length > 0 ? recentChats : fallbackChats;
|
||||
|
||||
// Update the first item in navSecondary to show the search space name
|
||||
const updatedNavSecondary = [...navSecondary];
|
||||
if (updatedNavSecondary.length > 0 && isClient) {
|
||||
updatedNavSecondary[0] = {
|
||||
...updatedNavSecondary[0],
|
||||
title: searchSpace?.name || (isLoadingSearchSpace ? 'Loading...' : searchSpaceError ? 'Error loading search space' : 'Unknown Search Space'),
|
||||
};
|
||||
}
|
||||
|
||||
// Create user object for AppSidebar
|
||||
const customUser = {
|
||||
name: isClient && user?.email ? user.email.split('@')[0] : 'User',
|
||||
email: isClient ? (user?.email || (isLoadingUser ? 'Loading...' : userError ? 'Error loading user' : 'Unknown User')) : 'Loading...',
|
||||
avatar: '/icon-128.png', // Default avatar
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppSidebar
|
||||
user={customUser}
|
||||
navSecondary={updatedNavSecondary}
|
||||
navMain={navMain}
|
||||
RecentChats={isClient ? displayChats : []}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog - Only render on client */}
|
||||
{isClient && (
|
||||
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<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?.name}</span>? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteDialog(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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
236
surfsense_web/components/sidebar/app-sidebar.tsx
Normal file
236
surfsense_web/components/sidebar/app-sidebar.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
BookOpen,
|
||||
Cable,
|
||||
FileStack,
|
||||
Undo2,
|
||||
MessageCircleMore,
|
||||
Settings2,
|
||||
SquareLibrary,
|
||||
SquareTerminal,
|
||||
AlertCircle,
|
||||
Info,
|
||||
ExternalLink,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { NavMain } from "@/components/sidebar/nav-main"
|
||||
import { NavProjects } from "@/components/sidebar/nav-projects"
|
||||
import { NavSecondary } from "@/components/sidebar/nav-secondary"
|
||||
import { NavUser } from "@/components/sidebar/nav-user"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
// Map of icon names to their components
|
||||
export const iconMap: Record<string, LucideIcon> = {
|
||||
BookOpen,
|
||||
Cable,
|
||||
FileStack,
|
||||
Undo2,
|
||||
MessageCircleMore,
|
||||
Settings2,
|
||||
SquareLibrary,
|
||||
SquareTerminal,
|
||||
AlertCircle,
|
||||
Info,
|
||||
ExternalLink,
|
||||
Trash2
|
||||
}
|
||||
|
||||
const defaultData = {
|
||||
user: {
|
||||
name: "Surf",
|
||||
email: "m@example.com",
|
||||
avatar: "/icon-128.png",
|
||||
},
|
||||
navMain: [
|
||||
{
|
||||
title: "Researcher",
|
||||
url: "#",
|
||||
icon: "SquareTerminal",
|
||||
isActive: true,
|
||||
items: [],
|
||||
},
|
||||
|
||||
{
|
||||
title: "Documents",
|
||||
url: "#",
|
||||
icon: "FileStack",
|
||||
items: [
|
||||
{
|
||||
title: "Upload Documents",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Manage Documents",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Connectors",
|
||||
url: "#",
|
||||
icon: "Cable",
|
||||
items: [
|
||||
{
|
||||
title: "Add Connector",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Manage Connectors",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Research Synthesizer's",
|
||||
url: "#",
|
||||
icon: "SquareLibrary",
|
||||
items: [
|
||||
{
|
||||
title: "Podcast Creator",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Presentation Creator",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
navSecondary: [
|
||||
{
|
||||
title: "SEARCH SPACE",
|
||||
url: "#",
|
||||
icon: "LifeBuoy",
|
||||
},
|
||||
],
|
||||
RecentChats: [
|
||||
{
|
||||
name: "Design Engineering",
|
||||
url: "#",
|
||||
icon: "MessageCircleMore",
|
||||
id: 1001,
|
||||
},
|
||||
{
|
||||
name: "Sales & Marketing",
|
||||
url: "#",
|
||||
icon: "MessageCircleMore",
|
||||
id: 1002,
|
||||
},
|
||||
{
|
||||
name: "Travel",
|
||||
url: "#",
|
||||
icon: "MessageCircleMore",
|
||||
id: 1003,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
user?: {
|
||||
name: string
|
||||
email: string
|
||||
avatar: string
|
||||
}
|
||||
navMain?: {
|
||||
title: string
|
||||
url: string
|
||||
icon: string
|
||||
isActive?: boolean
|
||||
items?: {
|
||||
title: string
|
||||
url: string
|
||||
}[]
|
||||
}[]
|
||||
navSecondary?: {
|
||||
title: string
|
||||
url: string
|
||||
icon: string // Changed to string (icon name)
|
||||
}[]
|
||||
RecentChats?: {
|
||||
name: string
|
||||
url: string
|
||||
icon: string // Changed to string (icon name)
|
||||
id?: number
|
||||
search_space_id?: number
|
||||
actions?: {
|
||||
name: string
|
||||
icon: string
|
||||
onClick: () => void
|
||||
}[]
|
||||
}[]
|
||||
}
|
||||
|
||||
export function AppSidebar({
|
||||
user = defaultData.user,
|
||||
navMain = defaultData.navMain,
|
||||
navSecondary = defaultData.navSecondary,
|
||||
RecentChats = defaultData.RecentChats,
|
||||
...props
|
||||
}: AppSidebarProps) {
|
||||
// Process navMain to resolve icon names to components
|
||||
const processedNavMain = React.useMemo(() => {
|
||||
return navMain.map(item => ({
|
||||
...item,
|
||||
icon: iconMap[item.icon] || SquareTerminal // Fallback to SquareTerminal if icon not found
|
||||
}))
|
||||
}, [navMain])
|
||||
|
||||
// Process navSecondary to resolve icon names to components
|
||||
const processedNavSecondary = React.useMemo(() => {
|
||||
return navSecondary.map(item => ({
|
||||
...item,
|
||||
icon: iconMap[item.icon] || Undo2 // Fallback to Undo2 if icon not found
|
||||
}))
|
||||
}, [navSecondary])
|
||||
|
||||
// Process RecentChats to resolve icon names to components
|
||||
const processedRecentChats = React.useMemo(() => {
|
||||
return RecentChats?.map(item => ({
|
||||
...item,
|
||||
icon: iconMap[item.icon] || MessageCircleMore // Fallback to MessageCircleMore if icon not found
|
||||
})) || [];
|
||||
}, [RecentChats])
|
||||
|
||||
return (
|
||||
<Sidebar variant="inset" {...props}>
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<div>
|
||||
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
|
||||
<Logo className="rounded-lg" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">SurfSense</span>
|
||||
<span className="truncate text-xs">beta v0.0.6</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain items={processedNavMain} />
|
||||
{processedRecentChats.length > 0 && <NavProjects projects={processedRecentChats} />}
|
||||
<NavSecondary items={processedNavSecondary} className="mt-auto" />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser user={user} />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
79
surfsense_web/components/sidebar/nav-main.tsx
Normal file
79
surfsense_web/components/sidebar/nav-main.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"use client"
|
||||
|
||||
import { ChevronRight, type LucideIcon } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
export function NavMain({
|
||||
items,
|
||||
}: {
|
||||
items: {
|
||||
title: string
|
||||
url: string
|
||||
icon: LucideIcon
|
||||
isActive?: boolean
|
||||
items?: {
|
||||
title: string
|
||||
url: string
|
||||
}[]
|
||||
}[]
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item, index) => (
|
||||
<Collapsible key={`${item.title}-${index}`} asChild defaultOpen={item.isActive}>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild tooltip={item.title}>
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
{item.items?.length ? (
|
||||
<>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuAction className="data-[state=open]:rotate-90">
|
||||
<ChevronRight />
|
||||
<span className="sr-only">Toggle</span>
|
||||
</SidebarMenuAction>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.items?.map((subItem, subIndex) => (
|
||||
<SidebarMenuSubItem key={`${subItem.title}-${subIndex}`}>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<a href={subItem.url}>
|
||||
<span>{subItem.title}</span>
|
||||
</a>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</>
|
||||
) : null}
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
122
surfsense_web/components/sidebar/nav-projects.tsx
Normal file
122
surfsense_web/components/sidebar/nav-projects.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
ExternalLink,
|
||||
Folder,
|
||||
MoreHorizontal,
|
||||
Share,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
// Map of icon names to their components
|
||||
const actionIconMap: Record<string, LucideIcon> = {
|
||||
ExternalLink,
|
||||
Folder,
|
||||
Share,
|
||||
Trash2,
|
||||
MoreHorizontal
|
||||
}
|
||||
|
||||
interface ChatAction {
|
||||
name: string;
|
||||
icon: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function NavProjects({
|
||||
projects,
|
||||
}: {
|
||||
projects: {
|
||||
name: string
|
||||
url: string
|
||||
icon: LucideIcon
|
||||
id?: number
|
||||
search_space_id?: number
|
||||
actions?: ChatAction[]
|
||||
}[]
|
||||
}) {
|
||||
const { isMobile } = useSidebar()
|
||||
const router = useRouter()
|
||||
|
||||
const searchSpaceId = projects[0]?.search_space_id || ""
|
||||
|
||||
return (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel>Recent Chats</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{projects.map((item, index) => (
|
||||
<SidebarMenuItem key={item.id ? `chat-${item.id}` : `chat-${item.name}-${index}`}>
|
||||
<SidebarMenuButton>
|
||||
<item.icon />
|
||||
<span>{item.name}</span>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction showOnHover>
|
||||
<MoreHorizontal />
|
||||
<span className="sr-only">More</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-48"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align={isMobile ? "end" : "start"}
|
||||
>
|
||||
{item.actions ? (
|
||||
// Use the actions provided by the item
|
||||
item.actions.map((action, actionIndex) => {
|
||||
const ActionIcon = actionIconMap[action.icon] || Folder;
|
||||
return (
|
||||
<DropdownMenuItem key={`${action.name}-${actionIndex}`} onClick={action.onClick}>
|
||||
<ActionIcon className="text-muted-foreground" />
|
||||
<span>{action.name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
// Default actions if none provided
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<Folder className="text-muted-foreground" />
|
||||
<span>View Chat</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<Trash2 className="text-muted-foreground" />
|
||||
<span>Delete Chat</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton onClick={() => router.push(`/dashboard/${searchSpaceId}/chats`)}>
|
||||
<MoreHorizontal />
|
||||
<span>View All Chats</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
43
surfsense_web/components/sidebar/nav-secondary.tsx
Normal file
43
surfsense_web/components/sidebar/nav-secondary.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type LucideIcon } from "lucide-react"
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarGroupLabel,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
|
||||
export function NavSecondary({
|
||||
items,
|
||||
...props
|
||||
}: {
|
||||
items: {
|
||||
title: string
|
||||
url: string
|
||||
icon: LucideIcon
|
||||
}[]
|
||||
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
|
||||
return (
|
||||
<SidebarGroup {...props}>
|
||||
<SidebarGroupLabel>SearchSpace</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item, index) => (
|
||||
<SidebarMenuItem key={`${item.title}-${index}`}>
|
||||
<SidebarMenuButton asChild size="sm">
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
108
surfsense_web/components/sidebar/nav-user.tsx
Normal file
108
surfsense_web/components/sidebar/nav-user.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
BadgeCheck,
|
||||
Bell,
|
||||
ChevronsUpDown,
|
||||
CreditCard,
|
||||
LogOut,
|
||||
Sparkles,
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/components/ui/avatar"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { useRouter, useParams } from "next/navigation"
|
||||
|
||||
export function NavUser({
|
||||
user,
|
||||
}: {
|
||||
user: {
|
||||
name: string
|
||||
email: string
|
||||
avatar: string
|
||||
}
|
||||
}) {
|
||||
const { isMobile } = useSidebar()
|
||||
const router = useRouter()
|
||||
const { search_space_id } = useParams()
|
||||
|
||||
const handleLogout = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('surfsense_bearer_token');
|
||||
router.push('/');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="truncate text-xs">{user.email}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="truncate text-xs">{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => router.push(`/dashboard/${search_space_id}/api-key`)}>
|
||||
<BadgeCheck />
|
||||
API Key
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
9
surfsense_web/components/theme/theme-provider.tsx
Normal file
9
surfsense_web/components/theme/theme-provider.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
import type { ThemeProviderProps } from "next-themes"
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
69
surfsense_web/components/theme/theme-toggle.tsx
Normal file
69
surfsense_web/components/theme/theme-toggle.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { MoonIcon, SunIcon } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export function ThemeTogglerComponent() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const [isClient, setIsClient] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
isClient && (
|
||||
<button
|
||||
onClick={() => {
|
||||
theme === "dark" ? setTheme("light") : setTheme("dark");
|
||||
}}
|
||||
className="w-8 h-8 flex hover:bg-gray-50 dark:hover:bg-white/[0.1] rounded-lg items-center justify-center outline-none focus:ring-0 focus:outline-none active:ring-0 active:outline-none overflow-hidden"
|
||||
>
|
||||
{theme === "light" && (
|
||||
<motion.div
|
||||
key={theme}
|
||||
initial={{
|
||||
x: 40,
|
||||
opacity: 0,
|
||||
}}
|
||||
animate={{
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
>
|
||||
<SunIcon className="h-4 w-4 flex-shrink-0 dark:text-neutral-500 text-neutral-700" />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{theme === "dark" && (
|
||||
<motion.div
|
||||
key={theme}
|
||||
initial={{
|
||||
x: 40,
|
||||
opacity: 0,
|
||||
}}
|
||||
animate={{
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
}}
|
||||
transition={{
|
||||
ease: "easeOut",
|
||||
duration: 0.3,
|
||||
}}
|
||||
>
|
||||
<MoonIcon className="h-4 w-4 flex-shrink-0 " />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
||||
60
surfsense_web/components/ui/accordion.tsx
Normal file
60
surfsense_web/components/ui/accordion.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Accordion = AccordionPrimitive.Root
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AccordionItem.displayName = "AccordionItem"
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
))
|
||||
AccordionTrigger.displayName = "AccordionTrigger"
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="pb-4 pt-0">{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
))
|
||||
AccordionContent.displayName = "AccordionContent"
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
157
surfsense_web/components/ui/alert-dialog.tsx
Normal file
157
surfsense_web/components/ui/alert-dialog.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
58
surfsense_web/components/ui/alert.tsx
Normal file
58
surfsense_web/components/ui/alert.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
53
surfsense_web/components/ui/avatar.tsx
Normal file
53
surfsense_web/components/ui/avatar.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
46
surfsense_web/components/ui/badge.tsx
Normal file
46
surfsense_web/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
109
surfsense_web/components/ui/breadcrumb.tsx
Normal file
109
surfsense_web/components/ui/breadcrumb.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
58
surfsense_web/components/ui/button.tsx
Normal file
58
surfsense_web/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
78
surfsense_web/components/ui/card.tsx
Normal file
78
surfsense_web/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }
|
||||
32
surfsense_web/components/ui/checkbox.tsx
Normal file
32
surfsense_web/components/ui/checkbox.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
33
surfsense_web/components/ui/collapsible.tsx
Normal file
33
surfsense_web/components/ui/collapsible.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"use client"
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
122
surfsense_web/components/ui/dialog.tsx
Normal file
122
surfsense_web/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
70
surfsense_web/components/ui/display-cards.tsx
Normal file
70
surfsense_web/components/ui/display-cards.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
interface DisplayCardProps {
|
||||
className?: string;
|
||||
icon?: React.ReactNode;
|
||||
title?: string;
|
||||
description?: string;
|
||||
date?: string;
|
||||
iconClassName?: string;
|
||||
titleClassName?: string;
|
||||
}
|
||||
|
||||
function DisplayCard({
|
||||
className,
|
||||
icon = <Sparkles className="size-4 text-blue-300" />,
|
||||
title = "Featured",
|
||||
description = "Discover amazing content",
|
||||
date = "Just now",
|
||||
iconClassName = "text-blue-500",
|
||||
titleClassName = "text-blue-500",
|
||||
}: DisplayCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex h-36 w-[22rem] -skew-y-[8deg] select-none flex-col justify-between rounded-xl border-2 bg-muted/70 backdrop-blur-sm px-4 py-3 transition-all duration-700 after:absolute after:-right-1 after:top-[-5%] after:h-[110%] after:w-[20rem] after:bg-gradient-to-l after:from-background after:to-transparent after:content-[''] hover:border-white/20 hover:bg-muted [&>*]:flex [&>*]:items-center [&>*]:gap-2",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<span className="relative inline-block rounded-full bg-blue-800 p-1">
|
||||
{icon}
|
||||
</span>
|
||||
<p className={cn("text-lg font-medium", titleClassName)}>{title}</p>
|
||||
</div>
|
||||
<p className="whitespace-nowrap text-lg">{description}</p>
|
||||
<p className="text-muted-foreground">{date}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DisplayCardsProps {
|
||||
cards?: DisplayCardProps[];
|
||||
}
|
||||
|
||||
export default function DisplayCards({ cards }: DisplayCardsProps) {
|
||||
const defaultCards = [
|
||||
{
|
||||
className: "[grid-area:stack] hover:-translate-y-10 before:absolute before:w-[100%] before:outline-1 before:rounded-xl before:outline-border before:h-[100%] before:content-[''] before:bg-blend-overlay before:bg-background/50 grayscale-[100%] hover:before:opacity-0 before:transition-opacity before:duration:700 hover:grayscale-0 before:left-0 before:top-0",
|
||||
},
|
||||
{
|
||||
className: "[grid-area:stack] translate-x-16 translate-y-10 hover:-translate-y-1 before:absolute before:w-[100%] before:outline-1 before:rounded-xl before:outline-border before:h-[100%] before:content-[''] before:bg-blend-overlay before:bg-background/50 grayscale-[100%] hover:before:opacity-0 before:transition-opacity before:duration:700 hover:grayscale-0 before:left-0 before:top-0",
|
||||
},
|
||||
{
|
||||
className: "[grid-area:stack] translate-x-32 translate-y-20 hover:translate-y-10",
|
||||
},
|
||||
];
|
||||
|
||||
const displayCards = cards || defaultCards;
|
||||
|
||||
return (
|
||||
<div className="grid [grid-template-areas:'stack'] place-items-center opacity-100 animate-in fade-in-0 duration-700">
|
||||
{displayCards.map((cardProps, index) => (
|
||||
<DisplayCard key={index} {...cardProps} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
257
surfsense_web/components/ui/dropdown-menu.tsx
Normal file
257
surfsense_web/components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
167
surfsense_web/components/ui/form.tsx
Normal file
167
surfsense_web/components/ui/form.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive-foreground", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive-foreground text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
56
surfsense_web/components/ui/input-with-inner-tags.tsx
Normal file
56
surfsense_web/components/ui/input-with-inner-tags.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"use client";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tag, TagInput } from "emblor";
|
||||
import { useState } from "react";
|
||||
|
||||
const tags = [
|
||||
{
|
||||
id: "1",
|
||||
text: "Red",
|
||||
},
|
||||
];
|
||||
|
||||
function InputDemo() {
|
||||
const [exampleTags, setExampleTags] = useState<Tag[]>(tags);
|
||||
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 w-[300px]">
|
||||
<Label htmlFor="input-57">Input with inner tags</Label>
|
||||
<TagInput
|
||||
id="input-57"
|
||||
tags={exampleTags}
|
||||
setTags={(newTags) => {
|
||||
setExampleTags(newTags);
|
||||
}}
|
||||
placeholder="Add a tag"
|
||||
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="mt-2 text-xs text-muted-foreground" role="region" aria-live="polite">
|
||||
Built with{" "}
|
||||
<a
|
||||
className="underline hover:text-foreground"
|
||||
href="https://github.com/JaleelB/emblor"
|
||||
target="_blank"
|
||||
rel="noopener nofollow"
|
||||
>
|
||||
emblor
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { InputDemo };
|
||||
21
surfsense_web/components/ui/input.tsx
Normal file
21
surfsense_web/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
24
surfsense_web/components/ui/label.tsx
Normal file
24
surfsense_web/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
127
surfsense_web/components/ui/pagination.tsx
Normal file
127
surfsense_web/components/ui/pagination.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import * as React from "react"
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="pagination-item" {...props} />
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span className="hidden sm:block">Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">Next</span>
|
||||
<ChevronRightIcon />
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
}
|
||||
48
surfsense_web/components/ui/popover.tsx
Normal file
48
surfsense_web/components/ui/popover.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
181
surfsense_web/components/ui/select.tsx
Normal file
181
surfsense_web/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex h-9 w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-2 py-1.5 text-sm font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
28
surfsense_web/components/ui/separator.tsx
Normal file
28
surfsense_web/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator-root"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
139
surfsense_web/components/ui/sheet.tsx
Normal file
139
surfsense_web/components/ui/sheet.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
723
surfsense_web/components/ui/sidebar.tsx
Normal file
723
surfsense_web/components/ui/sidebar.tsx
Normal file
|
|
@ -0,0 +1,723 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { VariantProps, cva } from "class-variance-authority"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContext = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContext | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo<SidebarContext>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer text-sidebar-foreground hidden md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-7 w-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
13
surfsense_web/components/ui/skeleton.tsx
Normal file
13
surfsense_web/components/ui/skeleton.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-primary/10 animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
29
surfsense_web/components/ui/sonner.tsx
Normal file
29
surfsense_web/components/ui/sonner.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground font-medium",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground font-medium",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
81
surfsense_web/components/ui/spotlight.tsx
Normal file
81
surfsense_web/components/ui/spotlight.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
'use client';
|
||||
import React, { useRef, useState, useCallback, useEffect } from 'react';
|
||||
import { motion, useSpring, useTransform, SpringOptions } from 'framer-motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type SpotlightProps = {
|
||||
className?: string;
|
||||
size?: number;
|
||||
springOptions?: SpringOptions;
|
||||
};
|
||||
|
||||
export function Spotlight({
|
||||
className,
|
||||
size = 200,
|
||||
springOptions = { bounce: 0 },
|
||||
}: SpotlightProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [parentElement, setParentElement] = useState<HTMLElement | null>(null);
|
||||
|
||||
const mouseX = useSpring(0, springOptions);
|
||||
const mouseY = useSpring(0, springOptions);
|
||||
|
||||
const spotlightLeft = useTransform(mouseX, (x) => `${x - size / 2}px`);
|
||||
const spotlightTop = useTransform(mouseY, (y) => `${y - size / 2}px`);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
const parent = containerRef.current.parentElement;
|
||||
if (parent) {
|
||||
parent.style.position = 'relative';
|
||||
parent.style.overflow = 'hidden';
|
||||
setParentElement(parent);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (!parentElement) return;
|
||||
const { left, top } = parentElement.getBoundingClientRect();
|
||||
mouseX.set(event.clientX - left);
|
||||
mouseY.set(event.clientY - top);
|
||||
},
|
||||
[mouseX, mouseY, parentElement]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!parentElement) return;
|
||||
|
||||
parentElement.addEventListener('mousemove', handleMouseMove);
|
||||
parentElement.addEventListener('mouseenter', () => setIsHovered(true));
|
||||
parentElement.addEventListener('mouseleave', () => setIsHovered(false));
|
||||
|
||||
return () => {
|
||||
parentElement.removeEventListener('mousemove', handleMouseMove);
|
||||
parentElement.removeEventListener('mouseenter', () => setIsHovered(true));
|
||||
parentElement.removeEventListener('mouseleave', () =>
|
||||
setIsHovered(false)
|
||||
);
|
||||
};
|
||||
}, [parentElement, handleMouseMove]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'pointer-events-none absolute rounded-full bg-[radial-gradient(circle_at_center,var(--tw-gradient-stops),transparent_80%)] blur-xl transition-opacity duration-200',
|
||||
'from-zinc-50 via-zinc-100 to-zinc-200',
|
||||
isHovered ? 'opacity-100' : 'opacity-0',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
left: spotlightLeft,
|
||||
top: spotlightTop,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
95
surfsense_web/components/ui/table.tsx
Normal file
95
surfsense_web/components/ui/table.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
||||
</div>
|
||||
),
|
||||
);
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => <thead ref={ref} className={cn(className)} {...props} />);
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t border-border bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableFooter.displayName = "TableFooter";
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b border-border transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-3 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:w-px [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-0.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-3 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-0.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
|
||||
));
|
||||
TableCaption.displayName = "TableCaption";
|
||||
|
||||
export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow };
|
||||
55
surfsense_web/components/ui/tabs.tsx
Normal file
55
surfsense_web/components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
92
surfsense_web/components/ui/tilt.tsx
Normal file
92
surfsense_web/components/ui/tilt.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
'use client';
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
import {
|
||||
motion,
|
||||
useMotionTemplate,
|
||||
useMotionValue,
|
||||
useSpring,
|
||||
useTransform,
|
||||
MotionStyle,
|
||||
SpringOptions,
|
||||
} from 'framer-motion';
|
||||
|
||||
type TiltProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
style?: MotionStyle;
|
||||
rotationFactor?: number;
|
||||
isRevese?: boolean;
|
||||
springOptions?: SpringOptions;
|
||||
};
|
||||
|
||||
export function Tilt({
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
rotationFactor = 15,
|
||||
isRevese = false,
|
||||
springOptions,
|
||||
}: TiltProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const x = useMotionValue(0);
|
||||
const y = useMotionValue(0);
|
||||
|
||||
const xSpring = useSpring(x, springOptions);
|
||||
const ySpring = useSpring(y, springOptions);
|
||||
|
||||
const rotateX = useTransform(
|
||||
ySpring,
|
||||
[-0.5, 0.5],
|
||||
isRevese
|
||||
? [rotationFactor, -rotationFactor]
|
||||
: [-rotationFactor, rotationFactor]
|
||||
);
|
||||
const rotateY = useTransform(
|
||||
xSpring,
|
||||
[-0.5, 0.5],
|
||||
isRevese
|
||||
? [-rotationFactor, rotationFactor]
|
||||
: [rotationFactor, -rotationFactor]
|
||||
);
|
||||
|
||||
const transform = useMotionTemplate`perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`;
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!ref.current) return;
|
||||
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
const xPos = mouseX / width - 0.5;
|
||||
const yPos = mouseY / height - 0.5;
|
||||
|
||||
x.set(xPos);
|
||||
y.set(yPos);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
x.set(0);
|
||||
y.set(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
className={className}
|
||||
style={{
|
||||
transformStyle: 'preserve-3d',
|
||||
...style,
|
||||
transform,
|
||||
}}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
61
surfsense_web/components/ui/tooltip.tsx
Normal file
61
surfsense_web/components/ui/tooltip.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
16
surfsense_web/eslint.config.mjs
Normal file
16
surfsense_web/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
1
surfsense_web/hooks/index.ts
Normal file
1
surfsense_web/hooks/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './useSearchSourceConnectors';
|
||||
58
surfsense_web/hooks/use-api-key.ts
Normal file
58
surfsense_web/hooks/use-api-key.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface UseApiKeyReturn {
|
||||
apiKey: string | null
|
||||
isLoading: boolean
|
||||
copied: boolean
|
||||
copyToClipboard: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useApiKey(): UseApiKeyReturn {
|
||||
const [apiKey, setApiKey] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// Load API key from localStorage
|
||||
const loadApiKey = () => {
|
||||
try {
|
||||
const token = localStorage.getItem('surfsense_bearer_token')
|
||||
setApiKey(token)
|
||||
} catch (error) {
|
||||
console.error('Error loading API key:', error)
|
||||
toast.error('Failed to load API key')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Add a small delay to simulate loading
|
||||
const timer = setTimeout(loadApiKey, 500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (!apiKey) return
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(apiKey)
|
||||
setCopied(true)
|
||||
toast.success('API key copied to clipboard')
|
||||
|
||||
setTimeout(() => {
|
||||
setCopied(false)
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
toast.error('Failed to copy API key')
|
||||
}
|
||||
}, [apiKey])
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
isLoading,
|
||||
copied,
|
||||
copyToClipboard
|
||||
}
|
||||
}
|
||||
117
surfsense_web/hooks/use-connectors.ts
Normal file
117
surfsense_web/hooks/use-connectors.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// Types for connector API
|
||||
export interface ConnectorConfig {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export interface Connector {
|
||||
id: number;
|
||||
name: string;
|
||||
connector_type: string;
|
||||
config: ConnectorConfig;
|
||||
created_at: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
export interface CreateConnectorRequest {
|
||||
name: string;
|
||||
connector_type: string;
|
||||
config: ConnectorConfig;
|
||||
}
|
||||
|
||||
// Get connector type display name
|
||||
export const getConnectorTypeDisplay = (type: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
"SERPER_API": "Serper API",
|
||||
"TAVILY_API": "Tavily API",
|
||||
// Add other connector types here as needed
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
||||
// API service for connectors
|
||||
export const ConnectorService = {
|
||||
// Create a new connector
|
||||
async createConnector(data: CreateConnectorRequest): Promise<Connector> {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || "Failed to create connector");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Get all connectors
|
||||
async getConnectors(skip = 0, limit = 100): Promise<Connector[]> {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/?skip=${skip}&limit=${limit}`, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || "Failed to fetch connectors");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Get a specific connector
|
||||
async getConnector(connectorId: number): Promise<Connector> {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || "Failed to fetch connector");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Update a connector
|
||||
async updateConnector(connectorId: number, data: CreateConnectorRequest): Promise<Connector> {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || "Failed to update connector");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// Delete a connector
|
||||
async deleteConnector(connectorId: number): Promise<void> {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || "Failed to delete connector");
|
||||
}
|
||||
},
|
||||
};
|
||||
115
surfsense_web/hooks/use-documents.ts
Normal file
115
surfsense_web/hooks/use-documents.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"use client"
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export interface Document {
|
||||
id: number;
|
||||
title: string;
|
||||
document_type: "EXTENSION" | "CRAWLED_URL" | "SLACK_CONNECTOR" | "NOTION_CONNECTOR" | "FILE";
|
||||
document_metadata: any;
|
||||
content: string;
|
||||
created_at: string;
|
||||
search_space_id: number;
|
||||
}
|
||||
|
||||
export function useDocuments(searchSpaceId: number) {
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDocuments = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('surfsense_bearer_token')}`,
|
||||
},
|
||||
method: "GET",
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error("Failed to fetch documents");
|
||||
throw new Error("Failed to fetch documents");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setDocuments(data);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch documents');
|
||||
console.error('Error fetching documents:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (searchSpaceId) {
|
||||
fetchDocuments();
|
||||
}
|
||||
}, [searchSpaceId]);
|
||||
|
||||
// Function to refresh the documents list
|
||||
const refreshDocuments = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('surfsense_bearer_token')}`,
|
||||
},
|
||||
method: "GET",
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error("Failed to fetch documents");
|
||||
throw new Error("Failed to fetch documents");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setDocuments(data);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch documents');
|
||||
console.error('Error fetching documents:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to delete a document
|
||||
const deleteDocument = async (documentId: number) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/${documentId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('surfsense_bearer_token')}`,
|
||||
},
|
||||
method: "DELETE",
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error("Failed to delete document");
|
||||
throw new Error("Failed to delete document");
|
||||
}
|
||||
|
||||
toast.success("Document deleted successfully");
|
||||
// Update the local state after successful deletion
|
||||
setDocuments(documents.filter(doc => doc.id !== documentId));
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to delete document');
|
||||
console.error('Error deleting document:', err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return { documents, loading, error, refreshDocuments, deleteDocument };
|
||||
}
|
||||
19
surfsense_web/hooks/use-mobile.ts
Normal file
19
surfsense_web/hooks/use-mobile.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
75
surfsense_web/hooks/use-search-spaces.ts
Normal file
75
surfsense_web/hooks/use-search-spaces.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"use client"
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface SearchSpace {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
// Add other fields from your SearchSpaceRead model
|
||||
}
|
||||
|
||||
export function useSearchSpaces() {
|
||||
const [searchSpaces, setSearchSpaces] = useState<SearchSpace[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSearchSpaces = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('surfsense_bearer_token')}`,
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error("Not authenticated");
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setSearchSpaces(data);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch search spaces');
|
||||
console.error('Error fetching search spaces:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSearchSpaces();
|
||||
}, []);
|
||||
|
||||
// Function to refresh the search spaces list
|
||||
const refreshSearchSpaces = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('surfsense_bearer_token')}`,
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error("Not authenticated");
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setSearchSpaces(data);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch search spaces');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { searchSpaces, loading, error, refreshSearchSpaces };
|
||||
}
|
||||
302
surfsense_web/hooks/useSearchSourceConnectors.ts
Normal file
302
surfsense_web/hooks/useSearchSourceConnectors.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
export interface SearchSourceConnector {
|
||||
id: number;
|
||||
name: string;
|
||||
connector_type: string;
|
||||
is_indexable: boolean;
|
||||
last_indexed_at: string | null;
|
||||
config: Record<string, any>;
|
||||
user_id?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface ConnectorSourceItem {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
sources: any[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch search source connectors from the API
|
||||
*/
|
||||
export const useSearchSourceConnectors = () => {
|
||||
const [connectors, setConnectors] = useState<SearchSourceConnector[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [connectorSourceItems, setConnectorSourceItems] = useState<ConnectorSourceItem[]>([
|
||||
{
|
||||
id: 1,
|
||||
name: "Crawled URL",
|
||||
type: "CRAWLED_URL",
|
||||
sources: [],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "File",
|
||||
type: "FILE",
|
||||
sources: [],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Extension",
|
||||
type: "EXTENSION",
|
||||
sources: [],
|
||||
}
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchConnectors = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const token = localStorage.getItem('surfsense_bearer_token');
|
||||
|
||||
if (!token) {
|
||||
throw new Error('No authentication token found');
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch connectors: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setConnectors(data);
|
||||
|
||||
// Update connector source items when connectors change
|
||||
updateConnectorSourceItems(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('An unknown error occurred'));
|
||||
console.error('Error fetching search source connectors:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchConnectors();
|
||||
}, []);
|
||||
|
||||
// Update connector source items when connectors change
|
||||
const updateConnectorSourceItems = (currentConnectors: SearchSourceConnector[]) => {
|
||||
// Start with the default hardcoded connectors
|
||||
const defaultConnectors: ConnectorSourceItem[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Crawled URL",
|
||||
type: "CRAWLED_URL",
|
||||
sources: [],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "File",
|
||||
type: "FILE",
|
||||
sources: [],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Extension",
|
||||
type: "EXTENSION",
|
||||
sources: [],
|
||||
}
|
||||
];
|
||||
|
||||
// Add the API connectors
|
||||
const apiConnectors: ConnectorSourceItem[] = currentConnectors.map((connector, index) => ({
|
||||
id: 1000 + index, // Use a high ID to avoid conflicts with hardcoded IDs
|
||||
name: connector.name,
|
||||
type: connector.connector_type,
|
||||
sources: [],
|
||||
}));
|
||||
|
||||
setConnectorSourceItems([...defaultConnectors, ...apiConnectors]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new search source connector
|
||||
*/
|
||||
const createConnector = async (connectorData: Omit<SearchSourceConnector, 'id' | 'user_id' | 'created_at'>) => {
|
||||
try {
|
||||
const token = localStorage.getItem('surfsense_bearer_token');
|
||||
|
||||
if (!token) {
|
||||
throw new Error('No authentication token found');
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(connectorData)
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create connector: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const newConnector = await response.json();
|
||||
const updatedConnectors = [...connectors, newConnector];
|
||||
setConnectors(updatedConnectors);
|
||||
updateConnectorSourceItems(updatedConnectors);
|
||||
return newConnector;
|
||||
} catch (err) {
|
||||
console.error('Error creating search source connector:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update an existing search source connector
|
||||
*/
|
||||
const updateConnector = async (
|
||||
connectorId: number,
|
||||
connectorData: Partial<Omit<SearchSourceConnector, 'id' | 'user_id' | 'created_at'>>
|
||||
) => {
|
||||
try {
|
||||
const token = localStorage.getItem('surfsense_bearer_token');
|
||||
|
||||
if (!token) {
|
||||
throw new Error('No authentication token found');
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(connectorData)
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update connector: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const updatedConnector = await response.json();
|
||||
const updatedConnectors = connectors.map(connector =>
|
||||
connector.id === connectorId ? updatedConnector : connector
|
||||
);
|
||||
setConnectors(updatedConnectors);
|
||||
updateConnectorSourceItems(updatedConnectors);
|
||||
return updatedConnector;
|
||||
} catch (err) {
|
||||
console.error('Error updating search source connector:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a search source connector
|
||||
*/
|
||||
const deleteConnector = async (connectorId: number) => {
|
||||
try {
|
||||
const token = localStorage.getItem('surfsense_bearer_token');
|
||||
|
||||
if (!token) {
|
||||
throw new Error('No authentication token found');
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete connector: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const updatedConnectors = connectors.filter(connector => connector.id !== connectorId);
|
||||
setConnectors(updatedConnectors);
|
||||
updateConnectorSourceItems(updatedConnectors);
|
||||
} catch (err) {
|
||||
console.error('Error deleting search source connector:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Index content from a connector to a search space
|
||||
*/
|
||||
const indexConnector = async (connectorId: number, searchSpaceId: string | number) => {
|
||||
try {
|
||||
const token = localStorage.getItem('surfsense_bearer_token');
|
||||
|
||||
if (!token) {
|
||||
throw new Error('No authentication token found');
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-source-connectors/${connectorId}/index?search_space_id=${searchSpaceId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to index connector content: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Update the connector's last_indexed_at timestamp
|
||||
const updatedConnectors = connectors.map(connector =>
|
||||
connector.id === connectorId
|
||||
? { ...connector, last_indexed_at: new Date().toISOString() }
|
||||
: connector
|
||||
);
|
||||
setConnectors(updatedConnectors);
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('Error indexing connector content:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get connector source items - memoized to prevent unnecessary re-renders
|
||||
*/
|
||||
const getConnectorSourceItems = useCallback(() => {
|
||||
return connectorSourceItems;
|
||||
}, [connectorSourceItems]);
|
||||
|
||||
return {
|
||||
connectors,
|
||||
isLoading,
|
||||
error,
|
||||
createConnector,
|
||||
updateConnector,
|
||||
deleteConnector,
|
||||
indexConnector,
|
||||
getConnectorSourceItems,
|
||||
connectorSourceItems
|
||||
};
|
||||
};
|
||||
173
surfsense_web/lib/api.ts
Normal file
173
surfsense_web/lib/api.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { toast } from "sonner";
|
||||
|
||||
/**
|
||||
* Custom fetch wrapper that handles authentication and redirects to home page on 401 Unauthorized
|
||||
*
|
||||
* @param url - The URL to fetch
|
||||
* @param options - Fetch options
|
||||
* @returns The fetch response
|
||||
*/
|
||||
export async function fetchWithAuth(
|
||||
url: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<Response> {
|
||||
// Only run on client-side
|
||||
if (typeof window === 'undefined') {
|
||||
return fetch(url, options);
|
||||
}
|
||||
|
||||
// Get token from localStorage
|
||||
const token = localStorage.getItem('surfsense_bearer_token');
|
||||
|
||||
// Add authorization header if token exists
|
||||
const headers = {
|
||||
...options.headers,
|
||||
...(token && { 'Authorization': `Bearer ${token}` }),
|
||||
};
|
||||
|
||||
// Make the request
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
// Handle 401 Unauthorized response
|
||||
if (response.status === 401) {
|
||||
// Show error toast
|
||||
toast.error("Session expired. Please log in again.");
|
||||
|
||||
// Clear token
|
||||
localStorage.removeItem('surfsense_bearer_token');
|
||||
|
||||
// Redirect to home page
|
||||
window.location.href = '/';
|
||||
|
||||
// Throw error to stop further processing
|
||||
throw new Error('Unauthorized: Redirecting to login page');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full API URL
|
||||
*
|
||||
* @param path - The API path
|
||||
* @returns The full API URL
|
||||
*/
|
||||
export function getApiUrl(path: string): string {
|
||||
// Remove leading slash if present
|
||||
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
||||
|
||||
// Get backend URL from environment variable
|
||||
const baseUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL;
|
||||
|
||||
if (!baseUrl) {
|
||||
console.error('NEXT_PUBLIC_FASTAPI_BACKEND_URL is not defined');
|
||||
return '';
|
||||
}
|
||||
|
||||
// Combine base URL and path
|
||||
return `${baseUrl}/${cleanPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* API client with methods for common operations
|
||||
*/
|
||||
export const apiClient = {
|
||||
/**
|
||||
* Make a GET request
|
||||
*
|
||||
* @param path - The API path
|
||||
* @param options - Additional fetch options
|
||||
* @returns The response data
|
||||
*/
|
||||
async get<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetchWithAuth(getApiUrl(path), {
|
||||
method: 'GET',
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(`API error: ${response.status} ${errorData?.detail || response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Make a POST request
|
||||
*
|
||||
* @param path - The API path
|
||||
* @param data - The request body
|
||||
* @param options - Additional fetch options
|
||||
* @returns The response data
|
||||
*/
|
||||
async post<T>(path: string, data: any, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetchWithAuth(getApiUrl(path), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(`API error: ${response.status} ${errorData?.detail || response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Make a PUT request
|
||||
*
|
||||
* @param path - The API path
|
||||
* @param data - The request body
|
||||
* @param options - Additional fetch options
|
||||
* @returns The response data
|
||||
*/
|
||||
async put<T>(path: string, data: any, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetchWithAuth(getApiUrl(path), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(`API error: ${response.status} ${errorData?.detail || response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Make a DELETE request
|
||||
*
|
||||
* @param path - The API path
|
||||
* @param options - Additional fetch options
|
||||
* @returns The response data
|
||||
*/
|
||||
async delete<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetchWithAuth(getApiUrl(path), {
|
||||
method: 'DELETE',
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(`API error: ${response.status} ${errorData?.detail || response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue