mirror of
https://github.com/MODSetter/SurfSense.git
synced 2025-09-01 18:19:08 +00:00
feat: monorepo
This commit is contained in:
parent
fe39077849
commit
a1474ca49e
144 changed files with 43821 additions and 1 deletions
2
surfsense_browser_extension/.env.example
Normal file
2
surfsense_browser_extension/.env.example
Normal file
|
@ -0,0 +1,2 @@
|
|||
PLASMO_PUBLIC_API_SECRET_KEY = "surfsense"
|
||||
PLASMO_PUBLIC_BACKEND_URL = "http://127.0.0.1:8000"
|
34
surfsense_browser_extension/.github/workflows/submityml
vendored
Normal file
34
surfsense_browser_extension/.github/workflows/submityml
vendored
Normal 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
35
surfsense_browser_extension/.gitignore
vendored
Normal 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
|
26
surfsense_browser_extension/.prettierrc.mjs
Normal file
26
surfsense_browser_extension/.prettierrc.mjs
Normal 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/(.*)$",
|
||||
"",
|
||||
"^~(.*)$",
|
||||
"",
|
||||
"^[./]"
|
||||
]
|
||||
}
|
37
surfsense_browser_extension/README.md
Normal file
37
surfsense_browser_extension/README.md
Normal 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!
|
BIN
surfsense_browser_extension/assets/brain.png
Normal file
BIN
surfsense_browser_extension/assets/brain.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.7 KiB |
BIN
surfsense_browser_extension/assets/icon.png
Normal file
BIN
surfsense_browser_extension/assets/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.5 KiB |
77
surfsense_browser_extension/background/index.ts
Normal file
77
surfsense_browser_extension/background/index.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
})
|
149
surfsense_browser_extension/background/messages/savedata.ts
Normal file
149
surfsense_browser_extension/background/messages/savedata.ts
Normal 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
|
145
surfsense_browser_extension/background/messages/savesnapshot.ts
Normal file
145
surfsense_browser_extension/background/messages/savesnapshot.ts
Normal 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
|
8
surfsense_browser_extension/content.ts
Normal file
8
surfsense_browser_extension/content.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import type { PlasmoCSConfig } from "plasmo"
|
||||
|
||||
export const config: PlasmoCSConfig = {
|
||||
matches: ["<all_urls>"],
|
||||
all_frames: true,
|
||||
world: "MAIN"
|
||||
}
|
||||
|
10
surfsense_browser_extension/font.css
Normal file
10
surfsense_browser_extension/font.css
Normal 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;
|
||||
}
|
6
surfsense_browser_extension/lib/utils.ts
Normal file
6
surfsense_browser_extension/lib/utils.ts
Normal 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))
|
||||
}
|
62
surfsense_browser_extension/package.json
Normal file
62
surfsense_browser_extension/package.json
Normal 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
8814
surfsense_browser_extension/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
15
surfsense_browser_extension/popup.tsx
Normal file
15
surfsense_browser_extension/popup.tsx
Normal 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
|
6
surfsense_browser_extension/postcss.config.js
Normal file
6
surfsense_browser_extension/postcss.config.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
13
surfsense_browser_extension/routes/index.tsx
Normal file
13
surfsense_browser_extension/routes/index.tsx
Normal 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>
|
||||
)
|
123
surfsense_browser_extension/routes/pages/ApiKeyForm.tsx
Normal file
123
surfsense_browser_extension/routes/pages/ApiKeyForm.tsx
Normal 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
|
476
surfsense_browser_extension/routes/pages/HomePage.tsx
Normal file
476
surfsense_browser_extension/routes/pages/HomePage.tsx
Normal 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
|
38
surfsense_browser_extension/routes/pages/Loading.tsx
Normal file
38
surfsense_browser_extension/routes/pages/Loading.tsx
Normal 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
|
56
surfsense_browser_extension/routes/ui/button.tsx
Normal file
56
surfsense_browser_extension/routes/ui/button.tsx
Normal 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 }
|
155
surfsense_browser_extension/routes/ui/command.tsx
Normal file
155
surfsense_browser_extension/routes/ui/command.tsx
Normal 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,
|
||||
}
|
122
surfsense_browser_extension/routes/ui/dialog.tsx
Normal file
122
surfsense_browser_extension/routes/ui/dialog.tsx
Normal file
|
@ -0,0 +1,122 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
31
surfsense_browser_extension/routes/ui/popover.tsx
Normal file
31
surfsense_browser_extension/routes/ui/popover.tsx
Normal 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 }
|
129
surfsense_browser_extension/routes/ui/toast.tsx
Normal file
129
surfsense_browser_extension/routes/ui/toast.tsx
Normal 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,
|
||||
}
|
35
surfsense_browser_extension/routes/ui/toaster.tsx
Normal file
35
surfsense_browser_extension/routes/ui/toaster.tsx
Normal 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>
|
||||
)
|
||||
}
|
194
surfsense_browser_extension/routes/ui/use-toast.tsx
Normal file
194
surfsense_browser_extension/routes/ui/use-toast.tsx
Normal 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 }
|
76
surfsense_browser_extension/tailwind.config.js
Normal file
76
surfsense_browser_extension/tailwind.config.js
Normal 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")],
|
||||
}
|
98
surfsense_browser_extension/tailwind.css
Normal file
98
surfsense_browser_extension/tailwind.css
Normal 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;
|
||||
}
|
||||
}
|
20
surfsense_browser_extension/tsconfig.json
Normal file
20
surfsense_browser_extension/tsconfig.json
Normal file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"extends": "plasmo/templates/tsconfig.base",
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
],
|
||||
"include": [
|
||||
".plasmo/index.d.ts",
|
||||
"./**/*.ts",
|
||||
"./**/*.tsx"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"~*": [
|
||||
"./*"
|
||||
],
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
}
|
||||
}
|
144
surfsense_browser_extension/utils/commons.ts
Normal file
144
surfsense_browser_extension/utils/commons.ts
Normal 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
|
||||
}
|
4
surfsense_browser_extension/utils/interfaces.ts
Normal file
4
surfsense_browser_extension/utils/interfaces.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export interface WebHistory {
|
||||
tabsessionId: number;
|
||||
tabHistory: any[];
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
Subproject commit 1de75613320f6d077ca04c6ec7a7441e07536613
|
1
surfsense_web/.cursorrules
Normal file
1
surfsense_web/.cursorrules
Normal file
|
@ -0,0 +1 @@
|
|||
use pnpm as default package manager
|
15
surfsense_web/.dockerignore
Normal file
15
surfsense_web/.dockerignore
Normal file
|
@ -0,0 +1,15 @@
|
|||
.git
|
||||
.gitignore
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
41
surfsense_web/.gitignore
vendored
Normal file
41
surfsense_web/.gitignore
vendored
Normal file
|
@ -0,0 +1,41 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
31
surfsense_web/.vscode/launch.json
vendored
Normal file
31
surfsense_web/.vscode/launch.json
vendored
Normal file
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Next.js: debug client-side",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:3000",
|
||||
"webRoot": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug server-side",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm run debug:server",
|
||||
"skipFiles": ["<node_internals>/**"]
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug full stack",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm run debug",
|
||||
"serverReadyAction": {
|
||||
"pattern": "- Local:.+(https?://.+)",
|
||||
"uriFormat": "%s",
|
||||
"action": "debugWithChrome"
|
||||
},
|
||||
"skipFiles": ["<node_internals>/**"]
|
||||
}
|
||||
]
|
||||
}
|
25
surfsense_web/Dockerfile
Normal file
25
surfsense_web/Dockerfile
Normal file
|
@ -0,0 +1,25 @@
|
|||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build app for production
|
||||
# For development, we'll mount the source code as a volume
|
||||
# so the build step will be skipped in development mode
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Start Next.js in development mode by default
|
||||
# This will be faster for development since we're mounting the code as a volume
|
||||
CMD ["pnpm", "dev"]
|
21
surfsense_web/LICENSE
Normal file
21
surfsense_web/LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 Rohan Verma
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
96
surfsense_web/README.md
Normal file
96
surfsense_web/README.md
Normal file
|
@ -0,0 +1,96 @@
|
|||
# Next.js Token Handler Component
|
||||
|
||||
This project includes a reusable client component for Next.js that handles token storage from URL parameters.
|
||||
|
||||
## TokenHandler Component
|
||||
|
||||
The `TokenHandler` component is designed to:
|
||||
|
||||
1. Extract a token from URL parameters
|
||||
2. Store the token in localStorage
|
||||
3. Redirect the user to a specified path
|
||||
|
||||
### Usage
|
||||
|
||||
```tsx
|
||||
import TokenHandler from '@/components/TokenHandler';
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Authentication Callback</h1>
|
||||
<TokenHandler
|
||||
redirectPath="/dashboard"
|
||||
tokenParamName="token"
|
||||
storageKey="auth_token"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
The component accepts the following props:
|
||||
|
||||
- `redirectPath` (optional): Path to redirect after storing token (default: '/')
|
||||
- `tokenParamName` (optional): Name of the URL parameter containing the token (default: 'token')
|
||||
- `storageKey` (optional): Key to use when storing in localStorage (default: 'auth_token')
|
||||
|
||||
### Example URL
|
||||
|
||||
After authentication, redirect users to:
|
||||
```
|
||||
https://your-domain.com/auth/callback?token=your-auth-token
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
- Uses Next.js's `useSearchParams` hook to access URL parameters
|
||||
- Uses `useRouter` for client-side navigation after token storage
|
||||
- Includes error handling for localStorage operations
|
||||
- Displays a loading message while processing
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- This implementation assumes the token is passed securely
|
||||
- Consider using HTTPS to prevent token interception
|
||||
- For enhanced security, consider using HTTP-only cookies instead of localStorage
|
||||
- The token in the URL might be visible in browser history and server logs
|
||||
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
19
surfsense_web/app/auth/callback/page.tsx
Normal file
19
surfsense_web/app/auth/callback/page.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { Suspense } from 'react';
|
||||
import TokenHandler from '@/components/TokenHandler';
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="text-2xl font-bold mb-4">Authentication Callback</h1>
|
||||
<Suspense fallback={<div className="flex items-center justify-center min-h-[200px]">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
</div>}>
|
||||
<TokenHandler
|
||||
redirectPath="/dashboard"
|
||||
tokenParamName="token"
|
||||
storageKey="surfsense_bearer_token"
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,176 @@
|
|||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { IconCheck, IconCopy, IconKey } from "@tabler/icons-react"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { useApiKey } from "@/hooks/use-api-key"
|
||||
|
||||
const fadeIn = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1, transition: { duration: 0.4 } }
|
||||
}
|
||||
|
||||
const staggerContainer = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ApiKeyClient = () => {
|
||||
const {
|
||||
apiKey,
|
||||
isLoading,
|
||||
copied,
|
||||
copyToClipboard
|
||||
} = useApiKey()
|
||||
|
||||
return (
|
||||
<div className="flex justify-center w-full min-h-screen py-10 px-4">
|
||||
<motion.div
|
||||
className="w-full max-w-3xl"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={staggerContainer}
|
||||
>
|
||||
<motion.div className="mb-8 text-center" variants={fadeIn}>
|
||||
<h1 className="text-3xl font-bold tracking-tight">API Key</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Your API key for authenticating with the SurfSense API.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeIn}>
|
||||
<Alert className="mb-8">
|
||||
<IconKey className="h-4 w-4" />
|
||||
<AlertTitle>Important</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your API key grants full access to your account. Never share it publicly or with unauthorized users.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeIn}>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle>Your API Key</CardTitle>
|
||||
<CardDescription>
|
||||
Use this key to authenticate your API requests.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AnimatePresence mode="wait">
|
||||
{isLoading ? (
|
||||
<motion.div
|
||||
key="loading"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="h-10 w-full bg-muted animate-pulse rounded-md"
|
||||
/>
|
||||
) : apiKey ? (
|
||||
<motion.div
|
||||
key="api-key"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<div className="bg-muted p-3 rounded-md flex-1 font-mono text-sm overflow-x-auto whitespace-nowrap">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{apiKey}
|
||||
</motion.div>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={copyToClipboard}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<motion.div
|
||||
whileTap={{ scale: 0.9 }}
|
||||
animate={copied ? { scale: [1, 1.2, 1] } : {}}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{copied ? <IconCheck className="h-4 w-4" /> : <IconCopy className="h-4 w-4" />}
|
||||
</motion.div>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{copied ? "Copied!" : "Copy to clipboard"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="no-key"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="text-muted-foreground text-center"
|
||||
>
|
||||
No API key found.
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="mt-8"
|
||||
variants={fadeIn}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<h2 className="text-xl font-semibold mb-4 text-center">How to use your API key</h2>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<motion.div
|
||||
className="space-y-4"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={staggerContainer}
|
||||
>
|
||||
<motion.div variants={fadeIn}>
|
||||
<h3 className="font-medium mb-2 text-center">Authentication</h3>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Include your API key in the Authorization header of your requests:
|
||||
</p>
|
||||
<motion.pre
|
||||
className="bg-muted p-3 rounded-md mt-2 overflow-x-auto"
|
||||
whileHover={{ scale: 1.01 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 10 }}
|
||||
>
|
||||
<code className="text-xs">
|
||||
Authorization: Bearer {apiKey || 'YOUR_API_KEY'}
|
||||
</code>
|
||||
</motion.pre>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ApiKeyClient
|
|
@ -0,0 +1,32 @@
|
|||
'use client'
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
// Loading component with animation
|
||||
const LoadingComponent = () => (
|
||||
<div className="flex flex-col justify-center items-center min-h-screen">
|
||||
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin mb-4"></div>
|
||||
<p className="text-muted-foreground">Loading API Key Management...</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Dynamically import the ApiKeyClient component
|
||||
const ApiKeyClient = dynamic(() => import('./api-key-client'), {
|
||||
ssr: false,
|
||||
loading: () => <LoadingComponent />
|
||||
})
|
||||
|
||||
export default function ClientWrapper() {
|
||||
const [isMounted, setIsMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!isMounted) {
|
||||
return <LoadingComponent />
|
||||
}
|
||||
|
||||
return <ApiKeyClient />
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import React from 'react'
|
||||
import ClientWrapper from './client-wrapper'
|
||||
|
||||
export default function ApiKeyPage() {
|
||||
return <ClientWrapper />
|
||||
}
|
|
@ -0,0 +1,510 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { MessageCircleMore, Search, Calendar, Tag, Trash2, ExternalLink, MoreHorizontal } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
// UI Components
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/pagination';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface Chat {
|
||||
created_at: string;
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
messages: ChatMessage[];
|
||||
search_space_id: number;
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
role: string;
|
||||
content: string;
|
||||
parts?: any;
|
||||
}
|
||||
|
||||
interface ChatsPageClientProps {
|
||||
searchSpaceId: string;
|
||||
}
|
||||
|
||||
const pageVariants = {
|
||||
initial: { opacity: 0 },
|
||||
enter: { opacity: 1, transition: { duration: 0.3, ease: 'easeInOut' } },
|
||||
exit: { opacity: 0, transition: { duration: 0.3, ease: 'easeInOut' } }
|
||||
};
|
||||
|
||||
const chatCardVariants = {
|
||||
initial: { y: 20, opacity: 0 },
|
||||
animate: { y: 0, opacity: 1 },
|
||||
exit: { y: -20, opacity: 0 }
|
||||
};
|
||||
|
||||
const MotionCard = motion(Card);
|
||||
|
||||
export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) {
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
const [filteredChats, setFilteredChats] = useState<Chat[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [selectedType, setSelectedType] = useState<string>('all');
|
||||
const [sortOrder, setSortOrder] = useState<string>('newest');
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [chatToDelete, setChatToDelete] = useState<{ id: number, title: string } | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const chatsPerPage = 9;
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Get initial page from URL params if it exists
|
||||
useEffect(() => {
|
||||
const pageParam = searchParams.get('page');
|
||||
if (pageParam) {
|
||||
const pageNumber = parseInt(pageParam, 10);
|
||||
if (!isNaN(pageNumber) && pageNumber > 0) {
|
||||
setCurrentPage(pageNumber);
|
||||
}
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Fetch chats from API
|
||||
useEffect(() => {
|
||||
const fetchChats = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Get token from localStorage
|
||||
const token = localStorage.getItem('surfsense_bearer_token');
|
||||
|
||||
if (!token) {
|
||||
setError('Authentication token not found. Please log in again.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch all chats for this search space
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/?search_space_id=${searchSpaceId}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(`Failed to fetch chats: ${response.status} ${errorData?.error || ''}`);
|
||||
}
|
||||
|
||||
const data: Chat[] = await response.json();
|
||||
setChats(data);
|
||||
setFilteredChats(data);
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
console.error('Error fetching chats:', error);
|
||||
setError(error instanceof Error ? error.message : 'Unknown error occurred');
|
||||
setChats([]);
|
||||
setFilteredChats([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchChats();
|
||||
}, [searchSpaceId]);
|
||||
|
||||
// Filter and sort chats based on search query, type, and sort order
|
||||
useEffect(() => {
|
||||
let result = [...chats];
|
||||
|
||||
// Filter by search term
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(chat =>
|
||||
chat.title.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by type
|
||||
if (selectedType !== 'all') {
|
||||
result = result.filter(chat => chat.type === selectedType);
|
||||
}
|
||||
|
||||
// Sort chats
|
||||
result.sort((a, b) => {
|
||||
const dateA = new Date(a.created_at).getTime();
|
||||
const dateB = new Date(b.created_at).getTime();
|
||||
|
||||
return sortOrder === 'newest' ? dateB - dateA : dateA - dateB;
|
||||
});
|
||||
|
||||
setFilteredChats(result);
|
||||
setTotalPages(Math.max(1, Math.ceil(result.length / chatsPerPage)));
|
||||
|
||||
// Reset to first page when filters change
|
||||
if (currentPage !== 1 && (searchQuery || selectedType !== 'all' || sortOrder !== 'newest')) {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
}, [chats, searchQuery, selectedType, sortOrder, currentPage]);
|
||||
|
||||
// Function to handle chat deletion
|
||||
const handleDeleteChat = async () => {
|
||||
if (!chatToDelete) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const token = localStorage.getItem('surfsense_bearer_token');
|
||||
if (!token) {
|
||||
setIsDeleting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${chatToDelete.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete chat: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Close dialog and refresh chats
|
||||
setDeleteDialogOpen(false);
|
||||
setChatToDelete(null);
|
||||
|
||||
// Update local state by removing the deleted chat
|
||||
setChats(prevChats => prevChats.filter(chat => chat.id !== chatToDelete.id));
|
||||
} catch (error) {
|
||||
console.error('Error deleting chat:', error);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate pagination
|
||||
const indexOfLastChat = currentPage * chatsPerPage;
|
||||
const indexOfFirstChat = indexOfLastChat - chatsPerPage;
|
||||
const currentChats = filteredChats.slice(indexOfFirstChat, indexOfLastChat);
|
||||
|
||||
// Get unique chat types for filter dropdown
|
||||
const chatTypes = ['all', ...Array.from(new Set(chats.map(chat => chat.type)))];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="container p-6 mx-auto"
|
||||
initial="initial"
|
||||
animate="enter"
|
||||
exit="exit"
|
||||
variants={pageVariants}
|
||||
>
|
||||
<div className="flex flex-col space-y-4 md:space-y-6">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">All Chats</h1>
|
||||
<p className="text-muted-foreground">View, search, and manage all your chats.</p>
|
||||
</div>
|
||||
|
||||
{/* Filter and Search Bar */}
|
||||
<div className="flex flex-col space-y-4 md:flex-row md:items-center md:justify-between md:space-y-0">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="relative w-full md:w-80">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search chats..."
|
||||
className="pl-8"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={selectedType} onValueChange={setSelectedType}>
|
||||
<SelectTrigger className="w-full md:w-40">
|
||||
<SelectValue placeholder="Filter by type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{chatTypes.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type === 'all' ? 'All Types' : type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Select value={sortOrder} onValueChange={setSortOrder}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Sort order" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="newest">Newest First</SelectItem>
|
||||
<SelectItem value="oldest">Oldest First</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Messages */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
<p className="text-sm text-muted-foreground">Loading chats...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !isLoading && (
|
||||
<div className="border border-destructive/50 text-destructive p-4 rounded-md">
|
||||
<h3 className="font-medium">Error loading chats</h3>
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && filteredChats.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-40 gap-2 text-center">
|
||||
<MessageCircleMore className="h-8 w-8 text-muted-foreground" />
|
||||
<h3 className="font-medium">No chats found</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{searchQuery || selectedType !== 'all'
|
||||
? 'Try adjusting your search filters'
|
||||
: 'Start a new chat to get started'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat Grid */}
|
||||
{!isLoading && !error && filteredChats.length > 0 && (
|
||||
<AnimatePresence mode="wait">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{currentChats.map((chat, index) => (
|
||||
<MotionCard
|
||||
key={chat.id}
|
||||
variants={chatCardVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
||||
className="overflow-hidden hover:shadow-md transition-shadow"
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="line-clamp-1">{chat.title || `Chat ${chat.id}`}</CardTitle>
|
||||
<CardDescription>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
<span>{format(new Date(chat.created_at), 'MMM d, yyyy')}</span>
|
||||
</span>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => window.location.href = `/dashboard/${chat.search_space_id}/researcher/${chat.id}`}>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
<span>View Chat</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => {
|
||||
setChatToDelete({ id: chat.id, title: chat.title || `Chat ${chat.id}` });
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>Delete Chat</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm text-muted-foreground line-clamp-3">
|
||||
{chat.messages && chat.messages.length > 0
|
||||
? typeof chat.messages[0] === 'string'
|
||||
? chat.messages[0]
|
||||
: chat.messages[0]?.content || 'No message content'
|
||||
: 'No messages in this chat.'}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between pt-2">
|
||||
<div className="flex items-center text-xs text-muted-foreground">
|
||||
<MessageCircleMore className="mr-1 h-3.5 w-3.5" />
|
||||
<span>{chat.messages?.length || 0} messages</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Tag className="mr-1 h-3 w-3" />
|
||||
{chat.type || 'Unknown'}
|
||||
</Badge>
|
||||
</CardFooter>
|
||||
</MotionCard>
|
||||
))}
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{!isLoading && !error && totalPages > 1 && (
|
||||
<Pagination className="mt-8">
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href={`?page=${Math.max(1, currentPage - 1)}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage > 1) setCurrentPage(currentPage - 1);
|
||||
}}
|
||||
className={currentPage <= 1 ? 'pointer-events-none opacity-50' : ''}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{Array.from({ length: totalPages }).map((_, index) => {
|
||||
const pageNumber = index + 1;
|
||||
const isVisible =
|
||||
pageNumber === 1 ||
|
||||
pageNumber === totalPages ||
|
||||
(pageNumber >= currentPage - 1 && pageNumber <= currentPage + 1);
|
||||
|
||||
if (!isVisible) {
|
||||
// Show ellipsis at appropriate positions
|
||||
if (pageNumber === 2 || pageNumber === totalPages - 1) {
|
||||
return (
|
||||
<PaginationItem key={pageNumber}>
|
||||
<span className="flex h-9 w-9 items-center justify-center">...</span>
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PaginationItem key={pageNumber}>
|
||||
<PaginationLink
|
||||
href={`?page=${pageNumber}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(pageNumber);
|
||||
}}
|
||||
isActive={pageNumber === currentPage}
|
||||
>
|
||||
{pageNumber}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
})}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href={`?page=${Math.min(totalPages, currentPage + 1)}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage < totalPages) setCurrentPage(currentPage + 1);
|
||||
}}
|
||||
className={currentPage >= totalPages ? 'pointer-events-none opacity-50' : ''}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Trash2 className="h-5 w-5 text-destructive" />
|
||||
<span>Delete Chat</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete <span className="font-medium">{chatToDelete?.title}</span>? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteChat}
|
||||
disabled={isDeleting}
|
||||
className="gap-2"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
18
surfsense_web/app/dashboard/[search_space_id]/chats/page.tsx
Normal file
18
surfsense_web/app/dashboard/[search_space_id]/chats/page.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { Suspense } from 'react';
|
||||
import ChatsPageClient from './chats-client';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
search_space_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function ChatsPage({ params }: PageProps) {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex items-center justify-center h-[60vh]">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
</div>}>
|
||||
<ChatsPageClient searchSpaceId={params.search_space_id} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
'use client';
|
||||
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"
|
||||
import React from 'react'
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider"
|
||||
|
||||
export function DashboardClientLayout({
|
||||
children,
|
||||
searchSpaceId,
|
||||
navSecondary,
|
||||
navMain
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
searchSpaceId: string;
|
||||
navSecondary: any[];
|
||||
navMain: any[];
|
||||
}) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
{/* Use AppSidebarProvider which fetches user, search space, and recent chats */}
|
||||
<AppSidebarProvider
|
||||
searchSpaceId={searchSpaceId}
|
||||
navSecondary={navSecondary}
|
||||
navMain={navMain}
|
||||
/>
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
<ThemeTogglerComponent />
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,256 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { toast } from "sonner";
|
||||
import { Edit, Plus, Search, Trash2, ExternalLink, RefreshCw } from "lucide-react";
|
||||
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
// Helper function to get connector type display name
|
||||
const getConnectorTypeDisplay = (type: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
"SERPER_API": "Serper API",
|
||||
"TAVILY_API": "Tavily API",
|
||||
"SLACK_CONNECTOR": "Slack",
|
||||
"NOTION_CONNECTOR": "Notion",
|
||||
// Add other connector types here as needed
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
||||
// Helper function to format date with time
|
||||
const formatDateTime = (dateString: string | null): string => {
|
||||
if (!dateString) return "Never";
|
||||
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
export default function ConnectorsPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
|
||||
const { connectors, isLoading, error, deleteConnector, indexConnector } = useSearchSourceConnectors();
|
||||
const [connectorToDelete, setConnectorToDelete] = useState<number | null>(null);
|
||||
const [indexingConnectorId, setIndexingConnectorId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast.error("Failed to load connectors");
|
||||
console.error("Error fetching connectors:", error);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
// Handle connector deletion
|
||||
const handleDeleteConnector = async () => {
|
||||
if (connectorToDelete === null) return;
|
||||
|
||||
try {
|
||||
await deleteConnector(connectorToDelete);
|
||||
toast.success("Connector deleted successfully");
|
||||
} catch (error) {
|
||||
console.error("Error deleting connector:", error);
|
||||
toast.error("Failed to delete connector");
|
||||
} finally {
|
||||
setConnectorToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle connector indexing
|
||||
const handleIndexConnector = async (connectorId: number) => {
|
||||
setIndexingConnectorId(connectorId);
|
||||
try {
|
||||
await indexConnector(connectorId, searchSpaceId);
|
||||
toast.success("Connector content indexed successfully");
|
||||
} catch (error) {
|
||||
console.error("Error indexing connector content:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to index connector content");
|
||||
} finally {
|
||||
setIndexingConnectorId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-6xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mb-8 flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connectors</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Manage your connected services and data sources.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Connector
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>Your Connectors</CardTitle>
|
||||
<CardDescription>
|
||||
View and manage all your connected services.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-pulse text-center">
|
||||
<div className="h-6 w-32 bg-muted rounded mx-auto mb-2"></div>
|
||||
<div className="h-4 w-48 bg-muted rounded mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : connectors.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-medium mb-2">No connectors found</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
You haven't added any connectors yet. Add one to enhance your search capabilities.
|
||||
</p>
|
||||
<Button onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Your First Connector
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Last Indexed</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{connectors.map((connector) => (
|
||||
<TableRow key={connector.id}>
|
||||
<TableCell className="font-medium">{connector.name}</TableCell>
|
||||
<TableCell>{getConnectorTypeDisplay(connector.connector_type)}</TableCell>
|
||||
<TableCell>
|
||||
{connector.is_indexable
|
||||
? formatDateTime(connector.last_indexed_at)
|
||||
: "Not indexable"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{connector.is_indexable && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleIndexConnector(connector.id)}
|
||||
disabled={indexingConnectorId === connector.id}
|
||||
>
|
||||
{indexingConnectorId === connector.id ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">Index Content</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Index Content</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/${connector.id}`)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
<span className="sr-only">Edit</span>
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive-foreground hover:bg-destructive/10"
|
||||
onClick={() => setConnectorToDelete(connector.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">Delete</span>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Connector</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this connector? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setConnectorToDelete(null)}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={handleDeleteConnector}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,279 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
|
||||
import { useSearchSourceConnectors, SearchSourceConnector } from "@/hooks/useSearchSourceConnectors";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const apiConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().min(10, {
|
||||
message: "API key is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Helper function to get connector type display name
|
||||
const getConnectorTypeDisplay = (type: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
"SERPER_API": "Serper API",
|
||||
"TAVILY_API": "Tavily API",
|
||||
"SLACK_CONNECTOR": "Slack Connector",
|
||||
"NOTION_CONNECTOR": "Notion Connector",
|
||||
// Add other connector types here as needed
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
||||
// Define the type for the form values
|
||||
type ApiConnectorFormValues = z.infer<typeof apiConnectorFormSchema>;
|
||||
|
||||
export default function EditConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const connectorId = parseInt(params.connector_id as string, 10);
|
||||
|
||||
const { connectors, updateConnector } = useSearchSourceConnectors();
|
||||
const [connector, setConnector] = useState<SearchSourceConnector | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<ApiConnectorFormValues>({
|
||||
resolver: zodResolver(apiConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Get API key field name based on connector type
|
||||
const getApiKeyFieldName = (connectorType: string): string => {
|
||||
const fieldMap: Record<string, string> = {
|
||||
"SERPER_API": "SERPER_API_KEY",
|
||||
"TAVILY_API": "TAVILY_API_KEY",
|
||||
"SLACK_CONNECTOR": "SLACK_BOT_TOKEN",
|
||||
"NOTION_CONNECTOR": "NOTION_INTEGRATION_TOKEN"
|
||||
};
|
||||
return fieldMap[connectorType] || "";
|
||||
};
|
||||
|
||||
// Find connector in the list
|
||||
useEffect(() => {
|
||||
const currentConnector = connectors.find(c => c.id === connectorId);
|
||||
|
||||
if (currentConnector) {
|
||||
setConnector(currentConnector);
|
||||
|
||||
// Check if connector type is supported
|
||||
const apiKeyField = getApiKeyFieldName(currentConnector.connector_type);
|
||||
if (apiKeyField) {
|
||||
form.reset({
|
||||
name: currentConnector.name,
|
||||
api_key: currentConnector.config[apiKeyField] || "",
|
||||
});
|
||||
} else {
|
||||
// Redirect if not a supported connector type
|
||||
toast.error("This connector type is not supported for editing");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
} else if (!isLoading && connectors.length > 0) {
|
||||
// If connectors are loaded but this one isn't found
|
||||
toast.error("Connector not found");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
}
|
||||
}, [connectors, connectorId, form, router, searchSpaceId, isLoading]);
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: ApiConnectorFormValues) => {
|
||||
if (!connector) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const apiKeyField = getApiKeyFieldName(connector.connector_type);
|
||||
|
||||
// Only update the API key if a new one was provided
|
||||
const updatedConfig = { ...connector.config };
|
||||
if (values.api_key) {
|
||||
updatedConfig[apiKeyField] = values.api_key;
|
||||
}
|
||||
|
||||
await updateConnector(connectorId, {
|
||||
name: values.name,
|
||||
connector_type: connector.connector_type,
|
||||
config: updatedConfig,
|
||||
});
|
||||
|
||||
toast.success("Connector updated successfully!");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error updating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to update connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl flex justify-center items-center min-h-[60vh]">
|
||||
<div className="animate-pulse text-center">
|
||||
<div className="h-8 w-48 bg-muted rounded mx-auto mb-4"></div>
|
||||
<div className="h-4 w-64 bg-muted rounded mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">
|
||||
Edit {connector ? getConnectorTypeDisplay(connector.connector_type) : ""} Connector
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Update your connector settings.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>API Key Security</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your API key is stored securely. For security reasons, we don't display your existing API key.
|
||||
If you don't update the API key field, your existing key will be preserved.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My API Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{connector?.connector_type === "SLACK_CONNECTOR"
|
||||
? "Slack Bot Token"
|
||||
: connector?.connector_type === "NOTION_CONNECTOR"
|
||||
? "Notion Integration Token"
|
||||
: "API Key"}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={
|
||||
connector?.connector_type === "SLACK_CONNECTOR"
|
||||
? "Enter your Slack Bot Token"
|
||||
: connector?.connector_type === "NOTION_CONNECTOR"
|
||||
? "Enter your Notion Integration Token"
|
||||
: "Enter your API key"
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{connector?.connector_type === "SLACK_CONNECTOR"
|
||||
? "Enter a new Slack Bot Token or leave blank to keep your existing token."
|
||||
: connector?.connector_type === "NOTION_CONNECTOR"
|
||||
? "Enter a new Notion Integration Token or leave blank to keep your existing token."
|
||||
: "Enter a new API key or leave blank to keep your existing key."}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Updating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Update Connector
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,317 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const notionConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
integration_token: z.string().min(10, {
|
||||
message: "Notion Integration Token is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type NotionConnectorFormValues = z.infer<typeof notionConnectorFormSchema>;
|
||||
|
||||
export default function NotionConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<NotionConnectorFormValues>({
|
||||
resolver: zodResolver(notionConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Notion Connector",
|
||||
integration_token: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: NotionConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: "NOTION_CONNECTOR",
|
||||
config: {
|
||||
NOTION_INTEGRATION_TOKEN: values.integration_token,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
|
||||
toast.success("Notion connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="connect">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Notion Workspace</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Notion to search and retrieve information from your workspace pages and databases. This connector can index your Notion content for search.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Notion Integration Token Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Notion Integration Token to use this connector. You can create a Notion integration and get the token from{" "}
|
||||
<a
|
||||
href="https://www.notion.so/my-integrations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Notion Integrations Dashboard
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Notion Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="integration_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Notion Integration Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="ntn_.."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Notion Integration Token will be encrypted and stored securely. It typically starts with "ntn_".
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Notion
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Notion integration:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Search through your Notion pages and databases</li>
|
||||
<li>Access documents, wikis, and knowledge bases</li>
|
||||
<li>Connect your team's knowledge directly to your search space</li>
|
||||
<li>Keep your search results up-to-date with latest Notion content</li>
|
||||
<li>Index your Notion documents for enhanced search capabilities</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documentation">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Notion Connector Documentation</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to set up and use the Notion connector to index your workspace data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The Notion connector uses the Notion search API to fetch all pages that the connector has access to within a workspace.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||
<li>For follow up indexing runs, the connector only retrieves pages that have been updated since the last indexing attempt.</li>
|
||||
<li>Indexing is configured to run every <strong>10 minutes</strong>, so page updates should appear within 10 minutes.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="authorization">
|
||||
<AccordionTrigger className="text-lg font-medium">Authorization</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>No Admin Access Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
There's no requirement to be an Admin to share information with an integration. Any member can share pages and databases with it.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Step 1: Create an integration</h4>
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Visit <a href="https://www.notion.com/my-integrations" target="_blank" rel="noopener noreferrer" className="font-medium underline underline-offset-4">https://www.notion.com/my-integrations</a> in your browser.</li>
|
||||
<li>Click the <strong>+ New integration</strong> button.</li>
|
||||
<li>Name the integration (something like "Search Connector" could work).</li>
|
||||
<li>Select "Read content" as the only capability required.</li>
|
||||
<li>Click <strong>Submit</strong> to create the integration.</li>
|
||||
<li>On the next page, you'll find your Notion integration token. Make a copy of it as you'll need it to configure the connector.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Step 2: Share pages/databases with your integration</h4>
|
||||
<p className="text-muted-foreground mb-3">
|
||||
To keep your information secure, integrations don't have access to any pages or databases in the workspace at first.
|
||||
You must share specific pages with an integration in order for the connector to access those pages.
|
||||
</p>
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Go to the page/database in your workspace.</li>
|
||||
<li>Click the <code>•••</code> on the top right corner of the page.</li>
|
||||
<li>Scroll to the bottom of the pop-up and click <strong>Add connections</strong>.</li>
|
||||
<li>Search for and select the new integration in the <code>Search for connections...</code> menu.</li>
|
||||
<li>
|
||||
<strong>Important:</strong>
|
||||
<ul className="list-disc pl-5 mt-1">
|
||||
<li>If you've added a page, all child pages also become accessible.</li>
|
||||
<li>If you've added a database, all rows (and their children) become accessible.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="indexing">
|
||||
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Navigate to the Connector Dashboard and select the <strong>Notion</strong> Connector.</li>
|
||||
<li>Place the <strong>Integration Token</strong> under <strong>Step 1 Provide Credentials</strong>.</li>
|
||||
<li>Click <strong>Connect</strong> to establish the connection.</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Indexing Behavior</AlertTitle>
|
||||
<AlertDescription>
|
||||
The Notion connector currently indexes everything it has access to. If you want to limit specific content being indexed, simply unshare the database from Notion with the integration.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,256 @@
|
|||
"use client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
IconBrandGoogle,
|
||||
IconBrandSlack,
|
||||
IconBrandWindows,
|
||||
IconBrandDiscord,
|
||||
IconSearch,
|
||||
IconMessages,
|
||||
IconDatabase,
|
||||
IconCloud,
|
||||
IconBrandGithub,
|
||||
IconBrandNotion,
|
||||
IconMail,
|
||||
IconBrandZoom,
|
||||
IconChevronRight,
|
||||
} from "@tabler/icons-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
|
||||
// Define connector categories and their connectors
|
||||
const connectorCategories = [
|
||||
{
|
||||
id: "search-engines",
|
||||
title: "Search Engines",
|
||||
description: "Connect to search engines to enhance your research capabilities.",
|
||||
icon: <IconSearch className="h-5 w-5" />,
|
||||
connectors: [
|
||||
{
|
||||
id: "tavily-api",
|
||||
title: "Tavily Search API",
|
||||
description: "Connect to Tavily Search API to search the web.",
|
||||
icon: <IconSearch className="h-6 w-6" />,
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "serper-api",
|
||||
title: "Serper API",
|
||||
description: "Connect to Serper API to search the web.",
|
||||
icon: <IconBrandGoogle className="h-6 w-6" />,
|
||||
status: "coming-soon",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "team-chats",
|
||||
title: "Team Chats",
|
||||
description: "Connect to your team communication platforms.",
|
||||
icon: <IconMessages className="h-5 w-5" />,
|
||||
connectors: [
|
||||
{
|
||||
id: "slack-connector",
|
||||
title: "Slack",
|
||||
description: "Connect to your Slack workspace to access messages and channels.",
|
||||
icon: <IconBrandSlack className="h-6 w-6" />,
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "ms-teams",
|
||||
title: "Microsoft Teams",
|
||||
description: "Connect to Microsoft Teams to access your team's conversations.",
|
||||
icon: <IconBrandWindows className="h-6 w-6" />,
|
||||
status: "coming-soon",
|
||||
},
|
||||
{
|
||||
id: "discord",
|
||||
title: "Discord",
|
||||
description: "Connect to Discord servers to access messages and channels.",
|
||||
icon: <IconBrandDiscord className="h-6 w-6" />,
|
||||
status: "coming-soon",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "knowledge-bases",
|
||||
title: "Knowledge Bases",
|
||||
description: "Connect to your knowledge bases and documentation.",
|
||||
icon: <IconDatabase className="h-5 w-5" />,
|
||||
connectors: [
|
||||
{
|
||||
id: "notion-connector",
|
||||
title: "Notion",
|
||||
description: "Connect to your Notion workspace to access pages and databases.",
|
||||
icon: <IconBrandNotion className="h-6 w-6" />,
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
title: "GitHub",
|
||||
description: "Connect to GitHub repositories to access code and documentation.",
|
||||
icon: <IconBrandGithub className="h-6 w-6" />,
|
||||
status: "coming-soon",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "communication",
|
||||
title: "Communication",
|
||||
description: "Connect to your email and meeting platforms.",
|
||||
icon: <IconMail className="h-5 w-5" />,
|
||||
connectors: [
|
||||
{
|
||||
id: "gmail",
|
||||
title: "Gmail",
|
||||
description: "Connect to your Gmail account to access emails.",
|
||||
icon: <IconMail className="h-6 w-6" />,
|
||||
status: "coming-soon",
|
||||
},
|
||||
{
|
||||
id: "zoom",
|
||||
title: "Zoom",
|
||||
description: "Connect to Zoom to access meeting recordings and transcripts.",
|
||||
icon: <IconBrandZoom className="h-6 w-6" />,
|
||||
status: "coming-soon",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function ConnectorsPage() {
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [expandedCategories, setExpandedCategories] = useState<string[]>(["search-engines"]);
|
||||
|
||||
const toggleCategory = (categoryId: string) => {
|
||||
setExpandedCategories(prev =>
|
||||
prev.includes(categoryId)
|
||||
? prev.filter(id => id !== categoryId)
|
||||
: [...prev, categoryId]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-6xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mb-8 text-center"
|
||||
>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connect Your Tools</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Integrate with your favorite services to enhance your research capabilities.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{connectorCategories.map((category, categoryIndex) => (
|
||||
<Collapsible
|
||||
key={category.id}
|
||||
open={expandedCategories.includes(category.id)}
|
||||
onOpenChange={() => toggleCategory(category.id)}
|
||||
className="border rounded-lg overflow-hidden bg-card"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: categoryIndex * 0.1 }}
|
||||
className="p-4 flex items-center justify-between cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-md bg-primary/10 text-primary">
|
||||
{category.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{category.title}</h2>
|
||||
<p className="text-sm text-muted-foreground">{category.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<IconChevronRight
|
||||
className={cn(
|
||||
"h-5 w-5 text-muted-foreground transition-transform duration-200",
|
||||
expandedCategories.includes(category.id) && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<Separator />
|
||||
<div className="p-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<AnimatePresence>
|
||||
{category.connectors.map((connector, index) => (
|
||||
<motion.div
|
||||
key={connector.id}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
delay: index * 0.05,
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30
|
||||
}}
|
||||
className={cn(
|
||||
"relative group flex flex-col p-4 rounded-lg border",
|
||||
connector.status === "coming-soon" ? "opacity-70" : ""
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition duration-200 bg-gradient-to-t from-accent/50 to-transparent rounded-lg pointer-events-none" />
|
||||
|
||||
<div className="mb-4 relative z-10 text-primary">
|
||||
{connector.icon}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-lg font-semibold group-hover:translate-x-1 transition duration-200">
|
||||
{connector.title}
|
||||
</h3>
|
||||
{connector.status === "coming-soon" && (
|
||||
<span className="text-xs bg-muted px-2 py-1 rounded-full">Coming soon</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-4 flex-grow">
|
||||
{connector.description}
|
||||
</p>
|
||||
|
||||
{connector.status === "available" ? (
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`}
|
||||
className="w-full mt-auto"
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full"
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mt-auto"
|
||||
disabled
|
||||
>
|
||||
Notify Me
|
||||
</Button>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,207 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const serperApiFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().min(10, {
|
||||
message: "API key is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type SerperApiFormValues = z.infer<typeof serperApiFormSchema>;
|
||||
|
||||
export default function SerperApiPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<SerperApiFormValues>({
|
||||
resolver: zodResolver(serperApiFormSchema),
|
||||
defaultValues: {
|
||||
name: "Serper API Connector",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: SerperApiFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: "SERPER_API",
|
||||
config: {
|
||||
SERPER_API_KEY: values.api_key,
|
||||
},
|
||||
is_indexable: false,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
|
||||
toast.success("Serper API connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Serper API</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Serper API to enhance your search capabilities with Google search results.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>API Key Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Serper API key to use this connector. You can get one by signing up at{" "}
|
||||
<a
|
||||
href="https://serper.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
serper.dev
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Serper API Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Serper API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your Serper API key"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your API key will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Serper API
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Serper API:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Access to Google search results directly in your research</li>
|
||||
<li>Real-time information from the web</li>
|
||||
<li>Enhanced search capabilities for your projects</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,351 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const slackConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
bot_token: z.string().min(10, {
|
||||
message: "Bot User OAuth Token is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type SlackConnectorFormValues = z.infer<typeof slackConnectorFormSchema>;
|
||||
|
||||
export default function SlackConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<SlackConnectorFormValues>({
|
||||
resolver: zodResolver(slackConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Slack Connector",
|
||||
bot_token: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: SlackConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: "SLACK_CONNECTOR",
|
||||
config: {
|
||||
SLACK_BOT_TOKEN: values.bot_token,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
|
||||
toast.success("Slack connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="connect">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Slack Workspace</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Slack to search and retrieve information from your workspace channels and conversations. This connector can index your Slack messages for search.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Bot User OAuth Token Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Slack Bot User OAuth Token to use this connector. You can create a Slack app and get the token from{" "}
|
||||
<a
|
||||
href="https://api.slack.com/apps"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Slack API Dashboard
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Slack Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bot_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slack Bot User OAuth Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="xoxb-..."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Bot User OAuth Token will be encrypted and stored securely. It typically starts with "xoxb-".
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Slack
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Slack integration:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Search through your Slack channels and conversations</li>
|
||||
<li>Access historical messages and shared files</li>
|
||||
<li>Connect your team's knowledge directly to your search space</li>
|
||||
<li>Keep your search results up-to-date with latest communications</li>
|
||||
<li>Index your Slack messages for enhanced search capabilities</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documentation">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Slack Connector Documentation</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to set up and use the Slack connector to index your workspace data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The Slack connector indexes all public channels for a given workspace.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||
<li>Upcoming: Support for private channels by tagging/adding the Slack Bot to private channels.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="authorization">
|
||||
<AccordionTrigger className="text-lg font-medium">Authorization</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Admin Access Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You must be an admin of the Slack workspace to set up the connector.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Navigate and sign in to <a href="https://api.slack.com/apps" target="_blank" rel="noopener noreferrer" className="font-medium underline underline-offset-4">https://api.slack.com/apps</a>.</li>
|
||||
<li>
|
||||
Create a new Slack app:
|
||||
<ul className="list-disc pl-5 mt-1">
|
||||
<li>Click the <strong>Create New App</strong> button in the top right.</li>
|
||||
<li>Select <strong>From an app manifest</strong> option.</li>
|
||||
<li>Select the relevant workspace from the dropdown and click <strong>Next</strong>.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
Select the "YAML" tab, paste the following manifest into the text box, and click <strong>Next</strong>:
|
||||
<div className="bg-muted p-4 rounded-md mt-2 overflow-x-auto">
|
||||
<pre className="text-xs">
|
||||
{`display_information:
|
||||
name: SlackConnector
|
||||
description: ReadOnly Connector for indexing
|
||||
features:
|
||||
bot_user:
|
||||
display_name: SlackConnector
|
||||
always_online: false
|
||||
oauth_config:
|
||||
scopes:
|
||||
bot:
|
||||
- channels:history
|
||||
- channels:read
|
||||
- groups:history
|
||||
- groups:read
|
||||
- channels:join
|
||||
- im:history
|
||||
- users:read
|
||||
- users:read.email
|
||||
- usergroups:read
|
||||
settings:
|
||||
org_deploy_enabled: false
|
||||
socket_mode_enabled: false
|
||||
token_rotation_enabled: false`}
|
||||
</pre>
|
||||
</div>
|
||||
</li>
|
||||
<li>Click the <strong>Create</strong> button.</li>
|
||||
<li>In the app page, navigate to the <strong>OAuth & Permissions</strong> tab under the <strong>Features</strong> header.</li>
|
||||
<li>Copy the <strong>Bot User OAuth Token</strong>, this will be used to access Slack.</li>
|
||||
</ol>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="indexing">
|
||||
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Navigate to the Connector Dashboard and select the <strong>Slack</strong> Connector.</li>
|
||||
<li>Place the <strong>Bot User OAuth Token</strong> under <strong>Step 1 Provide Credentials</strong>.</li>
|
||||
<li>Click <strong>Connect</strong> to establish the connection.</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Important: Invite Bot to Channels</AlertTitle>
|
||||
<AlertDescription>
|
||||
After connecting, you must invite the bot to each channel you want to index. In each Slack channel, type:
|
||||
<pre className="mt-2 bg-background p-2 rounded-md text-xs">/invite @YourBotName</pre>
|
||||
<p className="mt-2">Without this step, you'll get a "not_in_channel" error when the connector tries to access channel messages.</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Alert className="bg-muted mt-4">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>First Indexing</AlertTitle>
|
||||
<AlertDescription>
|
||||
The first indexing pulls all of the public channels and takes longer than future updates. Only channels where the bot has been invited will be fully indexed.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="mt-4">
|
||||
<h4 className="font-medium mb-2">Troubleshooting:</h4>
|
||||
<ul className="list-disc pl-5 space-y-2 text-muted-foreground">
|
||||
<li>
|
||||
<strong>not_in_channel error:</strong> If you see this error in logs, it means the bot hasn't been invited to a channel it's trying to access. Use the <code>/invite @YourBotName</code> command in that channel.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Alternative approach:</strong> You can add the <code>chat:write.public</code> scope to your Slack app to allow it to access public channels without an explicit invitation.
|
||||
</li>
|
||||
<li>
|
||||
<strong>For private channels:</strong> The bot must always be invited using the <code>/invite</code> command.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,207 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const tavilyApiFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().min(10, {
|
||||
message: "API key is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type TavilyApiFormValues = z.infer<typeof tavilyApiFormSchema>;
|
||||
|
||||
export default function TavilyApiPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<TavilyApiFormValues>({
|
||||
resolver: zodResolver(tavilyApiFormSchema),
|
||||
defaultValues: {
|
||||
name: "Tavily API Connector",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: TavilyApiFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: "TAVILY_API",
|
||||
config: {
|
||||
TAVILY_API_KEY: values.api_key,
|
||||
},
|
||||
is_indexable: false,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
|
||||
toast.success("Tavily API connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Tavily API</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Tavily API to enhance your search capabilities with AI-powered search results.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>API Key Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Tavily API key to use this connector. You can get one by signing up at{" "}
|
||||
<a
|
||||
href="https://tavily.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
tavily.com
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Tavily API Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tavily API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your Tavily API key"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your API key will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Tavily API
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Tavily API:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>AI-powered search results tailored to your queries</li>
|
||||
<li>Real-time information from the web</li>
|
||||
<li>Enhanced search capabilities for your projects</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,463 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useCallback, useRef } from "react"
|
||||
import { useDropzone } from "react-dropzone"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { toast } from "sonner"
|
||||
import { X, Upload, FileIcon, Tag, AlertCircle, CheckCircle2, Calendar, FileType } from "lucide-react"
|
||||
import { useRouter, useParams } from "next/navigation"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
|
||||
// Grid pattern component inspired by Aceternity UI
|
||||
function GridPattern() {
|
||||
const columns = 41;
|
||||
const rows = 11;
|
||||
return (
|
||||
<div className="flex bg-gray-100 dark:bg-neutral-900 flex-shrink-0 flex-wrap justify-center items-center gap-x-px gap-y-px scale-105">
|
||||
{Array.from({ length: rows }).map((_, row) =>
|
||||
Array.from({ length: columns }).map((_, col) => {
|
||||
const index = row * columns + col;
|
||||
return (
|
||||
<div
|
||||
key={`${col}-${row}`}
|
||||
className={`w-10 h-10 flex flex-shrink-0 rounded-[2px] ${index % 2 === 0
|
||||
? "bg-gray-50 dark:bg-neutral-950"
|
||||
: "bg-gray-50 dark:bg-neutral-950 shadow-[0px_0px_1px_3px_rgba(255,255,255,1)_inset] dark:shadow-[0px_0px_1px_3px_rgba(0,0,0,1)_inset]"
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FileUploader() {
|
||||
// Use the useParams hook to get the params
|
||||
const params = useParams();
|
||||
const search_space_id = params.search_space_id as string;
|
||||
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const router = useRouter();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const acceptedFileTypes = {
|
||||
'image/bmp': ['.bmp'],
|
||||
'text/csv': ['.csv'],
|
||||
'application/msword': ['.doc'],
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
|
||||
'message/rfc822': ['.eml'],
|
||||
'application/epub+zip': ['.epub'],
|
||||
'image/heic': ['.heic'],
|
||||
'text/html': ['.html'],
|
||||
'image/jpeg': ['.jpeg', '.jpg'],
|
||||
'image/png': ['.png'],
|
||||
'text/markdown': ['.md'],
|
||||
'application/vnd.ms-outlook': ['.msg'],
|
||||
'application/vnd.oasis.opendocument.text': ['.odt'],
|
||||
'text/x-org': ['.org'],
|
||||
'application/pkcs7-signature': ['.p7s'],
|
||||
'application/pdf': ['.pdf'],
|
||||
'application/vnd.ms-powerpoint': ['.ppt'],
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'],
|
||||
'text/x-rst': ['.rst'],
|
||||
'application/rtf': ['.rtf'],
|
||||
'image/tiff': ['.tiff'],
|
||||
'text/plain': ['.txt'],
|
||||
'text/tab-separated-values': ['.tsv'],
|
||||
'application/vnd.ms-excel': ['.xls'],
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
||||
'application/xml': ['.xml'],
|
||||
}
|
||||
|
||||
const supportedExtensions = Array.from(new Set(Object.values(acceptedFileTypes).flat())).sort()
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
setFiles((prevFiles) => [...prevFiles, ...acceptedFiles])
|
||||
}, [])
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: acceptedFileTypes,
|
||||
maxSize: 50 * 1024 * 1024, // 50MB
|
||||
})
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles((prevFiles) => prevFiles.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return "0 Bytes"
|
||||
const k = 1024
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"]
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
setIsUploading(true)
|
||||
|
||||
const formData = new FormData()
|
||||
files.forEach((file) => {
|
||||
formData.append("files", file)
|
||||
})
|
||||
|
||||
formData.append('search_space_id', search_space_id)
|
||||
|
||||
try {
|
||||
toast("File Upload", {
|
||||
description: "Files Uploading Initiated",
|
||||
})
|
||||
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL!}/api/v1/documents/fileupload`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Authorization': `Bearer ${window.localStorage.getItem("surfsense_bearer_token")}`
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Upload failed")
|
||||
}
|
||||
|
||||
await response.json()
|
||||
|
||||
toast("Upload Successful", {
|
||||
description: "Files Uploaded Successfully",
|
||||
})
|
||||
|
||||
router.push(`/dashboard/${search_space_id}/documents`);
|
||||
} catch (error: any) {
|
||||
setIsUploading(false)
|
||||
toast("Upload Error", {
|
||||
description: `Error uploading files: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const mainVariant = {
|
||||
initial: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
animate: {
|
||||
x: 20,
|
||||
y: -20,
|
||||
opacity: 0.9,
|
||||
},
|
||||
};
|
||||
|
||||
const secondaryVariant = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
when: "beforeChildren",
|
||||
staggerChildren: 0.1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 10 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.3 } }
|
||||
};
|
||||
|
||||
const fileItemVariants = {
|
||||
hidden: { opacity: 0, x: -20 },
|
||||
visible: { opacity: 1, x: 0, transition: { duration: 0.3 } },
|
||||
exit: { opacity: 0, x: 20, transition: { duration: 0.2 } }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grow flex items-center justify-center p-4 md:p-8">
|
||||
<motion.div
|
||||
className="w-full max-w-3xl mx-auto"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
>
|
||||
|
||||
<motion.div
|
||||
className="bg-background rounded-xl shadow-lg overflow-hidden border border-border"
|
||||
variants={itemVariants}
|
||||
>
|
||||
<motion.div
|
||||
className="p-10 group/file block rounded-lg cursor-pointer w-full relative overflow-hidden"
|
||||
whileHover="animate"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Grid background pattern */}
|
||||
<div className="absolute inset-0 [mask-image:radial-gradient(ellipse_at_center,white,transparent)]">
|
||||
<GridPattern />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
{/* Dropzone area */}
|
||||
<div {...getRootProps()} className="flex flex-col items-center justify-center">
|
||||
<input
|
||||
{...getInputProps()}
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<p className="relative z-20 font-sans font-bold text-neutral-700 dark:text-neutral-300 text-xl">
|
||||
Upload files
|
||||
</p>
|
||||
<p className="relative z-20 font-sans font-normal text-neutral-400 dark:text-neutral-400 text-base mt-2">
|
||||
Drag or drop your files here or click to upload
|
||||
</p>
|
||||
|
||||
<div className="relative w-full mt-10 max-w-xl mx-auto">
|
||||
{!files.length && (
|
||||
<motion.div
|
||||
layoutId="file-upload"
|
||||
variants={mainVariant}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 20,
|
||||
}}
|
||||
className="relative group-hover/file:shadow-2xl z-40 bg-white dark:bg-neutral-900 flex items-center justify-center h-32 mt-4 w-full max-w-[8rem] mx-auto rounded-md shadow-[0px_10px_50px_rgba(0,0,0,0.1)]"
|
||||
key="upload-icon"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
{isDragActive ? (
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-neutral-600 flex flex-col items-center"
|
||||
>
|
||||
Drop it
|
||||
<Upload className="h-4 w-4 text-neutral-600 dark:text-neutral-400 mt-2" />
|
||||
</motion.p>
|
||||
) : (
|
||||
<Upload className="h-8 w-8 text-neutral-600 dark:text-neutral-300" />
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{!files.length && (
|
||||
<motion.div
|
||||
variants={secondaryVariant}
|
||||
className="absolute opacity-0 border border-dashed border-primary inset-0 z-30 bg-transparent flex items-center justify-center h-32 mt-4 w-full max-w-[8rem] mx-auto rounded-md"
|
||||
key="upload-border"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
></motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* File list section */}
|
||||
<AnimatePresence mode="wait">
|
||||
{files.length > 0 && (
|
||||
<motion.div
|
||||
className="px-8 pb-8"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="font-medium">Selected Files ({files.length})</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Use AnimatePresence to properly handle the transition
|
||||
// This will ensure the file icon reappears properly
|
||||
setFiles([]);
|
||||
|
||||
// Force a re-render after animation completes
|
||||
setTimeout(() => {
|
||||
const event = new Event('resize');
|
||||
window.dispatchEvent(event);
|
||||
}, 350);
|
||||
}}
|
||||
disabled={isUploading}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
|
||||
<AnimatePresence>
|
||||
{files.map((file, index) => (
|
||||
<motion.div
|
||||
key={`${file.name}-${index}`}
|
||||
layoutId={index === 0 ? "file-upload" : `file-upload-${index}`}
|
||||
className="relative overflow-hidden z-40 bg-white dark:bg-neutral-900 flex flex-col items-start justify-start p-4 w-full mx-auto rounded-md shadow-sm border border-border"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
variants={fileItemVariants}
|
||||
>
|
||||
<div className="flex justify-between w-full items-center gap-4">
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
layout
|
||||
className="text-base text-neutral-700 dark:text-neutral-300 truncate max-w-xs font-medium"
|
||||
>
|
||||
{file.name}
|
||||
</motion.p>
|
||||
<div className="flex items-center gap-2">
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
layout
|
||||
className="rounded-lg px-2 py-1 w-fit flex-shrink-0 text-sm text-neutral-600 dark:bg-neutral-800 dark:text-white bg-neutral-100"
|
||||
>
|
||||
{formatFileSize(file.size)}
|
||||
</motion.p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeFile(index)}
|
||||
disabled={isUploading}
|
||||
className="h-8 w-8"
|
||||
aria-label={`Remove ${file.name}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex text-sm md:flex-row flex-col items-start md:items-center w-full mt-2 justify-between text-neutral-600 dark:text-neutral-400">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
layout
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md bg-gray-100 dark:bg-neutral-800"
|
||||
>
|
||||
<FileType className="h-3 w-3" />
|
||||
<span>{file.type || 'Unknown type'}</span>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
layout
|
||||
className="flex items-center gap-1 mt-2 md:mt-0"
|
||||
>
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>modified {new Date(file.lastModified).toLocaleDateString()}</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="mt-6"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<Button
|
||||
className="w-full py-6 text-base font-medium"
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading || files.length === 0}
|
||||
>
|
||||
{isUploading ? (
|
||||
<motion.div
|
||||
className="flex items-center gap-2"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
>
|
||||
<Upload className="h-5 w-5" />
|
||||
</motion.div>
|
||||
<span>Uploading...</span>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
className="flex items-center gap-2"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
<span>Upload {files.length} {files.length === 1 ? "file" : "files"}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* File type information */}
|
||||
<motion.div
|
||||
className="px-8 pb-8"
|
||||
variants={itemVariants}
|
||||
>
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Tag className="h-4 w-4 text-primary" />
|
||||
<p className="text-sm font-medium">Supported file types:</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{supportedExtensions.map((ext) => (
|
||||
<motion.span
|
||||
key={ext}
|
||||
className="px-2 py-1 bg-primary/10 text-primary text-xs rounded-full"
|
||||
whileHover={{ scale: 1.05, backgroundColor: "rgba(var(--primary), 0.2)" }}
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 1 }}
|
||||
layout
|
||||
>
|
||||
{ext}
|
||||
</motion.span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<style jsx global>{`
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(var(--muted-foreground), 0.3);
|
||||
border-radius: 20px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(var(--muted-foreground), 0.5);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,200 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { Tag, TagInput } from "emblor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { toast } from "sonner";
|
||||
import { Globe, Loader2 } from "lucide-react";
|
||||
|
||||
// URL validation regex
|
||||
const urlRegex = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/;
|
||||
|
||||
export default function WebpageCrawler() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const search_space_id = params.search_space_id as string;
|
||||
|
||||
const [urlTags, setUrlTags] = useState<Tag[]>([]);
|
||||
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Function to validate a URL
|
||||
const isValidUrl = (url: string): boolean => {
|
||||
return urlRegex.test(url);
|
||||
};
|
||||
|
||||
// Function to handle URL submission
|
||||
const handleSubmit = async () => {
|
||||
// Validate that we have at least one URL
|
||||
if (urlTags.length === 0) {
|
||||
setError("Please add at least one URL");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate all URLs
|
||||
const invalidUrls = urlTags.filter(tag => !isValidUrl(tag.text));
|
||||
if (invalidUrls.length > 0) {
|
||||
setError(`Invalid URLs detected: ${invalidUrls.map(tag => tag.text).join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
toast("URL Crawling", {
|
||||
description: "Starting URL crawling process...",
|
||||
});
|
||||
|
||||
// Extract URLs from tags
|
||||
const urls = urlTags.map(tag => tag.text);
|
||||
|
||||
// Make API call to backend
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem("surfsense_bearer_token")}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
"document_type": "CRAWLED_URL",
|
||||
"content": urls,
|
||||
"search_space_id": parseInt(search_space_id)
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to crawl URLs");
|
||||
}
|
||||
|
||||
await response.json();
|
||||
|
||||
toast("Crawling Successful", {
|
||||
description: "URLs have been submitted for crawling",
|
||||
});
|
||||
|
||||
// Redirect to documents page
|
||||
router.push(`/dashboard/${search_space_id}/documents`);
|
||||
} catch (error: any) {
|
||||
setError(error.message || "An error occurred while crawling URLs");
|
||||
toast("Crawling Error", {
|
||||
description: `Error crawling URLs: ${error.message}`,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to add a new URL tag
|
||||
const handleAddTag = (text: string) => {
|
||||
// Basic URL validation
|
||||
if (!isValidUrl(text)) {
|
||||
toast("Invalid URL", {
|
||||
description: "Please enter a valid URL",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
if (urlTags.some(tag => tag.text === text)) {
|
||||
toast("Duplicate URL", {
|
||||
description: "This URL has already been added",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the new tag
|
||||
const newTag: Tag = {
|
||||
id: Date.now().toString(),
|
||||
text: text,
|
||||
};
|
||||
|
||||
setUrlTags([...urlTags, newTag]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<Card className="max-w-2xl mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5" />
|
||||
Add Webpages for Crawling
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enter URLs to crawl and add to your document collection
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="url-input">Enter URLs to crawl</Label>
|
||||
<TagInput
|
||||
id="url-input"
|
||||
tags={urlTags}
|
||||
setTags={setUrlTags}
|
||||
placeholder="Enter a URL and press Enter"
|
||||
onAddTag={handleAddTag}
|
||||
styleClasses={{
|
||||
inlineTagsContainer:
|
||||
"border-input rounded-lg bg-background shadow-sm shadow-black/5 transition-shadow focus-within:border-ring focus-within:outline-none focus-within:ring-[3px] focus-within:ring-ring/20 p-1 gap-1",
|
||||
input: "w-full min-w-[80px] focus-visible:outline-none shadow-none px-2 h-7",
|
||||
tag: {
|
||||
body: "h-7 relative bg-background border border-input hover:bg-background rounded-md font-medium text-xs ps-2 pe-7 flex",
|
||||
closeButton:
|
||||
"absolute -inset-y-px -end-px p-0 rounded-e-lg flex size-7 transition-colors outline-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 text-muted-foreground/80 hover:text-foreground",
|
||||
},
|
||||
}}
|
||||
activeTagIndex={activeTagIndex}
|
||||
setActiveTagIndex={setActiveTagIndex}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Add multiple URLs by pressing Enter after each one
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-500 mt-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-4 text-sm">
|
||||
<h4 className="font-medium mb-2">Tips for URL crawling:</h4>
|
||||
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
|
||||
<li>Enter complete URLs including http:// or https://</li>
|
||||
<li>Make sure the websites allow crawling</li>
|
||||
<li>Public webpages work best</li>
|
||||
<li>Crawling may take some time depending on the website size</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/dashboard/${search_space_id}/documents`)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || urlTags.length === 0}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
'Submit URLs for Crawling'
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
99
surfsense_web/app/dashboard/[search_space_id]/layout.tsx
Normal file
99
surfsense_web/app/dashboard/[search_space_id]/layout.tsx
Normal file
|
@ -0,0 +1,99 @@
|
|||
// Server component
|
||||
import React, { use } from 'react'
|
||||
import { DashboardClientLayout } from './client-layout'
|
||||
|
||||
export default function DashboardLayout({
|
||||
params,
|
||||
children
|
||||
}: {
|
||||
params: Promise<{ search_space_id: string }>,
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
// Use React.use to unwrap the params Promise
|
||||
const { search_space_id } = use(params);
|
||||
|
||||
// TODO: Get search space name from our FastAPI backend
|
||||
const customNavSecondary = [
|
||||
{
|
||||
title: `All Search Spaces`,
|
||||
url: `#`,
|
||||
icon: "Info",
|
||||
},
|
||||
{
|
||||
title: `All Search Spaces`,
|
||||
url: "/dashboard",
|
||||
icon: "Undo2",
|
||||
},
|
||||
]
|
||||
|
||||
const customNavMain = [
|
||||
{
|
||||
title: "Researcher",
|
||||
url: `/dashboard/${search_space_id}/researcher`,
|
||||
icon: "SquareTerminal",
|
||||
isActive: true,
|
||||
items: [],
|
||||
},
|
||||
|
||||
{
|
||||
title: "Documents",
|
||||
url: "#",
|
||||
icon: "FileStack",
|
||||
items: [
|
||||
{
|
||||
title: "Upload Documents",
|
||||
url: `/dashboard/${search_space_id}/documents/upload`,
|
||||
},
|
||||
{
|
||||
title: "Add Webpages",
|
||||
url: `/dashboard/${search_space_id}/documents/webpage`,
|
||||
},
|
||||
{
|
||||
title: "Manage Documents",
|
||||
url: `/dashboard/${search_space_id}/documents`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Connectors",
|
||||
url: `#`,
|
||||
icon: "Cable",
|
||||
items: [
|
||||
{
|
||||
title: "Add Connector",
|
||||
url: `/dashboard/${search_space_id}/connectors/add`,
|
||||
},
|
||||
{
|
||||
title: "Manage Connectors",
|
||||
url: `/dashboard/${search_space_id}/connectors`,
|
||||
},
|
||||
],
|
||||
},
|
||||
// TODO: Add research synthesizer's
|
||||
// {
|
||||
// title: "Research Synthesizer's",
|
||||
// url: `#`,
|
||||
// icon: "SquareLibrary",
|
||||
// items: [
|
||||
// {
|
||||
// title: "Podcast Creator",
|
||||
// url: `/dashboard/${search_space_id}/synthesizer/podcast`,
|
||||
// },
|
||||
// {
|
||||
// title: "Presentation Creator",
|
||||
// url: `/dashboard/${search_space_id}/synthesizer/presentation`,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
]
|
||||
|
||||
return (
|
||||
<DashboardClientLayout
|
||||
searchSpaceId={search_space_id}
|
||||
navSecondary={customNavSecondary}
|
||||
navMain={customNavMain}
|
||||
>
|
||||
{children}
|
||||
</DashboardClientLayout>
|
||||
)
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,80 @@
|
|||
"use client";
|
||||
import React, { useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
const ResearcherPage = () => {
|
||||
const router = useRouter();
|
||||
const { search_space_id } = useParams();
|
||||
const [isCreating, setIsCreating] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const createChat = async () => {
|
||||
try {
|
||||
// Get token from localStorage
|
||||
const token = localStorage.getItem('surfsense_bearer_token');
|
||||
|
||||
if (!token) {
|
||||
setError('Authentication token not found');
|
||||
setIsCreating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new chat
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: "GENERAL",
|
||||
title: "Untitled Chat", // Empty title initially
|
||||
initial_connectors: ["CRAWLED_URL"], // Default connector
|
||||
messages: [],
|
||||
search_space_id: Number(search_space_id)
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create chat: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Redirect to the new chat page
|
||||
router.push(`/dashboard/${search_space_id}/researcher/${data.id}`);
|
||||
} catch (err) {
|
||||
console.error('Error creating chat:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to create chat');
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
createChat();
|
||||
}, [search_space_id, router]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-4rem)]">
|
||||
<div className="text-red-500 mb-4">Error: {error}</div>
|
||||
<button
|
||||
onClick={() => location.reload()}
|
||||
className="px-4 py-2 bg-primary text-white rounded-md"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-4rem)]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary mb-4" />
|
||||
<p className="text-muted-foreground">Creating new research chat...</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearcherPage;
|
353
surfsense_web/app/dashboard/page.tsx
Normal file
353
surfsense_web/app/dashboard/page.tsx
Normal file
|
@ -0,0 +1,353 @@
|
|||
"use client";
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Plus, Search, Trash2, AlertCircle, Loader2 } from 'lucide-react'
|
||||
import { Tilt } from '@/components/ui/tilt'
|
||||
import { Spotlight } from '@/components/ui/spotlight'
|
||||
import { Logo } from '@/components/Logo';
|
||||
import { ThemeTogglerComponent } from '@/components/theme/theme-toggle';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useSearchSpaces } from '@/hooks/use-search-spaces';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* Formats a date string into a readable format
|
||||
* @param dateString - The date string to format
|
||||
* @returns Formatted date string (e.g., "Jan 1, 2023")
|
||||
*/
|
||||
const formatDate = (dateString: string): string => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Loading screen component with animation
|
||||
*/
|
||||
const LoadingScreen = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xl font-medium">Loading</CardTitle>
|
||||
<CardDescription>Fetching your search spaces...</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center py-6">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, ease: "linear" }}
|
||||
>
|
||||
<Loader2 className="h-12 w-12 text-primary" />
|
||||
</motion.div>
|
||||
</CardContent>
|
||||
<CardFooter className="border-t pt-4 text-sm text-muted-foreground">
|
||||
This may take a moment
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Error screen component with animation
|
||||
*/
|
||||
const ErrorScreen = ({ message }: { message: string }) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="w-[400px] bg-background/60 backdrop-blur-sm border-destructive/20">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
<CardTitle className="text-xl font-medium">Error</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Something went wrong</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert variant="destructive" className="bg-destructive/10 border-destructive/30">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error Details</AlertTitle>
|
||||
<AlertDescription className="mt-2">
|
||||
{message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end gap-2 border-t pt-4">
|
||||
<Button variant="outline" onClick={() => router.refresh()}>
|
||||
Try Again
|
||||
</Button>
|
||||
<Button onClick={() => router.push('/')}>
|
||||
Go Home
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DashboardPage = () => {
|
||||
// Animation variants
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { y: 20, opacity: 0 },
|
||||
visible: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 24,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { searchSpaces, loading, error, refreshSearchSpaces } = useSearchSpaces();
|
||||
|
||||
if (loading) return <LoadingScreen />;
|
||||
if (error) return <ErrorScreen message={error} />;
|
||||
|
||||
const handleDeleteSearchSpace = async (id: number) => {
|
||||
// Send DELETE request to the API
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('surfsense_bearer_token')}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error("Failed to delete search space");
|
||||
throw new Error("Failed to delete search space");
|
||||
}
|
||||
|
||||
// Refresh the search spaces list after successful deletion
|
||||
refreshSearchSpaces();
|
||||
} catch (error) {
|
||||
console.error('Error deleting search space:', error);
|
||||
toast.error("An error occurred while deleting the search space");
|
||||
return;
|
||||
}
|
||||
toast.success("Search space deleted successfully");
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="container mx-auto py-10"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
>
|
||||
<motion.div className="flex flex-col space-y-6" variants={itemVariants}>
|
||||
<div className="flex flex-row space-x-4 justify-between">
|
||||
<div className="flex flex-row space-x-4">
|
||||
<Logo className="w-10 h-10 rounded-md" />
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h1 className="text-4xl font-bold">SurfSense Dashboard</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Welcome to your SurfSense dashboard.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ThemeTogglerComponent />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-6 mt-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-semibold">Your Search Spaces</h2>
|
||||
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
||||
<Link href="/dashboard/searchspaces">
|
||||
<Button className="h-10">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Search Space
|
||||
</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{searchSpaces && searchSpaces.map((space) => (
|
||||
<motion.div
|
||||
key={space.id}
|
||||
variants={itemVariants}
|
||||
className="aspect-[4/3]"
|
||||
>
|
||||
|
||||
<Tilt
|
||||
rotationFactor={6}
|
||||
isRevese
|
||||
springOptions={{
|
||||
stiffness: 26.7,
|
||||
damping: 4.1,
|
||||
mass: 0.2,
|
||||
}}
|
||||
className="group relative rounded-lg h-full"
|
||||
>
|
||||
<Spotlight
|
||||
className="z-10 from-blue-500/20 via-blue-300/10 to-blue-200/5 blur-2xl"
|
||||
size={248}
|
||||
springOptions={{
|
||||
stiffness: 26.7,
|
||||
damping: 4.1,
|
||||
mass: 0.2,
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col h-full overflow-hidden rounded-xl border bg-muted/30 backdrop-blur-sm transition-all hover:border-primary/50">
|
||||
<div className="relative h-32 w-full overflow-hidden">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1519389950473-47ba0277781c?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1740&q=80"
|
||||
alt={space.name}
|
||||
className="h-full w-full object-cover grayscale duration-700 group-hover:grayscale-0"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background/80 to-transparent" />
|
||||
<div className="absolute bottom-2 left-3 flex items-center gap-2">
|
||||
<Link href={`/dashboard/${space.id}/documents`}>
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100/80 dark:bg-blue-950/80">
|
||||
<Search className="h-4 w-4 text-blue-500" />
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="absolute top-2 right-2">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full bg-background/50 backdrop-blur-sm hover:bg-destructive/90 hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Search Space</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{space.name}"? This action cannot be undone.
|
||||
All documents, chats, and podcasts in this search space will be permanently deleted.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDeleteSearchSpace(space.id)}
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col justify-between p-4">
|
||||
<div>
|
||||
<h3 className="font-medium text-lg">{space.name}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{space.description}</p>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-between text-xs text-muted-foreground">
|
||||
{/* <span>{space.title}</span> */}
|
||||
<span>Created {formatDate(space.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tilt>
|
||||
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{searchSpaces.length === 0 && (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="col-span-full flex flex-col items-center justify-center p-12 text-center"
|
||||
>
|
||||
<div className="rounded-full bg-muted/50 p-4 mb-4">
|
||||
<Search className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-2">No search spaces found</h3>
|
||||
<p className="text-muted-foreground mb-6">Create your first search space to get started</p>
|
||||
<Link href="/dashboard/searchspaces">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Search Space
|
||||
</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{searchSpaces.length > 0 && (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="aspect-[4/3]"
|
||||
>
|
||||
<Tilt
|
||||
rotationFactor={6}
|
||||
isRevese
|
||||
springOptions={{
|
||||
stiffness: 26.7,
|
||||
damping: 4.1,
|
||||
mass: 0.2,
|
||||
}}
|
||||
className="group relative rounded-lg h-full"
|
||||
>
|
||||
<Link href="/dashboard/searchspaces" className="flex h-full">
|
||||
<div className="flex flex-col items-center justify-center h-full w-full rounded-xl border border-dashed bg-muted/10 hover:border-primary/50 transition-colors">
|
||||
<Plus className="h-10 w-10 mb-3 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Add New Search Space</span>
|
||||
</div>
|
||||
</Link>
|
||||
</Tilt>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DashboardPage
|
52
surfsense_web/app/dashboard/searchspaces/page.tsx
Normal file
52
surfsense_web/app/dashboard/searchspaces/page.tsx
Normal file
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { SearchSpaceForm } from "@/components/search-space-form";
|
||||
import { motion } from "framer-motion";
|
||||
import { useRouter } from "next/navigation";
|
||||
export default function SearchSpacesPage() {
|
||||
const router = useRouter();
|
||||
const handleCreateSearchSpace = async (data: { name: string; description: string }) => {
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('surfsense_bearer_token')}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error("Failed to create search space");
|
||||
throw new Error("Failed to create search space");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
toast.success("Search space created successfully", {
|
||||
description: `"${data.name}" has been created.`,
|
||||
});
|
||||
|
||||
router.push(`/dashboard`);
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
console.error('Error creating search space:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="container mx-auto py-10"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<SearchSpaceForm onSubmit={handleCreateSearchSpace} />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
BIN
surfsense_web/app/favicon.ico
Normal file
BIN
surfsense_web/app/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
150
surfsense_web/app/globals.css
Normal file
150
surfsense_web/app/globals.css
Normal file
|
@ -0,0 +1,150 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.145 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.145 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.985 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.396 0.141 25.723);
|
||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--border: oklch(0.269 0 0);
|
||||
--input: oklch(0.269 0 0);
|
||||
--ring: oklch(0.439 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.269 0 0);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
:root {
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
}
|
73
surfsense_web/app/layout.tsx
Normal file
73
surfsense_web/app/layout.tsx
Normal file
|
@ -0,0 +1,73 @@
|
|||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Roboto } from "next/font/google";
|
||||
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { ThemeProvider } from "@/components/theme/theme-provider";
|
||||
|
||||
const roboto = Roboto({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "700"],
|
||||
display: 'swap',
|
||||
variable: '--font-roboto',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "SurfSense - A Personal NotebookLM and Perplexity-like AI Assistant for Everyone.",
|
||||
description:
|
||||
"Have your own private NotebookLM and Perplexity with better integrations.",
|
||||
openGraph: {
|
||||
images: [
|
||||
{
|
||||
url: "https://surfsense.net/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "SurfSense - A Personal NotebookLM and Perplexity-like AI Assistant for Everyone.",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
site: "https://surfsense.net",
|
||||
creator: "https://surfsense.net",
|
||||
title: "SurfSense - A Personal NotebookLM and Perplexity-like AI Assistant for Everyone.",
|
||||
description:
|
||||
"Have your own private NotebookLM and Perplexity with better integrations.",
|
||||
images: [
|
||||
{
|
||||
url: "https://surfsense.net/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "SurfSense - A Personal NotebookLM and Perplexity-like AI Assistant for Everyone.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={cn(
|
||||
roboto.className,
|
||||
"bg-white dark:bg-black antialiased h-full w-full"
|
||||
)}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
defaultTheme="light"
|
||||
>
|
||||
{children}
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
98
surfsense_web/app/login/GoogleLoginButton.tsx
Normal file
98
surfsense_web/app/login/GoogleLoginButton.tsx
Normal file
|
@ -0,0 +1,98 @@
|
|||
"use client";
|
||||
import React from "react";
|
||||
import { IconBrandGoogleFilled } from "@tabler/icons-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Logo } from "@/components/Logo";
|
||||
|
||||
export function GoogleLoginButton() {
|
||||
const handleGoogleLogin = () => {
|
||||
// Redirect to Google OAuth authorization URL
|
||||
fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/google/authorize`)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get authorization URL');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.authorization_url) {
|
||||
window.location.href = data.authorization_url;
|
||||
} else {
|
||||
console.error('No authorization URL received');
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error during Google login:', error);
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden">
|
||||
<AmbientBackground />
|
||||
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
||||
<Logo className="rounded-md" />
|
||||
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
|
||||
Welcome Back
|
||||
</h1>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="group/btn relative flex w-full items-center justify-center space-x-2 rounded-lg bg-white px-6 py-4 text-neutral-700 shadow-lg transition-all duration-200 hover:shadow-xl dark:bg-neutral-800 dark:text-neutral-200"
|
||||
onClick={handleGoogleLogin}
|
||||
>
|
||||
<div className="absolute inset-0 h-full w-full transform opacity-0 transition duration-200 group-hover/btn:opacity-100">
|
||||
<div className="absolute -left-px -top-px h-4 w-4 rounded-tl-lg border-l-2 border-t-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-left-2 group-hover/btn:-top-2"></div>
|
||||
<div className="absolute -right-px -top-px h-4 w-4 rounded-tr-lg border-r-2 border-t-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-right-2 group-hover/btn:-top-2"></div>
|
||||
<div className="absolute -bottom-px -left-px h-4 w-4 rounded-bl-lg border-b-2 border-l-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-bottom-2 group-hover/btn:-left-2"></div>
|
||||
<div className="absolute -bottom-px -right-px h-4 w-4 rounded-br-lg border-b-2 border-r-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-bottom-2 group-hover/btn:-right-2"></div>
|
||||
</div>
|
||||
<IconBrandGoogleFilled className="h-5 w-5 text-neutral-700 dark:text-neutral-200" />
|
||||
<span className="text-base font-medium">Continue with Google</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
const AmbientBackground = () => {
|
||||
return (
|
||||
<div className="pointer-events-none absolute left-0 top-0 z-0 h-screen w-screen">
|
||||
<div
|
||||
style={{
|
||||
transform: "translateY(-350px) rotate(-45deg)",
|
||||
width: "560px",
|
||||
height: "1380px",
|
||||
background:
|
||||
"radial-gradient(68.54% 68.72% at 55.02% 31.46%, rgba(59, 130, 246, 0.08) 0%, rgba(59, 130, 246, 0.02) 50%, rgba(59, 130, 246, 0) 100%)",
|
||||
}}
|
||||
className="absolute left-0 top-0"
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
transform: "rotate(-45deg) translate(5%, -50%)",
|
||||
transformOrigin: "top left",
|
||||
width: "240px",
|
||||
height: "1380px",
|
||||
background:
|
||||
"radial-gradient(50% 50% at 50% 50%, rgba(59, 130, 246, 0.06) 0%, rgba(59, 130, 246, 0.02) 80%, transparent 100%)",
|
||||
}}
|
||||
className="absolute left-0 top-0"
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
borderRadius: "20px",
|
||||
transform: "rotate(-45deg) translate(-180%, -70%)",
|
||||
transformOrigin: "top left",
|
||||
width: "240px",
|
||||
height: "1380px",
|
||||
background:
|
||||
"radial-gradient(50% 50% at 50% 50%, rgba(59, 130, 246, 0.04) 0%, rgba(59, 130, 246, 0.02) 80%, transparent 100%)",
|
||||
}}
|
||||
className="absolute left-0 top-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
5
surfsense_web/app/login/page.tsx
Normal file
5
surfsense_web/app/login/page.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { GoogleLoginButton } from "./GoogleLoginButton";
|
||||
|
||||
export default function LoginPage() {
|
||||
return <GoogleLoginButton />;
|
||||
}
|
16
surfsense_web/app/page.tsx
Normal file
16
surfsense_web/app/page.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
"use client";
|
||||
import React from "react";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { motion } from "framer-motion";
|
||||
import { ModernHeroWithGradients } from "@/components/ModernHeroWithGradients";
|
||||
import { Footer } from "@/components/Footer";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white">
|
||||
<Navbar />
|
||||
<ModernHeroWithGradients />
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
21
surfsense_web/components.json
Normal file
21
surfsense_web/components.json
Normal file
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
102
surfsense_web/components/Footer.tsx
Normal file
102
surfsense_web/components/Footer.tsx
Normal file
|
@ -0,0 +1,102 @@
|
|||
"use client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
IconBrandGithub,
|
||||
IconBrandLinkedin,
|
||||
IconBrandTwitter,
|
||||
} from "@tabler/icons-react";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
export function Footer() {
|
||||
const pages = [
|
||||
{
|
||||
title: "Privacy",
|
||||
href: "#",
|
||||
},
|
||||
{
|
||||
title: "Terms",
|
||||
href: "#",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="border-t border-neutral-100 dark:border-white/[0.1] px-8 py-20 bg-white dark:bg-neutral-950 w-full relative overflow-hidden">
|
||||
<div className="max-w-7xl mx-auto text-sm text-neutral-500 justify-between items-start md:px-8">
|
||||
<div className="flex flex-col items-center justify-center w-full relative">
|
||||
<div className="mr-0 md:mr-4 md:flex mb-4">
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium text-black dark:text-white ml-2">SurfSense</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="transition-colors flex sm:flex-row flex-col hover:text-text-neutral-800 text-neutral-600 dark:text-neutral-300 list-none gap-4">
|
||||
{pages.map((page, idx) => (
|
||||
<li key={"pages" + idx} className="list-none">
|
||||
<Link
|
||||
className="transition-colors hover:text-text-neutral-800"
|
||||
href={page.href}
|
||||
>
|
||||
{page.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<GridLineHorizontal className="max-w-7xl mx-auto mt-8" />
|
||||
</div>
|
||||
<div className="flex sm:flex-row flex-col justify-between mt-8 items-center w-full">
|
||||
<p className="text-neutral-500 dark:text-neutral-400 mb-8 sm:mb-0">
|
||||
© SurfSense 2025
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<Link href="https://x.com/mod_setter">
|
||||
<IconBrandTwitter className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
|
||||
</Link>
|
||||
<Link href="https://www.linkedin.com/in/rohan-verma-sde/">
|
||||
<IconBrandLinkedin className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
|
||||
</Link>
|
||||
<Link href="https://github.com/MODSetter">
|
||||
<IconBrandGithub className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const GridLineHorizontal = ({
|
||||
className,
|
||||
offset,
|
||||
}: {
|
||||
className?: string;
|
||||
offset?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--background": "#ffffff",
|
||||
"--color": "rgba(0, 0, 0, 0.2)",
|
||||
"--height": "1px",
|
||||
"--width": "5px",
|
||||
"--fade-stop": "90%",
|
||||
"--offset": offset || "200px", //-100px if you want to keep the line inside
|
||||
"--color-dark": "rgba(255, 255, 255, 0.2)",
|
||||
maskComposite: "exclude",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"w-[calc(100%+var(--offset))] h-[var(--height)]",
|
||||
"bg-[linear-gradient(to_right,var(--color),var(--color)_50%,transparent_0,transparent)]",
|
||||
"[background-size:var(--width)_var(--height)]",
|
||||
"[mask:linear-gradient(to_left,var(--background)_var(--fade-stop),transparent),_linear-gradient(to_right,var(--background)_var(--fade-stop),transparent),_linear-gradient(black,black)]",
|
||||
"[mask-composite:exclude]",
|
||||
"z-30",
|
||||
"dark:bg-[linear-gradient(to_right,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
|
||||
className
|
||||
)}
|
||||
></div>
|
||||
);
|
||||
};
|
22
surfsense_web/components/Logo.tsx
Normal file
22
surfsense_web/components/Logo.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
"use client";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Logo = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<Link
|
||||
href="/"
|
||||
>
|
||||
<Image
|
||||
src="/icon-128.png"
|
||||
className={cn(className)}
|
||||
alt="logo"
|
||||
width={128}
|
||||
height={128}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
526
surfsense_web/components/ModernHeroWithGradients.tsx
Normal file
526
surfsense_web/components/ModernHeroWithGradients.tsx
Normal file
|
@ -0,0 +1,526 @@
|
|||
"use client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IconArrowRight, IconBrandGithub } from "@tabler/icons-react";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Logo } from "./Logo";
|
||||
|
||||
export function ModernHeroWithGradients() {
|
||||
return (
|
||||
<div className="relative h-full min-h-[50rem] w-full bg-gray-50 dark:bg-black">
|
||||
<div className="relative z-20 mx-auto w-full px-4 py-6 md:px-8 lg:px-4">
|
||||
<div className="relative my-12 overflow-hidden rounded-3xl bg-white py-16 shadow-sm dark:bg-gray-900/80 dark:shadow-lg dark:shadow-purple-900/10 md:py-48 mx-auto w-full max-w-[95%] xl:max-w-[98%]">
|
||||
<TopLines />
|
||||
<BottomLines />
|
||||
<SideLines />
|
||||
<TopGradient />
|
||||
<BottomGradient />
|
||||
<DarkModeGradient />
|
||||
|
||||
<div className="relative z-20 flex flex-col items-center justify-center overflow-hidden rounded-3xl p-4 md:p-12 lg:p-16">
|
||||
<Link
|
||||
href="https://github.com/MODSetter/SurfSense"
|
||||
className="flex items-center gap-1 rounded-full border border-gray-200 bg-gradient-to-b from-gray-50 to-gray-100 px-4 py-1 text-center text-sm text-gray-800 shadow-sm dark:border-[#404040] dark:bg-gradient-to-b dark:from-[#5B5B5D] dark:to-[#262627] dark:text-white dark:shadow-inner dark:shadow-purple-500/10"
|
||||
>
|
||||
<span>SurfSense v0.0.6 Released</span>
|
||||
<IconArrowRight className="h-4 w-4 text-gray-800 dark:text-white" />
|
||||
</Link>
|
||||
{/* Import the Logo component or define it in this file */}
|
||||
<div className="flex items-center justify-center gap-4 mt-10 mb-2">
|
||||
<div className="h-16 w-16">
|
||||
<Logo className="rounded-md" />
|
||||
</div>
|
||||
<h1 className="bg-gradient-to-b from-gray-800 to-gray-600 bg-clip-text py-4 text-center text-3xl text-transparent dark:from-white dark:to-purple-300 md:text-5xl lg:text-8xl">
|
||||
SurfSense
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mx-auto max-w-3xl py-6 text-center text-base text-gray-600 dark:text-neutral-300 md:text-lg lg:text-xl">
|
||||
A Customizable AI Research Agent just like NotebookLM or Perplexity, but connected to external sources such as search engines (Tavily), Slack, Notion, and more.
|
||||
</p>
|
||||
<div className="flex flex-col items-center gap-6 py-6 sm:flex-row">
|
||||
<Link
|
||||
href="/login"
|
||||
className="w-48 gap-1 rounded-full border border-gray-200 bg-gradient-to-b from-gray-50 to-gray-100 px-5 py-3 text-center text-sm font-medium text-gray-800 shadow-sm dark:border-[#404040] dark:bg-gradient-to-b dark:from-[#5B5B5D] dark:to-[#262627] dark:text-white dark:shadow-inner dark:shadow-purple-500/10"
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
<Link
|
||||
href="https://github.com/MODSetter/SurfSense"
|
||||
className="w-48 gap-1 rounded-full border border-transparent bg-gray-800 px-5 py-3 text-center text-sm font-medium text-white shadow-sm hover:bg-gray-700 dark:bg-gradient-to-r dark:from-purple-700 dark:to-indigo-800 dark:text-white dark:hover:from-purple-600 dark:hover:to-indigo-700 flex items-center justify-center"
|
||||
>
|
||||
<IconBrandGithub className="h-5 w-5 mr-2" />
|
||||
<span>GitHub</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TopLines = () => {
|
||||
return (
|
||||
<svg
|
||||
width="166"
|
||||
height="298"
|
||||
viewBox="0 0 166 298"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="aspect-square pointer-events-none absolute inset-x-0 top-0 h-[100px] w-full md:h-[200px]"
|
||||
>
|
||||
<line
|
||||
y1="-0.5"
|
||||
x2="406"
|
||||
y2="-0.5"
|
||||
transform="matrix(0 1 1 0 1 -108)"
|
||||
stroke="url(#paint0_linear_254_143)"
|
||||
/>
|
||||
<line
|
||||
y1="-0.5"
|
||||
x2="406"
|
||||
y2="-0.5"
|
||||
transform="matrix(0 1 1 0 34 -108)"
|
||||
stroke="url(#paint1_linear_254_143)"
|
||||
/>
|
||||
<line
|
||||
y1="-0.5"
|
||||
x2="406"
|
||||
y2="-0.5"
|
||||
transform="matrix(0 1 1 0 67 -108)"
|
||||
stroke="url(#paint2_linear_254_143)"
|
||||
/>
|
||||
<line
|
||||
y1="-0.5"
|
||||
x2="406"
|
||||
y2="-0.5"
|
||||
transform="matrix(0 1 1 0 100 -108)"
|
||||
stroke="url(#paint3_linear_254_143)"
|
||||
/>
|
||||
<line
|
||||
y1="-0.5"
|
||||
x2="406"
|
||||
y2="-0.5"
|
||||
transform="matrix(0 1 1 0 133 -108)"
|
||||
stroke="url(#paint4_linear_254_143)"
|
||||
/>
|
||||
<line
|
||||
y1="-0.5"
|
||||
x2="406"
|
||||
y2="-0.5"
|
||||
transform="matrix(0 1 1 0 166 -108)"
|
||||
stroke="url(#paint5_linear_254_143)"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_254_143"
|
||||
x1="-7.42412e-06"
|
||||
y1="0.500009"
|
||||
x2="405"
|
||||
y2="0.500009"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_254_143"
|
||||
x1="-7.42412e-06"
|
||||
y1="0.500009"
|
||||
x2="405"
|
||||
y2="0.500009"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_254_143"
|
||||
x1="-7.42412e-06"
|
||||
y1="0.500009"
|
||||
x2="405"
|
||||
y2="0.500009"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint3_linear_254_143"
|
||||
x1="-7.42412e-06"
|
||||
y1="0.500009"
|
||||
x2="405"
|
||||
y2="0.500009"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint4_linear_254_143"
|
||||
x1="-7.42412e-06"
|
||||
y1="0.500009"
|
||||
x2="405"
|
||||
y2="0.500009"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint5_linear_254_143"
|
||||
x1="-7.42412e-06"
|
||||
y1="0.500009"
|
||||
x2="405"
|
||||
y2="0.500009"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const BottomLines = () => {
|
||||
return (
|
||||
<svg
|
||||
width="445"
|
||||
height="418"
|
||||
viewBox="0 0 445 418"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="aspect-square pointer-events-none absolute inset-x-0 -bottom-20 z-20 h-[150px] w-full md:h-[300px]"
|
||||
>
|
||||
<line
|
||||
x1="139.5"
|
||||
y1="418"
|
||||
x2="139.5"
|
||||
y2="12"
|
||||
stroke="url(#paint0_linear_0_1)"
|
||||
/>
|
||||
<line
|
||||
x1="172.5"
|
||||
y1="418"
|
||||
x2="172.5"
|
||||
y2="12"
|
||||
stroke="url(#paint1_linear_0_1)"
|
||||
/>
|
||||
<line
|
||||
x1="205.5"
|
||||
y1="418"
|
||||
x2="205.5"
|
||||
y2="12"
|
||||
stroke="url(#paint2_linear_0_1)"
|
||||
/>
|
||||
<line
|
||||
x1="238.5"
|
||||
y1="418"
|
||||
x2="238.5"
|
||||
y2="12"
|
||||
stroke="url(#paint3_linear_0_1)"
|
||||
/>
|
||||
<line
|
||||
x1="271.5"
|
||||
y1="418"
|
||||
x2="271.5"
|
||||
y2="12"
|
||||
stroke="url(#paint4_linear_0_1)"
|
||||
/>
|
||||
<line
|
||||
x1="304.5"
|
||||
y1="418"
|
||||
x2="304.5"
|
||||
y2="12"
|
||||
stroke="url(#paint5_linear_0_1)"
|
||||
/>
|
||||
<path
|
||||
d="M1 149L109.028 235.894C112.804 238.931 115 243.515 115 248.361V417"
|
||||
stroke="url(#paint6_linear_0_1)"
|
||||
strokeOpacity="0.1"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M444 149L335.972 235.894C332.196 238.931 330 243.515 330 248.361V417"
|
||||
stroke="url(#paint7_linear_0_1)"
|
||||
strokeOpacity="0.1"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_0_1"
|
||||
x1="140.5"
|
||||
y1="418"
|
||||
x2="140.5"
|
||||
y2="13"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_0_1"
|
||||
x1="173.5"
|
||||
y1="418"
|
||||
x2="173.5"
|
||||
y2="13"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_0_1"
|
||||
x1="206.5"
|
||||
y1="418"
|
||||
x2="206.5"
|
||||
y2="13"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint3_linear_0_1"
|
||||
x1="239.5"
|
||||
y1="418"
|
||||
x2="239.5"
|
||||
y2="13"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint4_linear_0_1"
|
||||
x1="272.5"
|
||||
y1="418"
|
||||
x2="272.5"
|
||||
y2="13"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint5_linear_0_1"
|
||||
x1="305.5"
|
||||
y1="418"
|
||||
x2="305.5"
|
||||
y2="13"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint6_linear_0_1"
|
||||
x1="115"
|
||||
y1="390.591"
|
||||
x2="-59.1703"
|
||||
y2="205.673"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.481613" stopColor="#E8E8E8" className="dark:stop-color-[#F8F8F8]" />
|
||||
<stop offset="1" stopColor="#E8E8E8" stopOpacity="0" className="dark:stop-color-[#F8F8F8]" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint7_linear_0_1"
|
||||
x1="330"
|
||||
y1="390.591"
|
||||
x2="504.17"
|
||||
y2="205.673"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.481613" stopColor="#E8E8E8" className="dark:stop-color-[#F8F8F8]" />
|
||||
<stop offset="1" stopColor="#E8E8E8" stopOpacity="0" className="dark:stop-color-[#F8F8F8]" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const SideLines = () => {
|
||||
return (
|
||||
<svg
|
||||
width="1382"
|
||||
height="370"
|
||||
viewBox="0 0 1382 370"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="pointer-events-none absolute inset-0 z-30 h-full w-full"
|
||||
>
|
||||
<path
|
||||
d="M268 115L181.106 6.97176C178.069 3.19599 173.485 1 168.639 1H0"
|
||||
stroke="url(#paint0_linear_337_46)"
|
||||
strokeOpacity="0.1"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M1114 115L1200.89 6.97176C1203.93 3.19599 1208.52 1 1213.36 1H1382"
|
||||
stroke="url(#paint1_linear_337_46)"
|
||||
strokeOpacity="0.1"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M268 255L181.106 363.028C178.069 366.804 173.485 369 168.639 369H0"
|
||||
stroke="url(#paint2_linear_337_46)"
|
||||
strokeOpacity="0.1"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M1114 255L1200.89 363.028C1203.93 366.804 1208.52 369 1213.36 369H1382"
|
||||
stroke="url(#paint3_linear_337_46)"
|
||||
strokeOpacity="0.1"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_337_46"
|
||||
x1="26.4087"
|
||||
y1="1.00001"
|
||||
x2="211.327"
|
||||
y2="175.17"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.481613" stopColor="#E8E8E8" className="dark:stop-color-[#F8F8F8]" />
|
||||
<stop offset="1" stopColor="#E8E8E8" stopOpacity="0" className="dark:stop-color-[#F8F8F8]" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_337_46"
|
||||
x1="1355.59"
|
||||
y1="1.00001"
|
||||
x2="1170.67"
|
||||
y2="175.17"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.481613" stopColor="#E8E8E8" className="dark:stop-color-[#F8F8F8]" />
|
||||
<stop offset="1" stopColor="#E8E8E8" stopOpacity="0" className="dark:stop-color-[#F8F8F8]" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_337_46"
|
||||
x1="26.4087"
|
||||
y1="369"
|
||||
x2="211.327"
|
||||
y2="194.83"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.481613" stopColor="#E8E8E8" className="dark:stop-color-[#F8F8F8]" />
|
||||
<stop offset="1" stopColor="#E8E8E8" stopOpacity="0" className="dark:stop-color-[#F8F8F8]" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint3_linear_337_46"
|
||||
x1="1355.59"
|
||||
y1="369"
|
||||
x2="1170.67"
|
||||
y2="194.83"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.481613" stopColor="#E8E8E8" className="dark:stop-color-[#F8F8F8]" />
|
||||
<stop offset="1" stopColor="#E8E8E8" stopOpacity="0" className="dark:stop-color-[#F8F8F8]" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const BottomGradient = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="851"
|
||||
height="595"
|
||||
viewBox="0 0 851 595"
|
||||
fill="none"
|
||||
className={cn(
|
||||
"pointer-events-none absolute -right-80 bottom-0 h-full w-full opacity-30 dark:opacity-100 dark:hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<path
|
||||
d="M118.499 0H532.468L635.375 38.6161L665 194.625L562.093 346H0L24.9473 121.254L118.499 0Z"
|
||||
fill="url(#paint0_radial_254_132)"
|
||||
/>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="paint0_radial_254_132"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(412.5 346) rotate(-91.153) scale(397.581 423.744)"
|
||||
>
|
||||
<stop stopColor="#AAD3E9" />
|
||||
<stop offset="0.25" stopColor="#7FB8D4" />
|
||||
<stop offset="0.573634" stopColor="#5A9BB8" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const TopGradient = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1007"
|
||||
height="997"
|
||||
viewBox="0 0 1007 997"
|
||||
fill="none"
|
||||
className={cn(
|
||||
"pointer-events-none absolute -left-96 top-0 h-full w-full opacity-30 dark:opacity-100 dark:hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<path
|
||||
d="M807 110.119L699.5 -117.546L8.5 -154L-141 246.994L-7 952L127 782.111L279 652.114L513 453.337L807 110.119Z"
|
||||
fill="url(#paint0_radial_254_135)"
|
||||
/>
|
||||
<path
|
||||
d="M807 110.119L699.5 -117.546L8.5 -154L-141 246.994L-7 952L127 782.111L279 652.114L513 453.337L807 110.119Z"
|
||||
fill="url(#paint1_radial_254_135)"
|
||||
/>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="paint0_radial_254_135"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(77.0001 15.8894) rotate(90.3625) scale(869.41 413.353)"
|
||||
>
|
||||
<stop stopColor="#AAD3E9" />
|
||||
<stop offset="0.25" stopColor="#7FB8D4" />
|
||||
<stop offset="0.573634" stopColor="#5A9BB8" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id="paint1_radial_254_135"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(127.5 -31) rotate(1.98106) scale(679.906 715.987)"
|
||||
>
|
||||
<stop stopColor="#AAD3E9" />
|
||||
<stop offset="0.283363" stopColor="#7FB8D4" />
|
||||
<stop offset="0.573634" stopColor="#5A9BB8" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const DarkModeGradient = ({ className }: { className?: string } = {}) => {
|
||||
return (
|
||||
<div className="hidden dark:block">
|
||||
<div className="absolute -left-48 -top-48 h-[800px] w-[800px] rounded-full bg-purple-900/20 blur-[180px]"></div>
|
||||
<div className="absolute -right-48 -bottom-48 h-[800px] w-[800px] rounded-full bg-indigo-900/20 blur-[180px]"></div>
|
||||
<div className="absolute left-1/2 top-1/2 h-[400px] w-[400px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-purple-800/10 blur-[120px]"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
307
surfsense_web/components/Navbar.tsx
Normal file
307
surfsense_web/components/Navbar.tsx
Normal file
|
@ -0,0 +1,307 @@
|
|||
"use client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IconMenu2, IconX, IconBrandGoogleFilled } from "@tabler/icons-react";
|
||||
import {
|
||||
motion,
|
||||
AnimatePresence,
|
||||
useScroll,
|
||||
useMotionValueEvent,
|
||||
} from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Logo } from "./Logo";
|
||||
import { ThemeTogglerComponent } from "./theme/theme-toggle";
|
||||
|
||||
interface NavbarProps {
|
||||
navItems: {
|
||||
name: string;
|
||||
link: string;
|
||||
}[];
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export const Navbar = () => {
|
||||
const navItems = [
|
||||
{
|
||||
name: "",
|
||||
link: "/",
|
||||
},
|
||||
// {
|
||||
// name: "Product",
|
||||
// link: "/#product",
|
||||
// },
|
||||
// {
|
||||
// name: "Pricing",
|
||||
// link: "/#pricing",
|
||||
// },
|
||||
];
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { scrollY } = useScroll({
|
||||
target: ref,
|
||||
offset: ["start start", "end start"],
|
||||
});
|
||||
const [visible, setVisible] = useState<boolean>(false);
|
||||
|
||||
useMotionValueEvent(scrollY, "change", (latest) => {
|
||||
if (latest > 100) {
|
||||
setVisible(true);
|
||||
} else {
|
||||
setVisible(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<motion.div ref={ref} className="w-full fixed top-2 inset-x-0 z-50">
|
||||
<DesktopNav visible={visible} navItems={navItems} />
|
||||
<MobileNav visible={visible} navItems={navItems} />
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const DesktopNav = ({ navItems, visible }: NavbarProps) => {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
// Redirect to Google OAuth authorization URL
|
||||
fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/google/authorize`)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get authorization URL');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.authorization_url) {
|
||||
window.location.href = data.authorization_url;
|
||||
} else {
|
||||
console.error('No authorization URL received');
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error during Google login:', error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
animate={{
|
||||
backdropFilter: "blur(16px)",
|
||||
background: visible
|
||||
? "rgba(var(--background-rgb), 0.8)"
|
||||
: "rgba(var(--background-rgb), 0.6)",
|
||||
width: visible ? "38%" : "80%",
|
||||
height: visible ? "48px" : "64px",
|
||||
y: visible ? 8 : 0,
|
||||
}}
|
||||
initial={{
|
||||
width: "80%",
|
||||
height: "64px",
|
||||
background: "rgba(var(--background-rgb), 0.6)",
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
className={cn(
|
||||
"hidden lg:flex flex-row self-center items-center justify-between py-2 mx-auto px-6 rounded-full relative z-[60] backdrop-saturate-[1.8]",
|
||||
visible ? "border dark:border-white/10 border-gray-300/30" : "border-0"
|
||||
)}
|
||||
style={{
|
||||
"--background-rgb": "var(--tw-dark) ? '0, 0, 0' : '255, 255, 255'",
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Logo className="h-8 w-8 rounded-md" />
|
||||
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
|
||||
</div>
|
||||
<motion.div
|
||||
className="lg:flex flex-row flex-1 items-center justify-center space-x-1 text-sm"
|
||||
animate={{
|
||||
scale: visible ? 0.9 : 1,
|
||||
justifyContent: visible ? "flex-end" : "center",
|
||||
}}
|
||||
>
|
||||
{navItems.map((navItem, idx) => (
|
||||
<motion.div
|
||||
key={`nav-item-${idx}`}
|
||||
onHoverStart={() => setHoveredIndex(idx)}
|
||||
className="relative"
|
||||
>
|
||||
<Link
|
||||
className="dark:text-white/90 text-gray-800 relative px-3 py-1.5 transition-colors"
|
||||
href={navItem.link}
|
||||
>
|
||||
<span className="relative z-10">{navItem.name}</span>
|
||||
{hoveredIndex === idx && (
|
||||
<motion.div
|
||||
layoutId="menu-hover"
|
||||
className="absolute inset-0 rounded-full dark:bg-gradient-to-r dark:from-white/10 dark:to-white/20 bg-gradient-to-r from-gray-200 to-gray-300"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1.1,
|
||||
background: "var(--tw-dark) ? radial-gradient(circle at center, rgba(255,255,255,0.2) 0%, rgba(255,255,255,0.1) 50%, transparent 100%) : radial-gradient(circle at center, rgba(0,0,0,0.05) 0%, rgba(0,0,0,0.03) 50%, transparent 100%)",
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
},
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
bounce: 0.4,
|
||||
duration: 0.4,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeTogglerComponent />
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{!visible && (
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 25,
|
||||
},
|
||||
}}
|
||||
exit={{
|
||||
scale: 0.8,
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={handleGoogleLogin}
|
||||
variant="outline"
|
||||
className="hidden md:flex items-center gap-2 rounded-full dark:bg-white/20 dark:hover:bg-white/30 dark:text-white bg-gray-100 hover:bg-gray-200 text-gray-800 border-0"
|
||||
>
|
||||
<IconBrandGoogleFilled className="h-4 w-4" />
|
||||
<span>Sign in with Google</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileNav = ({ navItems, visible }: NavbarProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
// Redirect to the login page
|
||||
window.location.href = "./login";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
animate={{
|
||||
backdropFilter: "blur(16px)",
|
||||
background: visible
|
||||
? "rgba(var(--background-rgb), 0.8)"
|
||||
: "rgba(var(--background-rgb), 0.6)",
|
||||
width: visible ? "80%" : "90%",
|
||||
y: visible ? 0 : 8,
|
||||
borderRadius: open ? "24px" : "full",
|
||||
padding: "8px 16px",
|
||||
}}
|
||||
initial={{
|
||||
width: "80%",
|
||||
background: "rgba(var(--background-rgb), 0.6)",
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
className={cn(
|
||||
"flex relative flex-col lg:hidden w-full justify-between items-center max-w-[calc(100vw-2rem)] mx-auto z-50 backdrop-saturate-[1.8] rounded-full",
|
||||
visible ? "border border-solid dark:border-white/40 border-gray-300/30" : "border-0"
|
||||
)}
|
||||
style={{
|
||||
"--background-rgb": "var(--tw-dark) ? '0, 0, 0' : '255, 255, 255'",
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<div className="flex flex-row justify-between items-center w-full">
|
||||
<Logo className="h-8 w-8 rounded-md" />
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeTogglerComponent />
|
||||
{open ? (
|
||||
<IconX className="dark:text-white/90 text-gray-800" onClick={() => setOpen(!open)} />
|
||||
) : (
|
||||
<IconMenu2
|
||||
className="dark:text-white/90 text-gray-800"
|
||||
onClick={() => setOpen(!open)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
className="flex rounded-3xl absolute top-16 dark:bg-black/80 bg-white/90 backdrop-blur-xl backdrop-saturate-[1.8] inset-x-0 z-50 flex-col items-start justify-start gap-4 w-full px-6 py-8"
|
||||
>
|
||||
{navItems.map(
|
||||
(navItem: { link: string; name: string }, idx: number) => (
|
||||
<Link
|
||||
key={`link=${idx}`}
|
||||
href={navItem.link}
|
||||
onClick={() => setOpen(false)}
|
||||
className="relative dark:text-white/90 text-gray-800 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<motion.span className="block">{navItem.name}</motion.span>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
onClick={handleGoogleLogin}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 mt-4 w-full justify-center rounded-full dark:bg-white/20 dark:hover:bg-white/30 dark:text-white bg-gray-100 hover:bg-gray-200 text-gray-800 border-0"
|
||||
>
|
||||
<IconBrandGoogleFilled className="h-4 w-4" />
|
||||
<span>Sign in with Google</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
};
|
55
surfsense_web/components/TokenHandler.tsx
Normal file
55
surfsense_web/components/TokenHandler.tsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
interface TokenHandlerProps {
|
||||
redirectPath?: string; // Path to redirect after storing token
|
||||
tokenParamName?: string; // Name of the URL parameter containing the token
|
||||
storageKey?: string; // Key to use when storing in localStorage
|
||||
}
|
||||
|
||||
/**
|
||||
* Client component that extracts a token from URL parameters and stores it in localStorage
|
||||
*
|
||||
* @param redirectPath - Path to redirect after storing token (default: '/')
|
||||
* @param tokenParamName - Name of the URL parameter containing the token (default: 'token')
|
||||
* @param storageKey - Key to use when storing in localStorage (default: 'auth_token')
|
||||
*/
|
||||
const TokenHandler = ({
|
||||
redirectPath = '/',
|
||||
tokenParamName = 'token',
|
||||
storageKey = 'surfsense_bearer_token'
|
||||
}: TokenHandlerProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
// Only run on client-side
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// Get token from URL parameters
|
||||
const token = searchParams.get(tokenParamName);
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
// Store token in localStorage
|
||||
localStorage.setItem(storageKey, token);
|
||||
console.log(`Token stored in localStorage with key: ${storageKey}`);
|
||||
|
||||
// Redirect to specified path
|
||||
router.push(redirectPath);
|
||||
} catch (error) {
|
||||
console.error('Error storing token in localStorage:', error);
|
||||
}
|
||||
}
|
||||
}, [searchParams, tokenParamName, storageKey, redirectPath, router]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[200px]">
|
||||
<p className="text-gray-500">Processing authentication...</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TokenHandler;
|
112
surfsense_web/components/chat/Citation.tsx
Normal file
112
surfsense_web/components/chat/Citation.tsx
Normal file
|
@ -0,0 +1,112 @@
|
|||
import React, { useState } from 'react';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { getConnectorIcon } from './ConnectorComponents';
|
||||
import { Source } from './types';
|
||||
|
||||
type CitationProps = {
|
||||
citationId: number;
|
||||
citationText: string;
|
||||
position: number;
|
||||
source: Source | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Citation component to handle individual citations
|
||||
*/
|
||||
export const Citation = ({ citationId, citationText, position, source }: CitationProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const citationKey = `citation-${citationId}-${position}`;
|
||||
|
||||
if (!source) return <>{citationText}</>;
|
||||
|
||||
return (
|
||||
<span key={citationKey} className="relative inline-flex items-center">
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<sup>
|
||||
<span
|
||||
className="inline-flex items-center justify-center text-primary cursor-pointer bg-primary/10 hover:bg-primary/15 w-4 h-4 rounded-full text-[10px] font-medium ml-0.5 transition-colors border border-primary/20 shadow-sm"
|
||||
>
|
||||
{citationId}
|
||||
</span>
|
||||
</sup>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-80 p-0">
|
||||
<Card className="border-0 shadow-none">
|
||||
<div className="p-3 flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-7 h-7 flex items-center justify-center bg-muted rounded-full">
|
||||
{getConnectorIcon(source.connectorType || '')}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium text-sm text-card-foreground">{source.title}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{source.description}</p>
|
||||
<div className="mt-2 flex items-center text-xs text-muted-foreground">
|
||||
<span className="truncate max-w-[200px]">{source.url}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-full"
|
||||
onClick={() => window.open(source.url, '_blank')}
|
||||
title="Open in new tab"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to render text with citations
|
||||
*/
|
||||
export const renderTextWithCitations = (text: string, getCitationSource: (id: number) => Source | null) => {
|
||||
// Regular expression to find citation patterns like [1], [2], etc.
|
||||
const citationRegex = /\[(\d+)\]/g;
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
let position = 0;
|
||||
|
||||
while ((match = citationRegex.exec(text)) !== null) {
|
||||
// Add text before the citation
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.substring(lastIndex, match.index));
|
||||
}
|
||||
|
||||
// Add the citation component
|
||||
const citationId = parseInt(match[1], 10);
|
||||
parts.push(
|
||||
<Citation
|
||||
key={`citation-${citationId}-${position}`}
|
||||
citationId={citationId}
|
||||
citationText={match[0]}
|
||||
position={position}
|
||||
source={getCitationSource(citationId)}
|
||||
/>
|
||||
);
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
position++;
|
||||
}
|
||||
|
||||
// Add any remaining text after the last citation
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.substring(lastIndex));
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
162
surfsense_web/components/chat/ConnectorComponents.tsx
Normal file
162
surfsense_web/components/chat/ConnectorComponents.tsx
Normal file
|
@ -0,0 +1,162 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Search,
|
||||
Globe,
|
||||
BookOpen,
|
||||
Sparkles,
|
||||
Microscope,
|
||||
Telescope,
|
||||
File,
|
||||
Link,
|
||||
Slack,
|
||||
Webhook
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Connector, ResearchMode } from './types';
|
||||
|
||||
// Helper function to get connector icon
|
||||
export const getConnectorIcon = (connectorType: string) => {
|
||||
const iconProps = { className: "h-4 w-4" };
|
||||
|
||||
switch(connectorType) {
|
||||
case 'CRAWLED_URL':
|
||||
return <Globe {...iconProps} />;
|
||||
case 'FILE':
|
||||
return <File {...iconProps} />;
|
||||
case 'EXTENSION':
|
||||
return <Webhook {...iconProps} />;
|
||||
case 'SERPER_API':
|
||||
case 'TAVILY_API':
|
||||
return <Link {...iconProps} />;
|
||||
case 'SLACK_CONNECTOR':
|
||||
return <Slack {...iconProps} />;
|
||||
case 'NOTION_CONNECTOR':
|
||||
return <BookOpen {...iconProps} />;
|
||||
case 'DEEP':
|
||||
return <Sparkles {...iconProps} />;
|
||||
case 'DEEPER':
|
||||
return <Microscope {...iconProps} />;
|
||||
case 'DEEPEST':
|
||||
return <Telescope {...iconProps} />;
|
||||
default:
|
||||
return <Search {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
export const researcherOptions: { value: ResearchMode; label: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
value: 'GENERAL',
|
||||
label: 'General',
|
||||
icon: getConnectorIcon('GENERAL')
|
||||
},
|
||||
{
|
||||
value: 'DEEP',
|
||||
label: 'Deep',
|
||||
icon: getConnectorIcon('DEEP')
|
||||
},
|
||||
{
|
||||
value: 'DEEPER',
|
||||
label: 'Deeper',
|
||||
icon: getConnectorIcon('DEEPER')
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Displays a small icon for a connector type
|
||||
*/
|
||||
export const ConnectorIcon = ({ type, index = 0 }: { type: string; index?: number }) => (
|
||||
<div
|
||||
className="w-4 h-4 rounded-full flex items-center justify-center bg-muted border border-background"
|
||||
style={{ zIndex: 10 - index }}
|
||||
>
|
||||
{getConnectorIcon(type)}
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Displays a count indicator for additional connectors
|
||||
*/
|
||||
export const ConnectorCountBadge = ({ count }: { count: number }) => (
|
||||
<div className="w-4 h-4 rounded-full flex items-center justify-center bg-primary text-primary-foreground text-[8px] font-medium border border-background z-0">
|
||||
+{count}
|
||||
</div>
|
||||
);
|
||||
|
||||
type ConnectorButtonProps = {
|
||||
selectedConnectors: string[];
|
||||
onClick: () => void;
|
||||
connectorSources: Connector[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Button that displays selected connectors and opens connector selection dialog
|
||||
*/
|
||||
export const ConnectorButton = ({ selectedConnectors, onClick, connectorSources }: ConnectorButtonProps) => {
|
||||
const totalConnectors = connectorSources.length;
|
||||
const selectedCount = selectedConnectors.length;
|
||||
const progressPercentage = (selectedCount / totalConnectors) * 100;
|
||||
|
||||
// Get the name of a single selected connector
|
||||
const getSingleConnectorName = () => {
|
||||
const connector = connectorSources.find(c => c.type === selectedConnectors[0]);
|
||||
return connector?.name || '';
|
||||
};
|
||||
|
||||
// Get display text based on selection count
|
||||
const getDisplayText = () => {
|
||||
if (selectedCount === totalConnectors) return "All Connectors";
|
||||
if (selectedCount === 1) return getSingleConnectorName();
|
||||
return `${selectedCount} Connectors`;
|
||||
};
|
||||
|
||||
// Render the empty state (no connectors selected)
|
||||
const renderEmptyState = () => (
|
||||
<>
|
||||
<Plus className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Select Connectors</span>
|
||||
</>
|
||||
);
|
||||
|
||||
// Render the selected connectors preview
|
||||
const renderSelectedConnectors = () => (
|
||||
<>
|
||||
<div className="flex -space-x-1.5 mr-1">
|
||||
{/* Show up to 3 connector icons */}
|
||||
{selectedConnectors.slice(0, 3).map((type, index) => (
|
||||
<ConnectorIcon key={type} type={type} index={index} />
|
||||
))}
|
||||
|
||||
{/* Show count indicator if more than 3 connectors are selected */}
|
||||
{selectedCount > 3 && <ConnectorCountBadge count={selectedCount - 3} />}
|
||||
</div>
|
||||
|
||||
{/* Display text */}
|
||||
<span className="font-medium">{getDisplayText()}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-7 px-2 text-xs font-medium rounded-md border-border relative overflow-hidden group scale-90 origin-left"
|
||||
onClick={onClick}
|
||||
aria-label={selectedCount === 0 ? "Select Connectors" : `${selectedCount} connectors selected`}
|
||||
>
|
||||
{/* Progress indicator */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 h-1 bg-primary"
|
||||
style={{
|
||||
width: `${progressPercentage}%`,
|
||||
transition: 'width 0.3s ease'
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1.5 z-10 relative">
|
||||
{selectedCount === 0 ? renderEmptyState() : renderSelectedConnectors()}
|
||||
<ChevronDown className="h-3 w-3 ml-0.5 text-muted-foreground opacity-70" />
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
};
|
80
surfsense_web/components/chat/ScrollUtils.tsx
Normal file
80
surfsense_web/components/chat/ScrollUtils.tsx
Normal file
|
@ -0,0 +1,80 @@
|
|||
import React, { RefObject, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Function to scroll to the bottom of a container
|
||||
*/
|
||||
export const scrollToBottom = (ref: RefObject<HTMLDivElement>) => {
|
||||
ref.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to scroll to bottom when messages change
|
||||
*/
|
||||
export const useScrollToBottom = (ref: RefObject<HTMLDivElement>, dependencies: any[]) => {
|
||||
useEffect(() => {
|
||||
scrollToBottom(ref);
|
||||
}, dependencies);
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to check scroll position and update indicators
|
||||
*/
|
||||
export const updateScrollIndicators = (
|
||||
tabsListRef: RefObject<HTMLDivElement>,
|
||||
setCanScrollLeft: (value: boolean) => void,
|
||||
setCanScrollRight: (value: boolean) => void
|
||||
) => {
|
||||
if (tabsListRef.current) {
|
||||
const { scrollLeft, scrollWidth, clientWidth } = tabsListRef.current;
|
||||
setCanScrollLeft(scrollLeft > 0);
|
||||
setCanScrollRight(scrollLeft + clientWidth < scrollWidth - 10); // 10px buffer
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to initialize scroll indicators and add resize listener
|
||||
*/
|
||||
export const useScrollIndicators = (
|
||||
tabsListRef: RefObject<HTMLDivElement>,
|
||||
setCanScrollLeft: (value: boolean) => void,
|
||||
setCanScrollRight: (value: boolean) => void
|
||||
) => {
|
||||
const updateIndicators = () => updateScrollIndicators(tabsListRef, setCanScrollLeft, setCanScrollRight);
|
||||
|
||||
useEffect(() => {
|
||||
updateIndicators();
|
||||
// Add resize listener to update indicators when window size changes
|
||||
window.addEventListener('resize', updateIndicators);
|
||||
return () => window.removeEventListener('resize', updateIndicators);
|
||||
}, []);
|
||||
|
||||
return updateIndicators;
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to scroll tabs list left
|
||||
*/
|
||||
export const scrollTabsLeft = (
|
||||
tabsListRef: RefObject<HTMLDivElement>,
|
||||
updateIndicators: () => void
|
||||
) => {
|
||||
if (tabsListRef.current) {
|
||||
tabsListRef.current.scrollBy({ left: -200, behavior: 'smooth' });
|
||||
// Update indicators after scrolling
|
||||
setTimeout(updateIndicators, 300);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to scroll tabs list right
|
||||
*/
|
||||
export const scrollTabsRight = (
|
||||
tabsListRef: RefObject<HTMLDivElement>,
|
||||
updateIndicators: () => void
|
||||
) => {
|
||||
if (tabsListRef.current) {
|
||||
tabsListRef.current.scrollBy({ left: 200, behavior: 'smooth' });
|
||||
// Update indicators after scrolling
|
||||
setTimeout(updateIndicators, 300);
|
||||
}
|
||||
};
|
38
surfsense_web/components/chat/SegmentedControl.tsx
Normal file
38
surfsense_web/components/chat/SegmentedControl.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
import React from 'react';
|
||||
|
||||
type SegmentedControlProps<T extends string> = {
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
options: Array<{
|
||||
value: T;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* A segmented control component for selecting between different options
|
||||
*/
|
||||
function SegmentedControl<T extends string>({ value, onChange, options }: SegmentedControlProps<T>) {
|
||||
return (
|
||||
<div className="flex rounded-md border border-border overflow-hidden scale-90 origin-left">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
className={`flex items-center gap-1 px-2 py-1 text-xs transition-colors ${
|
||||
value === option.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted'
|
||||
}`}
|
||||
onClick={() => onChange(option.value)}
|
||||
aria-pressed={value === option.value}
|
||||
>
|
||||
{option.icon}
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SegmentedControl;
|
69
surfsense_web/components/chat/SourceUtils.tsx
Normal file
69
surfsense_web/components/chat/SourceUtils.tsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
import React from 'react';
|
||||
import { Source, Connector } from './types';
|
||||
|
||||
/**
|
||||
* Function to get sources for the main view
|
||||
*/
|
||||
export const getMainViewSources = (connector: Connector, initialSourcesDisplay: number) => {
|
||||
return connector.sources?.slice(0, initialSourcesDisplay);
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to get filtered sources for the dialog
|
||||
*/
|
||||
export const getFilteredSources = (connector: Connector, sourceFilter: string) => {
|
||||
if (!sourceFilter.trim()) {
|
||||
return connector.sources;
|
||||
}
|
||||
|
||||
const filter = sourceFilter.toLowerCase().trim();
|
||||
return connector.sources?.filter(source =>
|
||||
source.title.toLowerCase().includes(filter) ||
|
||||
source.description.toLowerCase().includes(filter)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to get paginated and filtered sources for the dialog
|
||||
*/
|
||||
export const getPaginatedDialogSources = (
|
||||
connector: Connector,
|
||||
sourceFilter: string,
|
||||
expandedSources: boolean,
|
||||
sourcesPage: number,
|
||||
sourcesPerPage: number
|
||||
) => {
|
||||
const filteredSources = getFilteredSources(connector, sourceFilter);
|
||||
|
||||
if (expandedSources) {
|
||||
return filteredSources;
|
||||
}
|
||||
return filteredSources?.slice(0, sourcesPage * sourcesPerPage);
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to get the count of sources for a connector type
|
||||
*/
|
||||
export const getSourcesCount = (connectorSources: Connector[], connectorType: string) => {
|
||||
const connector = connectorSources.find(c => c.type === connectorType);
|
||||
return connector?.sources?.length || 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to get a citation source by ID
|
||||
*/
|
||||
export const getCitationSource = (
|
||||
citationId: number,
|
||||
connectorSources: Connector[]
|
||||
): Source | null => {
|
||||
for (const connector of connectorSources) {
|
||||
const source = connector.sources?.find(s => s.id === citationId);
|
||||
if (source) {
|
||||
return {
|
||||
...source,
|
||||
connectorType: connector.type
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
18
surfsense_web/components/chat/connector-sources.ts
Normal file
18
surfsense_web/components/chat/connector-sources.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
// Connector sources
|
||||
export const connectorSourcesMenu = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Crawled URL",
|
||||
type: "CRAWLED_URL",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "File",
|
||||
type: "FILE",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Extension",
|
||||
type: "EXTENSION",
|
||||
},
|
||||
];
|
7
surfsense_web/components/chat/index.ts
Normal file
7
surfsense_web/components/chat/index.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
// Export all components and utilities from the chat folder
|
||||
export { default as SegmentedControl } from './SegmentedControl';
|
||||
export * from './ConnectorComponents';
|
||||
export * from './Citation';
|
||||
export * from './SourceUtils';
|
||||
export * from './ScrollUtils';
|
||||
export * from './types';
|
51
surfsense_web/components/chat/types.ts
Normal file
51
surfsense_web/components/chat/types.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Types for chat components
|
||||
*/
|
||||
|
||||
export type Source = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
connectorType?: string;
|
||||
};
|
||||
|
||||
export type Connector = {
|
||||
id: number;
|
||||
type: string;
|
||||
name: string;
|
||||
sources?: Source[];
|
||||
};
|
||||
|
||||
export type StatusMessage = {
|
||||
id: number;
|
||||
message: string;
|
||||
type: 'info' | 'success' | 'error' | 'warning';
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
// Define message types to match useChat() structure
|
||||
export type MessageRole = 'user' | 'assistant' | 'system' | 'data';
|
||||
|
||||
export interface ToolInvocation {
|
||||
state: 'call' | 'result';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: any;
|
||||
result?: any;
|
||||
}
|
||||
|
||||
export interface ToolInvocationUIPart {
|
||||
type: 'tool-invocation';
|
||||
toolInvocation: ToolInvocation;
|
||||
}
|
||||
|
||||
|
||||
export type ResearchMode = 'GENERAL' | 'DEEP' | 'DEEPER' | 'DEEPEST';
|
34
surfsense_web/components/document-viewer.tsx
Normal file
34
surfsense_web/components/document-viewer.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import React from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { FileText } from "lucide-react";
|
||||
|
||||
interface DocumentViewerProps {
|
||||
title: string;
|
||||
content: string;
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DocumentViewer({ title, content, trigger }: DocumentViewerProps) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="ghost" size="sm" className="flex items-center gap-1">
|
||||
<FileText size={16} />
|
||||
<span>View Content</span>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-4">
|
||||
<MarkdownViewer content={content} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
55
surfsense_web/components/json-metadata-viewer.tsx
Normal file
55
surfsense_web/components/json-metadata-viewer.tsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
import React from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FileJson } from "lucide-react";
|
||||
import { JsonView, defaultStyles } from "react-json-view-lite";
|
||||
import "react-json-view-lite/dist/index.css";
|
||||
|
||||
interface JsonMetadataViewerProps {
|
||||
title: string;
|
||||
metadata: any;
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function JsonMetadataViewer({ title, metadata, trigger }: JsonMetadataViewerProps) {
|
||||
// Ensure metadata is a valid object
|
||||
const jsonData = React.useMemo(() => {
|
||||
if (!metadata) return {};
|
||||
|
||||
try {
|
||||
// If metadata is a string, try to parse it
|
||||
if (typeof metadata === "string") {
|
||||
return JSON.parse(metadata);
|
||||
}
|
||||
// Otherwise, use it as is
|
||||
return metadata;
|
||||
} catch (error) {
|
||||
console.error("Error parsing JSON metadata:", error);
|
||||
return { error: "Invalid JSON metadata" };
|
||||
}
|
||||
}, [metadata]);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="ghost" size="sm" className="flex items-center gap-1">
|
||||
<FileJson size={16} />
|
||||
<span>View Metadata</span>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title} - Metadata</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 p-4 bg-muted/30 rounded-md">
|
||||
<JsonView
|
||||
data={jsonData}
|
||||
style={defaultStyles}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
154
surfsense_web/components/markdown-viewer.tsx
Normal file
154
surfsense_web/components/markdown-viewer.tsx
Normal file
|
@ -0,0 +1,154 @@
|
|||
import React from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize from "rehype-sanitize";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Citation } from "./chat/Citation";
|
||||
import { Source } from "./chat/types";
|
||||
|
||||
interface MarkdownViewerProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
getCitationSource?: (id: number) => Source | null;
|
||||
}
|
||||
|
||||
export function MarkdownViewer({ content, className, getCitationSource }: MarkdownViewerProps) {
|
||||
return (
|
||||
<div className={cn("prose prose-sm dark:prose-invert max-w-none", className)}>
|
||||
<ReactMarkdown
|
||||
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
// Define custom components for markdown elements
|
||||
p: ({node, children, ...props}) => {
|
||||
// If there's no getCitationSource function, just render normally
|
||||
if (!getCitationSource) {
|
||||
return <p className="my-2" {...props}>{children}</p>;
|
||||
}
|
||||
|
||||
// Process citations within paragraph content
|
||||
return <p className="my-2" {...props}>{processCitationsInReactChildren(children, getCitationSource)}</p>;
|
||||
},
|
||||
a: ({node, children, ...props}) => {
|
||||
// Process citations within link content if needed
|
||||
const processedChildren = getCitationSource
|
||||
? processCitationsInReactChildren(children, getCitationSource)
|
||||
: children;
|
||||
return <a className="text-primary hover:underline" {...props}>{processedChildren}</a>;
|
||||
},
|
||||
ul: ({node, ...props}) => <ul className="list-disc pl-5 my-2" {...props} />,
|
||||
ol: ({node, ...props}) => <ol className="list-decimal pl-5 my-2" {...props} />,
|
||||
h1: ({node, children, ...props}) => {
|
||||
const processedChildren = getCitationSource
|
||||
? processCitationsInReactChildren(children, getCitationSource)
|
||||
: children;
|
||||
return <h1 className="text-2xl font-bold mt-6 mb-2" {...props}>{processedChildren}</h1>;
|
||||
},
|
||||
h2: ({node, children, ...props}) => {
|
||||
const processedChildren = getCitationSource
|
||||
? processCitationsInReactChildren(children, getCitationSource)
|
||||
: children;
|
||||
return <h2 className="text-xl font-bold mt-5 mb-2" {...props}>{processedChildren}</h2>;
|
||||
},
|
||||
h3: ({node, children, ...props}) => {
|
||||
const processedChildren = getCitationSource
|
||||
? processCitationsInReactChildren(children, getCitationSource)
|
||||
: children;
|
||||
return <h3 className="text-lg font-bold mt-4 mb-2" {...props}>{processedChildren}</h3>;
|
||||
},
|
||||
h4: ({node, children, ...props}) => {
|
||||
const processedChildren = getCitationSource
|
||||
? processCitationsInReactChildren(children, getCitationSource)
|
||||
: children;
|
||||
return <h4 className="text-base font-bold mt-3 mb-1" {...props}>{processedChildren}</h4>;
|
||||
},
|
||||
blockquote: ({node, ...props}) => <blockquote className="border-l-4 border-muted pl-4 italic my-2" {...props} />,
|
||||
hr: ({node, ...props}) => <hr className="my-4 border-muted" {...props} />,
|
||||
img: ({node, ...props}) => <img className="max-w-full h-auto my-4 rounded" {...props} />,
|
||||
table: ({node, ...props}) => <div className="overflow-x-auto my-4"><table className="min-w-full divide-y divide-border" {...props} /></div>,
|
||||
th: ({node, ...props}) => <th className="px-3 py-2 text-left font-medium bg-muted" {...props} />,
|
||||
td: ({node, ...props}) => <td className="px-3 py-2 border-t border-border" {...props} />,
|
||||
code: ({node, className, children, ...props}: any) => {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const isInline = !match;
|
||||
return isInline
|
||||
? <code className="bg-muted px-1 py-0.5 rounded text-xs" {...props}>{children}</code>
|
||||
: (
|
||||
<div className="relative my-4">
|
||||
<pre className="bg-muted p-4 rounded-md overflow-x-auto">
|
||||
<code className="text-xs" {...props}>{children}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to process citations within React children
|
||||
function processCitationsInReactChildren(children: React.ReactNode, getCitationSource: (id: number) => Source | null): React.ReactNode {
|
||||
// If children is not an array or string, just return it
|
||||
if (!children || (typeof children !== 'string' && !Array.isArray(children))) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// Handle string content directly - this is where we process citation references
|
||||
if (typeof children === 'string') {
|
||||
return processCitationsInText(children, getCitationSource);
|
||||
}
|
||||
|
||||
// Handle arrays of children recursively
|
||||
if (Array.isArray(children)) {
|
||||
return React.Children.map(children, child => {
|
||||
if (typeof child === 'string') {
|
||||
return processCitationsInText(child, getCitationSource);
|
||||
}
|
||||
return child;
|
||||
});
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
// Process citation references in text content
|
||||
function processCitationsInText(text: string, getCitationSource: (id: number) => Source | null): React.ReactNode[] {
|
||||
const citationRegex = /\[(\d+)\]/g;
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
let position = 0;
|
||||
|
||||
while ((match = citationRegex.exec(text)) !== null) {
|
||||
// Add text before the citation
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.substring(lastIndex, match.index));
|
||||
}
|
||||
|
||||
// Add the citation component
|
||||
const citationId = parseInt(match[1], 10);
|
||||
parts.push(
|
||||
<Citation
|
||||
key={`citation-${citationId}-${position}`}
|
||||
citationId={citationId}
|
||||
citationText={match[0]}
|
||||
position={position}
|
||||
source={getCitationSource(citationId)}
|
||||
/>
|
||||
);
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
position++;
|
||||
}
|
||||
|
||||
// Add any remaining text after the last citation
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.substring(lastIndex));
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
247
surfsense_web/components/search-space-form.tsx
Normal file
247
surfsense_web/components/search-space-form.tsx
Normal file
|
@ -0,0 +1,247 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Plus, Search, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tilt } from "@/components/ui/tilt";
|
||||
import { Spotlight } from "@/components/ui/spotlight";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const searchSpaceFormSchema = z.object({
|
||||
name: z.string().min(3, "Name is required"),
|
||||
description: z.string().min(10, "Description is required"),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type SearchSpaceFormValues = z.infer<typeof searchSpaceFormSchema>;
|
||||
|
||||
interface SearchSpaceFormProps {
|
||||
onSubmit?: (data: { name: string; description: string }) => void;
|
||||
onDelete?: () => void;
|
||||
className?: string;
|
||||
isEditing?: boolean;
|
||||
initialData?: { name: string; description: string };
|
||||
}
|
||||
|
||||
export function SearchSpaceForm({
|
||||
onSubmit,
|
||||
onDelete,
|
||||
className,
|
||||
isEditing = false,
|
||||
initialData = { name: "", description: "" }
|
||||
}: SearchSpaceFormProps) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
// Initialize the form with React Hook Form and Zod validation
|
||||
const form = useForm<SearchSpaceFormValues>({
|
||||
resolver: zodResolver(searchSpaceFormSchema),
|
||||
defaultValues: {
|
||||
name: initialData.name,
|
||||
description: initialData.description,
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const handleFormSubmit = (values: SearchSpaceFormValues) => {
|
||||
if (onSubmit) {
|
||||
onSubmit(values);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete confirmation
|
||||
const handleDelete = () => {
|
||||
if (onDelete) {
|
||||
onDelete();
|
||||
}
|
||||
setShowDeleteDialog(false);
|
||||
};
|
||||
|
||||
// Animation variants
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { y: 20, opacity: 0 },
|
||||
visible: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 24,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={cn("space-y-8", className)}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
>
|
||||
<motion.div className="flex flex-col space-y-2" variants={itemVariants}>
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
{isEditing ? "Edit Search Space" : "Create Search Space"}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{isEditing
|
||||
? "Update your search space details"
|
||||
: "Create a new search space to organize your documents, chats, and podcasts."}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="w-full"
|
||||
variants={itemVariants}
|
||||
>
|
||||
<Tilt
|
||||
rotationFactor={6}
|
||||
isRevese
|
||||
springOptions={{
|
||||
stiffness: 26.7,
|
||||
damping: 4.1,
|
||||
mass: 0.2,
|
||||
}}
|
||||
className="group relative rounded-lg"
|
||||
>
|
||||
<Spotlight
|
||||
className="z-10 from-blue-500/20 via-blue-300/10 to-blue-200/5 blur-2xl"
|
||||
size={300}
|
||||
springOptions={{
|
||||
stiffness: 26.7,
|
||||
damping: 4.1,
|
||||
mass: 0.2,
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col p-8 rounded-xl border-2 bg-muted/30 backdrop-blur-sm transition-all hover:border-primary/50 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="p-3 rounded-full bg-blue-100 dark:bg-blue-950/50">
|
||||
<Search className="size-6 text-blue-500" />
|
||||
</span>
|
||||
<h3 className="text-xl font-semibold">Search Space</h3>
|
||||
</div>
|
||||
{isEditing && onDelete && (
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full hover:bg-destructive/90 hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete your search space.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
A search space allows you to organize and search through your documents,
|
||||
generate podcasts, and have AI-powered conversations about your content.
|
||||
</p>
|
||||
</div>
|
||||
</Tilt>
|
||||
</motion.div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleFormSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter search space name" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A unique name for your search space.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter search space description" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A brief description of what this search space will be used for.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{isEditing ? "Update" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchSpaceForm;
|
309
surfsense_web/components/sidebar/AppSidebarProvider.tsx
Normal file
309
surfsense_web/components/sidebar/AppSidebarProvider.tsx
Normal file
|
@ -0,0 +1,309 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AppSidebar } from '@/components/sidebar/app-sidebar';
|
||||
import { iconMap } from '@/components/sidebar/app-sidebar';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { apiClient } from '@/lib/api'; // Import the API client
|
||||
|
||||
interface Chat {
|
||||
created_at: string;
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
messages: string[];
|
||||
search_space_id: number;
|
||||
}
|
||||
|
||||
interface SearchSpace {
|
||||
created_at: string;
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
is_superuser: boolean;
|
||||
is_verified: boolean;
|
||||
}
|
||||
|
||||
interface AppSidebarProviderProps {
|
||||
searchSpaceId: string;
|
||||
navSecondary: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon: string;
|
||||
}[];
|
||||
navMain: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon: string;
|
||||
isActive?: boolean;
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export function AppSidebarProvider({
|
||||
searchSpaceId,
|
||||
navSecondary,
|
||||
navMain
|
||||
}: AppSidebarProviderProps) {
|
||||
const [recentChats, setRecentChats] = useState<{ name: string; url: string; icon: string; id: number; search_space_id: number; actions: { name: string; icon: string; onClick: () => void }[] }[]>([]);
|
||||
const [searchSpace, setSearchSpace] = useState<SearchSpace | null>(null);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoadingChats, setIsLoadingChats] = useState(true);
|
||||
const [isLoadingSearchSpace, setIsLoadingSearchSpace] = useState(true);
|
||||
const [isLoadingUser, setIsLoadingUser] = useState(true);
|
||||
const [chatError, setChatError] = useState<string | null>(null);
|
||||
const [searchSpaceError, setSearchSpaceError] = useState<string | null>(null);
|
||||
const [userError, setUserError] = useState<string | null>(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [chatToDelete, setChatToDelete] = useState<{ id: number, name: string } | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
// Set isClient to true when component mounts on the client
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
// Fetch user details
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
// Only run on client-side
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
// Use the API client instead of direct fetch
|
||||
const userData = await apiClient.get<User>('users/me');
|
||||
setUser(userData);
|
||||
setUserError(null);
|
||||
} catch (error) {
|
||||
console.error('Error fetching user:', error);
|
||||
setUserError(error instanceof Error ? error.message : 'Unknown error occurred');
|
||||
} finally {
|
||||
setIsLoadingUser(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in fetchUser:', error);
|
||||
setIsLoadingUser(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUser();
|
||||
}, []);
|
||||
|
||||
// Fetch recent chats
|
||||
useEffect(() => {
|
||||
const fetchRecentChats = async () => {
|
||||
try {
|
||||
// Only run on client-side
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
// Use the API client instead of direct fetch
|
||||
const chats: Chat[] = await apiClient.get<Chat[]>('api/v1/chats/?limit=5&skip=0');
|
||||
|
||||
// Transform API response to the format expected by AppSidebar
|
||||
const formattedChats = chats.map(chat => ({
|
||||
name: chat.title || `Chat ${chat.id}`, // Fallback if title is empty
|
||||
url: `/dashboard/${chat.search_space_id}/researcher/${chat.id}`,
|
||||
icon: 'MessageCircleMore',
|
||||
id: chat.id,
|
||||
search_space_id: chat.search_space_id,
|
||||
actions: [
|
||||
{
|
||||
name: 'View Details',
|
||||
icon: 'ExternalLink',
|
||||
onClick: () => {
|
||||
window.location.href = `/dashboard/${chat.search_space_id}/researcher/${chat.id}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
icon: 'Trash2',
|
||||
onClick: () => {
|
||||
setChatToDelete({ id: chat.id, name: chat.title || `Chat ${chat.id}` });
|
||||
setShowDeleteDialog(true);
|
||||
}
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
setRecentChats(formattedChats);
|
||||
setChatError(null);
|
||||
} catch (error) {
|
||||
console.error('Error fetching chats:', error);
|
||||
setChatError(error instanceof Error ? error.message : 'Unknown error occurred');
|
||||
// Provide empty array to ensure UI still renders
|
||||
setRecentChats([]);
|
||||
} finally {
|
||||
setIsLoadingChats(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in fetchRecentChats:', error);
|
||||
setIsLoadingChats(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRecentChats();
|
||||
|
||||
// Set up a refresh interval (every 5 minutes)
|
||||
const intervalId = setInterval(fetchRecentChats, 5 * 60 * 1000);
|
||||
|
||||
// Clean up interval on component unmount
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
// Handle delete chat
|
||||
const handleDeleteChat = async () => {
|
||||
if (!chatToDelete) return;
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
|
||||
// Use the API client instead of direct fetch
|
||||
await apiClient.delete(`api/v1/chats/${chatToDelete.id}`);
|
||||
|
||||
// Close dialog and refresh chats
|
||||
setRecentChats(recentChats.filter(chat => chat.id !== chatToDelete.id));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting chat:', error);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setShowDeleteDialog(false);
|
||||
setChatToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch search space details
|
||||
useEffect(() => {
|
||||
const fetchSearchSpace = async () => {
|
||||
try {
|
||||
// Only run on client-side
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
// Use the API client instead of direct fetch
|
||||
const data: SearchSpace = await apiClient.get<SearchSpace>(`api/v1/searchspaces/${searchSpaceId}`);
|
||||
setSearchSpace(data);
|
||||
setSearchSpaceError(null);
|
||||
} catch (error) {
|
||||
console.error('Error fetching search space:', error);
|
||||
setSearchSpaceError(error instanceof Error ? error.message : 'Unknown error occurred');
|
||||
} finally {
|
||||
setIsLoadingSearchSpace(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in fetchSearchSpace:', error);
|
||||
setIsLoadingSearchSpace(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSearchSpace();
|
||||
}, [searchSpaceId]);
|
||||
|
||||
// Create a fallback chat if there's an error or no chats
|
||||
const fallbackChats = chatError || (!isLoadingChats && recentChats.length === 0)
|
||||
? [{
|
||||
name: chatError ? "Error loading chats" : "No recent chats",
|
||||
url: "#",
|
||||
icon: chatError ? "AlertCircle" : "MessageCircleMore",
|
||||
id: 0,
|
||||
search_space_id: Number(searchSpaceId),
|
||||
actions: []
|
||||
}]
|
||||
: [];
|
||||
|
||||
// Use fallback chats if there's an error or no chats
|
||||
const displayChats = recentChats.length > 0 ? recentChats : fallbackChats;
|
||||
|
||||
// Update the first item in navSecondary to show the search space name
|
||||
const updatedNavSecondary = [...navSecondary];
|
||||
if (updatedNavSecondary.length > 0 && isClient) {
|
||||
updatedNavSecondary[0] = {
|
||||
...updatedNavSecondary[0],
|
||||
title: searchSpace?.name || (isLoadingSearchSpace ? 'Loading...' : searchSpaceError ? 'Error loading search space' : 'Unknown Search Space'),
|
||||
};
|
||||
}
|
||||
|
||||
// Create user object for AppSidebar
|
||||
const customUser = {
|
||||
name: isClient && user?.email ? user.email.split('@')[0] : 'User',
|
||||
email: isClient ? (user?.email || (isLoadingUser ? 'Loading...' : userError ? 'Error loading user' : 'Unknown User')) : 'Loading...',
|
||||
avatar: '/icon-128.png', // Default avatar
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppSidebar
|
||||
user={customUser}
|
||||
navSecondary={updatedNavSecondary}
|
||||
navMain={navMain}
|
||||
RecentChats={isClient ? displayChats : []}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog - Only render on client */}
|
||||
{isClient && (
|
||||
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Trash2 className="h-5 w-5 text-destructive" />
|
||||
<span>Delete Chat</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete <span className="font-medium">{chatToDelete?.name}</span>? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteDialog(false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteChat}
|
||||
disabled={isDeleting}
|
||||
className="gap-2"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
236
surfsense_web/components/sidebar/app-sidebar.tsx
Normal file
236
surfsense_web/components/sidebar/app-sidebar.tsx
Normal file
|
@ -0,0 +1,236 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
BookOpen,
|
||||
Cable,
|
||||
FileStack,
|
||||
Undo2,
|
||||
MessageCircleMore,
|
||||
Settings2,
|
||||
SquareLibrary,
|
||||
SquareTerminal,
|
||||
AlertCircle,
|
||||
Info,
|
||||
ExternalLink,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { NavMain } from "@/components/sidebar/nav-main"
|
||||
import { NavProjects } from "@/components/sidebar/nav-projects"
|
||||
import { NavSecondary } from "@/components/sidebar/nav-secondary"
|
||||
import { NavUser } from "@/components/sidebar/nav-user"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
// Map of icon names to their components
|
||||
export const iconMap: Record<string, LucideIcon> = {
|
||||
BookOpen,
|
||||
Cable,
|
||||
FileStack,
|
||||
Undo2,
|
||||
MessageCircleMore,
|
||||
Settings2,
|
||||
SquareLibrary,
|
||||
SquareTerminal,
|
||||
AlertCircle,
|
||||
Info,
|
||||
ExternalLink,
|
||||
Trash2
|
||||
}
|
||||
|
||||
const defaultData = {
|
||||
user: {
|
||||
name: "Surf",
|
||||
email: "m@example.com",
|
||||
avatar: "/icon-128.png",
|
||||
},
|
||||
navMain: [
|
||||
{
|
||||
title: "Researcher",
|
||||
url: "#",
|
||||
icon: "SquareTerminal",
|
||||
isActive: true,
|
||||
items: [],
|
||||
},
|
||||
|
||||
{
|
||||
title: "Documents",
|
||||
url: "#",
|
||||
icon: "FileStack",
|
||||
items: [
|
||||
{
|
||||
title: "Upload Documents",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Manage Documents",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Connectors",
|
||||
url: "#",
|
||||
icon: "Cable",
|
||||
items: [
|
||||
{
|
||||
title: "Add Connector",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Manage Connectors",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Research Synthesizer's",
|
||||
url: "#",
|
||||
icon: "SquareLibrary",
|
||||
items: [
|
||||
{
|
||||
title: "Podcast Creator",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Presentation Creator",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
navSecondary: [
|
||||
{
|
||||
title: "SEARCH SPACE",
|
||||
url: "#",
|
||||
icon: "LifeBuoy",
|
||||
},
|
||||
],
|
||||
RecentChats: [
|
||||
{
|
||||
name: "Design Engineering",
|
||||
url: "#",
|
||||
icon: "MessageCircleMore",
|
||||
id: 1001,
|
||||
},
|
||||
{
|
||||
name: "Sales & Marketing",
|
||||
url: "#",
|
||||
icon: "MessageCircleMore",
|
||||
id: 1002,
|
||||
},
|
||||
{
|
||||
name: "Travel",
|
||||
url: "#",
|
||||
icon: "MessageCircleMore",
|
||||
id: 1003,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
user?: {
|
||||
name: string
|
||||
email: string
|
||||
avatar: string
|
||||
}
|
||||
navMain?: {
|
||||
title: string
|
||||
url: string
|
||||
icon: string
|
||||
isActive?: boolean
|
||||
items?: {
|
||||
title: string
|
||||
url: string
|
||||
}[]
|
||||
}[]
|
||||
navSecondary?: {
|
||||
title: string
|
||||
url: string
|
||||
icon: string // Changed to string (icon name)
|
||||
}[]
|
||||
RecentChats?: {
|
||||
name: string
|
||||
url: string
|
||||
icon: string // Changed to string (icon name)
|
||||
id?: number
|
||||
search_space_id?: number
|
||||
actions?: {
|
||||
name: string
|
||||
icon: string
|
||||
onClick: () => void
|
||||
}[]
|
||||
}[]
|
||||
}
|
||||
|
||||
export function AppSidebar({
|
||||
user = defaultData.user,
|
||||
navMain = defaultData.navMain,
|
||||
navSecondary = defaultData.navSecondary,
|
||||
RecentChats = defaultData.RecentChats,
|
||||
...props
|
||||
}: AppSidebarProps) {
|
||||
// Process navMain to resolve icon names to components
|
||||
const processedNavMain = React.useMemo(() => {
|
||||
return navMain.map(item => ({
|
||||
...item,
|
||||
icon: iconMap[item.icon] || SquareTerminal // Fallback to SquareTerminal if icon not found
|
||||
}))
|
||||
}, [navMain])
|
||||
|
||||
// Process navSecondary to resolve icon names to components
|
||||
const processedNavSecondary = React.useMemo(() => {
|
||||
return navSecondary.map(item => ({
|
||||
...item,
|
||||
icon: iconMap[item.icon] || Undo2 // Fallback to Undo2 if icon not found
|
||||
}))
|
||||
}, [navSecondary])
|
||||
|
||||
// Process RecentChats to resolve icon names to components
|
||||
const processedRecentChats = React.useMemo(() => {
|
||||
return RecentChats?.map(item => ({
|
||||
...item,
|
||||
icon: iconMap[item.icon] || MessageCircleMore // Fallback to MessageCircleMore if icon not found
|
||||
})) || [];
|
||||
}, [RecentChats])
|
||||
|
||||
return (
|
||||
<Sidebar variant="inset" {...props}>
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<div>
|
||||
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
|
||||
<Logo className="rounded-lg" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">SurfSense</span>
|
||||
<span className="truncate text-xs">beta v0.0.6</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain items={processedNavMain} />
|
||||
{processedRecentChats.length > 0 && <NavProjects projects={processedRecentChats} />}
|
||||
<NavSecondary items={processedNavSecondary} className="mt-auto" />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser user={user} />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
79
surfsense_web/components/sidebar/nav-main.tsx
Normal file
79
surfsense_web/components/sidebar/nav-main.tsx
Normal file
|
@ -0,0 +1,79 @@
|
|||
"use client"
|
||||
|
||||
import { ChevronRight, type LucideIcon } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
export function NavMain({
|
||||
items,
|
||||
}: {
|
||||
items: {
|
||||
title: string
|
||||
url: string
|
||||
icon: LucideIcon
|
||||
isActive?: boolean
|
||||
items?: {
|
||||
title: string
|
||||
url: string
|
||||
}[]
|
||||
}[]
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item, index) => (
|
||||
<Collapsible key={`${item.title}-${index}`} asChild defaultOpen={item.isActive}>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild tooltip={item.title}>
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
{item.items?.length ? (
|
||||
<>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuAction className="data-[state=open]:rotate-90">
|
||||
<ChevronRight />
|
||||
<span className="sr-only">Toggle</span>
|
||||
</SidebarMenuAction>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.items?.map((subItem, subIndex) => (
|
||||
<SidebarMenuSubItem key={`${subItem.title}-${subIndex}`}>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<a href={subItem.url}>
|
||||
<span>{subItem.title}</span>
|
||||
</a>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</>
|
||||
) : null}
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
122
surfsense_web/components/sidebar/nav-projects.tsx
Normal file
122
surfsense_web/components/sidebar/nav-projects.tsx
Normal file
|
@ -0,0 +1,122 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
ExternalLink,
|
||||
Folder,
|
||||
MoreHorizontal,
|
||||
Share,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
// Map of icon names to their components
|
||||
const actionIconMap: Record<string, LucideIcon> = {
|
||||
ExternalLink,
|
||||
Folder,
|
||||
Share,
|
||||
Trash2,
|
||||
MoreHorizontal
|
||||
}
|
||||
|
||||
interface ChatAction {
|
||||
name: string;
|
||||
icon: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function NavProjects({
|
||||
projects,
|
||||
}: {
|
||||
projects: {
|
||||
name: string
|
||||
url: string
|
||||
icon: LucideIcon
|
||||
id?: number
|
||||
search_space_id?: number
|
||||
actions?: ChatAction[]
|
||||
}[]
|
||||
}) {
|
||||
const { isMobile } = useSidebar()
|
||||
const router = useRouter()
|
||||
|
||||
const searchSpaceId = projects[0]?.search_space_id || ""
|
||||
|
||||
return (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel>Recent Chats</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{projects.map((item, index) => (
|
||||
<SidebarMenuItem key={item.id ? `chat-${item.id}` : `chat-${item.name}-${index}`}>
|
||||
<SidebarMenuButton>
|
||||
<item.icon />
|
||||
<span>{item.name}</span>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction showOnHover>
|
||||
<MoreHorizontal />
|
||||
<span className="sr-only">More</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-48"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align={isMobile ? "end" : "start"}
|
||||
>
|
||||
{item.actions ? (
|
||||
// Use the actions provided by the item
|
||||
item.actions.map((action, actionIndex) => {
|
||||
const ActionIcon = actionIconMap[action.icon] || Folder;
|
||||
return (
|
||||
<DropdownMenuItem key={`${action.name}-${actionIndex}`} onClick={action.onClick}>
|
||||
<ActionIcon className="text-muted-foreground" />
|
||||
<span>{action.name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
// Default actions if none provided
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<Folder className="text-muted-foreground" />
|
||||
<span>View Chat</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<Trash2 className="text-muted-foreground" />
|
||||
<span>Delete Chat</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton onClick={() => router.push(`/dashboard/${searchSpaceId}/chats`)}>
|
||||
<MoreHorizontal />
|
||||
<span>View All Chats</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
43
surfsense_web/components/sidebar/nav-secondary.tsx
Normal file
43
surfsense_web/components/sidebar/nav-secondary.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type LucideIcon } from "lucide-react"
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarGroupLabel,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
|
||||
export function NavSecondary({
|
||||
items,
|
||||
...props
|
||||
}: {
|
||||
items: {
|
||||
title: string
|
||||
url: string
|
||||
icon: LucideIcon
|
||||
}[]
|
||||
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
|
||||
return (
|
||||
<SidebarGroup {...props}>
|
||||
<SidebarGroupLabel>SearchSpace</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item, index) => (
|
||||
<SidebarMenuItem key={`${item.title}-${index}`}>
|
||||
<SidebarMenuButton asChild size="sm">
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
108
surfsense_web/components/sidebar/nav-user.tsx
Normal file
108
surfsense_web/components/sidebar/nav-user.tsx
Normal file
|
@ -0,0 +1,108 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
BadgeCheck,
|
||||
Bell,
|
||||
ChevronsUpDown,
|
||||
CreditCard,
|
||||
LogOut,
|
||||
Sparkles,
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/components/ui/avatar"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { useRouter, useParams } from "next/navigation"
|
||||
|
||||
export function NavUser({
|
||||
user,
|
||||
}: {
|
||||
user: {
|
||||
name: string
|
||||
email: string
|
||||
avatar: string
|
||||
}
|
||||
}) {
|
||||
const { isMobile } = useSidebar()
|
||||
const router = useRouter()
|
||||
const { search_space_id } = useParams()
|
||||
|
||||
const handleLogout = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('surfsense_bearer_token');
|
||||
router.push('/');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="truncate text-xs">{user.email}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="truncate text-xs">{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => router.push(`/dashboard/${search_space_id}/api-key`)}>
|
||||
<BadgeCheck />
|
||||
API Key
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
9
surfsense_web/components/theme/theme-provider.tsx
Normal file
9
surfsense_web/components/theme/theme-provider.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
import type { ThemeProviderProps } from "next-themes"
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
69
surfsense_web/components/theme/theme-toggle.tsx
Normal file
69
surfsense_web/components/theme/theme-toggle.tsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { MoonIcon, SunIcon } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export function ThemeTogglerComponent() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const [isClient, setIsClient] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
isClient && (
|
||||
<button
|
||||
onClick={() => {
|
||||
theme === "dark" ? setTheme("light") : setTheme("dark");
|
||||
}}
|
||||
className="w-8 h-8 flex hover:bg-gray-50 dark:hover:bg-white/[0.1] rounded-lg items-center justify-center outline-none focus:ring-0 focus:outline-none active:ring-0 active:outline-none overflow-hidden"
|
||||
>
|
||||
{theme === "light" && (
|
||||
<motion.div
|
||||
key={theme}
|
||||
initial={{
|
||||
x: 40,
|
||||
opacity: 0,
|
||||
}}
|
||||
animate={{
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
>
|
||||
<SunIcon className="h-4 w-4 flex-shrink-0 dark:text-neutral-500 text-neutral-700" />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{theme === "dark" && (
|
||||
<motion.div
|
||||
key={theme}
|
||||
initial={{
|
||||
x: 40,
|
||||
opacity: 0,
|
||||
}}
|
||||
animate={{
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
}}
|
||||
transition={{
|
||||
ease: "easeOut",
|
||||
duration: 0.3,
|
||||
}}
|
||||
>
|
||||
<MoonIcon className="h-4 w-4 flex-shrink-0 " />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
60
surfsense_web/components/ui/accordion.tsx
Normal file
60
surfsense_web/components/ui/accordion.tsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Accordion = AccordionPrimitive.Root
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AccordionItem.displayName = "AccordionItem"
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
))
|
||||
AccordionTrigger.displayName = "AccordionTrigger"
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="pb-4 pt-0">{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
))
|
||||
AccordionContent.displayName = "AccordionContent"
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
157
surfsense_web/components/ui/alert-dialog.tsx
Normal file
157
surfsense_web/components/ui/alert-dialog.tsx
Normal file
|
@ -0,0 +1,157 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
58
surfsense_web/components/ui/alert.tsx
Normal file
58
surfsense_web/components/ui/alert.tsx
Normal file
|
@ -0,0 +1,58 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
53
surfsense_web/components/ui/avatar.tsx
Normal file
53
surfsense_web/components/ui/avatar.tsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
46
surfsense_web/components/ui/badge.tsx
Normal file
46
surfsense_web/components/ui/badge.tsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue