feat: monorepo

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2025-04-07 23:47:06 -07:00
parent fe39077849
commit a1474ca49e
144 changed files with 43821 additions and 1 deletions

View file

@ -0,0 +1,2 @@
PLASMO_PUBLIC_API_SECRET_KEY = "surfsense"
PLASMO_PUBLIC_BACKEND_URL = "http://127.0.0.1:8000"

View file

@ -0,0 +1,34 @@
# name: "Submit to Web Store"
# on:
# workflow_dispatch:
# jobs:
# build:
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v3
# - name: Cache pnpm modules
# uses: actions/cache@v3
# with:
# path: ~/.pnpm-store
# key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
# restore-keys: |
# ${{ runner.os }}-
# - uses: pnpm/action-setup@v2.2.4
# with:
# version: latest
# run_install: true
# - name: Use Node.js 16.x
# uses: actions/setup-node@v3.4.1
# with:
# node-version: 16.x
# cache: "pnpm"
# - name: Build the extension
# run: pnpm build
# - name: Package the extension into a zip artifact
# run: pnpm package
# - name: Browser Platform Publish
# uses: PlasmoHQ/bpp@v3
# with:
# keys: ${{ secrets.SUBMIT_KEYS }}
# artifact: build/chrome-mv3-prod.zip

35
surfsense_browser_extension/.gitignore vendored Normal file
View file

@ -0,0 +1,35 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
out/
build/
dist/
# plasmo
.plasmo
# typescript
.tsbuildinfo
/trash

View file

@ -0,0 +1,26 @@
/**
* @type {import('prettier').Options}
*/
export default {
printWidth: 80,
tabWidth: 2,
useTabs: false,
semi: false,
singleQuote: false,
trailingComma: "none",
bracketSpacing: true,
bracketSameLine: true,
plugins: ["@ianvs/prettier-plugin-sort-imports"],
importOrder: [
"<BUILTIN_MODULES>", // Node.js built-in modules
"<THIRD_PARTY_MODULES>", // Imports not matched by other special words or groups.
"", // Empty line
"^@plasmo/(.*)$",
"",
"^@plasmohq/(.*)$",
"",
"^~(.*)$",
"",
"^[./]"
]
}

View file

@ -0,0 +1,37 @@
# SurfSense Cross Browser Extension
Use this guide to build for your browser https://docs.plasmo.com/framework/workflows/build
This is a [Plasmo extension](https://docs.plasmo.com/) project bootstrapped with [`plasmo init`](https://www.npmjs.com/package/plasmo).
## Getting Started
First, run the development server:
```bash
pnpm dev
# or
npm run dev
```
Open your browser and load the appropriate development build. For example, if you are developing for the chrome browser, using manifest v3, use: `build/chrome-mv3-dev`.
You can start editing the popup by modifying `popup.tsx`. It should auto-update as you make changes. To add an options page, simply add a `options.tsx` file to the root of the project, with a react component default exported. Likewise to add a content page, add a `content.ts` file to the root of the project, importing some module and do some logic, then reload the extension on your browser.
For further guidance, [visit our Documentation](https://docs.plasmo.com/)
## Making production build
Run the following:
```bash
pnpm build
# or
npm run build
```
This should create a production bundle for your extension, ready to be zipped and published to the stores.
## Submit to the webstores
The easiest way to deploy your Plasmo extension is to use the built-in [bpp](https://bpp.browser.market) GitHub action. Prior to using this action however, make sure to build your extension and upload the first version to the store to establish the basic credentials. Then, simply follow [this setup instruction](https://docs.plasmo.com/framework/workflows/submit) and you should be on your way for automated submission!

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View file

@ -0,0 +1,77 @@
import { initQueues, initWebHistory } from "~utils/commons"
import type { WebHistory } from "~utils/interfaces"
import { Storage } from "@plasmohq/storage"
import {getRenderedHtml} from '~utils/commons'
chrome.tabs.onCreated.addListener(async (tab: any) => {
try {
await initWebHistory(tab.id)
await initQueues(tab.id)
} catch (error) {
console.log(error)
}
})
chrome.tabs.onUpdated.addListener(
async (tabId: number, changeInfo: any, tab: any) => {
if (changeInfo.status === "complete" && tab.url) {
const storage = new Storage({ area: "local" })
await initWebHistory(tab.id)
await initQueues(tab.id)
const result = await chrome.scripting.executeScript({
// @ts-ignore
target: { tabId: tab.id },
// @ts-ignore
func: getRenderedHtml
})
let toPushInTabHistory: any = result[0].result // const { renderedHtml, title, url, entryTime } = result[0].result;
let urlQueueListObj: any = await storage.get("urlQueueList")
let timeQueueListObj: any = await storage.get("timeQueueList")
urlQueueListObj.urlQueueList
.find((data: WebHistory) => data.tabsessionId === tabId)
.urlQueue.push(toPushInTabHistory.url)
timeQueueListObj.timeQueueList
.find((data: WebHistory) => data.tabsessionId === tabId)
.timeQueue.push(toPushInTabHistory.entryTime)
await storage.set("urlQueueList", {
urlQueueList: urlQueueListObj.urlQueueList
})
await storage.set("timeQueueList", {
timeQueueList: timeQueueListObj.timeQueueList
})
}
}
)
chrome.tabs.onRemoved.addListener(async (tabId: number, removeInfo: object) => {
const storage = new Storage({ area: "local" })
let urlQueueListObj: any = await storage.get("urlQueueList")
let timeQueueListObj: any = await storage.get("timeQueueList")
if (urlQueueListObj.urlQueueList && timeQueueListObj.timeQueueList) {
const urlQueueListToSave = urlQueueListObj.urlQueueList.map(
(element: WebHistory) => {
if (element.tabsessionId !== tabId) {
return element
}
}
)
const timeQueueListSave = timeQueueListObj.timeQueueList.map(
(element: WebHistory) => {
if (element.tabsessionId !== tabId) {
return element
}
}
)
await storage.set("urlQueueList", {
urlQueueList: urlQueueListToSave.filter((item: any) => item)
})
await storage.set("timeQueueList", {
timeQueueList: timeQueueListSave.filter((item: any) => item)
})
}
})

View file

@ -0,0 +1,149 @@
import type { PlasmoMessaging } from "@plasmohq/messaging"
import { Storage } from "@plasmohq/storage"
import {
emptyArr,
webhistoryToLangChainDocument
} from "~utils/commons"
const clearMemory = async () => {
try {
const storage = new Storage({ area: "local" })
let webHistory: any = await storage.get("webhistory")
let urlQueue: any = await storage.get("urlQueueList")
let timeQueue: any = await storage.get("timeQueueList")
if (!webHistory.webhistory) {
return
}
//Main Cleanup COde
chrome.tabs.query({}, async (tabs) => {
//Get Active Tabs Ids
// console.log("Event Tabs",tabs)
let actives = tabs.map((tab) => {
if (tab.id) {
return tab.id
}
})
actives = actives.filter((item: any) => item)
//Only retain which is still active
const newHistory = webHistory.webhistory.map((element: any) => {
//@ts-ignore
if (actives.includes(element.tabsessionId)) {
return element
}
})
const newUrlQueue = urlQueue.urlQueueList.map((element: any) => {
//@ts-ignore
if (actives.includes(element.tabsessionId)) {
return element
}
})
const newTimeQueue = timeQueue.timeQueueList.map((element: any) => {
//@ts-ignore
if (actives.includes(element.tabsessionId)) {
return element
}
})
await storage.set("webhistory", {
webhistory: newHistory.filter((item: any) => item)
})
await storage.set("urlQueueList", {
urlQueueList: newUrlQueue.filter((item: any) => item)
})
await storage.set("timeQueueList", {
timeQueueList: newTimeQueue.filter((item: any) => item)
})
})
} catch (error) {
console.log(error)
}
}
const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
try {
const storage = new Storage({ area: "local" })
const webhistoryObj: any = await storage.get("webhistory")
const webhistory = webhistoryObj.webhistory
if (webhistory) {
let toSaveFinally: any[] = []
let newHistoryAfterCleanup: any[] = []
for (let i = 0; i < webhistory.length; i++) {
const markdownFormat = webhistoryToLangChainDocument(
webhistory[i].tabsessionId,
webhistory[i].tabHistory
)
toSaveFinally.push(...markdownFormat)
newHistoryAfterCleanup.push({
tabsessionId: webhistory[i].tabsessionId,
tabHistory: emptyArr
})
}
await storage.set("webhistory",{ webhistory: newHistoryAfterCleanup });
// Log first item to debug metadata structure
if (toSaveFinally.length > 0) {
console.log("First item metadata:", toSaveFinally[0].metadata);
}
// Create content array for documents in the format expected by the new API
const content = toSaveFinally.map(item => ({
metadata: {
BrowsingSessionId: String(item.metadata.BrowsingSessionId || ""),
VisitedWebPageURL: String(item.metadata.VisitedWebPageURL || ""),
VisitedWebPageTitle: String(item.metadata.VisitedWebPageTitle || "No Title"),
VisitedWebPageDateWithTimeInISOString: String(item.metadata.VisitedWebPageDateWithTimeInISOString || ""),
VisitedWebPageReffererURL: String(item.metadata.VisitedWebPageReffererURL || ""),
VisitedWebPageVisitDurationInMilliseconds: String(item.metadata.VisitedWebPageVisitDurationInMilliseconds || "0")
},
pageContent: String(item.pageContent || "")
}));
const token = await storage.get("token");
const search_space_id = parseInt(await storage.get("search_space_id"), 10);
const toSend = {
document_type: "EXTENSION",
content: content,
search_space_id: search_space_id
}
console.log("toSend", toSend)
const requestOptions = {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify(toSend)
}
const response = await fetch(
`${process.env.PLASMO_PUBLIC_BACKEND_URL}/api/v1/documents/`,
requestOptions
)
const resp = await response.json()
if (resp) {
await clearMemory()
res.send({
message: "Save Job Started"
})
}
}
} catch (error) {
console.log(error)
}
}
export default handler

View file

@ -0,0 +1,145 @@
import { DOMParser } from "linkedom"
import { Storage } from "@plasmohq/storage"
import type { PlasmoMessaging } from "@plasmohq/messaging"
import type { WebHistory } from "~utils/interfaces"
import { webhistoryToLangChainDocument, getRenderedHtml } from "~utils/commons"
import { convertHtmlToMarkdown } from "dom-to-semantic-markdown"
// @ts-ignore
global.Node = {
ELEMENT_NODE: 1,
ATTRIBUTE_NODE: 2,
TEXT_NODE: 3,
CDATA_SECTION_NODE: 4,
PROCESSING_INSTRUCTION_NODE: 7,
COMMENT_NODE: 8,
DOCUMENT_NODE: 9,
DOCUMENT_TYPE_NODE: 10,
DOCUMENT_FRAGMENT_NODE: 11,
};
const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
try {
chrome.tabs.query(
{ active: true, currentWindow: true },
async function (tabs) {
const storage = new Storage({ area: "local" })
const tab = tabs[0]
if (tab.id) {
const tabId: number = tab.id
console.log("tabs", tabs)
const result = await chrome.scripting.executeScript({
// @ts-ignore
target: { tabId: tab.id },
// @ts-ignore
func: getRenderedHtml,
// world: "MAIN"
})
console.log("SnapRes", result)
let toPushInTabHistory: any = result[0].result // const { renderedHtml, title, url, entryTime } = result[0].result;
toPushInTabHistory.pageContentMarkdown = convertHtmlToMarkdown(
toPushInTabHistory.renderedHtml,
{
extractMainContent: true,
enableTableColumnTracking: true,
includeMetaData: false,
overrideDOMParser: new DOMParser()
}
)
delete toPushInTabHistory.renderedHtml
console.log("toPushInTabHistory", toPushInTabHistory)
const urlQueueListObj: any = await storage.get("urlQueueList")
const timeQueueListObj: any = await storage.get("timeQueueList")
const isUrlQueueThere = urlQueueListObj.urlQueueList.find(
(data: WebHistory) => data.tabsessionId === tabId
)
const isTimeQueueThere = timeQueueListObj.timeQueueList.find(
(data: WebHistory) => data.tabsessionId === tabId
)
toPushInTabHistory.duration =
toPushInTabHistory.entryTime -
isTimeQueueThere.timeQueue[isTimeQueueThere.timeQueue.length - 1]
if (isUrlQueueThere.urlQueue.length == 1) {
toPushInTabHistory.reffererUrl = "START"
}
if (isUrlQueueThere.urlQueue.length > 1) {
toPushInTabHistory.reffererUrl =
isUrlQueueThere.urlQueue[isUrlQueueThere.urlQueue.length - 2]
}
let toSaveFinally: any[] = []
const markdownFormat = webhistoryToLangChainDocument(
tab.id,
[toPushInTabHistory]
)
toSaveFinally.push(...markdownFormat)
console.log("toSaveFinally", toSaveFinally)
// Log first item to debug metadata structure
if (toSaveFinally.length > 0) {
console.log("First item metadata:", toSaveFinally[0].metadata);
}
// Create content array for documents in the format expected by the new API
// The metadata is already in the correct format in toSaveFinally
const content = toSaveFinally.map(item => ({
metadata: {
BrowsingSessionId: String(item.metadata.BrowsingSessionId || ""),
VisitedWebPageURL: String(item.metadata.VisitedWebPageURL || ""),
VisitedWebPageTitle: String(item.metadata.VisitedWebPageTitle || "No Title"),
VisitedWebPageDateWithTimeInISOString: String(item.metadata.VisitedWebPageDateWithTimeInISOString || ""),
VisitedWebPageReffererURL: String(item.metadata.VisitedWebPageReffererURL || ""),
VisitedWebPageVisitDurationInMilliseconds: String(item.metadata.VisitedWebPageVisitDurationInMilliseconds || "0")
},
pageContent: String(item.pageContent || "")
}));
const token = await storage.get("token");
const search_space_id = parseInt(await storage.get("search_space_id"), 10);
const toSend = {
document_type: "EXTENSION",
content: content,
search_space_id: search_space_id
}
const requestOptions = {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify(toSend)
}
const response = await fetch(
`${process.env.PLASMO_PUBLIC_BACKEND_URL}/api/v1/documents/`,
requestOptions
)
const resp = await response.json()
if (resp) {
res.send({
message: "Snapshot Saved Successfully"
})
}
}
}
)
} catch (error) {
console.log(error)
}
}
export default handler

View file

@ -0,0 +1,8 @@
import type { PlasmoCSConfig } from "plasmo"
export const config: PlasmoCSConfig = {
matches: ["<all_urls>"],
all_frames: true,
world: "MAIN"
}

View file

@ -0,0 +1,10 @@
@font-face {
font-family: "Fascinate";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(data-base64:~assets/Fascinate.woff2) format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}

View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View file

@ -0,0 +1,62 @@
{
"name": "surfsense",
"displayName": "Surfsense",
"version": "0.0.1",
"description": "Extension to collect Browsing History for SurfSense.",
"author": "https://github.com/MODSetter",
"scripts": {
"dev": "plasmo dev",
"build": "plasmo build",
"package": "plasmo package"
},
"dependencies": {
"@plasmohq/messaging": "^0.6.2",
"@plasmohq/storage": "^1.11.0",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.3",
"dom-to-semantic-markdown": "^1.2.11",
"linkedom": "0.1.34",
"lucide-react": "^0.454.0",
"plasmo": "0.89.4",
"postcss-loader": "^8.1.1",
"radix-ui": "^1.0.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hooks-global-state": "^2.1.0",
"react-router-dom": "^6.26.1",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "4.1.1",
"@types/chrome": "0.0.258",
"@types/node": "20.11.5",
"@types/react": "18.2.48",
"@types/react-dom": "18.2.18",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.41",
"prettier": "3.2.4",
"tailwindcss": "^3.4.10",
"typescript": "5.3.3"
},
"manifest": {
"host_permissions": [
"<all_urls>"
],
"name": "SurfSense",
"description": "Extension to collect Browsing History for SurfSense.",
"version": "0.0.3"
},
"permissions": [
"storage",
"scripting",
"unlimitedStorage",
"activeTab"
]
}

8814
surfsense_browser_extension/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
import { MemoryRouter } from "react-router-dom"
import { Routing } from "~routes"
import { Toaster } from "@/routes/ui/toaster"
function IndexPopup() {
return (
<MemoryRouter>
<Routing />
<Toaster />
</MemoryRouter>
)
}
export default IndexPopup

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -0,0 +1,13 @@
import { Route, Routes } from "react-router-dom"
import ApiKeyForm from "./pages/ApiKeyForm"
import HomePage from "./pages/HomePage"
import '../tailwind.css'
export const Routing = () => (
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/login" element={<ApiKeyForm />} />
</Routes>
)

View file

@ -0,0 +1,123 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom"
import icon from "data-base64:~assets/icon.png"
import { Storage } from "@plasmohq/storage"
import { Button } from "~/routes/ui/button"
import { ReloadIcon } from "@radix-ui/react-icons"
const ApiKeyForm = () => {
const navigation = useNavigate()
const [apiKey, setApiKey] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const storage = new Storage({ area: "local" })
const validateForm = () => {
if (!apiKey) {
setError('API key is required');
return false;
}
setError('');
return true;
};
const handleSubmit = async (event: { preventDefault: () => void; }) => {
event.preventDefault();
if (!validateForm()) return;
setLoading(true);
try {
// Verify token is valid by making a request to the API
const response = await fetch(`${process.env.PLASMO_PUBLIC_BACKEND_URL}/verify-token`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`,
}
});
setLoading(false);
if (response.ok) {
// Store the API key as the token
await storage.set('token', apiKey);
navigation("/")
} else {
setError('Invalid API key. Please check and try again.');
}
} catch (error) {
setLoading(false);
setError('An error occurred. Please try again later.');
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 flex flex-col items-center justify-center p-6">
<div className="w-full max-w-md mx-auto space-y-8">
<div className="flex flex-col items-center space-y-2">
<div className="bg-gray-800 p-3 rounded-full ring-2 ring-gray-700 shadow-lg">
<img className="w-12 h-12" src={icon} alt="SurfSense" />
</div>
<h1 className="text-3xl font-semibold tracking-tight text-white mt-4">SurfSense</h1>
</div>
<div className="bg-gray-800/70 backdrop-blur-sm rounded-xl shadow-xl border border-gray-700 p-6">
<div className="space-y-6">
<h2 className="text-xl font-medium text-white">Enter your API Key</h2>
<p className="text-gray-400 text-sm">
Your API key connects this extension to the SurfSense.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label htmlFor="apiKey" className="text-sm font-medium text-gray-300">
API Key
</label>
<input
type="text"
id="apiKey"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="w-full px-3 py-2 bg-gray-900/50 border border-gray-700 rounded-md focus:outline-none focus:ring-2 focus:ring-teal-500 text-white placeholder:text-gray-500"
placeholder="Enter your API key"
/>
{error && (
<p className="text-red-400 text-sm mt-1">{error}</p>
)}
</div>
<Button
type="submit"
disabled={loading}
className="w-full bg-teal-600 hover:bg-teal-500 text-white py-2 px-4 rounded-md transition-colors"
>
{loading ? (
<>
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
Verifying...
</>
) : (
"Connect"
)}
</Button>
</form>
<div className="text-center mt-4">
<p className="text-sm text-gray-400">
Need an API key?{" "}
<a
href="https://www.surfsense.net"
target="_blank"
className="text-teal-400 hover:text-teal-300 hover:underline"
>
Sign up
</a>
</p>
</div>
</div>
</div>
</div>
</div>
);
}
export default ApiKeyForm

View file

@ -0,0 +1,476 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"
import icon from "data-base64:~assets/icon.png"
import { convertHtmlToMarkdown } from "dom-to-semantic-markdown";
import type { WebHistory } from "~utils/interfaces";
import { getRenderedHtml } from "~utils/commons";
import Loading from "./Loading";
import brain from "data-base64:~assets/brain.png"
import { Storage } from "@plasmohq/storage"
import { sendToBackground } from "@plasmohq/messaging"
import { Check, ChevronsUpDown } from "lucide-react"
import { cn } from "~/lib/utils"
import { Button } from "~/routes/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "~/routes/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/routes/ui/popover"
import { useToast } from "~routes/ui/use-toast";
import {
CircleIcon,
CrossCircledIcon,
DiscIcon,
ExitIcon,
FileIcon,
ReloadIcon,
ResetIcon,
UploadIcon
} from "@radix-ui/react-icons"
const HomePage = () => {
const { toast } = useToast()
const navigation = useNavigate()
const [noOfWebPages, setNoOfWebPages] = useState<number>(0);
const [loading, setLoading] = useState(true);
const [open, setOpen] = React.useState(false)
const [value, setValue] = React.useState<string>("")
const [searchspaces, setSearchSpaces] = useState([])
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
const checkSearchSpaces = async () => {
const storage = new Storage({ area: "local" })
const token = await storage.get('token');
try {
const response = await fetch(
`${process.env.PLASMO_PUBLIC_BACKEND_URL}/api/v1/searchspaces/`,
{
headers: {
'Authorization': `Bearer ${token}`
}
}
);
if (!response.ok) {
throw new Error("Token verification failed");
} else {
const res = await response.json()
console.log(res)
setSearchSpaces(res)
}
} catch (error) {
await storage.remove('token');
await storage.remove('showShadowDom');
navigation("/login")
}
};
checkSearchSpaces();
setLoading(false);
}, []);
useEffect(() => {
async function onLoad() {
try {
chrome.storage.onChanged.addListener(
(changes: any, areaName: string) => {
if (changes.webhistory) {
const webhistory = JSON.parse(changes.webhistory.newValue);
console.log("webhistory", webhistory)
let sum = 0
webhistory.webhistory.forEach((element: any) => {
sum = sum + element.tabHistory.length
});
setNoOfWebPages(sum)
}
}
);
const storage = new Storage({ area: "local" })
const searchspace = await storage.get("search_space");
if(searchspace){
setValue(searchspace)
}
await storage.set("showShadowDom", true)
const webhistoryObj: any = await storage.get("webhistory");
if (webhistoryObj.webhistory.length) {
const webhistory = webhistoryObj.webhistory;
if (webhistoryObj) {
let sum = 0
webhistory.forEach((element: any) => {
sum = sum + element.tabHistory.length
});
setNoOfWebPages(sum)
}
} else {
setNoOfWebPages(0)
}
} catch (error) {
console.log(error);
}
}
onLoad()
}, []);
async function clearMem(): Promise<void> {
try {
const storage = new Storage({ area: "local" })
let webHistory: any = await storage.get("webhistory");
let urlQueue: any = await storage.get("urlQueueList");
let timeQueue: any = await storage.get("timeQueueList");
if (!webHistory.webhistory) {
return
}
//Main Cleanup COde
chrome.tabs.query({}, async (tabs) => {
//Get Active Tabs Ids
let actives = tabs.map((tab) => {
if (tab.id) {
return tab.id
}
})
actives = actives.filter((item: any) => item)
//Only retain which is still active
const newHistory = webHistory.webhistory.map((element: any) => {
//@ts-ignore
if (actives.includes(element.tabsessionId)) {
return element
}
})
const newUrlQueue = urlQueue.urlQueueList.map((element: any) => {
//@ts-ignore
if (actives.includes(element.tabsessionId)) {
return element
}
})
const newTimeQueue = timeQueue.timeQueueList.map((element: any) => {
//@ts-ignore
if (actives.includes(element.tabsessionId)) {
return element
}
})
await storage.set("webhistory", { webhistory: newHistory.filter((item: any) => item) });
await storage.set("urlQueueList", { urlQueueList: newUrlQueue.filter((item: any) => item) });
await storage.set("timeQueueList", { timeQueueList: newTimeQueue.filter((item: any) => item) });
toast({
title: "History store cleared",
description: "Inactive history sessions have been removed",
variant: "destructive",
})
});
} catch (error) {
console.log(error);
}
}
async function saveCurrSnapShot(): Promise<void> {
chrome.tabs.query({ active: true, currentWindow: true }, async function (tabs) {
const storage = new Storage({ area: "local" })
const tab = tabs[0];
if (tab.id) {
const tabId: number = tab.id
const result = await chrome.scripting.executeScript({
// @ts-ignore
target: { tabId: tab.id },
// @ts-ignore
func: getRenderedHtml,
});
let toPushInTabHistory: any = result[0].result;
//Updates 'tabhistory'
let webhistoryObj: any = await storage.get("webhistory");
const webHistoryOfTabId = webhistoryObj.webhistory.filter(
(data: WebHistory) => {
return data.tabsessionId === tab.id;
}
);
toPushInTabHistory.pageContentMarkdown = convertHtmlToMarkdown(
toPushInTabHistory.renderedHtml,
{
extractMainContent: true,
includeMetaData: false,
enableTableColumnTracking: true
}
)
delete toPushInTabHistory.renderedHtml
let tabhistory = webHistoryOfTabId[0].tabHistory;
const urlQueueListObj: any = await storage.get("urlQueueList");
const timeQueueListObj: any = await storage.get("timeQueueList");
const isUrlQueueThere = urlQueueListObj.urlQueueList.find((data: WebHistory) => data.tabsessionId === tabId)
const isTimeQueueThere = timeQueueListObj.timeQueueList.find((data: WebHistory) => data.tabsessionId === tabId)
toPushInTabHistory.duration = toPushInTabHistory.entryTime - isTimeQueueThere.timeQueue[isTimeQueueThere.timeQueue.length - 1]
if (isUrlQueueThere.urlQueue.length == 1) {
toPushInTabHistory.reffererUrl = 'START'
}
if (isUrlQueueThere.urlQueue.length > 1) {
toPushInTabHistory.reffererUrl = isUrlQueueThere.urlQueue[isUrlQueueThere.urlQueue.length - 2];
}
webHistoryOfTabId[0].tabHistory.push(toPushInTabHistory);
await storage.set("webhistory", webhistoryObj);
toast({
title: "Snapshot saved",
description: `Captured: ${toPushInTabHistory.title}`,
})
}
});
}
const saveDatamessage = async () => {
if (value === "") {
toast({
title: "Select a SearchSpace !",
})
return
}
const storage = new Storage({ area: "local" })
const search_space_id = await storage.get("search_space_id");
if (!search_space_id) {
toast({
title: "Invalid SearchSpace selected!",
variant: "destructive",
})
return
}
setIsSaving(true);
toast({
title: "Save job running",
description: "Saving captured content to SurfSense",
})
try {
const resp = await sendToBackground({
// @ts-ignore
name: "savedata",
})
toast({
title: resp.message,
})
} catch (error) {
toast({
title: "Error saving data",
description: "Please try again",
variant: "destructive",
})
} finally {
setIsSaving(false);
}
}
async function logOut(): Promise<void> {
const storage = new Storage({ area: "local" })
await storage.remove('token');
await storage.remove('showShadowDom');
navigation("/login")
}
if (loading) {
return <Loading />;
} else {
return searchspaces.length === 0 ? (
<div className="flex min-h-screen flex-col bg-gradient-to-br from-gray-900 to-gray-800">
<div className="flex flex-1 items-center justify-center p-4">
<div className="w-full max-w-md space-y-8">
<div className="flex flex-col items-center space-y-2 text-center">
<div className="rounded-full bg-gray-800 p-3 shadow-lg ring-2 ring-gray-700">
<img className="h-12 w-12" src={icon} alt="SurfSense" />
</div>
<h1 className="mt-4 text-3xl font-semibold tracking-tight text-white">SurfSense</h1>
<div className="mt-4 rounded-lg border border-yellow-500/20 bg-yellow-500/10 p-4 text-yellow-300">
<p className="text-sm">Please create a Search Space to continue</p>
</div>
</div>
<div className="mt-6 flex justify-center">
<Button
onClick={logOut}
variant="outline"
className="flex items-center space-x-2 border-gray-700 bg-gray-800 text-gray-200 hover:bg-gray-700"
>
<ExitIcon className="h-4 w-4" />
<span>Sign Out</span>
</Button>
</div>
</div>
</div>
</div>
) : (
<div className="flex min-h-screen flex-col bg-gradient-to-br from-gray-900 to-gray-800">
<div className="container mx-auto max-w-md p-4">
<div className="flex items-center justify-between border-b border-gray-700 pb-4">
<div className="flex items-center space-x-3">
<div className="rounded-full bg-gray-800 p-2 shadow-md ring-1 ring-gray-700">
<img className="h-6 w-6" src={icon} alt="SurfSense" />
</div>
<h1 className="text-xl font-semibold text-white">SurfSense</h1>
</div>
<Button
variant="ghost"
size="icon"
onClick={logOut}
className="rounded-full text-gray-400 hover:bg-gray-800 hover:text-white"
>
<ExitIcon className="h-4 w-4" />
<span className="sr-only">Log out</span>
</Button>
</div>
<div className="space-y-3 py-4">
<div className="flex flex-col items-center justify-center rounded-lg border border-gray-700 bg-gray-800/50 p-6 backdrop-blur-sm">
<div className="flex h-28 w-28 items-center justify-center rounded-full bg-gradient-to-br from-gray-700 to-gray-800 shadow-inner">
<div className="flex flex-col items-center">
<img className="mb-2 h-10 w-10 opacity-80" src={brain} alt="brain" />
<span className="text-2xl font-semibold text-white">{noOfWebPages}</span>
</div>
</div>
<p className="mt-4 text-sm text-gray-400">Captured web pages</p>
</div>
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-4 backdrop-blur-sm">
<label className="mb-2 block text-sm font-medium text-gray-300">
Search Space
</label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between border-gray-700 bg-gray-900 text-white hover:bg-gray-700"
>
{value
? searchspaces.find((space) => space.name === value)?.name
: "Select Search Space..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full border-gray-700 bg-gray-800/90 p-0 backdrop-blur-sm">
<Command className="bg-transparent">
<CommandInput placeholder="Search spaces..." className="border-gray-700 bg-gray-900 text-gray-200" />
<CommandList>
<CommandEmpty>No search spaces found.</CommandEmpty>
<CommandGroup>
{searchspaces.map((space) => (
<CommandItem
key={space.name}
value={space.name}
onSelect={async (currentValue) => {
const storage = new Storage({ area: "local" })
if (currentValue === value) {
await storage.set("search_space", "");
await storage.set("search_space_id", 0);
} else {
const selectedSpace = searchspaces.find((space) => space.name === currentValue);
await storage.set("search_space", currentValue);
await storage.set("search_space_id", selectedSpace.id);
}
setValue(currentValue === value ? "" : currentValue)
setOpen(false)
}}
className="aria-selected:bg-gray-700"
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === space.name ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex items-center">
<DiscIcon className="mr-2 h-4 w-4 text-teal-400" />
{space.name}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="grid gap-3">
<Button
variant="destructive"
className="group flex w-full items-center justify-center space-x-2 bg-red-500/90 text-white hover:bg-red-600"
onClick={() => clearMem()}
>
<CrossCircledIcon className="h-4 w-4 transition-transform group-hover:scale-110" />
<span>Clear Inactive History</span>
</Button>
<Button
variant="outline"
className="group flex w-full items-center justify-center space-x-2 border-amber-500/50 bg-amber-500/10 text-amber-200 hover:bg-amber-500/20"
onClick={() => saveCurrSnapShot()}
>
<FileIcon className="h-4 w-4 transition-transform group-hover:scale-110" />
<span>Save Current Page</span>
</Button>
<Button
variant="default"
className="group flex w-full items-center justify-center space-x-2 bg-gradient-to-r from-teal-500 to-emerald-500 text-white transition-all hover:from-teal-600 hover:to-emerald-600"
onClick={() => saveDatamessage()}
disabled={isSaving}
>
{isSaving ? (
<>
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
<span>Saving to SurfSense...</span>
</>
) : (
<>
<UploadIcon className="h-4 w-4 transition-transform group-hover:scale-110" />
<span>Save to SurfSense</span>
</>
)}
</Button>
</div>
</div>
</div>
</div>
);
}
};
export default HomePage

View file

@ -0,0 +1,38 @@
import React from 'react'
import icon from "data-base64:~assets/icon.png"
import { ReloadIcon } from "@radix-ui/react-icons"
const Loading = () => {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-gray-900 to-gray-800">
<div className="w-full max-w-md mx-auto space-y-8">
<div className="flex flex-col items-center space-y-2">
<div className="bg-gray-800 p-3 rounded-full ring-2 ring-gray-700 shadow-lg">
<img className="w-12 h-12" src={icon} alt="SurfSense" />
</div>
<h1 className="text-3xl font-semibold tracking-tight text-white mt-4">SurfSense</h1>
</div>
<div className="flex flex-col items-center mt-8">
<ReloadIcon className="h-10 w-10 text-teal-400 animate-spin" />
<div className="mt-6 text-lg text-gray-300 flex space-x-1">
{Array.from("LOADING").map((letter, i) => (
<span
key={i}
className="inline-block animate-pulse text-teal-400"
style={{
animationDelay: `${i * 0.1}s`,
animationDuration: '1.5s'
}}
>
{letter}
</span>
))}
</div>
</div>
</div>
</div>
)
}
export default Loading

View file

@ -0,0 +1,56 @@
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 ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View file

@ -0,0 +1,155 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "~/lib/utils"
import { Dialog, DialogContent } from "~/routes/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View 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,
}

View file

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "~/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View file

@ -0,0 +1,129 @@
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View file

@ -0,0 +1,35 @@
"use client"
import { useToast } from "@/routes/ui/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/routes/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View file

@ -0,0 +1,194 @@
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/routes/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

View file

@ -0,0 +1,76 @@
const { fontFamily } = require("tailwindcss/defaultTheme")
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: ["./*.{js,jsx,ts,tsx}","./routes/*.tsx","./routes/**/*.tsx"],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: `var(--radius)`,
md: `calc(var(--radius) - 2px)`,
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
sans: ["var(--font-sans)", ...fontFamily.sans],
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
}

View file

@ -0,0 +1,98 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 180 100% 37%;
--primary-foreground: 0 0% 98%;
--secondary: 240 5.9% 10%;
--secondary-foreground: 0 0% 98%;
--muted: 240 5.9% 10%;
--muted-foreground: 240 5% 64.9%;
--accent: 169 97% 37%;
--accent-foreground: 0 0% 98%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 24%;
--input: 240 5.9% 10%;
--ring: 180 100% 37%;
--radius: 0.5rem;
}
.dark {
--background: 224 71% 4%;
--foreground: 213 31% 91%;
--muted: 223 47% 11%;
--muted-foreground: 215.4 16.3% 56.9%;
--accent: 216 34% 17%;
--accent-foreground: 210 40% 98%;
--popover: 224 71% 4%;
--popover-foreground: 215 20.2% 65.1%;
--border: 216 34% 17%;
--input: 216 34% 17%;
--card: 224 71% 4%;
--card-foreground: 213 31% 91%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 1.2%;
--secondary: 222.2 47.4% 11.2%;
--secondary-foreground: 210 40% 98%;
--destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%;
--ring: 216 34% 17%;
--radius: 0.5rem;
}
}
body {
min-width: 380px;
min-height: 580px;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
}
/* Styling for shadcn/ui components */
.command-dialog {
@apply dark;
}
}
/* Popup page dimensions */
@media (prefers-color-scheme: dark) {
body {
@apply bg-slate-950 text-white;
}
}

View file

@ -0,0 +1,20 @@
{
"extends": "plasmo/templates/tsconfig.base",
"exclude": [
"node_modules"
],
"include": [
".plasmo/index.d.ts",
"./**/*.ts",
"./**/*.tsx"
],
"compilerOptions": {
"paths": {
"~*": [
"./*"
],
"@/*": ["./*"]
},
"baseUrl": "."
}
}

View file

@ -0,0 +1,144 @@
import { Storage } from "@plasmohq/storage"
import type { WebHistory } from "./interfaces"
export const emptyArr: any[] = []
export const initQueues = async (tabId: number) => {
const storage = new Storage({ area: "local" })
let urlQueueListObj: any = await storage.get("urlQueueList")
let timeQueueListObj: any = await storage.get("timeQueueList")
if (!urlQueueListObj && !timeQueueListObj) {
await storage.set("urlQueueList", {
urlQueueList: [{ tabsessionId: tabId, urlQueue: [] }]
})
await storage.set("timeQueueList", {
timeQueueList: [{ tabsessionId: tabId, timeQueue: [] }]
})
return
}
if (urlQueueListObj.urlQueueList && timeQueueListObj.timeQueueList) {
const isUrlQueueThere = urlQueueListObj.urlQueueList.find(
(data: WebHistory) => data.tabsessionId === tabId
)
const isTimeQueueThere = timeQueueListObj.timeQueueList.find(
(data: WebHistory) => data.tabsessionId === tabId
)
if (!isUrlQueueThere) {
urlQueueListObj.urlQueueList.push({ tabsessionId: tabId, urlQueue: [] })
await storage.set("urlQueueList", {
urlQueueList: urlQueueListObj.urlQueueList
})
}
if (!isTimeQueueThere) {
timeQueueListObj.timeQueueList.push({
tabsessionId: tabId,
timeQueue: []
})
await storage.set("timeQueueList", {
timeQueueList: timeQueueListObj.timeQueueList
})
}
return
}
}
export function getRenderedHtml() {
return {
url: window.location.href,
entryTime: Date.now(),
title: document.title,
renderedHtml: document.documentElement.outerHTML
}
}
export const initWebHistory = async (tabId: number) => {
const storage = new Storage({ area: "local" })
const result: any = await storage.get("webhistory")
if (result === undefined) {
await storage.set("webhistory", { webhistory: emptyArr })
return
}
const ifIdExists = result.webhistory.find(
(data: WebHistory) => data.tabsessionId === tabId
)
if (ifIdExists === undefined) {
let webHistory = result.webhistory
const initData = {
tabsessionId: tabId,
tabHistory: emptyArr
}
webHistory.push(initData)
try {
await storage.set("webhistory", { webhistory: webHistory })
return
} catch (error) {
console.log(error)
}
} else {
return
}
}
export function toIsoString(date: Date) {
var tzo = -date.getTimezoneOffset(),
dif = tzo >= 0 ? "+" : "-",
pad = function (num: number) {
return (num < 10 ? "0" : "") + num
}
return (
date.getFullYear() +
"-" +
pad(date.getMonth() + 1) +
"-" +
pad(date.getDate()) +
"T" +
pad(date.getHours()) +
":" +
pad(date.getMinutes()) +
":" +
pad(date.getSeconds()) +
dif +
pad(Math.floor(Math.abs(tzo) / 60)) +
":" +
pad(Math.abs(tzo) % 60)
)
}
export const webhistoryToLangChainDocument = (
tabId: number,
tabHistory: any[]
) => {
let toSaveFinally = []
for (let j = 0; j < tabHistory.length; j++) {
const mtadata = {
BrowsingSessionId: `${tabId}`,
VisitedWebPageURL: `${tabHistory[j].url}`,
VisitedWebPageTitle: `${tabHistory[j].title}`,
VisitedWebPageDateWithTimeInISOString: `${toIsoString(new Date(tabHistory[j].entryTime))}`,
VisitedWebPageReffererURL: `${tabHistory[j].reffererUrl}`,
VisitedWebPageVisitDurationInMilliseconds: tabHistory[j].duration
}
toSaveFinally.push({
metadata: mtadata,
pageContent: tabHistory[j].pageContentMarkdown
})
}
return toSaveFinally
}

View file

@ -0,0 +1,4 @@
export interface WebHistory {
tabsessionId: number;
tabHistory: any[];
}

@ -1 +0,0 @@
Subproject commit 1de75613320f6d077ca04c6ec7a7441e07536613

View file

@ -0,0 +1 @@
use pnpm as default package manager

View 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
View 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
View 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
View 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
View 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
View 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.

View 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>
);
}

View file

@ -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

View file

@ -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 />
}

View file

@ -0,0 +1,6 @@
import React from 'react'
import ClientWrapper from './client-wrapper'
export default function ApiKeyPage() {
return <ClientWrapper />
}

View file

@ -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>
);
}

View 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>
);
}

View file

@ -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>
)
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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

View file

@ -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>
)
}

View file

@ -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>
);
}

View 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

View file

@ -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;

View 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

View 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>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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%;
}
}

View 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>
);
}

View 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>
);
};

View file

@ -0,0 +1,5 @@
import { GoogleLoginButton } from "./GoogleLoginButton";
export default function LoginPage() {
return <GoogleLoginButton />;
}

View 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>
);
}

View 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"
}

View 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">
&copy; 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
</>
);
};

View 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;

View 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;
};

View 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>
);
};

View 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);
}
};

View 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;

View 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;
};

View 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",
},
];

View 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';

View 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';

View 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>
);
}

View 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>
);
}

View 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;
}

View 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;

View 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>
)}
</>
);
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
}

View 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>
)
);
}

View 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 }

View 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,
}

View 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 }

View 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 }

View 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 }

Some files were not shown because too many files have changed in this diff Show more