Merge pull request #237 from Utkarsh-Patel-13/main

Biome formatter and Linter for SurfSense Web and Extensions
This commit is contained in:
Rohan Verma 2025-07-29 02:34:10 +05:30 committed by GitHub
commit eb067ee6d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
192 changed files with 27524 additions and 32966 deletions

View file

@ -61,7 +61,7 @@ jobs:
echo "Running file quality hooks on changed files against $BASE_REF"
# Run each hook individually on changed files
SKIP=detect-secrets,bandit,ruff,ruff-format,prettier,eslint,typescript-check-web,typescript-check-extension,commitizen \
SKIP=detect-secrets,bandit,ruff,ruff-format,biome-check-web,biome-check-extension,commitizen \
pre-commit run --from-ref $BASE_REF --to-ref HEAD || exit_code=$?
# Exit with the same code as pre-commit
@ -118,7 +118,7 @@ jobs:
echo "Running security scans on changed files against $BASE_REF"
# Run only security hooks on changed files
SKIP=check-yaml,check-json,check-toml,check-merge-conflict,check-added-large-files,debug-statements,check-case-conflict,ruff,ruff-format,prettier,eslint,typescript-check-web,typescript-check-extension,commitizen \
SKIP=check-yaml,check-json,check-toml,check-merge-conflict,check-added-large-files,debug-statements,check-case-conflict,ruff,ruff-format,biome-check-web,biome-check-extension,commitizen \
pre-commit run --from-ref $BASE_REF --to-ref HEAD || exit_code=$?
# Exit with the same code as pre-commit
@ -199,7 +199,89 @@ jobs:
echo "Running Python backend checks on changed files against $BASE_REF"
# Run only ruff hooks on changed Python files
SKIP=detect-secrets,bandit,check-yaml,check-json,check-toml,check-merge-conflict,check-added-large-files,debug-statements,check-case-conflict,prettier,eslint,typescript-check-web,typescript-check-extension,commitizen \
SKIP=detect-secrets,bandit,check-yaml,check-json,check-toml,check-merge-conflict,check-added-large-files,debug-statements,check-case-conflict,biome-check-web,biome-check-extension,commitizen \
pre-commit run --from-ref $BASE_REF --to-ref HEAD || exit_code=$?
# Exit with the same code as pre-commit
exit ${exit_code:-0}
typescript-frontend:
name: TypeScript/JavaScript Quality
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch base branch
run: |
git fetch origin ${{ github.base_ref }}:${{ github.base_ref }} 2>/dev/null || git fetch origin ${{ github.base_ref }} 2>/dev/null || true
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: latest
- name: Check if frontend files changed
id: frontend-changes
uses: dorny/paths-filter@v3
with:
filters: |
web:
- 'surfsense_web/**'
extension:
- 'surfsense_browser_extension/**'
- name: Install dependencies for web
if: steps.frontend-changes.outputs.web == 'true'
working-directory: surfsense_web
run: pnpm install --frozen-lockfile
- name: Install dependencies for extension
if: steps.frontend-changes.outputs.extension == 'true'
working-directory: surfsense_browser_extension
run: pnpm install --frozen-lockfile
- name: Install pre-commit
run: pip install pre-commit
- name: Cache pre-commit hooks
uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: pre-commit-frontend-${{ hashFiles('.pre-commit-config.yaml') }}
restore-keys: |
pre-commit-frontend-
- name: Install hook environments (cache)
run: pre-commit install-hooks
- name: Run TypeScript/JavaScript quality checks
run: |
# Get base ref for comparison
if git show-ref --verify --quiet refs/heads/${{ github.base_ref }}; then
BASE_REF="${{ github.base_ref }}"
elif git show-ref --verify --quiet refs/remotes/origin/${{ github.base_ref }}; then
BASE_REF="origin/${{ github.base_ref }}"
else
echo "Base branch reference not found, running TypeScript/JavaScript checks on all files"
pre-commit run --all-files biome-check-web biome-check-extension
exit 0
fi
echo "Running TypeScript/JavaScript checks on changed files against $BASE_REF"
# Run only Biome hooks on changed TypeScript/JavaScript files
# Biome hooks use --diagnostic-level=error to only fail on errors, not warnings
SKIP=detect-secrets,bandit,check-yaml,check-json,check-toml,check-merge-conflict,check-added-large-files,debug-statements,check-case-conflict,ruff,ruff-format,commitizen \
pre-commit run --from-ref $BASE_REF --to-ref HEAD || exit_code=$?
# Exit with the same code as pre-commit
@ -208,7 +290,7 @@ jobs:
quality-gate:
name: Quality Gate
runs-on: ubuntu-latest
needs: [file-quality, security-scan, python-backend]
needs: [file-quality, security-scan, python-backend, typescript-frontend]
if: always()
steps:
@ -216,7 +298,8 @@ jobs:
run: |
if [[ "${{ needs.file-quality.result }}" == "failure" ||
"${{ needs.security-scan.result }}" == "failure" ||
"${{ needs.python-backend.result }}" == "failure" ]]; then
"${{ needs.python-backend.result }}" == "failure" ||
"${{ needs.typescript-frontend.result }}" == "failure" ]]; then
echo "❌ Code quality checks failed"
exit 1
else

View file

@ -60,45 +60,28 @@ repos:
args: ['-f', 'json', '--severity-level', 'high', '--confidence-level', 'high']
exclude: ^surfsense_backend/(tests/|test_.*\.py|.*test.*\.py|alembic/)
# Frontend/Extension Hooks (TypeScript/JavaScript)
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.8
hooks:
- id: prettier
files: ^(surfsense_web|surfsense_browser_extension)/
types_or: [javascript, jsx, ts, tsx, json, yaml, markdown]
exclude: '(package-lock\.json|\.next/|build/|dist/)'
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v9.31.0
hooks:
- id: eslint
files: ^surfsense_web/
types: [file]
types_or: [javascript, jsx, ts, tsx]
additional_dependencies:
- 'eslint@^9'
- 'eslint-config-next@15.2.0'
- '@eslint/eslintrc@^3'
args: [--fix]
exclude: '(\.next/|build/|dist/)'
# TypeScript compilation check
# Biome hooks for TypeScript/JavaScript projects
- repo: local
hooks:
- id: typescript-check-web
name: TypeScript Check (Web)
entry: bash -c 'cd surfsense_web && (command -v pnpm >/dev/null 2>&1 && pnpm build --dry-run || npx next build --dry-run)'
# Biome check for surfsense_web
- id: biome-check-web
name: biome-check-web
entry: bash -c 'cd surfsense_web && npx @biomejs/biome check --diagnostic-level=error .'
language: system
files: ^surfsense_web/.*\.(ts|tsx)$
files: ^surfsense_web/
pass_filenames: false
always_run: true
stages: [pre-commit]
- id: typescript-check-extension
name: TypeScript Check (Browser Extension)
entry: bash -c 'cd surfsense_browser_extension && npx tsc --noEmit'
# Biome check for surfsense_browser_extension
- id: biome-check-extension
name: biome-check-extension
entry: bash -c 'cd surfsense_browser_extension && npx @biomejs/biome check --diagnostic-level=error .'
language: system
files: ^surfsense_browser_extension/.*\.(ts|tsx)$
files: ^surfsense_browser_extension/
pass_filenames: false
always_run: true
stages: [pre-commit]
# Commit message linting
- repo: https://github.com/commitizen-tools/commitizen

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"biome.configurationPath": "./surfsense_web/biome.json"
}

115
biome.json Normal file
View file

@ -0,0 +1,115 @@
{
"$schema": "https://biomejs.dev/schemas/2.1.2/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"experimentalScannerIgnores": ["node_modules", ".git", ".next", "dist", "build", "coverage"],
"maxSize": 1048576
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"indentWidth": 2,
"lineWidth": 100,
"lineEnding": "lf",
"formatWithErrors": false
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "warn",
"noArrayIndexKey": "warn"
},
"style": {
"useConst": "error",
"useTemplate": "warn"
},
"correctness": {
"useExhaustiveDependencies": "warn"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "es5",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSameLine": false,
"bracketSpacing": true
},
"linter": {
"enabled": true
},
"assist": {
"enabled": true
}
},
"json": {
"formatter": {
"enabled": true,
"indentStyle": "tab",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"enabled": true
}
},
"css": {
"formatter": {
"enabled": true,
"indentStyle": "tab",
"indentWidth": 2,
"lineWidth": 100,
"quoteStyle": "double"
},
"linter": {
"enabled": true
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"overrides": [
{
"includes": ["*.json", "*.jsonc"],
"json": {
"parser": {
"allowComments": true,
"allowTrailingCommas": false
}
}
},
{
"includes": [".vscode/**/*.json"],
"json": {
"parser": {
"allowComments": true,
"allowTrailingCommas": true
}
}
},
{
"includes": ["**/*.config.*", "**/next.config.*"],
"javascript": {
"formatter": {
"semicolons": "always"
}
}
}
]
}

View file

@ -76,18 +76,10 @@ async def generate_chat_podcast(
chat_history_str += f"<user_message>{message['content']}</user_message>"
processed_messages += 1
elif message["role"] == "assistant":
# Last annotation type will always be "ANSWER" here
answer_annotation = message["annotations"][-1]
answer_text = ""
if answer_annotation["type"] == "ANSWER":
answer_text = answer_annotation["content"]
# If content is a list, join it into a single string
if isinstance(answer_text, list):
answer_text = "\n".join(answer_text)
chat_history_str += (
f"<assistant_message>{answer_text}</assistant_message>"
)
processed_messages += 1
chat_history_str += (
f"<assistant_message>{message['content']}</assistant_message>"
)
processed_messages += 1
chat_history_str += "</chat_history>"

View file

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

View file

@ -1,77 +1,70 @@
import { initQueues, initWebHistory } from "~utils/commons"
import type { WebHistory } from "~utils/interfaces"
import { Storage } from "@plasmohq/storage"
import {getRenderedHtml} from '~utils/commons'
import { Storage } from "@plasmohq/storage";
import { getRenderedHtml, initQueues, initWebHistory } from "~utils/commons";
import type { WebHistory } from "~utils/interfaces";
chrome.tabs.onCreated.addListener(async (tab: any) => {
try {
await initWebHistory(tab.id)
await initQueues(tab.id)
} catch (error) {
console.log(error)
}
})
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)
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
})
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;
const 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")
const urlQueueListObj: any = await storage.get("urlQueueList");
const 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)
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
})
}
}
)
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)
})
}
})
const storage = new Storage({ area: "local" });
const urlQueueListObj: any = await storage.get("urlQueueList");
const timeQueueListObj: any = await storage.get("timeQueueList");
if (urlQueueListObj.urlQueueList && timeQueueListObj.timeQueueList) {
const urlQueueListToSave = urlQueueListObj.urlQueueList.map((element: WebHistory) => {
if (element.tabsessionId !== tabId) {
return element;
}
});
const timeQueueListSave = timeQueueListObj.timeQueueList.map((element: WebHistory) => {
if (element.tabsessionId !== tabId) {
return element;
}
});
await storage.set("urlQueueList", {
urlQueueList: urlQueueListToSave.filter((item: any) => item),
});
await storage.set("timeQueueList", {
timeQueueList: timeQueueListSave.filter((item: any) => item),
});
}
});

View file

@ -1,149 +1,150 @@
import type { PlasmoMessaging } from "@plasmohq/messaging"
import { Storage } from "@plasmohq/storage"
import type { PlasmoMessaging } from "@plasmohq/messaging";
import { Storage } from "@plasmohq/storage";
import {
emptyArr,
webhistoryToLangChainDocument
} from "~utils/commons"
import { emptyArr, webhistoryToLangChainDocument } from "~utils/commons";
const clearMemory = async () => {
try {
const storage = new Storage({ area: "local" })
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")
const webHistory: any = await storage.get("webhistory");
const urlQueue: any = await storage.get("urlQueueList");
const timeQueue: any = await storage.get("timeQueueList");
if (!webHistory.webhistory) {
return
}
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
}
})
//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)
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
}
})
//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 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
}
})
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)
}
}
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" })
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[] = []
const webhistoryObj: any = await storage.get("webhistory");
const webhistory = webhistoryObj.webhistory;
if (webhistory) {
const toSaveFinally: any[] = [];
const 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
})
}
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 });
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);
}
// 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 || "")
}));
// 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 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 toSend = {
document_type: "EXTENSION",
content: content,
search_space_id: search_space_id,
};
console.log("toSend", toSend)
console.log("toSend", toSend);
const requestOptions = {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify(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)
}
}
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
export default handler;

View file

@ -1,145 +1,142 @@
import { DOMParser } from "linkedom"
import type { PlasmoMessaging } from "@plasmohq/messaging";
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"
import { Storage } from "@plasmohq/storage";
import { convertHtmlToMarkdown } from "dom-to-semantic-markdown";
import { DOMParser } from "linkedom";
import { getRenderedHtml, webhistoryToLangChainDocument } from "~utils/commons";
import type { WebHistory } from "~utils/interfaces";
// @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,
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"
})
try {
chrome.tabs.query({ active: true, currentWindow: true }, async (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)
console.log("SnapRes", result);
let toPushInTabHistory: any = result[0].result // const { renderedHtml, title, url, entryTime } = result[0].result;
const 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()
}
)
toPushInTabHistory.pageContentMarkdown = convertHtmlToMarkdown(
toPushInTabHistory.renderedHtml,
{
extractMainContent: true,
enableTableColumnTracking: true,
includeMetaData: false,
overrideDOMParser: new DOMParser(),
}
);
delete toPushInTabHistory.renderedHtml
delete toPushInTabHistory.renderedHtml;
console.log("toPushInTabHistory", toPushInTabHistory)
console.log("toPushInTabHistory", toPushInTabHistory);
const urlQueueListObj: any = await storage.get("urlQueueList")
const timeQueueListObj: any = await storage.get("timeQueueList")
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
)
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]
}
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 toSaveFinally: any[] = [];
const markdownFormat = webhistoryToLangChainDocument(
tab.id,
[toPushInTabHistory]
)
toSaveFinally.push(...markdownFormat)
console.log("toSaveFinally", toSaveFinally)
const markdownFormat = webhistoryToLangChainDocument(tab.id, [toPushInTabHistory]);
toSaveFinally.push(...markdownFormat);
// Log first item to debug metadata structure
if (toSaveFinally.length > 0) {
console.log("First item metadata:", toSaveFinally[0].metadata);
}
console.log("toSaveFinally", toSaveFinally);
// 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 || "")
}));
// Log first item to debug metadata structure
if (toSaveFinally.length > 0) {
console.log("First item metadata:", toSaveFinally[0].metadata);
}
const token = await storage.get("token");
const search_space_id = parseInt(await storage.get("search_space_id"), 10);
// 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 toSend = {
document_type: "EXTENSION",
content: content,
search_space_id: search_space_id
}
const token = await storage.get("token");
const search_space_id = parseInt(await storage.get("search_space_id"), 10);
const requestOptions = {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify(toSend)
}
const toSend = {
document_type: "EXTENSION",
content: content,
search_space_id: search_space_id,
};
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)
}
}
const requestOptions = {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(toSend),
};
export default handler
const response = await fetch(
`${process.env.PLASMO_PUBLIC_BACKEND_URL}/api/v1/documents/`,
requestOptions
);
const resp = await response.json();
if (resp) {
res.send({
message: "Snapshot Saved Successfully",
});
}
}
});
} catch (error) {
console.log(error);
}
};
export default handler;

View file

@ -0,0 +1,115 @@
{
"$schema": "https://biomejs.dev/schemas/2.1.2/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"experimentalScannerIgnores": ["node_modules", ".git", ".next", "dist", "build", "coverage"],
"maxSize": 1048576
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"indentWidth": 2,
"lineWidth": 100,
"lineEnding": "lf",
"formatWithErrors": false
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "warn",
"noArrayIndexKey": "warn"
},
"style": {
"useConst": "error",
"useTemplate": "warn"
},
"correctness": {
"useExhaustiveDependencies": "warn"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "es5",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSameLine": false,
"bracketSpacing": true
},
"linter": {
"enabled": true
},
"assist": {
"enabled": true
}
},
"json": {
"formatter": {
"enabled": true,
"indentStyle": "tab",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"enabled": true
}
},
"css": {
"formatter": {
"enabled": true,
"indentStyle": "tab",
"indentWidth": 2,
"lineWidth": 100,
"quoteStyle": "double"
},
"linter": {
"enabled": true
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"overrides": [
{
"includes": ["*.json", "*.jsonc"],
"json": {
"parser": {
"allowComments": true,
"allowTrailingCommas": false
}
}
},
{
"includes": [".vscode/**/*.json"],
"json": {
"parser": {
"allowComments": true,
"allowTrailingCommas": true
}
}
},
{
"includes": ["**/*.config.*", "**/next.config.*"],
"javascript": {
"formatter": {
"semicolons": "always"
}
}
}
]
}

View file

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

View file

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

View file

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

View file

@ -1,62 +1,62 @@
{
"name": "surfsense_browser_extension",
"displayName": "Surfsense Browser Extension",
"version": "0.0.7",
"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"
]
"name": "surfsense_browser_extension",
"displayName": "Surfsense Browser Extension",
"version": "0.0.7",
"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-label": "^2.1.7",
"@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": {
"@biomejs/biome": "2.1.2",
"@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",
"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"
]
}

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -1,13 +1,12 @@
import { Route, Routes } from "react-router-dom"
import ApiKeyForm from "./pages/ApiKeyForm"
import HomePage from "./pages/HomePage"
import '../tailwind.css'
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>
)
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/login" element={<ApiKeyForm />} />
</Routes>
);

View file

@ -1,123 +1,122 @@
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"
import icon from "data-base64:~assets/icon.png";
import { Storage } from "@plasmohq/storage";
import { ReloadIcon } from "@radix-ui/react-icons";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "~/routes/ui/button";
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 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 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);
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}`,
}
});
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);
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.');
}
};
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>
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>
<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>
<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>
<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>
);
}
<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"
rel="noopener"
>
Sign up
</a>
</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default ApiKeyForm
export default ApiKeyForm;

View file

@ -1,476 +1,478 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"
import icon from "data-base64:~assets/icon.png"
import brain from "data-base64:~assets/brain.png";
import icon from "data-base64:~assets/icon.png";
import { sendToBackground } from "@plasmohq/messaging";
import { Storage } from "@plasmohq/storage";
import {
CrossCircledIcon,
DiscIcon,
ExitIcon,
FileIcon,
ReloadIcon,
UploadIcon,
} from "@radix-ui/react-icons";
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 { Check, ChevronsUpDown } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
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"
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "~/routes/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "~/routes/ui/popover";
import { Label } from "~routes/ui/label";
import { useToast } from "~routes/ui/use-toast";
import {
CircleIcon,
CrossCircledIcon,
DiscIcon,
ExitIcon,
FileIcon,
ReloadIcon,
ResetIcon,
UploadIcon
} from "@radix-ui/react-icons"
import { getRenderedHtml } from "~utils/commons";
import type { WebHistory } from "~utils/interfaces";
import Loading from "./Loading";
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);
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}`
}
}
);
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")
}
};
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);
}, []);
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);
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;
});
let sum = 0
webhistory.webhistory.forEach((element: any) => {
sum = sum + element.tabHistory.length
});
setNoOfWebPages(sum);
}
});
setNoOfWebPages(sum)
}
}
);
const storage = new Storage({ area: "local" });
const searchspace = await storage.get("search_space");
const storage = new Storage({ area: "local" })
const searchspace = await storage.get("search_space");
if (searchspace) {
setValue(searchspace);
}
if(searchspace){
setValue(searchspace)
}
await storage.set("showShadowDom", true);
await storage.set("showShadowDom", true)
const webhistoryObj: any = await storage.get("webhistory");
if (webhistoryObj.webhistory.length) {
const webhistory = webhistoryObj.webhistory;
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);
}
}
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();
}, []);
onLoad()
}, []);
async function clearMem(): Promise<void> {
try {
const storage = new Storage({ area: "local" });
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);
}
}
const webHistory: any = await storage.get("webhistory");
const urlQueue: any = await storage.get("urlQueueList");
const timeQueue: any = await storage.get("timeQueueList");
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,
});
if (!webHistory.webhistory) {
return;
}
let toPushInTabHistory: any = result[0].result;
//Main Cleanup COde
chrome.tabs.query({}, async (tabs) => {
//Get Active Tabs Ids
let actives = tabs.map((tab) => {
if (tab.id) {
return tab.id;
}
});
//Updates 'tabhistory'
let webhistoryObj: any = await storage.get("webhistory");
actives = actives.filter((item: any) => item);
const webHistoryOfTabId = webhistoryObj.webhistory.filter(
(data: WebHistory) => {
return data.tabsessionId === tab.id;
}
);
//Only retain which is still active
const newHistory = webHistory.webhistory.map((element: any) => {
//@ts-ignore
if (actives.includes(element.tabsessionId)) {
return element;
}
});
toPushInTabHistory.pageContentMarkdown = convertHtmlToMarkdown(
toPushInTabHistory.renderedHtml,
{
extractMainContent: true,
includeMetaData: false,
enableTableColumnTracking: true
}
)
const newUrlQueue = urlQueue.urlQueueList.map((element: any) => {
//@ts-ignore
if (actives.includes(element.tabsessionId)) {
return element;
}
});
delete toPushInTabHistory.renderedHtml
const newTimeQueue = timeQueue.timeQueueList.map((element: any) => {
//@ts-ignore
if (actives.includes(element.tabsessionId)) {
return element;
}
});
let tabhistory = webHistoryOfTabId[0].tabHistory;
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);
}
}
const urlQueueListObj: any = await storage.get("urlQueueList");
const timeQueueListObj: any = await storage.get("timeQueueList");
async function saveCurrSnapShot(): Promise<void> {
chrome.tabs.query({ active: true, currentWindow: true }, async (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,
});
const isUrlQueueThere = urlQueueListObj.urlQueueList.find((data: WebHistory) => data.tabsessionId === tabId)
const isTimeQueueThere = timeQueueListObj.timeQueueList.find((data: WebHistory) => data.tabsessionId === tabId)
const toPushInTabHistory: any = result[0].result;
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];
}
//Updates 'tabhistory'
const webhistoryObj: any = await storage.get("webhistory");
webHistoryOfTabId[0].tabHistory.push(toPushInTabHistory);
await storage.set("webhistory", webhistoryObj);
toast({
title: "Snapshot saved",
description: `Captured: ${toPushInTabHistory.title}`,
})
}
const webHistoryOfTabId = webhistoryObj.webhistory.filter((data: WebHistory) => {
return data.tabsessionId === tab.id;
});
});
}
toPushInTabHistory.pageContentMarkdown = convertHtmlToMarkdown(
toPushInTabHistory.renderedHtml,
{
extractMainContent: true,
includeMetaData: false,
enableTableColumnTracking: true,
}
);
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
}
delete toPushInTabHistory.renderedHtml;
setIsSaving(true);
toast({
title: "Save job running",
description: "Saving captured content to SurfSense",
})
const tabhistory = webHistoryOfTabId[0].tabHistory;
try {
const resp = await sendToBackground({
// @ts-ignore
name: "savedata",
})
const urlQueueListObj: any = await storage.get("urlQueueList");
const timeQueueListObj: any = await storage.get("timeQueueList");
toast({
title: resp.message,
})
} catch (error) {
toast({
title: "Error saving data",
description: "Please try again",
variant: "destructive",
})
} finally {
setIsSaving(false);
}
}
const isUrlQueueThere = urlQueueListObj.urlQueueList.find(
(data: WebHistory) => data.tabsessionId === tabId
);
const isTimeQueueThere = timeQueueListObj.timeQueueList.find(
(data: WebHistory) => data.tabsessionId === tabId
);
async function logOut(): Promise<void> {
const storage = new Storage({ area: "local" })
await storage.remove('token');
await storage.remove('showShadowDom');
navigation("/login")
}
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];
}
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>
webHistoryOfTabId[0].tabHistory.push(toPushInTabHistory);
<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>
await storage.set("webhistory", webhistoryObj);
<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>
toast({
title: "Snapshot saved",
description: `Captured: ${toPushInTabHistory.title}`,
});
}
});
}
<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>
);
}
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"
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
export default HomePage;

View file

@ -1,38 +1,37 @@
import React from 'react'
import icon from "data-base64:~assets/icon.png"
import { ReloadIcon } from "@radix-ui/react-icons"
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>
)
}
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>
export default Loading
<div className="flex flex-col items-center mt-8">
<ReloadIcon className="h-10 w-10 text-teal-400 animate-spin" />
<div className="mt-6 text-lg text-gray-300 flex space-x-1">
{Array.from("LOADING").map((letter, i) => (
<span
key={i}
className="inline-block animate-pulse text-teal-400"
style={{
animationDelay: `${i * 0.1}s`,
animationDuration: "1.5s",
}}
>
{letter}
</span>
))}
</div>
</div>
</div>
</div>
);
};
export default Loading;

View file

@ -1,56 +1,49 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "~/lib/utils"
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",
},
}
)
"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
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"
({ 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 }
export { Button, buttonVariants };

View file

@ -1,155 +1,145 @@
"use client"
"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 type { DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import * as React from "react";
import { cn } from "~/lib/utils"
import { Dialog, DialogContent } from "~/routes/ui/dialog"
import { cn } from "~/lib/utils";
import { Dialog, DialogContent } from "~/routes/ui/dialog";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
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
<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>
)
}
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>
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>
))
<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
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
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}
/>
))
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
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}
/>
))
<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
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
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
<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>
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}
/>
))
<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
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"
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,
}
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View file

@ -1,122 +1,104 @@
"use client"
"use client";
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
const Dialog = DialogPrimitive.Root
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
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
<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>
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
<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 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 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>
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
<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>
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
<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,
}
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View file

@ -0,0 +1,21 @@
"use client";
import * as LabelPrimitive from "@radix-ui/react-label";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
);
}
export { Label };

View file

@ -1,31 +1,31 @@
"use client"
"use client";
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import * as PopoverPrimitive from "@radix-ui/react-popover";
import * as React from "react";
import { cn } from "~/lib/utils"
import { cn } from "~/lib/utils";
const Popover = PopoverPrimitive.Root
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
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
<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 }
export { Popover, PopoverTrigger, PopoverContent };

View file

@ -1,129 +1,124 @@
"use client"
"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 * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const ToastProvider = ToastPrimitives.Provider
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
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
<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",
},
}
)
"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>
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
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>
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
<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>
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
<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>
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
<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>
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
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View file

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

View file

@ -1,194 +1,189 @@
"use client"
"use client";
// Inspired by react-hot-toast library
import * as React from "react"
import * as React from "react";
import type {
ToastActionElement,
ToastProps,
} from "@/routes/ui/toast"
import type { ToastActionElement, ToastProps } from "@/routes/ui/toast";
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
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
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes
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"]
}
| {
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[]
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout)
}
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),
}
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 "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
};
case "DISMISS_TOAST": {
const { toastId } = action
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)
})
}
// ! 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),
}
}
}
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> = []
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] }
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId()
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
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()
},
},
})
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
}
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
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])
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 }),
}
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast }
export { useToast, toast };

View file

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

View file

@ -3,96 +3,97 @@
@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%;
:root {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--border: 240 5.9% 24%;
--input: 240 5.9% 10%;
--ring: 180 100% 37%;
--radius: 0.5rem;
}
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
.dark {
--background: 224 71% 4%;
--foreground: 213 31% 91%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--muted: 223 47% 11%;
--muted-foreground: 215.4 16.3% 56.9%;
--primary: 180 100% 37%;
--primary-foreground: 0 0% 98%;
--accent: 216 34% 17%;
--accent-foreground: 210 40% 98%;
--secondary: 240 5.9% 10%;
--secondary-foreground: 0 0% 98%;
--popover: 224 71% 4%;
--popover-foreground: 215 20.2% 65.1%;
--muted: 240 5.9% 10%;
--muted-foreground: 240 5% 64.9%;
--border: 216 34% 17%;
--input: 216 34% 17%;
--accent: 169 97% 37%;
--accent-foreground: 0 0% 98%;
--card: 224 71% 4%;
--card-foreground: 213 31% 91%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 1.2%;
--border: 240 5.9% 24%;
--input: 240 5.9% 10%;
--ring: 180 100% 37%;
--secondary: 222.2 47.4% 11.2%;
--secondary-foreground: 210 40% 98%;
--radius: 0.5rem;
}
--destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%;
.dark {
--background: 224 71% 4%;
--foreground: 213 31% 91%;
--ring: 216 34% 17%;
--muted: 223 47% 11%;
--muted-foreground: 215.4 16.3% 56.9%;
--radius: 0.5rem;
}
--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;
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;
}
* {
@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;
}
/* Styling for shadcn/ui components */
.command-dialog {
@apply dark;
}
}
/* Popup page dimensions */
@media (prefers-color-scheme: dark) {
body {
@apply bg-slate-950 text-white;
}
body {
@apply bg-slate-950 text-white;
}
}

View file

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

View file

@ -1,144 +1,137 @@
import { Storage } from "@plasmohq/storage"
import type { WebHistory } from "./interfaces"
import { Storage } from "@plasmohq/storage";
import type { WebHistory } from "./interfaces";
export const emptyArr: any[] = []
export const emptyArr: any[] = [];
export const initQueues = async (tabId: number) => {
const storage = new Storage({ area: "local" })
const storage = new Storage({ area: "local" });
let urlQueueListObj: any = await storage.get("urlQueueList")
let timeQueueListObj: any = await storage.get("timeQueueList")
const urlQueueListObj: any = await storage.get("urlQueueList");
const 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: [] }]
})
if (!urlQueueListObj && !timeQueueListObj) {
await storage.set("urlQueueList", {
urlQueueList: [{ tabsessionId: tabId, urlQueue: [] }],
});
await storage.set("timeQueueList", {
timeQueueList: [{ tabsessionId: tabId, timeQueue: [] }],
});
return
}
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 (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: [] })
if (!isUrlQueueThere) {
urlQueueListObj.urlQueueList.push({ tabsessionId: tabId, urlQueue: [] });
await storage.set("urlQueueList", {
urlQueueList: urlQueueListObj.urlQueueList
})
}
await storage.set("urlQueueList", {
urlQueueList: urlQueueListObj.urlQueueList,
});
}
if (!isTimeQueueThere) {
timeQueueListObj.timeQueueList.push({
tabsessionId: tabId,
timeQueue: []
})
if (!isTimeQueueThere) {
timeQueueListObj.timeQueueList.push({
tabsessionId: tabId,
timeQueue: [],
});
await storage.set("timeQueueList", {
timeQueueList: timeQueueListObj.timeQueueList
})
}
await storage.set("timeQueueList", {
timeQueueList: timeQueueListObj.timeQueueList,
});
}
return
}
}
return;
}
};
export function getRenderedHtml() {
return {
url: window.location.href,
entryTime: Date.now(),
title: document.title,
renderedHtml: document.documentElement.outerHTML
}
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")
const storage = new Storage({ area: "local" });
const result: any = await storage.get("webhistory");
if (result === undefined) {
await storage.set("webhistory", { webhistory: emptyArr })
return
}
if (result === undefined) {
await storage.set("webhistory", { webhistory: emptyArr });
return;
}
const ifIdExists = result.webhistory.find(
(data: WebHistory) => data.tabsessionId === tabId
)
const ifIdExists = result.webhistory.find((data: WebHistory) => data.tabsessionId === tabId);
if (ifIdExists === undefined) {
let webHistory = result.webhistory
const initData = {
tabsessionId: tabId,
tabHistory: emptyArr
}
if (ifIdExists === undefined) {
const webHistory = result.webhistory;
const initData = {
tabsessionId: tabId,
tabHistory: emptyArr,
};
webHistory.push(initData)
webHistory.push(initData);
try {
await storage.set("webhistory", { webhistory: webHistory })
return
} catch (error) {
console.log(error)
}
} else {
return
}
}
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
}
var tzo = -date.getTimezoneOffset(),
dif = tzo >= 0 ? "+" : "-",
pad = (num: number) => (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)
)
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
}
export const webhistoryToLangChainDocument = (tabId: number, tabHistory: any[]) => {
const 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
})
}
toSaveFinally.push({
metadata: mtadata,
pageContent: tabHistory[j].pageContentMarkdown,
});
}
return toSaveFinally
}
return toSaveFinally;
};

View file

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

View file

@ -1,31 +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>/**"]
}
]
}
"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>/**"]
}
]
}

View file

@ -1,4 +1,4 @@
import { source } from '@/lib/source';
import { createFromSource } from 'fumadocs-core/search/server';
export const { GET } = createFromSource(source);
import { createFromSource } from "fumadocs-core/search/server";
import { source } from "@/lib/source";
export const { GET } = createFromSource(source);

View file

@ -1,19 +1,23 @@
import { Suspense } from 'react';
import TokenHandler from '@/components/TokenHandler';
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>
);
}
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Authentication Callback</h1>
<Suspense
fallback={
<div className="flex items-center justify-center min-h-[200px]">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
</div>
}
>
<TokenHandler
redirectPath="/dashboard"
tokenParamName="token"
storageKey="surfsense_bearer_token"
/>
</Suspense>
</div>
);
}

View file

@ -1,21 +1,25 @@
import { Suspense } from 'react';
import ChatsPageClient from './chats-client';
import { Suspense } from "react";
import ChatsPageClient from "./chats-client";
interface PageProps {
params: {
search_space_id: string;
};
params: {
search_space_id: string;
};
}
export default async function ChatsPage({ params }: PageProps) {
// Get search space ID from the route parameter
const { search_space_id: searchSpaceId } = await Promise.resolve(params);
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={searchSpaceId} />
</Suspense>
);
}
// Get search space ID from the route parameter
const { search_space_id: searchSpaceId } = await Promise.resolve(params);
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={searchSpaceId} />
</Suspense>
);
}

View file

@ -1,44 +1,40 @@
'use client';
"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"
import type React from "react";
import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider";
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
import { Separator } from "@/components/ui/separator";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
export function DashboardClientLayout({
children,
searchSpaceId,
navSecondary,
navMain
children,
searchSpaceId,
navSecondary,
navMain,
}: {
children: React.ReactNode;
searchSpaceId: string;
navSecondary: any[];
navMain: any[];
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>
)
}
return (
<SidebarProvider>
{/* Use AppSidebarProvider which fetches user, search space, and recent chats */}
<AppSidebarProvider
searchSpaceId={searchSpaceId}
navSecondary={navSecondary}
navMain={navMain}
/>
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="h-6" />
<ThemeTogglerComponent />
</div>
</header>
{children}
</SidebarInset>
</SidebarProvider>
);
}

View file

@ -1,34 +1,12 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { format } from "date-fns";
import { motion } from "framer-motion";
import { Calendar as CalendarIcon, Edit, Plus, RefreshCw, Trash2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import {
Edit,
Plus,
Trash2,
RefreshCw,
Calendar as CalendarIcon,
} from "lucide-react";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { getConnectorIcon } from "@/components/chat";
import {
AlertDialog,
AlertDialogAction,
@ -40,12 +18,9 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
@ -53,14 +28,20 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Label } from "@/components/ui/label";
import { getConnectorIcon } from "@/components/chat";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
import { cn } from "@/lib/utils";
import { format } from "date-fns";
// Helper function to format date with time
const formatDateTime = (dateString: string | null): string => {
@ -83,14 +64,12 @@ export default function ConnectorsPage() {
const { connectors, isLoading, error, deleteConnector, indexConnector } =
useSearchSourceConnectors();
const [connectorToDelete, setConnectorToDelete] = useState<number | null>(
null,
);
const [indexingConnectorId, setIndexingConnectorId] = useState<number | null>(
null,
);
const [connectorToDelete, setConnectorToDelete] = useState<number | null>(null);
const [indexingConnectorId, setIndexingConnectorId] = useState<number | null>(null);
const [datePickerOpen, setDatePickerOpen] = useState(false);
const [selectedConnectorForIndexing, setSelectedConnectorForIndexing] = useState<number | null>(null);
const [selectedConnectorForIndexing, setSelectedConnectorForIndexing] = useState<number | null>(
null
);
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
@ -127,21 +106,17 @@ export default function ConnectorsPage() {
if (selectedConnectorForIndexing === null) return;
setDatePickerOpen(false);
try {
setIndexingConnectorId(selectedConnectorForIndexing);
const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined;
const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined;
await indexConnector(selectedConnectorForIndexing, searchSpaceId, startDateStr, endDateStr);
toast.success("Connector content indexing started");
} catch (error) {
console.error("Error indexing connector content:", error);
toast.error(
error instanceof Error
? error.message
: "Failed to index connector content",
);
toast.error(error instanceof Error ? error.message : "Failed to index connector content");
} finally {
setIndexingConnectorId(null);
setSelectedConnectorForIndexing(null);
@ -158,11 +133,7 @@ export default function ConnectorsPage() {
toast.success("Connector content indexing started");
} catch (error) {
console.error("Error indexing connector content:", error);
toast.error(
error instanceof Error
? error.message
: "Failed to index connector content",
);
toast.error(error instanceof Error ? error.message : "Failed to index connector content");
} finally {
setIndexingConnectorId(null);
}
@ -182,11 +153,7 @@ export default function ConnectorsPage() {
Manage your connected services and data sources.
</p>
</div>
<Button
onClick={() =>
router.push(`/dashboard/${searchSpaceId}/connectors/add`)
}
>
<Button onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}>
<Plus className="mr-2 h-4 w-4" />
Add Connector
</Button>
@ -195,9 +162,7 @@ export default function ConnectorsPage() {
<Card>
<CardHeader className="pb-3">
<CardTitle>Your Connectors</CardTitle>
<CardDescription>
View and manage all your connected services.
</CardDescription>
<CardDescription>View and manage all your connected services.</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
@ -211,14 +176,9 @@ export default function ConnectorsPage() {
<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.
You haven't added any connectors yet. Add one to enhance your search capabilities.
</p>
<Button
onClick={() =>
router.push(`/dashboard/${searchSpaceId}/connectors/add`)
}
>
<Button onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}>
<Plus className="mr-2 h-4 w-4" />
Add Your First Connector
</Button>
@ -237,12 +197,8 @@ export default function ConnectorsPage() {
<TableBody>
{connectors.map((connector) => (
<TableRow key={connector.id}>
<TableCell className="font-medium">
{connector.name}
</TableCell>
<TableCell>
{getConnectorIcon(connector.connector_type)}
</TableCell>
<TableCell className="font-medium">{connector.name}</TableCell>
<TableCell>{getConnectorIcon(connector.connector_type)}</TableCell>
<TableCell>
{connector.is_indexable
? formatDateTime(connector.last_indexed_at)
@ -258,21 +214,15 @@ export default function ConnectorsPage() {
<Button
variant="outline"
size="sm"
onClick={() =>
handleOpenDatePicker(connector.id)
}
disabled={
indexingConnectorId === connector.id
}
onClick={() => handleOpenDatePicker(connector.id)}
disabled={indexingConnectorId === connector.id}
>
{indexingConnectorId === connector.id ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<CalendarIcon className="h-4 w-4" />
)}
<span className="sr-only">
Index with Date Range
</span>
<span className="sr-only">Index with Date Range</span>
</Button>
</TooltipTrigger>
<TooltipContent>
@ -286,21 +236,15 @@ export default function ConnectorsPage() {
<Button
variant="outline"
size="sm"
onClick={() =>
handleQuickIndexConnector(connector.id)
}
disabled={
indexingConnectorId === connector.id
}
onClick={() => handleQuickIndexConnector(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">
Quick Index
</span>
<span className="sr-only">Quick Index</span>
</Button>
</TooltipTrigger>
<TooltipContent>
@ -315,7 +259,7 @@ export default function ConnectorsPage() {
size="sm"
onClick={() =>
router.push(
`/dashboard/${searchSpaceId}/connectors/${connector.id}/edit`,
`/dashboard/${searchSpaceId}/connectors/${connector.id}/edit`
)
}
>
@ -328,9 +272,7 @@ export default function ConnectorsPage() {
variant="outline"
size="sm"
className="text-destructive-foreground hover:bg-destructive/10"
onClick={() =>
setConnectorToDelete(connector.id)
}
onClick={() => setConnectorToDelete(connector.id)}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
@ -338,18 +280,14 @@ export default function ConnectorsPage() {
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Delete Connector
</AlertDialogTitle>
<AlertDialogTitle>Delete Connector</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this
connector? This action cannot be undone.
Are you sure you want to delete this connector? This action cannot
be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => setConnectorToDelete(null)}
>
<AlertDialogCancel onClick={() => setConnectorToDelete(null)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
@ -404,9 +342,7 @@ export default function ConnectorsPage() {
mode="single"
selected={startDate}
onSelect={setStartDate}
disabled={(date) =>
date > new Date() || (endDate ? date > endDate : false)
}
disabled={(date) => date > new Date() || (endDate ? date > endDate : false)}
initialFocus
/>
</PopoverContent>
@ -493,9 +429,7 @@ export default function ConnectorsPage() {
>
Cancel
</Button>
<Button onClick={handleIndexConnector}>
Start Indexing
</Button>
<Button onClick={handleIndexConnector}>Start Indexing</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View file

@ -1,280 +1,269 @@
"use client";
import React, { useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { motion } from "framer-motion";
import { ArrowLeft, Check, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useEffect } from "react";
import { toast } from "sonner";
import { ArrowLeft, Check, Loader2, Github } from "lucide-react";
import { Form } from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
// Import Utils, Types, Hook, and Components
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
import { useConnectorEditPage } from "@/hooks/useConnectorEditPage";
import { getConnectorIcon } from "@/components/chat";
import { EditConnectorLoadingSkeleton } from "@/components/editConnector/EditConnectorLoadingSkeleton";
import { EditConnectorNameForm } from "@/components/editConnector/EditConnectorNameForm";
import { EditGitHubConnectorConfig } from "@/components/editConnector/EditGitHubConnectorConfig";
import { EditSimpleTokenForm } from "@/components/editConnector/EditSimpleTokenForm";
import { getConnectorIcon } from "@/components/chat";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Form } from "@/components/ui/form";
import { useConnectorEditPage } from "@/hooks/useConnectorEditPage";
// Import Utils, Types, Hook, and Components
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
export default function EditConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
// Ensure connectorId is parsed safely
const connectorIdParam = params.connector_id as string;
const connectorId = connectorIdParam ? parseInt(connectorIdParam, 10) : NaN;
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
// Ensure connectorId is parsed safely
const connectorIdParam = params.connector_id as string;
const connectorId = connectorIdParam ? parseInt(connectorIdParam, 10) : NaN;
// Use the custom hook to manage state and logic
const {
connectorsLoading,
connector,
isSaving,
editForm,
patForm, // Needed for GitHub child component
handleSaveChanges,
// GitHub specific props for the child component
editMode,
setEditMode, // Pass down if needed by GitHub component
originalPat,
currentSelectedRepos,
fetchedRepos,
setFetchedRepos,
newSelectedRepos,
setNewSelectedRepos,
isFetchingRepos,
handleFetchRepositories,
handleRepoSelectionChange,
} = useConnectorEditPage(connectorId, searchSpaceId);
// Use the custom hook to manage state and logic
const {
connectorsLoading,
connector,
isSaving,
editForm,
patForm, // Needed for GitHub child component
handleSaveChanges,
// GitHub specific props for the child component
editMode,
setEditMode, // Pass down if needed by GitHub component
originalPat,
currentSelectedRepos,
fetchedRepos,
setFetchedRepos,
newSelectedRepos,
setNewSelectedRepos,
isFetchingRepos,
handleFetchRepositories,
handleRepoSelectionChange,
} = useConnectorEditPage(connectorId, searchSpaceId);
// Redirect if connectorId is not a valid number after parsing
useEffect(() => {
if (isNaN(connectorId)) {
toast.error("Invalid Connector ID.");
router.push(`/dashboard/${searchSpaceId}/connectors`);
}
}, [connectorId, router, searchSpaceId]);
// Redirect if connectorId is not a valid number after parsing
useEffect(() => {
if (Number.isNaN(connectorId)) {
toast.error("Invalid Connector ID.");
router.push(`/dashboard/${searchSpaceId}/connectors`);
}
}, [connectorId, router, searchSpaceId]);
// Loading State
if (connectorsLoading || !connector) {
// Handle NaN case before showing skeleton
if (isNaN(connectorId)) return null;
return <EditConnectorLoadingSkeleton />;
}
// Loading State
if (connectorsLoading || !connector) {
// Handle NaN case before showing skeleton
if (Number.isNaN(connectorId)) return null;
return <EditConnectorLoadingSkeleton />;
}
// Main Render using data/handlers from the hook
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>
// Main Render using data/handlers from the hook
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 flex items-center gap-2">
{getConnectorIcon(connector.connector_type)}
Edit {getConnectorTypeDisplay(connector.connector_type)} Connector
</CardTitle>
<CardDescription>
Modify connector name and configuration.
</CardDescription>
</CardHeader>
<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 flex items-center gap-2">
{getConnectorIcon(connector.connector_type)}
Edit {getConnectorTypeDisplay(connector.connector_type)} Connector
</CardTitle>
<CardDescription>Modify connector name and configuration.</CardDescription>
</CardHeader>
<Form {...editForm}>
{/* Pass hook's handleSaveChanges */}
<form
onSubmit={editForm.handleSubmit(handleSaveChanges)}
className="space-y-6"
>
<CardContent className="space-y-6">
{/* Pass form control from hook */}
<EditConnectorNameForm control={editForm.control} />
<Form {...editForm}>
{/* Pass hook's handleSaveChanges */}
<form onSubmit={editForm.handleSubmit(handleSaveChanges)} className="space-y-6">
<CardContent className="space-y-6">
{/* Pass form control from hook */}
<EditConnectorNameForm control={editForm.control} />
<hr />
<hr />
<h3 className="text-lg font-semibold">Configuration</h3>
<h3 className="text-lg font-semibold">Configuration</h3>
{/* == GitHub == */}
{connector.connector_type === "GITHUB_CONNECTOR" && (
<EditGitHubConnectorConfig
// Pass relevant state and handlers from hook
editMode={editMode}
setEditMode={setEditMode} // Pass setter if child manages mode
originalPat={originalPat}
currentSelectedRepos={currentSelectedRepos}
fetchedRepos={fetchedRepos}
newSelectedRepos={newSelectedRepos}
isFetchingRepos={isFetchingRepos}
patForm={patForm}
handleFetchRepositories={handleFetchRepositories}
handleRepoSelectionChange={handleRepoSelectionChange}
setNewSelectedRepos={setNewSelectedRepos}
setFetchedRepos={setFetchedRepos}
/>
)}
{/* == GitHub == */}
{connector.connector_type === "GITHUB_CONNECTOR" && (
<EditGitHubConnectorConfig
// Pass relevant state and handlers from hook
editMode={editMode}
setEditMode={setEditMode} // Pass setter if child manages mode
originalPat={originalPat}
currentSelectedRepos={currentSelectedRepos}
fetchedRepos={fetchedRepos}
newSelectedRepos={newSelectedRepos}
isFetchingRepos={isFetchingRepos}
patForm={patForm}
handleFetchRepositories={handleFetchRepositories}
handleRepoSelectionChange={handleRepoSelectionChange}
setNewSelectedRepos={setNewSelectedRepos}
setFetchedRepos={setFetchedRepos}
/>
)}
{/* == Slack == */}
{connector.connector_type === "SLACK_CONNECTOR" && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="SLACK_BOT_TOKEN"
fieldLabel="Slack Bot Token"
fieldDescription="Update the Slack Bot Token if needed."
placeholder="Begins with xoxb-..."
/>
)}
{/* == Notion == */}
{connector.connector_type === "NOTION_CONNECTOR" && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="NOTION_INTEGRATION_TOKEN"
fieldLabel="Notion Integration Token"
fieldDescription="Update the Notion Integration Token if needed."
placeholder="Begins with secret_..."
/>
)}
{/* == Serper == */}
{connector.connector_type === "SERPER_API" && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="SERPER_API_KEY"
fieldLabel="Serper API Key"
fieldDescription="Update the Serper API Key if needed."
/>
)}
{/* == Tavily == */}
{connector.connector_type === "TAVILY_API" && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="TAVILY_API_KEY"
fieldLabel="Tavily API Key"
fieldDescription="Update the Tavily API Key if needed."
/>
)}
{/* == Slack == */}
{connector.connector_type === "SLACK_CONNECTOR" && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="SLACK_BOT_TOKEN"
fieldLabel="Slack Bot Token"
fieldDescription="Update the Slack Bot Token if needed."
placeholder="Begins with xoxb-..."
/>
)}
{/* == Notion == */}
{connector.connector_type === "NOTION_CONNECTOR" && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="NOTION_INTEGRATION_TOKEN"
fieldLabel="Notion Integration Token"
fieldDescription="Update the Notion Integration Token if needed."
placeholder="Begins with secret_..."
/>
)}
{/* == Serper == */}
{connector.connector_type === "SERPER_API" && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="SERPER_API_KEY"
fieldLabel="Serper API Key"
fieldDescription="Update the Serper API Key if needed."
/>
)}
{/* == Tavily == */}
{connector.connector_type === "TAVILY_API" && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="TAVILY_API_KEY"
fieldLabel="Tavily API Key"
fieldDescription="Update the Tavily API Key if needed."
/>
)}
{/* == Linear == */}
{connector.connector_type === "LINEAR_CONNECTOR" && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="LINEAR_API_KEY"
fieldLabel="Linear API Key"
fieldDescription="Update your Linear API Key if needed."
placeholder="Begins with lin_api_..."
/>
)}
{/* == Linear == */}
{connector.connector_type === "LINEAR_CONNECTOR" && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="LINEAR_API_KEY"
fieldLabel="Linear API Key"
fieldDescription="Update your Linear API Key if needed."
placeholder="Begins with lin_api_..."
/>
)}
{/* == Jira == */}
{connector.connector_type === "JIRA_CONNECTOR" && (
<div className="space-y-4">
<EditSimpleTokenForm
control={editForm.control}
fieldName="JIRA_BASE_URL"
fieldLabel="Jira Base URL"
fieldDescription="Update your Jira instance URL if needed."
placeholder="https://yourcompany.atlassian.net"
/>
<EditSimpleTokenForm
control={editForm.control}
fieldName="JIRA_EMAIL"
fieldLabel="Jira Email"
fieldDescription="Update your Atlassian account email if needed."
placeholder="your.email@company.com"
/>
<EditSimpleTokenForm
control={editForm.control}
fieldName="JIRA_API_TOKEN"
fieldLabel="Jira API Token"
fieldDescription="Update your Jira API Token if needed."
placeholder="Your Jira API Token"
/>
</div>
)}
{/* == Jira == */}
{connector.connector_type === "JIRA_CONNECTOR" && (
<div className="space-y-4">
<EditSimpleTokenForm
control={editForm.control}
fieldName="JIRA_BASE_URL"
fieldLabel="Jira Base URL"
fieldDescription="Update your Jira instance URL if needed."
placeholder="https://yourcompany.atlassian.net"
/>
<EditSimpleTokenForm
control={editForm.control}
fieldName="JIRA_EMAIL"
fieldLabel="Jira Email"
fieldDescription="Update your Atlassian account email if needed."
placeholder="your.email@company.com"
/>
<EditSimpleTokenForm
control={editForm.control}
fieldName="JIRA_API_TOKEN"
fieldLabel="Jira API Token"
fieldDescription="Update your Jira API Token if needed."
placeholder="Your Jira API Token"
/>
</div>
)}
{/* == Confluence == */}
{connector.connector_type === "CONFLUENCE_CONNECTOR" && (
<div className="space-y-4">
<EditSimpleTokenForm
control={editForm.control}
fieldName="CONFLUENCE_BASE_URL"
fieldLabel="Confluence Base URL"
fieldDescription="Update your Confluence instance URL if needed."
placeholder="https://yourcompany.atlassian.net"
/>
<EditSimpleTokenForm
control={editForm.control}
fieldName="CONFLUENCE_EMAIL"
fieldLabel="Confluence Email"
fieldDescription="Update your Atlassian account email if needed."
placeholder="your.email@company.com"
/>
<EditSimpleTokenForm
control={editForm.control}
fieldName="CONFLUENCE_API_TOKEN"
fieldLabel="Confluence API Token"
fieldDescription="Update your Confluence API Token if needed."
placeholder="Your Confluence API Token"
/>
</div>
)}
{/* == Confluence == */}
{connector.connector_type === "CONFLUENCE_CONNECTOR" && (
<div className="space-y-4">
<EditSimpleTokenForm
control={editForm.control}
fieldName="CONFLUENCE_BASE_URL"
fieldLabel="Confluence Base URL"
fieldDescription="Update your Confluence instance URL if needed."
placeholder="https://yourcompany.atlassian.net"
/>
<EditSimpleTokenForm
control={editForm.control}
fieldName="CONFLUENCE_EMAIL"
fieldLabel="Confluence Email"
fieldDescription="Update your Atlassian account email if needed."
placeholder="your.email@company.com"
/>
<EditSimpleTokenForm
control={editForm.control}
fieldName="CONFLUENCE_API_TOKEN"
fieldLabel="Confluence API Token"
fieldDescription="Update your Confluence API Token if needed."
placeholder="Your Confluence API Token"
/>
</div>
)}
{/* == Linkup == */}
{connector.connector_type === "LINKUP_API" && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="LINKUP_API_KEY"
fieldLabel="Linkup API Key"
fieldDescription="Update your Linkup API Key if needed."
placeholder="Begins with linkup_..."
/>
)}
{/* == Linkup == */}
{connector.connector_type === "LINKUP_API" && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="LINKUP_API_KEY"
fieldLabel="Linkup API Key"
fieldDescription="Update your Linkup API Key if needed."
placeholder="Begins with linkup_..."
/>
)}
{/* == Discord == */}
{connector.connector_type === "DISCORD_CONNECTOR" && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="DISCORD_BOT_TOKEN"
fieldLabel="Discord Bot Token"
fieldDescription="Update the Discord Bot Token if needed."
placeholder="Bot token..."
/>
)}
</CardContent>
<CardFooter className="border-t pt-6">
<Button
type="submit"
disabled={isSaving}
className="w-full sm:w-auto"
>
{isSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Check className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
</CardFooter>
</form>
</Form>
</Card>
</motion.div>
</div>
);
{/* == Discord == */}
{connector.connector_type === "DISCORD_CONNECTOR" && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="DISCORD_BOT_TOKEN"
fieldLabel="Discord Bot Token"
fieldDescription="Update the Discord Bot Token if needed."
placeholder="Bot token..."
/>
)}
</CardContent>
<CardFooter className="border-t pt-6">
<Button type="submit" disabled={isSaving} className="w-full sm:w-auto">
{isSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Check className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
</CardFooter>
</form>
</Form>
</Card>
</motion.div>
</div>
);
}

View file

@ -1,311 +1,286 @@
"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 { motion } from "framer-motion";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
useSearchSourceConnectors,
SearchSourceConnector,
} from "@/hooks/useSearchSourceConnectors";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
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";
type SearchSourceConnector,
useSearchSourceConnectors,
} from "@/hooks/useSearchSourceConnectors";
// 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.",
}),
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",
GITHUB_CONNECTOR: "GitHub Connector",
LINEAR_CONNECTOR: "Linear Connector",
JIRA_CONNECTOR: "Jira Connector",
DISCORD_CONNECTOR: "Discord Connector",
LINKUP_API: "Linkup",
// Add other connector types here as needed
};
return typeMap[type] || type;
const typeMap: Record<string, string> = {
SERPER_API: "Serper API",
TAVILY_API: "Tavily API",
SLACK_CONNECTOR: "Slack Connector",
NOTION_CONNECTOR: "Notion Connector",
GITHUB_CONNECTOR: "GitHub Connector",
LINEAR_CONNECTOR: "Linear Connector",
JIRA_CONNECTOR: "Jira Connector",
DISCORD_CONNECTOR: "Discord Connector",
LINKUP_API: "Linkup",
// Add other connector types here as needed
};
return typeMap[type] || type;
};
// Define the type for the form values
type ApiConnectorFormValues = z.infer<typeof apiConnectorFormSchema>;
// 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",
GITHUB_CONNECTOR: "GITHUB_PAT",
DISCORD_CONNECTOR: "DISCORD_BOT_TOKEN",
LINKUP_API: "LINKUP_API_KEY",
};
return fieldMap[connectorType] || "";
};
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 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);
// console.log("connector", connector);
// Initialize the form
const form = useForm<ApiConnectorFormValues>({
resolver: zodResolver(apiConnectorFormSchema),
defaultValues: {
name: "",
api_key: "",
},
});
const { connectors, updateConnector } = useSearchSourceConnectors();
const [connector, setConnector] = useState<SearchSourceConnector | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
// console.log("connector", connector);
// 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",
GITHUB_CONNECTOR: "GITHUB_PAT",
DISCORD_CONNECTOR: "DISCORD_BOT_TOKEN",
LINKUP_API: "LINKUP_API_KEY",
};
return fieldMap[connectorType] || "";
};
// Find connector in the list
useEffect(() => {
const currentConnector = connectors.find((c) => c.id === connectorId);
// Find connector in the list
useEffect(() => {
const currentConnector = connectors.find((c) => c.id === connectorId);
if (currentConnector) {
setConnector(currentConnector);
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`);
}
// 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]);
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;
// Handle form submission
const onSubmit = async (values: ApiConnectorFormValues) => {
if (!connector) return;
setIsSubmitting(true);
try {
const apiKeyField = getApiKeyFieldName(connector.connector_type);
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;
}
// 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,
is_indexable: connector.is_indexable,
last_indexed_at: connector.last_indexed_at,
});
await updateConnector(connectorId, {
name: values.name,
connector_type: connector.connector_type,
config: updatedConfig,
is_indexable: connector.is_indexable,
last_indexed_at: connector.last_indexed_at,
});
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);
}
};
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>
);
}
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>
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>
<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>
)}
/>
<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"
: connector?.connector_type === "GITHUB_CONNECTOR"
? "GitHub Personal Access Token (PAT)"
: connector?.connector_type === "LINKUP_API"
? "Linkup API Key"
: "API Key"}
</FormLabel>
<FormControl>
<Input
type="password"
placeholder={
connector?.connector_type === "SLACK_CONNECTOR"
? "Enter new Slack Bot Token (optional)"
: connector?.connector_type === "NOTION_CONNECTOR"
? "Enter new Notion Token (optional)"
: connector?.connector_type === "GITHUB_CONNECTOR"
? "Enter new GitHub PAT (optional)"
: connector?.connector_type === "LINKUP_API"
? "Enter new Linkup API Key (optional)"
: "Enter new API key (optional)"
}
{...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."
: connector?.connector_type === "GITHUB_CONNECTOR"
? "Enter a new GitHub PAT or leave blank to keep your existing token."
: connector?.connector_type === "LINKUP_API"
? "Enter a new Linkup API Key or leave blank to keep your existing key."
: "Enter a new API key or leave blank to keep your existing key."}
</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"
: connector?.connector_type === "GITHUB_CONNECTOR"
? "GitHub Personal Access Token (PAT)"
: connector?.connector_type === "LINKUP_API"
? "Linkup API Key"
: "API Key"}
</FormLabel>
<FormControl>
<Input
type="password"
placeholder={
connector?.connector_type === "SLACK_CONNECTOR"
? "Enter new Slack Bot Token (optional)"
: connector?.connector_type === "NOTION_CONNECTOR"
? "Enter new Notion Token (optional)"
: connector?.connector_type ===
"GITHUB_CONNECTOR"
? "Enter new GitHub PAT (optional)"
: connector?.connector_type === "LINKUP_API"
? "Enter new Linkup API Key (optional)"
: "Enter new API key (optional)"
}
{...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."
: connector?.connector_type === "GITHUB_CONNECTOR"
? "Enter a new GitHub PAT or leave blank to keep your existing token."
: connector?.connector_type === "LINKUP_API"
? "Enter a new Linkup API Key or leave blank to keep your existing key."
: "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>
);
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Updating...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Update Connector
</>
)}
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</motion.div>
</div>
);
}

View file

@ -1,324 +1,297 @@
"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 { motion } from "framer-motion";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
// Define the form schema with Zod
const confluenceConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
base_url: z
.string()
.url({
message:
"Please enter a valid Confluence URL (e.g., https://yourcompany.atlassian.net)",
})
.refine(
(url) => {
return url.includes("atlassian.net") || url.includes("confluence");
},
{
message: "Please enter a valid Confluence instance URL",
},
),
email: z.string().email({
message: "Please enter a valid email address.",
}),
api_token: z.string().min(10, {
message: "Confluence API Token is required and must be valid.",
}),
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
base_url: z
.string()
.url({
message: "Please enter a valid Confluence URL (e.g., https://yourcompany.atlassian.net)",
})
.refine(
(url) => {
return url.includes("atlassian.net") || url.includes("confluence");
},
{
message: "Please enter a valid Confluence instance URL",
}
),
email: z.string().email({
message: "Please enter a valid email address.",
}),
api_token: z.string().min(10, {
message: "Confluence API Token is required and must be valid.",
}),
});
// Define the type for the form values
type ConfluenceConnectorFormValues = z.infer<typeof confluenceConnectorFormSchema>;
export default function ConfluenceConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false);
const { createConnector } = useSearchSourceConnectors();
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<ConfluenceConnectorFormValues>({
resolver: zodResolver(confluenceConnectorFormSchema),
defaultValues: {
name: "Confluence Connector",
base_url: "",
email: "",
api_token: "",
},
});
// Initialize the form
const form = useForm<ConfluenceConnectorFormValues>({
resolver: zodResolver(confluenceConnectorFormSchema),
defaultValues: {
name: "Confluence Connector",
base_url: "",
email: "",
api_token: "",
},
});
// Handle form submission
const onSubmit = async (values: ConfluenceConnectorFormValues) => {
setIsSubmitting(true);
try {
await createConnector({
name: values.name,
connector_type: "CONFLUENCE_CONNECTOR",
config: {
CONFLUENCE_BASE_URL: values.base_url,
CONFLUENCE_EMAIL: values.email,
CONFLUENCE_API_TOKEN: values.api_token,
},
is_indexable: true,
last_indexed_at: null,
});
// Handle form submission
const onSubmit = async (values: ConfluenceConnectorFormValues) => {
setIsSubmitting(true);
try {
await createConnector({
name: values.name,
connector_type: "CONFLUENCE_CONNECTOR",
config: {
CONFLUENCE_BASE_URL: values.base_url,
CONFLUENCE_EMAIL: values.email,
CONFLUENCE_API_TOKEN: values.api_token,
},
is_indexable: true,
last_indexed_at: null,
});
toast.success("Confluence connector created successfully!");
toast.success("Confluence 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);
}
};
// 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>
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>
<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>
<CardHeader>
<CardTitle>Connect to Confluence</CardTitle>
<CardDescription>
Connect your Confluence instance to index pages and comments from your spaces.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
You'll need to create an API token from your{" "}
<a
href="https://id.atlassian.com/manage-profile/security/api-tokens"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Atlassian Account Settings
</a>
</AlertDescription>
</Alert>
<TabsContent value="connect">
<Card>
<CardHeader>
<CardTitle>Connect to Confluence</CardTitle>
<CardDescription>
Connect your Confluence instance to index pages and comments from your spaces.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
You'll need to create an API token from your{" "}
<a
href="https://id.atlassian.com/manage-profile/security/api-tokens"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Atlassian Account Settings
</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 Confluence Connector" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<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 Confluence Connector" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="base_url"
render={({ field }) => (
<FormItem>
<FormLabel>Confluence Instance URL</FormLabel>
<FormControl>
<Input
placeholder="https://yourcompany.atlassian.net"
{...field}
/>
</FormControl>
<FormDescription>
Your Confluence instance URL. For Atlassian Cloud, this is
typically https://yourcompany.atlassian.net
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="base_url"
render={({ field }) => (
<FormItem>
<FormLabel>Confluence Instance URL</FormLabel>
<FormControl>
<Input placeholder="https://yourcompany.atlassian.net" {...field} />
</FormControl>
<FormDescription>
Your Confluence instance URL. For Atlassian Cloud, this is typically
https://yourcompany.atlassian.net
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input
type="email"
placeholder="your.email@company.com"
{...field}
/>
</FormControl>
<FormDescription>
Your Atlassian account email address.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input type="email" placeholder="your.email@company.com" {...field} />
</FormControl>
<FormDescription>Your Atlassian account email address.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_token"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Your Confluence API Token"
{...field}
/>
</FormControl>
<FormDescription>
Your Confluence API Token will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_token"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Your Confluence API Token"
{...field}
/>
</FormControl>
<FormDescription>
Your Confluence API Token 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 Confluence
</>
)}
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</TabsContent>
<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 Confluence
</>
)}
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="documentation">
<Card>
<CardHeader>
<CardTitle>Confluence Integration Guide</CardTitle>
<CardDescription>
Learn how to set up and use the Confluence connector.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-3">What gets indexed?</h3>
<ul className="list-disc list-inside space-y-2 text-sm text-muted-foreground">
<li>All pages from accessible spaces</li>
<li>Page content and metadata</li>
<li>Comments on pages (both footer and inline comments)</li>
<li>Page titles and descriptions</li>
</ul>
</div>
<TabsContent value="documentation">
<Card>
<CardHeader>
<CardTitle>Confluence Integration Guide</CardTitle>
<CardDescription>
Learn how to set up and use the Confluence connector.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-3">What gets indexed?</h3>
<ul className="list-disc list-inside space-y-2 text-sm text-muted-foreground">
<li>All pages from accessible spaces</li>
<li>Page content and metadata</li>
<li>Comments on pages (both footer and inline comments)</li>
<li>Page titles and descriptions</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold mb-3">Setup Instructions</h3>
<ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground">
<li>Go to your Atlassian Account Settings</li>
<li>Navigate to Security API tokens</li>
<li>Create a new API token with appropriate permissions</li>
<li>Copy the token and paste it in the form above</li>
<li>Ensure your account has read access to the spaces you want to index</li>
</ol>
</div>
<div>
<h3 className="text-lg font-semibold mb-3">Setup Instructions</h3>
<ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground">
<li>Go to your Atlassian Account Settings</li>
<li>Navigate to Security API tokens</li>
<li>Create a new API token with appropriate permissions</li>
<li>Copy the token and paste it in the form above</li>
<li>Ensure your account has read access to the spaces you want to index</li>
</ol>
</div>
<div>
<h3 className="text-lg font-semibold mb-3">Permissions Required</h3>
<ul className="list-disc list-inside space-y-2 text-sm text-muted-foreground">
<li>Read access to Confluence spaces</li>
<li>View pages and comments</li>
<li>Access to space metadata</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold mb-3">Permissions Required</h3>
<ul className="list-disc list-inside space-y-2 text-sm text-muted-foreground">
<li>Read access to Confluence spaces</li>
<li>View pages and comments</li>
<li>Access to space metadata</li>
</ul>
</div>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
The connector will only index content that your account has permission to view.
Make sure your API token has the necessary permissions for the spaces you want to index.
</AlertDescription>
</Alert>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
</div>
);
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
The connector will only index content that your account has permission to view.
Make sure your API token has the necessary permissions for the spaces you want
to index.
</AlertDescription>
</Alert>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
</div>
);
}

View file

@ -1,315 +1,345 @@
"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 { motion } from "framer-motion";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
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";
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
// Define the form schema with Zod
const discordConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
bot_token: z.string()
.min(50, { message: "Discord Bot Token appears to be too short." })
.regex(/^[A-Za-z0-9._-]+$/, { message: "Discord Bot Token contains invalid characters." }),
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
bot_token: z
.string()
.min(50, { message: "Discord Bot Token appears to be too short." })
.regex(/^[A-Za-z0-9._-]+$/, { message: "Discord Bot Token contains invalid characters." }),
});
// Define the type for the form values
type DiscordConnectorFormValues = z.infer<typeof discordConnectorFormSchema>;
export default function DiscordConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false);
const { createConnector } = useSearchSourceConnectors();
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<DiscordConnectorFormValues>({
resolver: zodResolver(discordConnectorFormSchema),
defaultValues: {
name: "Discord Connector",
bot_token: "",
},
});
// Initialize the form
const form = useForm<DiscordConnectorFormValues>({
resolver: zodResolver(discordConnectorFormSchema),
defaultValues: {
name: "Discord Connector",
bot_token: "",
},
});
// Handle form submission
const onSubmit = async (values: DiscordConnectorFormValues) => {
setIsSubmitting(true);
try {
await createConnector({
name: values.name,
connector_type: "DISCORD_CONNECTOR",
config: {
DISCORD_BOT_TOKEN: values.bot_token,
},
is_indexable: true,
last_indexed_at: null,
});
// Handle form submission
const onSubmit = async (values: DiscordConnectorFormValues) => {
setIsSubmitting(true);
try {
await createConnector({
name: values.name,
connector_type: "DISCORD_CONNECTOR",
config: {
DISCORD_BOT_TOKEN: values.bot_token,
},
is_indexable: true,
last_indexed_at: null,
});
toast.success("Discord connector created successfully!");
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);
}
};
toast.success("Discord connector created successfully!");
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>
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 Discord Server</CardTitle>
<CardDescription>
Integrate with Discord to search and retrieve information from your servers and channels. This connector can index your Discord messages for search.
</CardDescription>
</CardHeader>
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Bot Token Required</AlertTitle>
<AlertDescription>
You'll need a Discord Bot Token to use this connector. You can create a Discord bot and get the token from the{" "}
<a
href="https://discord.com/developers/applications"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Discord Developer Portal
</a>.
</AlertDescription>
</Alert>
<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>
<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 Discord Connector" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<TabsContent value="connect">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">Connect Discord Server</CardTitle>
<CardDescription>
Integrate with Discord to search and retrieve information from your servers and
channels. This connector can index your Discord messages for search.
</CardDescription>
</CardHeader>
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Bot Token Required</AlertTitle>
<AlertDescription>
You'll need a Discord Bot Token to use this connector. You can create a Discord
bot and get the token from the{" "}
<a
href="https://discord.com/developers/applications"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Discord Developer Portal
</a>
.
</AlertDescription>
</Alert>
<FormField
control={form.control}
name="bot_token"
render={({ field }) => (
<FormItem>
<FormLabel>Discord Bot Token</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Bot Token..."
{...field}
/>
</FormControl>
<FormDescription>
Your Discord Bot Token will be encrypted and stored securely. You can find it in the Bot section of your application in the Discord Developer Portal.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<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 Discord Connector" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</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 Discord
</>
)}
</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 Discord integration:</h4>
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
<li>Search through your Discord servers and channels</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 Discord 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">Discord Connector Documentation</CardTitle>
<CardDescription>
Learn how to set up and use the Discord connector to index your server 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 Discord connector indexes all accessible channels for a given bot in your servers.
</p>
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
<li>Upcoming: Support for private channels by granting the bot access.</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>Bot Setup Required</AlertTitle>
<AlertDescription>
You must create a Discord bot and add it to your server with the correct permissions.
</AlertDescription>
</Alert>
<ol className="list-decimal pl-5 space-y-3">
<li>Go to <a href="https://discord.com/developers/applications" target="_blank" rel="noopener noreferrer" className="font-medium underline underline-offset-4">https://discord.com/developers/applications</a>.</li>
<li>Create a new application and add a bot to it.</li>
<li>Copy the Bot Token from the Bot section.</li>
<li>Invite the bot to your server with the following OAuth2 scopes and permissions:
<ul className="list-disc pl-5 mt-1">
<li>Scopes: <code>bot</code></li>
<li>Bot Permissions: <code>Read Messages/View Channels</code>, <code>Read Message History</code>, <code>Send Messages</code></li>
</ul>
</li>
<li>Paste the Bot Token above to connect.</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>Discord</strong> Connector.</li>
<li>Place the <strong>Bot Token</strong> under <strong>Step 1 Provide Credentials</strong>.</li>
<li>Click <strong>Connect</strong> to establish the connection.</li>
</ol>
<FormField
control={form.control}
name="bot_token"
render={({ field }) => (
<FormItem>
<FormLabel>Discord Bot Token</FormLabel>
<FormControl>
<Input type="password" placeholder="Bot Token..." {...field} />
</FormControl>
<FormDescription>
Your Discord Bot Token will be encrypted and stored securely. You can
find it in the Bot section of your application in the Discord Developer
Portal.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Important: Bot Channel Access</AlertTitle>
<AlertDescription>
After connecting, ensure the bot has access to all channels you want to index. You may need to adjust channel permissions in Discord.
</AlertDescription>
</Alert>
<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 Discord
</>
)}
</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 Discord integration:</h4>
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
<li>Search through your Discord servers and channels</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 Discord messages for enhanced search capabilities</li>
</ul>
</CardFooter>
</Card>
</TabsContent>
<Alert className="bg-muted mt-4">
<Info className="h-4 w-4" />
<AlertTitle>First Indexing</AlertTitle>
<AlertDescription>
The first indexing pulls all accessible channels and may take longer than future updates. Only channels where the bot has access will be 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>Missing messages:</strong> If you don't see messages from a channel, check the bot's permissions for that channel.
</li>
<li>
<strong>Bot not responding:</strong> Make sure the bot is online and the token is correct.
</li>
<li>
<strong>Private channels:</strong> The bot must be explicitly granted access to private channels.
</li>
</ul>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
</div>
);
<TabsContent value="documentation">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">
Discord Connector Documentation
</CardTitle>
<CardDescription>
Learn how to set up and use the Discord connector to index your server 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 Discord connector indexes all accessible channels for a given bot in your
servers.
</p>
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
<li>Upcoming: Support for private channels by granting the bot access.</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>Bot Setup Required</AlertTitle>
<AlertDescription>
You must create a Discord bot and add it to your server with the correct
permissions.
</AlertDescription>
</Alert>
<ol className="list-decimal pl-5 space-y-3">
<li>
Go to{" "}
<a
href="https://discord.com/developers/applications"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
https://discord.com/developers/applications
</a>
.
</li>
<li>Create a new application and add a bot to it.</li>
<li>Copy the Bot Token from the Bot section.</li>
<li>
Invite the bot to your server with the following OAuth2 scopes and
permissions:
<ul className="list-disc pl-5 mt-1">
<li>
Scopes: <code>bot</code>
</li>
<li>
Bot Permissions: <code>Read Messages/View Channels</code>,{" "}
<code>Read Message History</code>, <code>Send Messages</code>
</li>
</ul>
</li>
<li>Paste the Bot Token above to connect.</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>Discord</strong> Connector.
</li>
<li>
Place the <strong>Bot 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: Bot Channel Access</AlertTitle>
<AlertDescription>
After connecting, ensure the bot has access to all channels you want to
index. You may need to adjust channel permissions in Discord.
</AlertDescription>
</Alert>
<Alert className="bg-muted mt-4">
<Info className="h-4 w-4" />
<AlertTitle>First Indexing</AlertTitle>
<AlertDescription>
The first indexing pulls all accessible channels and may take longer than
future updates. Only channels where the bot has access will be 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>Missing messages:</strong> If you don't see messages from a
channel, check the bot's permissions for that channel.
</li>
<li>
<strong>Bot not responding:</strong> Make sure the bot is online and the
token is correct.
</li>
<li>
<strong>Private channels:</strong> The bot must be explicitly granted
access to private channels.
</li>
</ul>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
</div>
);
}

View file

@ -1,61 +1,59 @@
"use client";
import { useState } from "react";
import { useRouter, useParams } from "next/navigation";
import { motion } from "framer-motion";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { ArrowLeft, Check, CircleAlert, Github, Info, ListChecks, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { toast } from "sonner";
import { ArrowLeft, Check, Info, Loader2, Github, CircleAlert, ListChecks } from "lucide-react";
// Assuming useSearchSourceConnectors hook exists and works similarly
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
import * as z from "zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
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";
import { Checkbox } from "@/components/ui/checkbox";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
// Assuming useSearchSourceConnectors hook exists and works similarly
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
// Define the form schema with Zod for GitHub PAT entry step
const githubPatFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
github_pat: z.string()
.min(20, { // Apply min length first
message: "GitHub Personal Access Token seems too short.",
})
.refine(pat => pat.startsWith('ghp_') || pat.startsWith('github_pat_'), { // Then refine the pattern
message: "GitHub PAT should start with 'ghp_' or 'github_pat_'",
}),
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
github_pat: z
.string()
.min(20, {
// Apply min length first
message: "GitHub Personal Access Token seems too short.",
})
.refine((pat) => pat.startsWith("ghp_") || pat.startsWith("github_pat_"), {
// Then refine the pattern
message: "GitHub PAT should start with 'ghp_' or 'github_pat_'",
}),
});
// Define the type for the form values
@ -63,394 +61,468 @@ type GithubPatFormValues = z.infer<typeof githubPatFormSchema>;
// Type for fetched GitHub repositories
interface GithubRepo {
id: number;
name: string;
full_name: string;
private: boolean;
url: string;
description: string | null;
last_updated: string | null;
id: number;
name: string;
full_name: string;
private: boolean;
url: string;
description: string | null;
last_updated: string | null;
}
export default function GithubConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [step, setStep] = useState<'enter_pat' | 'select_repos'>('enter_pat');
const [isFetchingRepos, setIsFetchingRepos] = useState(false);
const [isCreatingConnector, setIsCreatingConnector] = useState(false);
const [repositories, setRepositories] = useState<GithubRepo[]>([]);
const [selectedRepos, setSelectedRepos] = useState<string[]>([]);
const [connectorName, setConnectorName] = useState<string>("GitHub Connector");
const [validatedPat, setValidatedPat] = useState<string>(""); // Store the validated PAT
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [step, setStep] = useState<"enter_pat" | "select_repos">("enter_pat");
const [isFetchingRepos, setIsFetchingRepos] = useState(false);
const [isCreatingConnector, setIsCreatingConnector] = useState(false);
const [repositories, setRepositories] = useState<GithubRepo[]>([]);
const [selectedRepos, setSelectedRepos] = useState<string[]>([]);
const [connectorName, setConnectorName] = useState<string>("GitHub Connector");
const [validatedPat, setValidatedPat] = useState<string>(""); // Store the validated PAT
const { createConnector } = useSearchSourceConnectors();
const { createConnector } = useSearchSourceConnectors();
// Initialize the form for PAT entry
const form = useForm<GithubPatFormValues>({
resolver: zodResolver(githubPatFormSchema),
defaultValues: {
name: connectorName,
github_pat: "",
},
});
// Initialize the form for PAT entry
const form = useForm<GithubPatFormValues>({
resolver: zodResolver(githubPatFormSchema),
defaultValues: {
name: connectorName,
github_pat: "",
},
});
// Function to fetch repositories using the new backend endpoint
const fetchRepositories = async (values: GithubPatFormValues) => {
setIsFetchingRepos(true);
setConnectorName(values.name); // Store the name
setValidatedPat(values.github_pat); // Store the PAT temporarily
try {
const token = localStorage.getItem('surfsense_bearer_token');
if (!token) {
throw new Error('No authentication token found');
}
// Function to fetch repositories using the new backend endpoint
const fetchRepositories = async (values: GithubPatFormValues) => {
setIsFetchingRepos(true);
setConnectorName(values.name); // Store the name
setValidatedPat(values.github_pat); // Store the PAT temporarily
try {
const token = localStorage.getItem("surfsense_bearer_token");
if (!token) {
throw new Error("No authentication token found");
}
const response = await fetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/github/repositories/`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ github_pat: values.github_pat })
}
);
const response = await fetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/github/repositories/`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ github_pat: values.github_pat }),
}
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || `Failed to fetch repositories: ${response.statusText}`);
}
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || `Failed to fetch repositories: ${response.statusText}`);
}
const data: GithubRepo[] = await response.json();
setRepositories(data);
setStep('select_repos'); // Move to the next step
toast.success(`Found ${data.length} repositories.`);
} catch (error) {
console.error("Error fetching GitHub repositories:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to fetch repositories. Please check the PAT and try again.";
toast.error(errorMessage);
} finally {
setIsFetchingRepos(false);
}
};
const data: GithubRepo[] = await response.json();
setRepositories(data);
setStep("select_repos"); // Move to the next step
toast.success(`Found ${data.length} repositories.`);
} catch (error) {
console.error("Error fetching GitHub repositories:", error);
const errorMessage =
error instanceof Error
? error.message
: "Failed to fetch repositories. Please check the PAT and try again.";
toast.error(errorMessage);
} finally {
setIsFetchingRepos(false);
}
};
// Handle final connector creation
const handleCreateConnector = async () => {
if (selectedRepos.length === 0) {
toast.warning("Please select at least one repository to index.");
return;
}
// Handle final connector creation
const handleCreateConnector = async () => {
if (selectedRepos.length === 0) {
toast.warning("Please select at least one repository to index.");
return;
}
setIsCreatingConnector(true);
try {
await createConnector({
name: connectorName, // Use the stored name
connector_type: "GITHUB_CONNECTOR",
config: {
GITHUB_PAT: validatedPat, // Use the stored validated PAT
repo_full_names: selectedRepos, // Add the selected repo names
},
is_indexable: true,
last_indexed_at: null,
});
setIsCreatingConnector(true);
try {
await createConnector({
name: connectorName, // Use the stored name
connector_type: "GITHUB_CONNECTOR",
config: {
GITHUB_PAT: validatedPat, // Use the stored validated PAT
repo_full_names: selectedRepos, // Add the selected repo names
},
is_indexable: true,
last_indexed_at: null,
});
toast.success("GitHub connector created successfully!");
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating GitHub connector:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to create GitHub connector.";
toast.error(errorMessage);
} finally {
setIsCreatingConnector(false);
}
};
toast.success("GitHub connector created successfully!");
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating GitHub connector:", error);
const errorMessage =
error instanceof Error ? error.message : "Failed to create GitHub connector.";
toast.error(errorMessage);
} finally {
setIsCreatingConnector(false);
}
};
// Handle checkbox changes
const handleRepoSelection = (repoFullName: string, checked: boolean) => {
setSelectedRepos(prev =>
checked
? [...prev, repoFullName]
: prev.filter(name => name !== repoFullName)
);
};
// Handle checkbox changes
const handleRepoSelection = (repoFullName: string, checked: boolean) => {
setSelectedRepos((prev) =>
checked ? [...prev, repoFullName] : prev.filter((name) => name !== repoFullName)
);
};
return (
<div className="container mx-auto py-8 max-w-3xl">
<Button
variant="ghost"
className="mb-6"
onClick={() => {
if (step === 'select_repos') {
// Go back to PAT entry, clear sensitive/fetched data
setStep('enter_pat');
setRepositories([]);
setSelectedRepos([]);
setValidatedPat("");
// Reset form PAT field, keep name
form.reset({ name: connectorName, github_pat: "" });
} else {
router.push(`/dashboard/${searchSpaceId}/connectors/add`);
}
}}
>
<ArrowLeft className="mr-2 h-4 w-4" />
{step === 'select_repos' ? "Back to PAT Entry" : "Back to Add Connectors"}
</Button>
return (
<div className="container mx-auto py-8 max-w-3xl">
<Button
variant="ghost"
className="mb-6"
onClick={() => {
if (step === "select_repos") {
// Go back to PAT entry, clear sensitive/fetched data
setStep("enter_pat");
setRepositories([]);
setSelectedRepos([]);
setValidatedPat("");
// Reset form PAT field, keep name
form.reset({ name: connectorName, github_pat: "" });
} else {
router.push(`/dashboard/${searchSpaceId}/connectors/add`);
}
}}
>
<ArrowLeft className="mr-2 h-4 w-4" />
{step === "select_repos" ? "Back to PAT Entry" : "Back to Add 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 GitHub</TabsTrigger>
<TabsTrigger value="documentation">Setup Guide</TabsTrigger>
</TabsList>
<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 GitHub</TabsTrigger>
<TabsTrigger value="documentation">Setup Guide</TabsTrigger>
</TabsList>
<TabsContent value="connect">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold flex items-center gap-2">
{step === 'enter_pat' ? <Github className="h-6 w-6" /> : <ListChecks className="h-6 w-6" />}
{step === 'enter_pat' ? "Connect GitHub Account" : "Select Repositories to Index"}
</CardTitle>
<CardDescription>
{step === 'enter_pat'
? "Provide a name and GitHub Personal Access Token (PAT) to fetch accessible repositories."
: `Select which repositories you want SurfSense to index for search. Found ${repositories.length} repositories accessible via your PAT.`
}
</CardDescription>
</CardHeader>
<TabsContent value="connect">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold flex items-center gap-2">
{step === "enter_pat" ? (
<Github className="h-6 w-6" />
) : (
<ListChecks className="h-6 w-6" />
)}
{step === "enter_pat" ? "Connect GitHub Account" : "Select Repositories to Index"}
</CardTitle>
<CardDescription>
{step === "enter_pat"
? "Provide a name and GitHub Personal Access Token (PAT) to fetch accessible repositories."
: `Select which repositories you want SurfSense to index for search. Found ${repositories.length} repositories accessible via your PAT.`}
</CardDescription>
</CardHeader>
<Form {...form}>
{step === 'enter_pat' && (
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>GitHub Personal Access Token (PAT) Required</AlertTitle>
<AlertDescription>
You'll need a GitHub PAT with the appropriate scopes (e.g., 'repo') to fetch repositories. You can create one from your{' '}
<a
href="https://github.com/settings/personal-access-tokens"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
GitHub Developer Settings
</a>. The PAT will be used to fetch repositories and then stored securely to enable indexing.
</AlertDescription>
</Alert>
<Form {...form}>
{step === "enter_pat" && (
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>GitHub Personal Access Token (PAT) Required</AlertTitle>
<AlertDescription>
You'll need a GitHub PAT with the appropriate scopes (e.g., 'repo') to fetch
repositories. You can create one from your{" "}
<a
href="https://github.com/settings/personal-access-tokens"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
GitHub Developer Settings
</a>
. The PAT will be used to fetch repositories and then stored securely to
enable indexing.
</AlertDescription>
</Alert>
<form onSubmit={form.handleSubmit(fetchRepositories)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Connector Name</FormLabel>
<FormControl>
<Input placeholder="My GitHub Connector" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this GitHub connection.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<form onSubmit={form.handleSubmit(fetchRepositories)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Connector Name</FormLabel>
<FormControl>
<Input placeholder="My GitHub Connector" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this GitHub connection.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="github_pat"
render={({ field }) => (
<FormItem>
<FormLabel>GitHub Personal Access Token (PAT)</FormLabel>
<FormControl>
<Input
type="password"
placeholder="ghp_... or github_pat_..."
{...field}
/>
</FormControl>
<FormDescription>
Enter your GitHub PAT here to fetch your repositories. It will be stored encrypted later.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="github_pat"
render={({ field }) => (
<FormItem>
<FormLabel>GitHub Personal Access Token (PAT)</FormLabel>
<FormControl>
<Input
type="password"
placeholder="ghp_... or github_pat_..."
{...field}
/>
</FormControl>
<FormDescription>
Enter your GitHub PAT here to fetch your repositories. It will be
stored encrypted later.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
disabled={isFetchingRepos}
className="w-full sm:w-auto"
>
{isFetchingRepos ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Fetching Repositories...
</>
) : (
"Fetch Repositories"
)}
</Button>
</div>
</form>
</CardContent>
)}
<div className="flex justify-end">
<Button
type="submit"
disabled={isFetchingRepos}
className="w-full sm:w-auto"
>
{isFetchingRepos ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Fetching Repositories...
</>
) : (
"Fetch Repositories"
)}
</Button>
</div>
</form>
</CardContent>
)}
{step === 'select_repos' && (
<CardContent>
{repositories.length === 0 ? (
<Alert variant="destructive">
<CircleAlert className="h-4 w-4" />
<AlertTitle>No Repositories Found</AlertTitle>
<AlertDescription>
No repositories were found or accessible with the provided PAT. Please check the token and its permissions, then go back and try again.
</AlertDescription>
</Alert>
) : (
<div className="space-y-4">
<FormLabel>Repositories ({selectedRepos.length} selected)</FormLabel>
<div className="h-64 w-full rounded-md border p-4 overflow-y-auto">
{repositories.map((repo) => (
<div key={repo.id} className="flex items-center space-x-2 mb-2 py-1">
<Checkbox
id={`repo-${repo.id}`}
checked={selectedRepos.includes(repo.full_name)}
onCheckedChange={(checked) => handleRepoSelection(repo.full_name, !!checked)}
/>
<label
htmlFor={`repo-${repo.id}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{repo.full_name} {repo.private && "(Private)"}
</label>
</div>
))}
</div>
<FormDescription>
Select the repositories you wish to index. Only checked repositories will be processed.
</FormDescription>
{step === "select_repos" && (
<CardContent>
{repositories.length === 0 ? (
<Alert variant="destructive">
<CircleAlert className="h-4 w-4" />
<AlertTitle>No Repositories Found</AlertTitle>
<AlertDescription>
No repositories were found or accessible with the provided PAT. Please
check the token and its permissions, then go back and try again.
</AlertDescription>
</Alert>
) : (
<div className="space-y-4">
<FormLabel>Repositories ({selectedRepos.length} selected)</FormLabel>
<div className="h-64 w-full rounded-md border p-4 overflow-y-auto">
{repositories.map((repo) => (
<div key={repo.id} className="flex items-center space-x-2 mb-2 py-1">
<Checkbox
id={`repo-${repo.id}`}
checked={selectedRepos.includes(repo.full_name)}
onCheckedChange={(checked) =>
handleRepoSelection(repo.full_name, !!checked)
}
/>
<label
htmlFor={`repo-${repo.id}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{repo.full_name} {repo.private && "(Private)"}
</label>
</div>
))}
</div>
<FormDescription>
Select the repositories you wish to index. Only checked repositories will
be processed.
</FormDescription>
<div className="flex justify-between items-center pt-4">
<Button
variant="outline"
onClick={() => {
setStep('enter_pat');
setRepositories([]);
setSelectedRepos([]);
setValidatedPat("");
form.reset({ name: connectorName, github_pat: "" });
}}
>
Back
</Button>
<Button
onClick={handleCreateConnector}
disabled={isCreatingConnector || selectedRepos.length === 0}
className="w-full sm:w-auto"
>
{isCreatingConnector ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating Connector...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Create Connector
</>
)}
</Button>
</div>
</div>
)}
</CardContent>
)}
</Form>
<div className="flex justify-between items-center pt-4">
<Button
variant="outline"
onClick={() => {
setStep("enter_pat");
setRepositories([]);
setSelectedRepos([]);
setValidatedPat("");
form.reset({ name: connectorName, github_pat: "" });
}}
>
Back
</Button>
<Button
onClick={handleCreateConnector}
disabled={isCreatingConnector || selectedRepos.length === 0}
className="w-full sm:w-auto"
>
{isCreatingConnector ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating Connector...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Create Connector
</>
)}
</Button>
</div>
</div>
)}
</CardContent>
)}
</Form>
<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 GitHub integration:</h4>
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
<li>Search through code and documentation in your selected repositories</li>
<li>Access READMEs, Markdown files, and common code files</li>
<li>Connect your project knowledge directly to your search space</li>
<li>Index your selected repositories for enhanced search capabilities</li>
</ul>
</CardFooter>
</Card>
</TabsContent>
<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 GitHub integration:</h4>
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
<li>Search through code and documentation in your selected repositories</li>
<li>Access READMEs, Markdown files, and common code files</li>
<li>Connect your project knowledge directly to your search space</li>
<li>Index your selected repositories 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">GitHub Connector Setup Guide</CardTitle>
<CardDescription>
Learn how to generate a Personal Access Token (PAT) and connect your GitHub account.
</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 GitHub connector uses a Personal Access Token (PAT) to authenticate with the GitHub API. First, it fetches a list of repositories accessible to the token. You then select which repositories you want to index. The connector indexes relevant files (code, markdown, text) from only the selected repositories.
</p>
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
<li>The connector indexes files based on common code and documentation extensions.</li>
<li>Large files (over 1MB) are skipped during indexing.</li>
<li>Only selected repositories are indexed.</li>
<li>Indexing runs periodically (check connector settings for frequency) to keep content up-to-date.</li>
</ul>
</div>
<TabsContent value="documentation">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">GitHub Connector Setup Guide</CardTitle>
<CardDescription>
Learn how to generate a Personal Access Token (PAT) and connect your GitHub
account.
</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 GitHub connector uses a Personal Access Token (PAT) to authenticate with the
GitHub API. First, it fetches a list of repositories accessible to the token.
You then select which repositories you want to index. The connector indexes
relevant files (code, markdown, text) from only the selected repositories.
</p>
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
<li>
The connector indexes files based on common code and documentation extensions.
</li>
<li>Large files (over 1MB) are skipped during indexing.</li>
<li>Only selected repositories are indexed.</li>
<li>
Indexing runs periodically (check connector settings for frequency) to keep
content up-to-date.
</li>
</ul>
</div>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="create_pat">
<AccordionTrigger className="text-lg font-medium">Step 1: Generate GitHub PAT</AccordionTrigger>
<AccordionContent>
<div className="space-y-6">
<div>
<h4 className="font-medium mb-2">Generating a Token:</h4>
<ol className="list-decimal pl-5 space-y-3">
<li>Go to your GitHub <a href="https://github.com/settings/tokens" target="_blank" rel="noopener noreferrer" className="font-medium underline underline-offset-4">Developer settings</a>.</li>
<li>Click on <strong>Personal access tokens</strong>, then choose <strong>Tokens (classic)</strong> or <strong>Fine-grained tokens</strong> (recommended if available and suitable).</li>
<li>Click <strong>Generate new token</strong> (and choose the appropriate type).</li>
<li>Give your token a descriptive name (e.g., "SurfSense Connector").</li>
<li>Set an expiration date for the token (recommended for security).</li>
<li>Under <strong>Select scopes</strong> (for classic tokens) or <strong>Repository access</strong> (for fine-grained), grant the necessary permissions. At minimum, the <strong>`repo`</strong> scope (or equivalent read access to repositories for fine-grained tokens) is required to read repository content.</li>
<li>Click <strong>Generate token</strong>.</li>
<li><strong>Important:</strong> Copy your new PAT immediately. You won't be able to see it again after leaving the page.</li>
</ol>
</div>
</div>
</AccordionContent>
</AccordionItem>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="create_pat">
<AccordionTrigger className="text-lg font-medium">
Step 1: Generate GitHub PAT
</AccordionTrigger>
<AccordionContent>
<div className="space-y-6">
<div>
<h4 className="font-medium mb-2">Generating a Token:</h4>
<ol className="list-decimal pl-5 space-y-3">
<li>
Go to your GitHub{" "}
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Developer settings
</a>
.
</li>
<li>
Click on <strong>Personal access tokens</strong>, then choose{" "}
<strong>Tokens (classic)</strong> or{" "}
<strong>Fine-grained tokens</strong> (recommended if available and
suitable).
</li>
<li>
Click <strong>Generate new token</strong> (and choose the appropriate
type).
</li>
<li>
Give your token a descriptive name (e.g., "SurfSense Connector").
</li>
<li>
Set an expiration date for the token (recommended for security).
</li>
<li>
Under <strong>Select scopes</strong> (for classic tokens) or{" "}
<strong>Repository access</strong> (for fine-grained), grant the
necessary permissions. At minimum, the <strong>`repo`</strong> scope
(or equivalent read access to repositories for fine-grained tokens) is
required to read repository content.
</li>
<li>
Click <strong>Generate token</strong>.
</li>
<li>
<strong>Important:</strong> Copy your new PAT immediately. You won't
be able to see it again after leaving the page.
</li>
</ol>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="connect_app">
<AccordionTrigger className="text-lg font-medium">Step 2: Connect in SurfSense</AccordionTrigger>
<AccordionContent className="space-y-4">
<ol className="list-decimal pl-5 space-y-3">
<li>Navigate to the "Connect GitHub" tab.</li>
<li>Enter a name for your connector.</li>
<li>Paste the copied GitHub PAT into the "GitHub Personal Access Token (PAT)" field.</li>
<li>Click <strong>Fetch Repositories</strong>.</li>
<li>If the PAT is valid, you'll see a list of your accessible repositories.</li>
<li>Select the repositories you want SurfSense to index using the checkboxes.</li>
<li>Click the <strong>Create Connector</strong> button.</li>
<li>If the connection is successful, you will be redirected and can start indexing from the Connectors page.</li>
</ol>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
</div>
);
}
<AccordionItem value="connect_app">
<AccordionTrigger className="text-lg font-medium">
Step 2: Connect in SurfSense
</AccordionTrigger>
<AccordionContent className="space-y-4">
<ol className="list-decimal pl-5 space-y-3">
<li>Navigate to the "Connect GitHub" tab.</li>
<li>Enter a name for your connector.</li>
<li>
Paste the copied GitHub PAT into the "GitHub Personal Access Token (PAT)"
field.
</li>
<li>
Click <strong>Fetch Repositories</strong>.
</li>
<li>
If the PAT is valid, you'll see a list of your accessible repositories.
</li>
<li>
Select the repositories you want SurfSense to index using the checkboxes.
</li>
<li>
Click the <strong>Create Connector</strong> button.
</li>
<li>
If the connection is successful, you will be redirected and can start
indexing from the Connectors page.
</li>
</ol>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
</div>
);
}

View file

@ -1,472 +1,401 @@
"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 { motion } from "framer-motion";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
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";
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
// Define the form schema with Zod
const jiraConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
base_url: z
.string()
.url({
message:
"Please enter a valid Jira URL (e.g., https://yourcompany.atlassian.net)",
})
.refine(
(url) => {
return url.includes("atlassian.net") || url.includes("jira");
},
{
message: "Please enter a valid Jira instance URL",
},
),
email: z.string().email({
message: "Please enter a valid email address.",
}),
api_token: z.string().min(10, {
message: "Jira API Token is required and must be valid.",
}),
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
base_url: z
.string()
.url({
message: "Please enter a valid Jira URL (e.g., https://yourcompany.atlassian.net)",
})
.refine(
(url) => {
return url.includes("atlassian.net") || url.includes("jira");
},
{
message: "Please enter a valid Jira instance URL",
}
),
email: z.string().email({
message: "Please enter a valid email address.",
}),
api_token: z.string().min(10, {
message: "Jira API Token is required and must be valid.",
}),
});
// Define the type for the form values
type JiraConnectorFormValues = z.infer<typeof jiraConnectorFormSchema>;
export default function JiraConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false);
const { createConnector } = useSearchSourceConnectors();
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<JiraConnectorFormValues>({
resolver: zodResolver(jiraConnectorFormSchema),
defaultValues: {
name: "Jira Connector",
base_url: "",
email: "",
api_token: "",
},
});
// Initialize the form
const form = useForm<JiraConnectorFormValues>({
resolver: zodResolver(jiraConnectorFormSchema),
defaultValues: {
name: "Jira Connector",
base_url: "",
email: "",
api_token: "",
},
});
// Handle form submission
const onSubmit = async (values: JiraConnectorFormValues) => {
setIsSubmitting(true);
try {
await createConnector({
name: values.name,
connector_type: "JIRA_CONNECTOR",
config: {
JIRA_BASE_URL: values.base_url,
JIRA_EMAIL: values.email,
JIRA_API_TOKEN: values.api_token,
},
is_indexable: true,
last_indexed_at: null,
});
// Handle form submission
const onSubmit = async (values: JiraConnectorFormValues) => {
setIsSubmitting(true);
try {
await createConnector({
name: values.name,
connector_type: "JIRA_CONNECTOR",
config: {
JIRA_BASE_URL: values.base_url,
JIRA_EMAIL: values.email,
JIRA_API_TOKEN: values.api_token,
},
is_indexable: true,
last_indexed_at: null,
});
toast.success("Jira connector created successfully!");
toast.success("Jira 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);
}
};
// 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>
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>
<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 Jira Instance
</CardTitle>
<CardDescription>
Integrate with Jira to search and retrieve information from
your issues, tickets, and comments. This connector can index
your Jira content for search.
</CardDescription>
</CardHeader>
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Jira Personal Access Token Required</AlertTitle>
<AlertDescription>
You'll need a Jira Personal Access Token to use this
connector. You can create one from{" "}
<a
href="https://id.atlassian.com/manage-profile/security/api-tokens"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Atlassian Account Settings
</a>
</AlertDescription>
</Alert>
<TabsContent value="connect">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">Connect Jira Instance</CardTitle>
<CardDescription>
Integrate with Jira to search and retrieve information from your issues, tickets,
and comments. This connector can index your Jira content for search.
</CardDescription>
</CardHeader>
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Jira Personal Access Token Required</AlertTitle>
<AlertDescription>
You'll need a Jira Personal Access Token to use this connector. You can create
one from{" "}
<a
href="https://id.atlassian.com/manage-profile/security/api-tokens"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Atlassian Account Settings
</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 Jira Connector" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<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 Jira Connector" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="base_url"
render={({ field }) => (
<FormItem>
<FormLabel>Jira Instance URL</FormLabel>
<FormControl>
<Input
placeholder="https://yourcompany.atlassian.net"
{...field}
/>
</FormControl>
<FormDescription>
Your Jira instance URL. For Atlassian Cloud, this is
typically https://yourcompany.atlassian.net
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="base_url"
render={({ field }) => (
<FormItem>
<FormLabel>Jira Instance URL</FormLabel>
<FormControl>
<Input placeholder="https://yourcompany.atlassian.net" {...field} />
</FormControl>
<FormDescription>
Your Jira instance URL. For Atlassian Cloud, this is typically
https://yourcompany.atlassian.net
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input
type="email"
placeholder="your.email@company.com"
{...field}
/>
</FormControl>
<FormDescription>
Your Atlassian account email address.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input type="email" placeholder="your.email@company.com" {...field} />
</FormControl>
<FormDescription>Your Atlassian account email address.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_token"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Your Jira API Token"
{...field}
/>
</FormControl>
<FormDescription>
Your Jira API Token will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_token"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input type="password" placeholder="Your Jira API Token" {...field} />
</FormControl>
<FormDescription>
Your Jira API Token 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 Jira
</>
)}
</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 Jira integration:
</h4>
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
<li>Search through all your Jira issues and tickets</li>
<li>
Access issue descriptions, comments, and full discussion
threads
</li>
<li>
Connect your team's project management directly to your
search space
</li>
<li>
Keep your search results up-to-date with latest Jira content
</li>
<li>
Index your Jira issues for enhanced search capabilities
</li>
<li>
Search by issue keys, status, priority, and assignee
information
</li>
</ul>
</CardFooter>
</Card>
</TabsContent>
<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 Jira
</>
)}
</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 Jira integration:</h4>
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
<li>Search through all your Jira issues and tickets</li>
<li>Access issue descriptions, comments, and full discussion threads</li>
<li>Connect your team's project management directly to your search space</li>
<li>Keep your search results up-to-date with latest Jira content</li>
<li>Index your Jira issues for enhanced search capabilities</li>
<li>Search by issue keys, status, priority, and assignee information</li>
</ul>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="documentation">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">
Jira Connector Documentation
</CardTitle>
<CardDescription>
Learn how to set up and use the Jira connector to index your
project management 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 Jira connector uses the Jira REST API with Basic Authentication
to fetch all issues and comments that your account has
access to within your Jira instance.
</p>
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
<li>
For follow up indexing runs, the connector retrieves
issues and comments that have been updated since the last
indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates
should appear in your search results within minutes.
</li>
</ul>
</div>
<TabsContent value="documentation">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">Jira Connector Documentation</CardTitle>
<CardDescription>
Learn how to set up and use the Jira connector to index your project management
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 Jira connector uses the Jira REST API with Basic Authentication to fetch all
issues and comments that your account has access to within your Jira instance.
</p>
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
<li>
For follow up indexing runs, the connector retrieves issues and comments that
have been updated since the last indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates should appear in your
search results within 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>Read-Only Access is Sufficient</AlertTitle>
<AlertDescription>
You only need read access for this connector to work.
The API Token will only be used to read your Jira data.
</AlertDescription>
</Alert>
<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>Read-Only Access is Sufficient</AlertTitle>
<AlertDescription>
You only need read access for this connector to work. The API Token will
only be used to read your Jira data.
</AlertDescription>
</Alert>
<div className="space-y-6">
<div>
<h4 className="font-medium mb-2">
Step 1: Create an API Token
</h4>
<ol className="list-decimal pl-5 space-y-3">
<li>Log in to your Atlassian account</li>
<li>
Navigate to{" "}
<a
href="https://id.atlassian.com/manage-profile/security/api-tokens"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
https://id.atlassian.com/manage-profile/security/api-tokens
</a>
</li>
<li>
Click <strong>Create API token</strong>
</li>
<li>
Enter a label for your token (like "SurfSense
Connector")
</li>
<li>
Click <strong>Create</strong>
</li>
<li>
Copy the generated token as it will only be shown
once
</li>
</ol>
</div>
<div className="space-y-6">
<div>
<h4 className="font-medium mb-2">Step 1: Create an API Token</h4>
<ol className="list-decimal pl-5 space-y-3">
<li>Log in to your Atlassian account</li>
<li>
Navigate to{" "}
<a
href="https://id.atlassian.com/manage-profile/security/api-tokens"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
https://id.atlassian.com/manage-profile/security/api-tokens
</a>
</li>
<li>
Click <strong>Create API token</strong>
</li>
<li>Enter a label for your token (like "SurfSense Connector")</li>
<li>
Click <strong>Create</strong>
</li>
<li>Copy the generated token as it will only be shown once</li>
</ol>
</div>
<div>
<h4 className="font-medium mb-2">
Step 2: Grant necessary access
</h4>
<p className="text-muted-foreground mb-3">
The API Token will have access to all projects and
issues that your user account can see. Make sure your
account has appropriate permissions for the projects
you want to index.
</p>
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Data Privacy</AlertTitle>
<AlertDescription>
Only issues, comments, and basic metadata will be
indexed. Jira attachments and linked files are not
indexed by this connector.
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
<div>
<h4 className="font-medium mb-2">Step 2: Grant necessary access</h4>
<p className="text-muted-foreground mb-3">
The API Token will have access to all projects and issues that your user
account can see. Make sure your account has appropriate permissions for
the projects you want to index.
</p>
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Data Privacy</AlertTitle>
<AlertDescription>
Only issues, comments, and basic metadata will be indexed. Jira
attachments and linked files are not indexed by this connector.
</AlertDescription>
</Alert>
</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>Jira</strong> Connector.
</li>
<li>
Enter your <strong>Jira Instance URL</strong> (e.g.,
https://yourcompany.atlassian.net)
</li>
<li>
Place your <strong>Personal Access Token</strong> in
the form field.
</li>
<li>
Click <strong>Connect</strong> to establish the
connection.
</li>
<li>
Once connected, your Jira issues will be indexed
automatically.
</li>
</ol>
<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>Jira</strong>{" "}
Connector.
</li>
<li>
Enter your <strong>Jira Instance URL</strong> (e.g.,
https://yourcompany.atlassian.net)
</li>
<li>
Place your <strong>Personal Access Token</strong> in the form field.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
<li>Once connected, your Jira issues will be indexed automatically.</li>
</ol>
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>What Gets Indexed</AlertTitle>
<AlertDescription>
<p className="mb-2">
The Jira connector indexes the following data:
</p>
<ul className="list-disc pl-5">
<li>Issue keys and summaries (e.g., PROJ-123)</li>
<li>Issue descriptions</li>
<li>Issue comments and discussion threads</li>
<li>
Issue status, priority, and type information
</li>
<li>Assignee and reporter information</li>
<li>Project information</li>
</ul>
</AlertDescription>
</Alert>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
</div>
);
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>What Gets Indexed</AlertTitle>
<AlertDescription>
<p className="mb-2">The Jira connector indexes the following data:</p>
<ul className="list-disc pl-5">
<li>Issue keys and summaries (e.g., PROJ-123)</li>
<li>Issue descriptions</li>
<li>Issue comments and discussion threads</li>
<li>Issue status, priority, and type information</li>
<li>Assignee and reporter information</li>
<li>Project information</li>
</ul>
</AlertDescription>
</Alert>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
</div>
);
}

View file

@ -1,321 +1,353 @@
"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 { motion } from "framer-motion";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
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";
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
// Define the form schema with Zod
const linearConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
api_key: z.string().min(10, {
message: "Linear API Key is required and must be valid.",
}).regex(/^lin_api_/, {
message: "Linear API Key should start with 'lin_api_'",
}),
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
api_key: z
.string()
.min(10, {
message: "Linear API Key is required and must be valid.",
})
.regex(/^lin_api_/, {
message: "Linear API Key should start with 'lin_api_'",
}),
});
// Define the type for the form values
type LinearConnectorFormValues = z.infer<typeof linearConnectorFormSchema>;
export default function LinearConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false);
const { createConnector } = useSearchSourceConnectors();
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<LinearConnectorFormValues>({
resolver: zodResolver(linearConnectorFormSchema),
defaultValues: {
name: "Linear Connector",
api_key: "",
},
});
// Initialize the form
const form = useForm<LinearConnectorFormValues>({
resolver: zodResolver(linearConnectorFormSchema),
defaultValues: {
name: "Linear Connector",
api_key: "",
},
});
// Handle form submission
const onSubmit = async (values: LinearConnectorFormValues) => {
setIsSubmitting(true);
try {
await createConnector({
name: values.name,
connector_type: "LINEAR_CONNECTOR",
config: {
LINEAR_API_KEY: values.api_key,
},
is_indexable: true,
last_indexed_at: null,
});
// Handle form submission
const onSubmit = async (values: LinearConnectorFormValues) => {
setIsSubmitting(true);
try {
await createConnector({
name: values.name,
connector_type: "LINEAR_CONNECTOR",
config: {
LINEAR_API_KEY: values.api_key,
},
is_indexable: true,
last_indexed_at: null,
});
toast.success("Linear 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);
}
};
toast.success("Linear connector created successfully!");
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>
// 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);
}
};
<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 Linear Workspace</CardTitle>
<CardDescription>
Integrate with Linear to search and retrieve information from your issues and comments. This connector can index your Linear content for search.
</CardDescription>
</CardHeader>
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Linear API Key Required</AlertTitle>
<AlertDescription>
You'll need a Linear API Key to use this connector. You can create a Linear API key from{" "}
<a
href="https://linear.app/settings/api"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Linear API Settings
</a>
</AlertDescription>
</Alert>
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>
<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 Linear Connector" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<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>
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>Linear API Key</FormLabel>
<FormControl>
<Input
type="password"
placeholder="lin_api_..."
{...field}
/>
</FormControl>
<FormDescription>
Your Linear API Key will be encrypted and stored securely. It typically starts with "lin_api_".
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<TabsContent value="connect">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">Connect Linear Workspace</CardTitle>
<CardDescription>
Integrate with Linear to search and retrieve information from your issues and
comments. This connector can index your Linear content for search.
</CardDescription>
</CardHeader>
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Linear API Key Required</AlertTitle>
<AlertDescription>
You'll need a Linear API Key to use this connector. You can create a Linear API
key from{" "}
<a
href="https://linear.app/settings/api"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Linear API Settings
</a>
</AlertDescription>
</Alert>
<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 Linear
</>
)}
</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 Linear integration:</h4>
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
<li>Search through all your Linear issues and comments</li>
<li>Access issue titles, descriptions, and full discussion threads</li>
<li>Connect your team's project management directly to your search space</li>
<li>Keep your search results up-to-date with latest Linear content</li>
<li>Index your Linear issues 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">Linear Connector Documentation</CardTitle>
<CardDescription>
Learn how to set up and use the Linear connector to index your project management 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 Linear connector uses the Linear GraphQL API to fetch all issues and comments that the API key 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 retrieves issues and comments that have been updated since the last indexing attempt.</li>
<li>Indexing is configured to run periodically, so updates should appear in your search results within 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>Read-Only Access is Sufficient</AlertTitle>
<AlertDescription>
You only need a read-only API key for this connector to work. This limits the permissions to just reading your Linear data.
</AlertDescription>
</Alert>
<div className="space-y-6">
<div>
<h4 className="font-medium mb-2">Step 1: Create an API key</h4>
<ol className="list-decimal pl-5 space-y-3">
<li>Log in to your Linear account</li>
<li>Navigate to <a href="https://linear.app/settings/api" target="_blank" rel="noopener noreferrer" className="font-medium underline underline-offset-4">https://linear.app/settings/api</a> in your browser.</li>
<li>Alternatively, click on your profile picture Settings API</li>
<li>Click the <strong>+ New API key</strong> button.</li>
<li>Enter a description for your key (like "Search Connector").</li>
<li>Select "Read-only" as the permission.</li>
<li>Click <strong>Create</strong> to generate the API key.</li>
<li>Copy the generated API key that starts with 'lin_api_' as it will only be shown once.</li>
</ol>
</div>
<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 Linear Connector" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
<h4 className="font-medium mb-2">Step 2: Grant necessary access</h4>
<p className="text-muted-foreground mb-3">
The API key will have access to all issues and comments that your user account can see. If you're creating the key as an admin, it will have access to all issues in the workspace.
</p>
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Data Privacy</AlertTitle>
<AlertDescription>
Only issues and comments will be indexed. Linear attachments and linked files are not indexed by this connector.
</AlertDescription>
</Alert>
</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>Linear</strong> Connector.</li>
<li>Place the <strong>API Key</strong> in the form field.</li>
<li>Click <strong>Connect</strong> to establish the connection.</li>
<li>Once connected, your Linear issues will be indexed automatically.</li>
</ol>
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>What Gets Indexed</AlertTitle>
<AlertDescription>
<p className="mb-2">The Linear connector indexes the following data:</p>
<ul className="list-disc pl-5">
<li>Issue titles and identifiers (e.g., PROJ-123)</li>
<li>Issue descriptions</li>
<li>Issue comments</li>
<li>Issue status and metadata</li>
</ul>
</AlertDescription>
</Alert>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
</div>
);
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>Linear API Key</FormLabel>
<FormControl>
<Input type="password" placeholder="lin_api_..." {...field} />
</FormControl>
<FormDescription>
Your Linear API Key will be encrypted and stored securely. It typically
starts with "lin_api_".
</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 Linear
</>
)}
</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 Linear integration:</h4>
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
<li>Search through all your Linear issues and comments</li>
<li>Access issue titles, descriptions, and full discussion threads</li>
<li>Connect your team's project management directly to your search space</li>
<li>Keep your search results up-to-date with latest Linear content</li>
<li>Index your Linear issues 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">Linear Connector Documentation</CardTitle>
<CardDescription>
Learn how to set up and use the Linear connector to index your project management
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 Linear connector uses the Linear GraphQL API to fetch all issues and
comments that the API key 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 retrieves issues and comments that
have been updated since the last indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates should appear in your
search results within 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>Read-Only Access is Sufficient</AlertTitle>
<AlertDescription>
You only need a read-only API key for this connector to work. This limits
the permissions to just reading your Linear data.
</AlertDescription>
</Alert>
<div className="space-y-6">
<div>
<h4 className="font-medium mb-2">Step 1: Create an API key</h4>
<ol className="list-decimal pl-5 space-y-3">
<li>Log in to your Linear account</li>
<li>
Navigate to{" "}
<a
href="https://linear.app/settings/api"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
https://linear.app/settings/api
</a>{" "}
in your browser.
</li>
<li>Alternatively, click on your profile picture Settings API</li>
<li>
Click the <strong>+ New API key</strong> button.
</li>
<li>Enter a description for your key (like "Search Connector").</li>
<li>Select "Read-only" as the permission.</li>
<li>
Click <strong>Create</strong> to generate the API key.
</li>
<li>
Copy the generated API key that starts with 'lin_api_' as it will only
be shown once.
</li>
</ol>
</div>
<div>
<h4 className="font-medium mb-2">Step 2: Grant necessary access</h4>
<p className="text-muted-foreground mb-3">
The API key will have access to all issues and comments that your user
account can see. If you're creating the key as an admin, it will have
access to all issues in the workspace.
</p>
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Data Privacy</AlertTitle>
<AlertDescription>
Only issues and comments will be indexed. Linear attachments and
linked files are not indexed by this connector.
</AlertDescription>
</Alert>
</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>Linear</strong>{" "}
Connector.
</li>
<li>
Place the <strong>API Key</strong> in the form field.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
<li>Once connected, your Linear issues will be indexed automatically.</li>
</ol>
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>What Gets Indexed</AlertTitle>
<AlertDescription>
<p className="mb-2">The Linear connector indexes the following data:</p>
<ul className="list-disc pl-5">
<li>Issue titles and identifiers (e.g., PROJ-123)</li>
<li>Issue descriptions</li>
<li>Issue comments</li>
<li>Issue status and metadata</li>
</ul>
</AlertDescription>
</Alert>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
</div>
);
}

View file

@ -1,207 +1,193 @@
"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 { motion } from "framer-motion";
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 { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert";
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
// Define the form schema with Zod
const linkupApiFormSchema = 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.",
}),
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 LinkupApiFormValues = z.infer<typeof linkupApiFormSchema>;
export default function LinkupApiPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false);
const { createConnector } = useSearchSourceConnectors();
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<LinkupApiFormValues>({
resolver: zodResolver(linkupApiFormSchema),
defaultValues: {
name: "Linkup API Connector",
api_key: "",
},
});
// Initialize the form
const form = useForm<LinkupApiFormValues>({
resolver: zodResolver(linkupApiFormSchema),
defaultValues: {
name: "Linkup API Connector",
api_key: "",
},
});
// Handle form submission
const onSubmit = async (values: LinkupApiFormValues) => {
setIsSubmitting(true);
try {
await createConnector({
name: values.name,
connector_type: "LINKUP_API",
config: {
LINKUP_API_KEY: values.api_key,
},
is_indexable: false,
last_indexed_at: null,
});
// Handle form submission
const onSubmit = async (values: LinkupApiFormValues) => {
setIsSubmitting(true);
try {
await createConnector({
name: values.name,
connector_type: "LINKUP_API",
config: {
LINKUP_API_KEY: values.api_key,
},
is_indexable: false,
last_indexed_at: null,
});
toast.success("Linkup 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);
}
};
toast.success("Linkup API connector created successfully!");
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>
// 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);
}
};
<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 Linkup API</CardTitle>
<CardDescription>
Integrate with Linkup 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 Linkup API key to use this connector. You can get one by signing up at{" "}
<a
href="https://linkup.so"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
linkup.so
</a>
</AlertDescription>
</Alert>
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>
<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 Linkup API Connector" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<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 Linkup API</CardTitle>
<CardDescription>
Integrate with Linkup 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 Linkup API key to use this connector. You can get one by signing up at{" "}
<a
href="https://linkup.so"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
linkup.so
</a>
</AlertDescription>
</Alert>
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>Linkup API Key</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your Linkup API key"
{...field}
/>
</FormControl>
<FormDescription>
Your API key will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<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 Linkup API Connector" {...field} />
</FormControl>
<FormDescription>A friendly name to identify this connector.</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 Linkup 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 Linkup 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>
);
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>Linkup API Key</FormLabel>
<FormControl>
<Input type="password" placeholder="Enter your Linkup 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 Linkup 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 Linkup 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>
);
}

View file

@ -1,317 +1,364 @@
"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 { motion } from "framer-motion";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
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";
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
// 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.",
}),
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();
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: "",
},
});
// 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,
});
// 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);
}
};
toast.success("Notion connector created successfully!");
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>
// 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);
}
};
<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>
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>
<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>
)}
/>
<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>
<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>
)}
/>
<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>
<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>
<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>
)}
/>
<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>
);
<FormField
control={form.control}
name="integration_token"
render={({ field }) => (
<FormItem>
<FormLabel>Notion Integration Token</FormLabel>
<FormControl>
<Input type="password" placeholder="ntn_.." {...field} />
</FormControl>
<FormDescription>
Your Notion Integration Token will be encrypted and stored securely. It
typically starts with "ntn_".
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Connect Notion
</>
)}
</Button>
</div>
</form>
</Form>
</CardContent>
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
<h4 className="text-sm font-medium">What you get with Notion integration:</h4>
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
<li>Search through your Notion pages and databases</li>
<li>Access documents, wikis, and knowledge bases</li>
<li>Connect your team's knowledge directly to your search space</li>
<li>Keep your search results up-to-date with latest Notion content</li>
<li>Index your Notion documents for enhanced search capabilities</li>
</ul>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="documentation">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">Notion Connector Documentation</CardTitle>
<CardDescription>
Learn how to set up and use the Notion connector to index your workspace data.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="text-xl font-semibold mb-2">How it works</h3>
<p className="text-muted-foreground">
The Notion connector uses the Notion search API to fetch all pages that the
connector has access to within a workspace.
</p>
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
<li>
For follow up indexing runs, the connector only retrieves pages that have been
updated since the last indexing attempt.
</li>
<li>
Indexing is configured to run every <strong>10 minutes</strong>, so page
updates should appear within 10 minutes.
</li>
</ul>
</div>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="authorization">
<AccordionTrigger className="text-lg font-medium">
Authorization
</AccordionTrigger>
<AccordionContent className="space-y-4">
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>No Admin Access Required</AlertTitle>
<AlertDescription>
There's no requirement to be an Admin to share information with an
integration. Any member can share pages and databases with it.
</AlertDescription>
</Alert>
<div className="space-y-6">
<div>
<h4 className="font-medium mb-2">Step 1: Create an integration</h4>
<ol className="list-decimal pl-5 space-y-3">
<li>
Visit{" "}
<a
href="https://www.notion.com/my-integrations"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
https://www.notion.com/my-integrations
</a>{" "}
in your browser.
</li>
<li>
Click the <strong>+ New integration</strong> button.
</li>
<li>
Name the integration (something like "Search Connector" could work).
</li>
<li>Select "Read content" as the only capability required.</li>
<li>
Click <strong>Submit</strong> to create the integration.
</li>
<li>
On the next page, you'll find your Notion integration token. Make a
copy of it as you'll need it to configure the connector.
</li>
</ol>
</div>
<div>
<h4 className="font-medium mb-2">
Step 2: Share pages/databases with your integration
</h4>
<p className="text-muted-foreground mb-3">
To keep your information secure, integrations don't have access to any
pages or databases in the workspace at first. You must share specific
pages with an integration in order for the connector to access those
pages.
</p>
<ol className="list-decimal pl-5 space-y-3">
<li>Go to the page/database in your workspace.</li>
<li>
Click the <code></code> on the top right corner of the page.
</li>
<li>
Scroll to the bottom of the pop-up and click{" "}
<strong>Add connections</strong>.
</li>
<li>
Search for and select the new integration in the{" "}
<code>Search for connections...</code> menu.
</li>
<li>
<strong>Important:</strong>
<ul className="list-disc pl-5 mt-1">
<li>
If you've added a page, all child pages also become accessible.
</li>
<li>
If you've added a database, all rows (and their children) become
accessible.
</li>
</ul>
</li>
</ol>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="indexing">
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
<AccordionContent className="space-y-4">
<ol className="list-decimal pl-5 space-y-3">
<li>
Navigate to the Connector Dashboard and select the <strong>Notion</strong>{" "}
Connector.
</li>
<li>
Place the <strong>Integration Token</strong> under{" "}
<strong>Step 1 Provide Credentials</strong>.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
</ol>
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Indexing Behavior</AlertTitle>
<AlertDescription>
The Notion connector currently indexes everything it has access to. If you
want to limit specific content being indexed, simply unshare the database
from Notion with the integration.
</AlertDescription>
</Alert>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
</div>
);
}

View file

@ -1,408 +1,370 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
IconBook,
IconBrandDiscord,
IconBrandGithub,
IconBrandNotion,
IconBrandSlack,
IconBrandWindows,
IconBrandZoom,
IconChevronDown,
IconChevronRight,
IconMail,
IconWorldWww,
IconTicket,
IconLayoutKanban,
IconLinkPlus,
IconBook,
IconBrandDiscord,
IconBrandGithub,
IconBrandNotion,
IconBrandSlack,
IconBrandWindows,
IconBrandZoom,
IconChevronDown,
IconChevronRight,
IconLayoutKanban,
IconLinkPlus,
IconMail,
IconTicket,
IconWorldWww,
} from "@tabler/icons-react";
import { AnimatePresence, motion } from "framer-motion";
import { AnimatePresence, motion, type Variants } from "framer-motion";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
// Define the Connector type
interface Connector {
id: string;
title: string;
description: string;
icon: React.ReactNode;
status: "available" | "coming-soon" | "connected";
id: string;
title: string;
description: string;
icon: React.ReactNode;
status: "available" | "coming-soon" | "connected";
}
interface ConnectorCategory {
id: string;
title: string;
connectors: Connector[];
id: string;
title: string;
connectors: Connector[];
}
// Define connector categories and their connectors
const connectorCategories: ConnectorCategory[] = [
{
id: "search-engines",
title: "Search Engines",
connectors: [
{
id: "tavily-api",
title: "Tavily API",
description: "Search the web using the Tavily API",
icon: <IconWorldWww className="h-6 w-6" />,
status: "available",
},
{
id: "linkup-api",
title: "Linkup API",
description: "Search the web using the Linkup API",
icon: <IconLinkPlus className="h-6 w-6" />,
status: "available",
},
],
},
{
id: "team-chats",
title: "Team Chats",
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-connector",
title: "Discord",
description:
"Connect to Discord servers to access messages and channels.",
icon: <IconBrandDiscord className="h-6 w-6" />,
status: "available",
},
],
},
{
id: "project-management",
title: "Project Management",
connectors: [
{
id: "linear-connector",
title: "Linear",
description:
"Connect to Linear to search issues, comments and project data.",
icon: <IconLayoutKanban className="h-6 w-6" />,
status: "available",
},
{
id: "jira-connector",
title: "Jira",
description:
"Connect to Jira to search issues, tickets and project data.",
icon: <IconTicket className="h-6 w-6" />,
status: "available",
},
],
},
{
id: "knowledge-bases",
title: "Knowledge Bases",
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-connector",
title: "GitHub",
description:
"Connect a GitHub PAT to index code and docs from accessible repositories.",
icon: <IconBrandGithub className="h-6 w-6" />,
status: "available",
},
{
id: "confluence-connector",
title: "Confluence",
description:
"Connect to Confluence to search pages, comments and documentation.",
icon: <IconBook className="h-6 w-6" />,
status: "available",
},
],
},
{
id: "communication",
title: "Communication",
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",
},
],
},
{
id: "search-engines",
title: "Search Engines",
connectors: [
{
id: "tavily-api",
title: "Tavily API",
description: "Search the web using the Tavily API",
icon: <IconWorldWww className="h-6 w-6" />,
status: "available",
},
{
id: "linkup-api",
title: "Linkup API",
description: "Search the web using the Linkup API",
icon: <IconLinkPlus className="h-6 w-6" />,
status: "available",
},
],
},
{
id: "team-chats",
title: "Team Chats",
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-connector",
title: "Discord",
description: "Connect to Discord servers to access messages and channels.",
icon: <IconBrandDiscord className="h-6 w-6" />,
status: "available",
},
],
},
{
id: "project-management",
title: "Project Management",
connectors: [
{
id: "linear-connector",
title: "Linear",
description: "Connect to Linear to search issues, comments and project data.",
icon: <IconLayoutKanban className="h-6 w-6" />,
status: "available",
},
{
id: "jira-connector",
title: "Jira",
description: "Connect to Jira to search issues, tickets and project data.",
icon: <IconTicket className="h-6 w-6" />,
status: "available",
},
],
},
{
id: "knowledge-bases",
title: "Knowledge Bases",
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-connector",
title: "GitHub",
description: "Connect a GitHub PAT to index code and docs from accessible repositories.",
icon: <IconBrandGithub className="h-6 w-6" />,
status: "available",
},
{
id: "confluence-connector",
title: "Confluence",
description: "Connect to Confluence to search pages, comments and documentation.",
icon: <IconBook className="h-6 w-6" />,
status: "available",
},
],
},
{
id: "communication",
title: "Communication",
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",
},
],
},
];
// Animation variants
const fadeIn = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.4 } },
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.4 } },
};
const staggerContainer = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const cardVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 260,
damping: 20,
},
},
hover: {
scale: 1.02,
boxShadow:
"0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
transition: {
type: "spring",
stiffness: 400,
damping: 10,
},
},
const cardVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 260,
damping: 20,
},
},
hover: {
scale: 1.02,
boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
transition: {
type: "spring",
stiffness: 400,
damping: 10,
},
},
};
export default function ConnectorsPage() {
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [expandedCategories, setExpandedCategories] = useState<string[]>([
"search-engines",
"knowledge-bases",
"project-management",
"team-chats",
]);
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [expandedCategories, setExpandedCategories] = useState<string[]>([
"search-engines",
"knowledge-bases",
"project-management",
"team-chats",
]);
const toggleCategory = (categoryId: string) => {
setExpandedCategories((prev) =>
prev.includes(categoryId)
? prev.filter((id) => id !== categoryId)
: [...prev, categoryId],
);
};
const toggleCategory = (categoryId: string) => {
setExpandedCategories((prev) =>
prev.includes(categoryId) ? prev.filter((id) => id !== categoryId) : [...prev, categoryId]
);
};
return (
<div className="container mx-auto py-12 max-w-6xl">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.6,
ease: [0.22, 1, 0.36, 1],
}}
className="mb-12 text-center"
>
<h1 className="text-4xl font-bold tracking-tight bg-gradient-to-r from-indigo-500 to-purple-500 bg-clip-text text-transparent">
Connect Your Tools
</h1>
<p className="text-muted-foreground mt-3 text-lg max-w-2xl mx-auto">
Integrate with your favorite services to enhance your research
capabilities.
</p>
</motion.div>
return (
<div className="container mx-auto py-12 max-w-6xl">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.6,
ease: [0.22, 1, 0.36, 1],
}}
className="mb-12 text-center"
>
<h1 className="text-4xl font-bold tracking-tight bg-gradient-to-r from-indigo-500 to-purple-500 bg-clip-text text-transparent">
Connect Your Tools
</h1>
<p className="text-muted-foreground mt-3 text-lg max-w-2xl mx-auto">
Integrate with your favorite services to enhance your research capabilities.
</p>
</motion.div>
<motion.div
className="space-y-8"
initial="hidden"
animate="visible"
variants={staggerContainer}
>
{connectorCategories.map((category) => (
<motion.div
key={category.id}
variants={fadeIn}
className="rounded-lg border bg-card text-card-foreground shadow-sm"
>
<Collapsible
open={expandedCategories.includes(category.id)}
onOpenChange={() => toggleCategory(category.id)}
className="w-full"
>
<div className="flex items-center justify-between space-x-4 p-4">
<h3 className="text-xl font-semibold">{category.title}</h3>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="w-9 p-0 hover:bg-muted"
>
<motion.div
animate={{
rotate: expandedCategories.includes(category.id)
? 180
: 0,
}}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
<IconChevronDown className="h-5 w-5" />
</motion.div>
<span className="sr-only">Toggle</span>
</Button>
</CollapsibleTrigger>
</div>
<motion.div
className="space-y-8"
initial="hidden"
animate="visible"
variants={staggerContainer}
>
{connectorCategories.map((category) => (
<motion.div
key={category.id}
variants={fadeIn}
className="rounded-lg border bg-card text-card-foreground shadow-sm"
>
<Collapsible
open={expandedCategories.includes(category.id)}
onOpenChange={() => toggleCategory(category.id)}
className="w-full"
>
<div className="flex items-center justify-between space-x-4 p-4">
<h3 className="text-xl font-semibold">{category.title}</h3>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="w-9 p-0 hover:bg-muted">
<motion.div
animate={{
rotate: expandedCategories.includes(category.id) ? 180 : 0,
}}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
<IconChevronDown className="h-5 w-5" />
</motion.div>
<span className="sr-only">Toggle</span>
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<AnimatePresence>
<motion.div
className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 p-4"
variants={staggerContainer}
initial="hidden"
animate="visible"
exit="hidden"
>
{category.connectors.map((connector) => (
<motion.div
key={connector.id}
variants={cardVariants}
whileHover="hover"
className="col-span-1"
>
<Card className="h-full flex flex-col overflow-hidden border-transparent transition-all duration-200 hover:border-primary/50">
<CardHeader className="flex-row items-center gap-4 pb-2">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 dark:bg-primary/20">
<motion.div
whileHover={{ rotate: 5, scale: 1.1 }}
className="text-primary"
>
{connector.icon}
</motion.div>
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-medium">
{connector.title}
</h3>
{connector.status === "coming-soon" && (
<Badge
variant="outline"
className="text-xs bg-amber-100 dark:bg-amber-950 text-amber-800 dark:text-amber-300 border-amber-200 dark:border-amber-800"
>
Coming soon
</Badge>
)}
{connector.status === "connected" && (
<Badge
variant="outline"
className="text-xs bg-green-100 dark:bg-green-950 text-green-800 dark:text-green-300 border-green-200 dark:border-green-800"
>
Connected
</Badge>
)}
</div>
</div>
</CardHeader>
<CollapsibleContent>
<AnimatePresence>
<motion.div
className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 p-4"
variants={staggerContainer}
initial="hidden"
animate="visible"
exit="hidden"
>
{category.connectors.map((connector) => (
<motion.div
key={connector.id}
variants={cardVariants}
whileHover="hover"
className="col-span-1"
>
<Card className="h-full flex flex-col overflow-hidden border-transparent transition-all duration-200 hover:border-primary/50">
<CardHeader className="flex-row items-center gap-4 pb-2">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 dark:bg-primary/20">
<motion.div
whileHover={{ rotate: 5, scale: 1.1 }}
className="text-primary"
>
{connector.icon}
</motion.div>
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-medium">{connector.title}</h3>
{connector.status === "coming-soon" && (
<Badge
variant="outline"
className="text-xs bg-amber-100 dark:bg-amber-950 text-amber-800 dark:text-amber-300 border-amber-200 dark:border-amber-800"
>
Coming soon
</Badge>
)}
{connector.status === "connected" && (
<Badge
variant="outline"
className="text-xs bg-green-100 dark:bg-green-950 text-green-800 dark:text-green-300 border-green-200 dark:border-green-800"
>
Connected
</Badge>
)}
</div>
</div>
</CardHeader>
<CardContent className="pb-4">
<p className="text-sm text-muted-foreground">
{connector.description}
</p>
</CardContent>
<CardContent className="pb-4">
<p className="text-sm text-muted-foreground">{connector.description}</p>
</CardContent>
<CardFooter className="mt-auto pt-2">
{connector.status === "available" && (
<Link
href={`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`}
className="w-full"
>
<Button
variant="default"
className="w-full group"
>
<span>Connect</span>
<motion.div
className="ml-1"
initial={{ x: 0 }}
whileHover={{ x: 3 }}
transition={{
type: "spring",
stiffness: 400,
damping: 10,
}}
>
<IconChevronRight className="h-4 w-4" />
</motion.div>
</Button>
</Link>
)}
{connector.status === "coming-soon" && (
<Button
variant="outline"
disabled
className="w-full opacity-70"
>
Coming Soon
</Button>
)}
{connector.status === "connected" && (
<Button
variant="outline"
className="w-full border-green-500 text-green-600 hover:bg-green-50 dark:hover:bg-green-950"
>
Manage
</Button>
)}
</CardFooter>
</Card>
</motion.div>
))}
</motion.div>
</AnimatePresence>
</CollapsibleContent>
</Collapsible>
</motion.div>
))}
</motion.div>
</div>
);
<CardFooter className="mt-auto pt-2">
{connector.status === "available" && (
<Link
href={`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`}
className="w-full"
>
<Button variant="default" className="w-full group">
<span>Connect</span>
<motion.div
className="ml-1"
initial={{ x: 0 }}
whileHover={{ x: 3 }}
transition={{
type: "spring",
stiffness: 400,
damping: 10,
}}
>
<IconChevronRight className="h-4 w-4" />
</motion.div>
</Button>
</Link>
)}
{connector.status === "coming-soon" && (
<Button variant="outline" disabled className="w-full opacity-70">
Coming Soon
</Button>
)}
{connector.status === "connected" && (
<Button
variant="outline"
className="w-full border-green-500 text-green-600 hover:bg-green-50 dark:hover:bg-green-950"
>
Manage
</Button>
)}
</CardFooter>
</Card>
</motion.div>
))}
</motion.div>
</AnimatePresence>
</CollapsibleContent>
</Collapsible>
</motion.div>
))}
</motion.div>
</div>
);
}

View file

@ -1,207 +1,193 @@
"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 { motion } from "framer-motion";
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 { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert";
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
// 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.",
}),
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();
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: "",
},
});
// 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,
});
// 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);
}
};
toast.success("Serper API connector created successfully!");
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>
// 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);
}
};
<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>
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>
<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>
)}
/>
<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>
<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>
)}
/>
<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>
)}
/>
<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>
);
}
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>Serper API Key</FormLabel>
<FormControl>
<Input type="password" placeholder="Enter your Serper API key" {...field} />
</FormControl>
<FormDescription>
Your API key will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Connect Serper API
</>
)}
</Button>
</div>
</form>
</Form>
</CardContent>
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
<h4 className="text-sm font-medium">What you get with Serper API:</h4>
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
<li>Access to Google search results directly in your research</li>
<li>Real-time information from the web</li>
<li>Enhanced search capabilities for your projects</li>
</ul>
</CardFooter>
</Card>
</motion.div>
</div>
);
}

View file

@ -1,270 +1,285 @@
"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 { motion } from "framer-motion";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
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";
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
// 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.",
}),
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();
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: "",
},
});
// 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,
});
// 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);
}
};
toast.success("Slack connector created successfully!");
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>
// 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);
}
};
<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>
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>
<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>
)}
/>
<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>
<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>
)}
/>
<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>
<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:
<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:
@ -287,65 +302,94 @@ 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>
);
</pre>
</div>
</li>
<li>
Click the <strong>Create</strong> button.
</li>
<li>
In the app page, navigate to the <strong>OAuth & Permissions</strong> tab
under the <strong>Features</strong> header.
</li>
<li>
Copy the <strong>Bot User OAuth Token</strong>, this will be used to
access Slack.
</li>
</ol>
</AccordionContent>
</AccordionItem>
<AccordionItem value="indexing">
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
<AccordionContent className="space-y-4">
<ol className="list-decimal pl-5 space-y-3">
<li>
Navigate to the Connector Dashboard and select the <strong>Slack</strong>{" "}
Connector.
</li>
<li>
Place the <strong>Bot User OAuth Token</strong> under{" "}
<strong>Step 1 Provide Credentials</strong>.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
</ol>
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Important: Invite Bot to Channels</AlertTitle>
<AlertDescription>
After connecting, you must invite the bot to each channel you want to
index. In each Slack channel, type:
<pre className="mt-2 bg-background p-2 rounded-md text-xs">
/invite @YourBotName
</pre>
<p className="mt-2">
Without this step, you'll get a "not_in_channel" error when the
connector tries to access channel messages.
</p>
</AlertDescription>
</Alert>
<Alert className="bg-muted mt-4">
<Info className="h-4 w-4" />
<AlertTitle>First Indexing</AlertTitle>
<AlertDescription>
The first indexing pulls all of the public channels and takes longer than
future updates. Only channels where the bot has been invited will be fully
indexed.
</AlertDescription>
</Alert>
<div className="mt-4">
<h4 className="font-medium mb-2">Troubleshooting:</h4>
<ul className="list-disc pl-5 space-y-2 text-muted-foreground">
<li>
<strong>not_in_channel error:</strong> If you see this error in logs, it
means the bot hasn't been invited to a channel it's trying to access.
Use the <code>/invite @YourBotName</code> command in that channel.
</li>
<li>
<strong>Alternative approach:</strong> You can add the{" "}
<code>chat:write.public</code> scope to your Slack app to allow it to
access public channels without an explicit invitation.
</li>
<li>
<strong>For private channels:</strong> The bot must always be invited
using the <code>/invite</code> command.
</li>
</ul>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
</div>
);
}

View file

@ -1,207 +1,193 @@
"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 { motion } from "framer-motion";
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 { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert";
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
// 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.",
}),
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();
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: "",
},
});
// 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,
});
// 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);
}
};
toast.success("Tavily API connector created successfully!");
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>
// 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);
}
};
<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>
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>
<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>
)}
/>
<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>
<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>
)}
/>
<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>
)}
/>
<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>
);
}
<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>
);
}

View file

@ -1,200 +1,201 @@
"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 { type Tag, TagInput } from "emblor";
import { Globe, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
// 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);
const params = useParams();
const router = useRouter();
const search_space_id = params.search_space_id as string;
// Function to validate a URL
const isValidUrl = (url: string): boolean => {
return urlRegex.test(url);
};
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 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;
}
// Function to validate a URL
const isValidUrl = (url: string): boolean => {
return urlRegex.test(url);
};
// 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;
}
// 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;
}
setError(null);
setIsSubmitting(true);
// 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;
}
try {
toast("URL Crawling", {
description: "Starting URL crawling process...",
});
setError(null);
setIsSubmitting(true);
// Extract URLs from tags
const urls = urlTags.map(tag => tag.text);
try {
toast("URL Crawling", {
description: "Starting URL crawling process...",
});
// 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)
}),
});
// Extract URLs from tags
const urls = urlTags.map((tag) => tag.text);
if (!response.ok) {
throw new Error("Failed to crawl URLs");
}
// 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),
}),
}
);
await response.json();
if (!response.ok) {
throw new Error("Failed to crawl URLs");
}
toast("Crawling Successful", {
description: "URLs have been submitted for crawling",
});
await response.json();
// 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);
}
};
toast("Crawling Successful", {
description: "URLs have been submitted for crawling",
});
// 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;
}
// 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);
}
};
// Check for duplicates
if (urlTags.some(tag => tag.text === text)) {
toast("Duplicate URL", {
description: "This URL has already been added",
});
return;
}
// 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;
}
// Add the new tag
const newTag: Tag = {
id: Date.now().toString(),
text: text,
};
// Check for duplicates
if (urlTags.some((tag) => tag.text === text)) {
toast("Duplicate URL", {
description: "This URL has already been added",
});
return;
}
setUrlTags([...urlTags, newTag]);
};
// Add the new tag
const newTag: Tag = {
id: Date.now().toString(),
text: text,
};
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>
setUrlTags([...urlTags, newTag]);
};
{error && (
<div className="text-sm text-red-500 mt-2">
{error}
</div>
)}
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>
<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>
);
}
{error && <div className="text-sm text-red-500 mt-2">{error}</div>}
<div className="bg-muted/50 rounded-lg p-4 text-sm">
<h4 className="font-medium mb-2">Tips for URL crawling:</h4>
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
<li>Enter complete URLs including http:// or https://</li>
<li>Make sure the websites allow crawling</li>
<li>Public webpages work best</li>
<li>Crawling may take some time depending on the website size</li>
</ul>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button
variant="outline"
onClick={() => router.push(`/dashboard/${search_space_id}/documents`)}
>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting || urlTags.length === 0}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Submitting...
</>
) : (
"Submit URLs for Crawling"
)}
</Button>
</CardFooter>
</Card>
</div>
);
}

View file

@ -1,302 +1,304 @@
"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 { IconBrandYoutube } from "@tabler/icons-react";
import { type Tag, TagInput } from "emblor";
import { motion, type Variants } from "framer-motion";
import { Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { Youtube, Loader2 } from "lucide-react";
import { motion } from "framer-motion";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
// YouTube video ID validation regex
const youtubeRegex = /^(https:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})$/;
const youtubeRegex =
/^(https:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})$/;
export default function YouTubeVideoAdder() {
const params = useParams();
const router = useRouter();
const search_space_id = params.search_space_id as string;
const [videoTags, setVideoTags] = useState<Tag[]>([]);
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const params = useParams();
const router = useRouter();
const search_space_id = params.search_space_id as string;
// Function to validate a YouTube URL
const isValidYoutubeUrl = (url: string): boolean => {
return youtubeRegex.test(url);
};
const [videoTags, setVideoTags] = useState<Tag[]>([]);
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Function to extract video ID from URL
const extractVideoId = (url: string): string | null => {
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
return match ? match[1] : null;
};
// Function to validate a YouTube URL
const isValidYoutubeUrl = (url: string): boolean => {
return youtubeRegex.test(url);
};
// Function to handle video URL submission
const handleSubmit = async () => {
// Validate that we have at least one video URL
if (videoTags.length === 0) {
setError("Please add at least one YouTube video URL");
return;
}
// Function to extract video ID from URL
const extractVideoId = (url: string): string | null => {
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
return match ? match[1] : null;
};
// Validate all URLs
const invalidUrls = videoTags.filter(tag => !isValidYoutubeUrl(tag.text));
if (invalidUrls.length > 0) {
setError(`Invalid YouTube URLs detected: ${invalidUrls.map(tag => tag.text).join(', ')}`);
return;
}
// Function to handle video URL submission
const handleSubmit = async () => {
// Validate that we have at least one video URL
if (videoTags.length === 0) {
setError("Please add at least one YouTube video URL");
return;
}
setError(null);
setIsSubmitting(true);
// Validate all URLs
const invalidUrls = videoTags.filter((tag) => !isValidYoutubeUrl(tag.text));
if (invalidUrls.length > 0) {
setError(`Invalid YouTube URLs detected: ${invalidUrls.map((tag) => tag.text).join(", ")}`);
return;
}
try {
toast("YouTube Video Processing", {
description: "Starting YouTube video processing...",
});
setError(null);
setIsSubmitting(true);
// Extract URLs from tags
const videoUrls = videoTags.map(tag => tag.text);
try {
toast("YouTube Video Processing", {
description: "Starting YouTube video processing...",
});
// 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": "YOUTUBE_VIDEO",
"content": videoUrls,
"search_space_id": parseInt(search_space_id)
}),
});
// Extract URLs from tags
const videoUrls = videoTags.map((tag) => tag.text);
if (!response.ok) {
throw new Error("Failed to process YouTube videos");
}
// 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: "YOUTUBE_VIDEO",
content: videoUrls,
search_space_id: parseInt(search_space_id),
}),
}
);
await response.json();
if (!response.ok) {
throw new Error("Failed to process YouTube videos");
}
toast("Processing Successful", {
description: "YouTube videos have been submitted for processing",
});
await response.json();
// Redirect to documents page
router.push(`/dashboard/${search_space_id}/documents`);
} catch (error: any) {
setError(error.message || "An error occurred while processing YouTube videos");
toast("Processing Error", {
description: `Error processing YouTube videos: ${error.message}`,
});
} finally {
setIsSubmitting(false);
}
};
toast("Processing Successful", {
description: "YouTube videos have been submitted for processing",
});
// Function to add a new video URL tag
const handleAddTag = (text: string) => {
// Basic URL validation
if (!isValidYoutubeUrl(text)) {
toast("Invalid YouTube URL", {
description: "Please enter a valid YouTube video URL",
});
return;
}
// Redirect to documents page
router.push(`/dashboard/${search_space_id}/documents`);
} catch (error: any) {
setError(error.message || "An error occurred while processing YouTube videos");
toast("Processing Error", {
description: `Error processing YouTube videos: ${error.message}`,
});
} finally {
setIsSubmitting(false);
}
};
// Check for duplicates
if (videoTags.some(tag => tag.text === text)) {
toast("Duplicate URL", {
description: "This YouTube video has already been added",
});
return;
}
// Function to add a new video URL tag
const handleAddTag = (text: string) => {
// Basic URL validation
if (!isValidYoutubeUrl(text)) {
toast("Invalid YouTube URL", {
description: "Please enter a valid YouTube video URL",
});
return;
}
// Add the new tag
const newTag: Tag = {
id: Date.now().toString(),
text: text,
};
// Check for duplicates
if (videoTags.some((tag) => tag.text === text)) {
toast("Duplicate URL", {
description: "This YouTube video has already been added",
});
return;
}
setVideoTags([...videoTags, newTag]);
};
// Add the new tag
const newTag: Tag = {
id: Date.now().toString(),
text: text,
};
// 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
}
}
};
setVideoTags([...videoTags, newTag]);
};
return (
<div className="container mx-auto py-8">
<motion.div
initial="hidden"
animate="visible"
variants={containerVariants}
>
<Card className="max-w-2xl mx-auto">
<motion.div variants={itemVariants}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Youtube className="h-5 w-5" />
Add YouTube Videos
</CardTitle>
<CardDescription>
Enter YouTube video URLs to add to your document collection
</CardDescription>
</CardHeader>
</motion.div>
<motion.div variants={itemVariants}>
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="video-input">Enter YouTube Video URLs</Label>
<TagInput
id="video-input"
tags={videoTags}
setTags={setVideoTags}
placeholder="Enter a YouTube 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 YouTube URLs by pressing Enter after each one
</p>
</div>
// Animation variants
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
{error && (
<motion.div
className="text-sm text-red-500 mt-2"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
>
{error}
</motion.div>
)}
const itemVariants: Variants = {
hidden: { y: 20, opacity: 0 },
visible: {
y: 0,
opacity: 1,
transition: {
type: "spring",
stiffness: 300,
damping: 24,
},
},
};
<motion.div
variants={itemVariants}
className="bg-muted/50 rounded-lg p-4 text-sm"
>
<h4 className="font-medium mb-2">Tips for adding YouTube videos:</h4>
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
<li>Use standard YouTube URLs (youtube.com/watch?v= or youtu.be/)</li>
<li>Make sure videos are publicly accessible</li>
<li>Supported formats: youtube.com/watch?v=VIDEO_ID or youtu.be/VIDEO_ID</li>
<li>Processing may take some time depending on video length</li>
</ul>
</motion.div>
return (
<div className="container mx-auto py-8">
<motion.div initial="hidden" animate="visible" variants={containerVariants}>
<Card className="max-w-2xl mx-auto">
<motion.div variants={itemVariants}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconBrandYoutube className="h-5 w-5" />
Add YouTube Videos
</CardTitle>
<CardDescription>
Enter YouTube video URLs to add to your document collection
</CardDescription>
</CardHeader>
</motion.div>
{videoTags.length > 0 && (
<motion.div
variants={itemVariants}
className="mt-4 space-y-2"
>
<h4 className="font-medium">Preview:</h4>
<div className="grid grid-cols-1 gap-3">
{videoTags.map((tag, index) => {
const videoId = extractVideoId(tag.text);
return videoId ? (
<motion.div
key={tag.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="relative aspect-video rounded-lg overflow-hidden border"
>
<iframe
width="100%"
height="100%"
src={`https://www.youtube.com/embed/${videoId}`}
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
</motion.div>
) : null;
})}
</div>
</motion.div>
)}
</div>
</CardContent>
</motion.div>
<motion.div variants={itemVariants}>
<CardFooter className="flex justify-between">
<Button
variant="outline"
onClick={() => router.push(`/dashboard/${search_space_id}/documents`)}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || videoTags.length === 0}
className="relative overflow-hidden"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
<>
<motion.span
initial={{ x: -5, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ delay: 0.2 }}
className="mr-2"
>
<Youtube className="h-4 w-4" />
</motion.span>
Submit YouTube Videos
</>
)}
<motion.div
className="absolute inset-0 bg-primary/10"
initial={{ x: "-100%" }}
animate={isSubmitting ? { x: "0%" } : { x: "-100%" }}
transition={{ duration: 0.5, ease: "easeInOut" }}
/>
</Button>
</CardFooter>
</motion.div>
</Card>
</motion.div>
</div>
);
<motion.div variants={itemVariants}>
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="video-input">Enter YouTube Video URLs</Label>
<TagInput
id="video-input"
tags={videoTags}
setTags={setVideoTags}
placeholder="Enter a YouTube 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 YouTube URLs by pressing Enter after each one
</p>
</div>
{error && (
<motion.div
className="text-sm text-red-500 mt-2"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
>
{error}
</motion.div>
)}
<motion.div variants={itemVariants} className="bg-muted/50 rounded-lg p-4 text-sm">
<h4 className="font-medium mb-2">Tips for adding YouTube videos:</h4>
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
<li>Use standard YouTube URLs (youtube.com/watch?v= or youtu.be/)</li>
<li>Make sure videos are publicly accessible</li>
<li>Supported formats: youtube.com/watch?v=VIDEO_ID or youtu.be/VIDEO_ID</li>
<li>Processing may take some time depending on video length</li>
</ul>
</motion.div>
{videoTags.length > 0 && (
<motion.div variants={itemVariants} className="mt-4 space-y-2">
<h4 className="font-medium">Preview:</h4>
<div className="grid grid-cols-1 gap-3">
{videoTags.map((tag, index) => {
const videoId = extractVideoId(tag.text);
return videoId ? (
<motion.div
key={tag.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="relative aspect-video rounded-lg overflow-hidden border"
>
<iframe
width="100%"
height="100%"
src={`https://www.youtube.com/embed/${videoId}`}
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
</motion.div>
) : null;
})}
</div>
</motion.div>
)}
</div>
</CardContent>
</motion.div>
<motion.div variants={itemVariants}>
<CardFooter className="flex justify-between">
<Button
variant="outline"
onClick={() => router.push(`/dashboard/${search_space_id}/documents`)}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || videoTags.length === 0}
className="relative overflow-hidden"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
<>
<motion.span
initial={{ x: -5, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ delay: 0.2 }}
className="mr-2"
>
<IconBrandYoutube className="h-4 w-4" />
</motion.span>
Submit YouTube Videos
</>
)}
<motion.div
className="absolute inset-0 bg-primary/10"
initial={{ x: "-100%" }}
animate={isSubmitting ? { x: "0%" } : { x: "-100%" }}
transition={{ duration: 0.5, ease: "easeInOut" }}
/>
</Button>
</CardFooter>
</motion.div>
</Card>
</motion.div>
</div>
);
}

View file

@ -1,100 +1,99 @@
// Server component
import React, { use } from 'react'
import { DashboardClientLayout } from './client-layout'
import type React from "react";
import { use } from "react";
import { DashboardClientLayout } from "./client-layout";
export default function DashboardLayout({
params,
children
}: {
params: Promise<{ search_space_id: string }>,
children: React.ReactNode
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);
// Use React.use to unwrap the params Promise
const { search_space_id } = use(params);
const customNavSecondary = [
{
title: `All Search Spaces`,
url: `#`,
icon: "Info",
},
{
title: `All Search Spaces`,
url: "/dashboard",
icon: "Undo2",
},
]
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: [],
},
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`,
},
// { TODO: FIX THIS AND ADD IT BACK
// title: "Add Webpages",
// url: `/dashboard/${search_space_id}/documents/webpage`,
// },
{
title: "Add Youtube Videos",
url: `/dashboard/${search_space_id}/documents/youtube`,
},
{
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`,
},
],
},
{
title: "Podcasts",
url: `/dashboard/${search_space_id}/podcasts`,
icon: "Podcast",
items: [
],
},
{
title: "Logs",
url: `/dashboard/${search_space_id}/logs`,
icon: "FileText",
items: [
],
}
]
{
title: "Documents",
url: "#",
icon: "FileStack",
items: [
{
title: "Upload Documents",
url: `/dashboard/${search_space_id}/documents/upload`,
},
// { TODO: FIX THIS AND ADD IT BACK
// title: "Add Webpages",
// url: `/dashboard/${search_space_id}/documents/webpage`,
// },
{
title: "Add Youtube Videos",
url: `/dashboard/${search_space_id}/documents/youtube`,
},
{
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`,
},
],
},
{
title: "Podcasts",
url: `/dashboard/${search_space_id}/podcasts`,
icon: "Podcast",
items: [],
},
{
title: "Logs",
url: `/dashboard/${search_space_id}/logs`,
icon: "FileText",
items: [],
},
];
return (
<DashboardClientLayout
searchSpaceId={search_space_id}
navSecondary={customNavSecondary}
navMain={customNavMain}
>
{children}
</DashboardClientLayout>
)
}
return (
<DashboardClientLayout
searchSpaceId={search_space_id}
navSecondary={customNavSecondary}
navMain={customNavMain}
>
{children}
</DashboardClientLayout>
);
}

View file

@ -1,20 +1,24 @@
import { Suspense } from 'react';
import PodcastsPageClient from './podcasts-client';
import { Suspense } from "react";
import PodcastsPageClient from "./podcasts-client";
interface PageProps {
params: {
search_space_id: string;
};
params: {
search_space_id: string;
};
}
export default async function PodcastsPage({ params }: PageProps) {
const { search_space_id: searchSpaceId } = await Promise.resolve(params);
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>}>
<PodcastsPageClient searchSpaceId={searchSpaceId} />
</Suspense>
);
const { search_space_id: searchSpaceId } = await Promise.resolve(params);
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>
}
>
<PodcastsPageClient searchSpaceId={searchSpaceId} />
</Suspense>
);
}

View file

@ -1,7 +1,7 @@
"use client";
import { format } from "date-fns";
import { AnimatePresence, motion } from "framer-motion";
import { AnimatePresence, motion, type Variants } from "framer-motion";
import {
Calendar,
MoreHorizontal,
@ -16,8 +16,9 @@ import {
VolumeX,
X,
} from "lucide-react";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
// UI Components
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
@ -45,7 +46,6 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { toast } from "sonner";
interface PodcastItem {
id: number;
@ -60,7 +60,7 @@ interface PodcastsPageClientProps {
searchSpaceId: string;
}
const pageVariants = {
const pageVariants: Variants = {
initial: { opacity: 0 },
enter: {
opacity: 1,
@ -69,7 +69,7 @@ const pageVariants = {
exit: { opacity: 0, transition: { duration: 0.3, ease: "easeInOut" } },
};
const podcastCardVariants = {
const podcastCardVariants: Variants = {
initial: { scale: 0.95, y: 20, opacity: 0 },
animate: {
scale: 1,
@ -83,9 +83,7 @@ const podcastCardVariants = {
const MotionCard = motion(Card);
export default function PodcastsPageClient({
searchSpaceId,
}: PodcastsPageClientProps) {
export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClientProps) {
const [podcasts, setPodcasts] = useState<PodcastItem[]>([]);
const [filteredPodcasts, setFilteredPodcasts] = useState<PodcastItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
@ -100,9 +98,7 @@ export default function PodcastsPageClient({
const [isDeleting, setIsDeleting] = useState(false);
// Audio player state
const [currentPodcast, setCurrentPodcast] = useState<PodcastItem | null>(
null,
);
const [currentPodcast, setCurrentPodcast] = useState<PodcastItem | null>(null);
const [audioSrc, setAudioSrc] = useState<string | undefined>(undefined);
const [isAudioLoading, setIsAudioLoading] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
@ -141,13 +137,13 @@ export default function PodcastsPageClient({
"Content-Type": "application/json",
},
cache: "no-store",
},
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(
`Failed to fetch podcasts: ${response.status} ${errorData?.detail || ""}`,
`Failed to fetch podcasts: ${response.status} ${errorData?.detail || ""}`
);
}
@ -157,9 +153,7 @@ export default function PodcastsPageClient({
setError(null);
} catch (error) {
console.error("Error fetching podcasts:", error);
setError(
error instanceof Error ? error.message : "Unknown error occurred",
);
setError(error instanceof Error ? error.message : "Unknown error occurred");
setPodcasts([]);
setFilteredPodcasts([]);
} finally {
@ -168,7 +162,7 @@ export default function PodcastsPageClient({
};
fetchPodcasts();
}, [searchSpaceId]);
}, []);
// Filter and sort podcasts based on search query and sort order
useEffect(() => {
@ -177,15 +171,11 @@ export default function PodcastsPageClient({
// Filter by search term
if (searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter((podcast) =>
podcast.title.toLowerCase().includes(query),
);
result = result.filter((podcast) => podcast.title.toLowerCase().includes(query));
}
// Filter by search space
result = result.filter(
(podcast) => podcast.search_space_id === parseInt(searchSpaceId),
);
result = result.filter((podcast) => podcast.search_space_id === parseInt(searchSpaceId));
// Sort podcasts
result.sort((a, b) => {
@ -294,7 +284,7 @@ export default function PodcastsPageClient({
if (audioRef.current) {
audioRef.current.currentTime = Math.min(
audioRef.current.duration,
audioRef.current.currentTime + 10,
audioRef.current.currentTime + 10
);
}
};
@ -302,10 +292,7 @@ export default function PodcastsPageClient({
// Skip backward 10 seconds
const skipBackward = () => {
if (audioRef.current) {
audioRef.current.currentTime = Math.max(
0,
audioRef.current.currentTime - 10,
);
audioRef.current.currentTime = Math.max(0, audioRef.current.currentTime - 10);
}
};
@ -361,13 +348,11 @@ export default function PodcastsPageClient({
Authorization: `Bearer ${token}`,
},
signal: controller.signal,
},
}
);
if (!response.ok) {
throw new Error(
`Failed to fetch audio stream: ${response.statusText}`,
);
throw new Error(`Failed to fetch audio stream: ${response.statusText}`);
}
const blob = await response.blob();
@ -389,11 +374,7 @@ export default function PodcastsPageClient({
}
} catch (error) {
console.error("Error fetching or playing podcast:", error);
toast.error(
error instanceof Error
? error.message
: "Failed to load podcast audio.",
);
toast.error(error instanceof Error ? error.message : "Failed to load podcast audio.");
// Reset state on error
setCurrentPodcast(null);
setAudioSrc(undefined);
@ -422,7 +403,7 @@ export default function PodcastsPageClient({
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
},
}
);
if (!response.ok) {
@ -435,7 +416,7 @@ export default function PodcastsPageClient({
// Update local state by removing the deleted podcast
setPodcasts((prevPodcasts) =>
prevPodcasts.filter((podcast) => podcast.id !== podcastToDelete.id),
prevPodcasts.filter((podcast) => podcast.id !== podcastToDelete.id)
);
// If the current playing podcast is deleted, stop playback
@ -450,9 +431,7 @@ export default function PodcastsPageClient({
toast.success("Podcast deleted successfully");
} catch (error) {
console.error("Error deleting podcast:", error);
toast.error(
error instanceof Error ? error.message : "Failed to delete podcast",
);
toast.error(error instanceof Error ? error.message : "Failed to delete podcast");
} finally {
setIsDeleting(false);
}
@ -507,9 +486,7 @@ export default function PodcastsPageClient({
<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 podcasts...
</p>
<p className="text-sm text-muted-foreground">Loading podcasts...</p>
</div>
</div>
)}
@ -563,11 +540,13 @@ export default function PodcastsPageClient({
>
<div className="relative w-full aspect-[16/10] mb-4 rounded-lg overflow-hidden">
{/* Podcast image with gradient overlay */}
<img
<Image
src={PODCAST_IMAGE_URL}
alt="Podcast illustration"
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105 brightness-[0.85] contrast-[1.1]"
loading="lazy"
width={100}
height={100}
/>
{/* Better overlay with gradient for improved text legibility */}
@ -589,18 +568,13 @@ export default function PodcastsPageClient({
transition={{ type: "spring", damping: 20 }}
>
<div className="h-14 w-14 rounded-full border-4 border-primary/30 border-t-primary animate-spin"></div>
<p className="text-sm text-foreground font-medium">
Loading podcast...
</p>
<p className="text-sm text-foreground font-medium">Loading podcast...</p>
</motion.div>
</motion.div>
)}
{/* Play button with animations */}
{!(
currentPodcast?.id === podcast.id &&
(isPlaying || isAudioLoading)
) && (
{!(currentPodcast?.id === podcast.id && (isPlaying || isAudioLoading)) && (
<motion.div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10"
whileHover={{ scale: 1.1 }}
@ -636,42 +610,40 @@ export default function PodcastsPageClient({
)}
{/* Pause button with animations */}
{currentPodcast?.id === podcast.id &&
isPlaying &&
!isAudioLoading && (
<motion.div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<Button
variant="secondary"
size="icon"
className="h-16 w-16 rounded-full
{currentPodcast?.id === podcast.id && isPlaying && !isAudioLoading && (
<motion.div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<Button
variant="secondary"
size="icon"
className="h-16 w-16 rounded-full
bg-background/80 hover:bg-background/95 backdrop-blur-md
transition-all duration-200 shadow-xl border-0
flex items-center justify-center"
onClick={(e) => {
e.stopPropagation();
togglePlayPause();
onClick={(e) => {
e.stopPropagation();
togglePlayPause();
}}
disabled={isAudioLoading}
>
<motion.div
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
transition={{
type: "spring",
stiffness: 400,
damping: 10,
}}
disabled={isAudioLoading}
className="text-primary w-10 h-10 flex items-center justify-center"
>
<motion.div
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
transition={{
type: "spring",
stiffness: 400,
damping: 10,
}}
className="text-primary w-10 h-10 flex items-center justify-center"
>
<Pause className="h-8 w-8" />
</motion.div>
</Button>
</motion.div>
)}
<Pause className="h-8 w-8" />
</motion.div>
</Button>
</motion.div>
)}
{/* Now playing indicator */}
{currentPodcast?.id === podcast.id && !isAudioLoading && (
@ -705,7 +677,8 @@ export default function PodcastsPageClient({
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<div
<Button
variant="ghost"
className="h-1.5 bg-muted rounded-full cursor-pointer group relative overflow-hidden"
onClick={(e) => {
e.stopPropagation();
@ -713,10 +686,7 @@ export default function PodcastsPageClient({
const container = e.currentTarget;
const rect = container.getBoundingClientRect();
const x = e.clientX - rect.left;
const percentage = Math.max(
0,
Math.min(1, x / rect.width),
);
const percentage = Math.max(0, Math.min(1, x / rect.width));
const newTime = percentage * duration;
handleSeek([newTime]);
}}
@ -735,7 +705,7 @@ export default function PodcastsPageClient({
whileHover={{ scale: 1.5 }}
/>
</motion.div>
</div>
</Button>
<div className="flex justify-between mt-1.5 text-xs text-muted-foreground">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
@ -750,10 +720,7 @@ export default function PodcastsPageClient({
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<motion.div
whileHover={{ scale: 1.2 }}
whileTap={{ scale: 0.95 }}
>
<motion.div whileHover={{ scale: 1.2 }} whileTap={{ scale: 0.95 }}>
<Button
variant="ghost"
size="icon"
@ -768,10 +735,7 @@ export default function PodcastsPageClient({
<SkipBack className="w-5 h-5" />
</Button>
</motion.div>
<motion.div
whileHover={{ scale: 1.2 }}
whileTap={{ scale: 0.95 }}
>
<motion.div whileHover={{ scale: 1.2 }} whileTap={{ scale: 0.95 }}>
<Button
variant="ghost"
size="icon"
@ -789,10 +753,7 @@ export default function PodcastsPageClient({
)}
</Button>
</motion.div>
<motion.div
whileHover={{ scale: 1.2 }}
whileTap={{ scale: 0.95 }}
>
<motion.div whileHover={{ scale: 1.2 }} whileTap={{ scale: 0.95 }}>
<Button
variant="ghost"
size="icon"
@ -872,9 +833,7 @@ export default function PodcastsPageClient({
</div>
<div className="flex-grow min-w-0">
<h4 className="font-medium text-sm line-clamp-1">
{currentPodcast.title}
</h4>
<h4 className="font-medium text-sm line-clamp-1">{currentPodcast.title}</h4>
<div className="flex items-center gap-2 mt-2">
<div className="flex-grow relative">
@ -901,24 +860,13 @@ export default function PodcastsPageClient({
</div>
<div className="flex items-center gap-2">
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
>
<Button
variant="ghost"
size="icon"
onClick={skipBackward}
className="h-8 w-8"
>
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
<Button variant="ghost" size="icon" onClick={skipBackward} className="h-8 w-8">
<SkipBack className="h-4 w-4" />
</Button>
</motion.div>
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
>
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
<Button
variant="default"
size="icon"
@ -933,25 +881,14 @@ export default function PodcastsPageClient({
</Button>
</motion.div>
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
>
<Button
variant="ghost"
size="icon"
onClick={skipForward}
className="h-8 w-8"
>
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
<Button variant="ghost" size="icon" onClick={skipForward} className="h-8 w-8">
<SkipForward className="h-4 w-4" />
</Button>
</motion.div>
<div className="hidden md:flex items-center gap-2 ml-4 w-32">
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
>
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
<Button
variant="ghost"
size="icon"
@ -984,10 +921,7 @@ export default function PodcastsPageClient({
</div>
</div>
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
>
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
<Button
variant="default"
size="icon"
@ -1014,8 +948,8 @@ export default function PodcastsPageClient({
</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-medium">{podcastToDelete?.title}</span>?
This action cannot be undone.
<span className="font-medium">{podcastToDelete?.title}</span>? This action cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end">
@ -1086,17 +1020,16 @@ export default function PodcastsPageClient({
console.error("Audio error code:", audioRef.current.error.code);
// Don't show error message for aborted loads
if (
audioRef.current.error.code !==
audioRef.current.error.MEDIA_ERR_ABORTED
) {
if (audioRef.current.error.code !== audioRef.current.error.MEDIA_ERR_ABORTED) {
toast.error("Error playing audio. Please try again.");
}
}
// Reset playing state on error
setIsPlaying(false);
}}
/>
>
<track kind="captions" />
</audio>
</motion.div>
);
}

View file

@ -58,19 +58,12 @@ export default function ResearcherPage() {
const getChatStateStorageKey = (searchSpaceId: string, chatId: string) =>
`surfsense_chat_state_${searchSpaceId}_${chatId}`;
const storeChatState = (
searchSpaceId: string,
chatId: string,
state: ChatState,
) => {
const storeChatState = (searchSpaceId: string, chatId: string, state: ChatState) => {
const key = getChatStateStorageKey(searchSpaceId, chatId);
localStorage.setItem(key, JSON.stringify(state));
};
const restoreChatState = (
searchSpaceId: string,
chatId: string,
): ChatState | null => {
const restoreChatState = (searchSpaceId: string, chatId: string): ChatState | null => {
const key = getChatStateStorageKey(searchSpaceId, chatId);
const stored = localStorage.getItem(key);
if (stored) {
@ -108,13 +101,9 @@ export default function ResearcherPage() {
const customHandlerAppend = async (
message: Message | CreateMessage,
chatRequestOptions?: { data?: any },
chatRequestOptions?: { data?: any }
) => {
const newChatId = await createChat(
message.content,
researchMode,
selectedConnectors,
);
const newChatId = await createChat(message.content, researchMode, selectedConnectors);
if (newChatId) {
// Store chat state before navigation
storeChatState(search_space_id as string, newChatId, {
@ -138,10 +127,7 @@ export default function ResearcherPage() {
// Restore chat state from localStorage on page load
useEffect(() => {
if (chatIdParam && search_space_id) {
const restoredState = restoreChatState(
search_space_id as string,
chatIdParam,
);
const restoredState = restoreChatState(search_space_id as string, chatIdParam);
if (restoredState) {
setSelectedDocuments(restoredState.selectedDocuments);
setSelectedConnectors(restoredState.selectedConnectors);
@ -168,19 +154,13 @@ export default function ResearcherPage() {
setResearchMode(chatData.type as ResearchMode);
}
if (
chatData.initial_connectors &&
Array.isArray(chatData.initial_connectors)
) {
if (chatData.initial_connectors && Array.isArray(chatData.initial_connectors)) {
setSelectedConnectors(chatData.initial_connectors);
}
// Load existing messages
if (chatData.messages && Array.isArray(chatData.messages)) {
if (
chatData.messages.length === 1 &&
chatData.messages[0].role === "user"
) {
if (chatData.messages.length === 1 && chatData.messages[0].role === "user") {
// Single user message - append to trigger LLM response
handler.append({
role: "user",
@ -205,12 +185,7 @@ export default function ResearcherPage() {
handler.messages.length > 0 &&
handler.messages[handler.messages.length - 1]?.role === "assistant"
) {
updateChat(
chatIdParam,
handler.messages,
researchMode,
selectedConnectors,
);
updateChat(chatIdParam, handler.messages, researchMode, selectedConnectors);
}
}, [handler.messages, handler.status, chatIdParam, isNewChat]);

View file

@ -1,204 +1,185 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft } from "lucide-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 { AnimatePresence, motion } from "framer-motion";
import { ArrowLeft } from "lucide-react";
import { useRouter } from "next/navigation";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
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 } },
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.4 } },
};
const staggerContainer = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const ApiKeyClient = () => {
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
const router = useRouter();
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>
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
const router = useRouter();
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}>
<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 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>
<button
onClick={() => router.push("/dashboard")}
className="flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 hover:bg-primary/30 transition-colors"
aria-label="Back to Dashboard"
type="button"
>
<ArrowLeft className="h-5 w-5 text-primary" />
</button>
</div>
</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>
<button
onClick={() => router.push("/dashboard")}
className="flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 hover:bg-primary/30 transition-colors"
aria-label="Back to Dashboard"
type="button"
>
<ArrowLeft className="h-5 w-5 text-primary" />
</button>
</div>
</div>
);
};
export default ApiKeyClient;

View file

@ -1,32 +1,32 @@
'use client'
"use client";
import React, { useEffect, useState } from 'react'
import dynamic from 'next/dynamic'
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
// 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>
)
<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 />
})
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 />
}
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return <LoadingComponent />;
}
return <ApiKeyClient />;
}

View file

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

View file

@ -1,90 +1,92 @@
"use client";
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useLLMPreferences } from '@/hooks/use-llm-configs';
import { Loader2 } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useLLMPreferences } from "@/hooks/use-llm-configs";
interface DashboardLayoutProps {
children: React.ReactNode;
children: React.ReactNode;
}
export default function DashboardLayout({ children }: DashboardLayoutProps) {
const router = useRouter();
const { loading, error, isOnboardingComplete } = useLLMPreferences();
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
const router = useRouter();
const { loading, error, isOnboardingComplete } = useLLMPreferences();
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
useEffect(() => {
// Check if user is authenticated
const token = localStorage.getItem('surfsense_bearer_token');
if (!token) {
router.push('/login');
return;
}
setIsCheckingAuth(false);
}, [router]);
useEffect(() => {
// Check if user is authenticated
const token = localStorage.getItem("surfsense_bearer_token");
if (!token) {
router.push("/login");
return;
}
setIsCheckingAuth(false);
}, [router]);
useEffect(() => {
// Wait for preferences to load, then check if onboarding is complete
if (!loading && !error && !isCheckingAuth) {
if (!isOnboardingComplete()) {
router.push('/onboard');
}
}
}, [loading, error, isCheckingAuth, isOnboardingComplete, router]);
useEffect(() => {
// Wait for preferences to load, then check if onboarding is complete
if (!loading && !error && !isCheckingAuth) {
if (!isOnboardingComplete()) {
router.push("/onboard");
}
}
}, [loading, error, isCheckingAuth, isOnboardingComplete, router]);
// Show loading screen while checking authentication or loading preferences
if (isCheckingAuth || loading) {
return (
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
<CardHeader className="pb-2">
<CardTitle className="text-xl font-medium">Loading Dashboard</CardTitle>
<CardDescription>Checking your configuration...</CardDescription>
</CardHeader>
<CardContent className="flex justify-center py-6">
<Loader2 className="h-12 w-12 text-primary animate-spin" />
</CardContent>
</Card>
</div>
);
}
// Show loading screen while checking authentication or loading preferences
if (isCheckingAuth || loading) {
return (
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
<CardHeader className="pb-2">
<CardTitle className="text-xl font-medium">Loading Dashboard</CardTitle>
<CardDescription>Checking your configuration...</CardDescription>
</CardHeader>
<CardContent className="flex justify-center py-6">
<Loader2 className="h-12 w-12 text-primary animate-spin" />
</CardContent>
</Card>
</div>
);
}
// Show error screen if there's an error loading preferences
if (error) {
return (
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
<Card className="w-[400px] bg-background/60 backdrop-blur-sm border-destructive/20">
<CardHeader className="pb-2">
<CardTitle className="text-xl font-medium text-destructive">Configuration Error</CardTitle>
<CardDescription>Failed to load your LLM configuration</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{error}</p>
</CardContent>
</Card>
</div>
);
}
// Show error screen if there's an error loading preferences
if (error) {
return (
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
<Card className="w-[400px] bg-background/60 backdrop-blur-sm border-destructive/20">
<CardHeader className="pb-2">
<CardTitle className="text-xl font-medium text-destructive">
Configuration Error
</CardTitle>
<CardDescription>Failed to load your LLM configuration</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{error}</p>
</CardContent>
</Card>
</div>
);
}
// Only render children if onboarding is complete
if (isOnboardingComplete()) {
return <>{children}</>;
}
// Only render children if onboarding is complete
if (isOnboardingComplete()) {
return <>{children}</>;
}
// This should not be reached due to redirect, but just in case
return (
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
<CardHeader className="pb-2">
<CardTitle className="text-xl font-medium">Redirecting...</CardTitle>
<CardDescription>Taking you to complete your setup</CardDescription>
</CardHeader>
<CardContent className="flex justify-center py-6">
<Loader2 className="h-12 w-12 text-primary animate-spin" />
</CardContent>
</Card>
</div>
);
}
// This should not be reached due to redirect, but just in case
return (
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
<CardHeader className="pb-2">
<CardTitle className="text-xl font-medium">Redirecting...</CardTitle>
<CardDescription>Taking you to complete your setup</CardDescription>
</CardHeader>
<CardContent className="flex justify-center py-6">
<Loader2 className="h-12 w-12 text-primary animate-spin" />
</CardContent>
</Card>
</div>
);
}

View file

@ -1,43 +1,47 @@
"use client";
import React, { useEffect, useState } 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 { UserDropdown } from '@/components/UserDropdown';
import { toast } from 'sonner';
import { motion, type Variants } from "framer-motion";
import { AlertCircle, Loader2, Plus, Search, Trash2 } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Logo } from "@/components/Logo";
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
import { UserDropdown } from "@/components/UserDropdown";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
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 { apiClient } from '@/lib/api';
import { useRouter } from 'next/navigation';
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Spotlight } from "@/components/ui/spotlight";
import { Tilt } from "@/components/ui/tilt";
import { useSearchSpaces } from "@/hooks/use-search-spaces";
import { apiClient } from "@/lib/api";
interface User {
id: string;
email: string;
is_active: boolean;
is_superuser: boolean;
is_verified: boolean;
id: string;
email: string;
is_active: boolean;
is_superuser: boolean;
is_verified: boolean;
}
/**
@ -46,356 +50,354 @@ interface User {
* @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'
});
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>
);
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();
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>
);
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,
},
},
};
// Animation variants
const containerVariants: Variants = {
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 itemVariants: Variants = {
hidden: { y: 20, opacity: 0 },
visible: {
y: 0,
opacity: 1,
transition: {
type: "spring",
stiffness: 300,
damping: 24,
},
},
};
const router = useRouter();
const { searchSpaces, loading, error, refreshSearchSpaces } = useSearchSpaces();
// User state management
const [user, setUser] = useState<User | null>(null);
const [isLoadingUser, setIsLoadingUser] = useState(true);
const [userError, setUserError] = useState<string | null>(null);
const { searchSpaces, loading, error, refreshSearchSpaces } = useSearchSpaces();
// Fetch user details
useEffect(() => {
const fetchUser = async () => {
try {
if (typeof window === 'undefined') return;
// User state management
const [user, setUser] = useState<User | null>(null);
const [isLoadingUser, setIsLoadingUser] = useState(true);
const [userError, setUserError] = useState<string | null>(null);
try {
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);
}
};
// Fetch user details
useEffect(() => {
const fetchUser = async () => {
try {
if (typeof window === "undefined") return;
fetchUser();
}, []);
try {
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);
}
};
// Create user object for UserDropdown
const customUser = {
name: user?.email ? user.email.split('@')[0] : 'User',
email: user?.email || (isLoadingUser ? 'Loading...' : userError ? 'Error loading user' : 'Unknown User'),
avatar: '/icon-128.png', // Default avatar
};
fetchUser();
}, []);
if (loading) return <LoadingScreen />;
if (error) return <ErrorScreen message={error} />;
// Create user object for UserDropdown
const customUser = {
name: user?.email ? user.email.split("@")[0] : "User",
email:
user?.email ||
(isLoadingUser ? "Loading..." : userError ? "Error loading user" : "Unknown User"),
avatar: "/icon-128.png", // Default avatar
};
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");
};
if (loading) return <LoadingScreen />;
if (error) return <ErrorScreen message={error} />;
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>
<div className="flex items-center space-x-3">
<UserDropdown user={customUser} />
<ThemeTogglerComponent />
</div>
</div>
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")}`,
},
}
);
<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>
if (!response.ok) {
toast.error("Failed to delete search space");
throw new Error("Failed to delete search space");
}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{searchSpaces && searchSpaces.map((space) => (
<Link href={`/dashboard/${space.id}/documents`} key={space.id}>
<motion.div
key={space.id}
variants={itemVariants}
className="aspect-[4/3]"
>
// 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");
};
<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 top-2 right-2">
<div onClick={(e) => e.preventDefault()}>
<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 cursor-pointer"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Search Space</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{space.name}&quot;? 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>
<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>
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>
<div className="flex items-center space-x-3">
<UserDropdown user={customUser} />
<ThemeTogglerComponent />
</div>
</div>
</motion.div>
</Link>
))}
<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>
{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>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{searchSpaces &&
searchSpaces.length > 0 &&
searchSpaces.map((space) => (
<Link href={`/dashboard/${space.id}/documents`} key={space.id}>
<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">
<Image
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"
width={248}
height={248}
/>
<div className="absolute inset-0 bg-gradient-to-t from-background/80 to-transparent" />
<div className="absolute top-2 right-2">
<div>
<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 cursor-pointer"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Search Space</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{space.name}&quot;? 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>
{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>
)
}
<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>
</Link>
))}
export default DashboardPage
{searchSpaces.length === 0 && (
<motion.div
variants={itemVariants}
className="col-span-full flex flex-col items-center justify-center p-12 text-center"
>
<div className="rounded-full bg-muted/50 p-4 mb-4">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-2">No search spaces found</h3>
<p className="text-muted-foreground mb-6">
Create your first search space to get started
</p>
<Link href="/dashboard/searchspaces">
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Search Space
</Button>
</Link>
</motion.div>
)}
{searchSpaces.length > 0 && (
<motion.div variants={itemVariants} className="aspect-[4/3]">
<Tilt
rotationFactor={6}
isRevese
springOptions={{
stiffness: 26.7,
damping: 4.1,
mass: 0.2,
}}
className="group relative rounded-lg h-full"
>
<Link href="/dashboard/searchspaces" className="flex h-full">
<div className="flex flex-col items-center justify-center h-full w-full rounded-xl border border-dashed bg-muted/10 hover:border-primary/50 transition-colors">
<Plus className="h-10 w-10 mb-3 text-muted-foreground" />
<span className="text-sm font-medium">Add New Search Space</span>
</div>
</Link>
</Tilt>
</motion.div>
)}
</div>
</div>
</motion.div>
</motion.div>
);
};
export default DashboardPage;

View file

@ -1,52 +1,55 @@
"use client";
import { toast } from "sonner";
import { SearchSpaceForm } from "@/components/search-space-form";
import { motion } from "framer-motion";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { SearchSpaceForm } from "@/components/search-space-form";
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.`,
});
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),
}
);
router.push(`/dashboard`);
return result;
} catch (error: any) {
console.error('Error creating search space:', error);
throw error;
}
};
if (!response.ok) {
toast.error("Failed to create search space");
throw new Error("Failed to create search space");
}
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>
);
}
const result = await response.json();
toast.success("Search space created successfully", {
description: `"${data.name}" has been created.`,
});
router.push(`/dashboard`);
return result;
} catch (error) {
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>
);
}

View file

@ -1,46 +1,37 @@
import { source } from '@/lib/source';
import {
DocsBody,
DocsDescription,
DocsPage,
DocsTitle,
} from 'fumadocs-ui/page';
import { notFound } from 'next/navigation';
import { getMDXComponents } from '@/mdx-components';
export default async function Page(props: {
params: Promise<{ slug?: string[] }>;
}) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
const MDX = page.data.body;
return (
<DocsPage toc={page.data.toc} full={page.data.full}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<MDX components={getMDXComponents()} />
</DocsBody>
</DocsPage>
);
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from "fumadocs-ui/page";
import { notFound } from "next/navigation";
import { source } from "@/lib/source";
import { getMDXComponents } from "@/mdx-components";
export default async function Page(props: { params: Promise<{ slug?: string[] }> }) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
const MDX = page.data.body;
return (
<DocsPage toc={page.data.toc} full={page.data.full}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<MDX components={getMDXComponents()} />
</DocsBody>
</DocsPage>
);
}
export async function generateStaticParams() {
return source.generateParams();
return source.generateParams();
}
export async function generateMetadata(props: { params: Promise<{ slug?: string[] }> }) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
return {
title: page.data.title,
description: page.data.description,
};
}
export async function generateMetadata(props: {
params: Promise<{ slug?: string[] }>;
}) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
return {
title: page.data.title,
description: page.data.description,
};
}

View file

@ -1,12 +1,12 @@
import { source } from '@/lib/source';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import type { ReactNode } from 'react';
import { baseOptions } from '@/app/layout.config';
import { DocsLayout } from "fumadocs-ui/layouts/docs";
import type { ReactNode } from "react";
import { baseOptions } from "@/app/layout.config";
import { source } from "@/lib/source";
export default function Layout({ children }: { children: ReactNode }) {
return (
<DocsLayout tree={source.pageTree} {...baseOptions}>
{children}
</DocsLayout>
);
}
return (
<DocsLayout tree={source.pageTree} {...baseOptions}>
{children}
</DocsLayout>
);
}

View file

@ -1,160 +1,160 @@
@import 'tailwindcss';
@import 'fumadocs-ui/css/neutral.css';
@import 'fumadocs-ui/css/preset.css';
@import "tailwindcss";
@import "fumadocs-ui/css/neutral.css";
@import "fumadocs-ui/css/preset.css";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--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);
--syntax-bg: #f5f5f5;
--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);
--syntax-bg: #f5f5f5;
}
.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);
--syntax-bg: #1e1e1e;
--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);
--syntax-bg: #1e1e1e;
}
@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);
--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%;
}
* {
@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%;
}
.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%;
}
}
button {
cursor: pointer;
cursor: pointer;
}
@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}'
@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}';

View file

@ -1,7 +1,7 @@
import { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
export const baseOptions: BaseLayoutProps = {
nav: {
title: 'SurfSense Documentation',
},
};
nav: {
title: "SurfSense Documentation",
},
};

View file

@ -1,108 +1,102 @@
import type { Metadata } from "next";
import "./globals.css";
import { cn } from "@/lib/utils";
import { RootProvider } from "fumadocs-ui/provider";
import { Roboto } from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
import { ThemeProvider } from "@/components/theme/theme-provider";
import { RootProvider } from 'fumadocs-ui/provider';
import { Toaster } from "@/components/ui/sonner";
import { cn } from "@/lib/utils";
const roboto = Roboto({
subsets: ["latin"],
weight: ["400", "500", "700"],
display: 'swap',
variable: '--font-roboto',
const roboto = Roboto({
subsets: ["latin"],
weight: ["400", "500", "700"],
display: "swap",
variable: "--font-roboto",
});
export const metadata: Metadata = {
title: "SurfSense Customizable AI Research & Knowledge Management Assistant",
description:
"SurfSense is an AI-powered research assistant that integrates with tools like Notion, GitHub, Slack, and more to help you efficiently manage, search, and chat with your documents. Generate podcasts, perform hybrid search, and unlock insights from your knowledge base.",
keywords: [
"SurfSense",
"AI research assistant",
"AI knowledge management",
"AI document assistant",
"customizable AI assistant",
"notion integration",
"slack integration",
"github integration",
"hybrid search",
"vector search",
"RAG",
"LangChain",
"FastAPI",
"LLM apps",
"AI document chat",
"knowledge management AI",
"AI-powered document search",
"personal AI assistant",
"AI research tools",
"AI podcast generator",
"AI knowledge base",
"AI document assistant tools",
"AI-powered search assistant",
],
openGraph: {
title: "SurfSense AI Research & Knowledge Management Assistant",
description:
"Connect your documents and tools like Notion, Slack, GitHub, and more to your private AI assistant. SurfSense offers powerful search, document chat, podcast generation, and RAG APIs to enhance your workflow.",
url: "https://surfsense.net",
siteName: "SurfSense",
type: "website",
images: [
{
url: "https://surfsense.net/og-image.png",
width: 1200,
height: 630,
alt: "SurfSense AI Research Assistant",
},
],
locale: "en_US",
},
twitter: {
card: "summary_large_image",
title: "SurfSense AI Assistant for Research & Knowledge Management",
description:
"Have your own NotebookLM or Perplexity, but better. SurfSense connects external tools, allows chat with your documents, and generates fast, high-quality podcasts.",
creator: "https://surfsense.net",
site: "https://surfsense.net",
images: [
{
url: "https://surfsense.net/og-image-twitter.png",
width: 1200,
height: 630,
alt: "SurfSense AI Assistant Preview",
},
],
}
title: "SurfSense Customizable AI Research & Knowledge Management Assistant",
description:
"SurfSense is an AI-powered research assistant that integrates with tools like Notion, GitHub, Slack, and more to help you efficiently manage, search, and chat with your documents. Generate podcasts, perform hybrid search, and unlock insights from your knowledge base.",
keywords: [
"SurfSense",
"AI research assistant",
"AI knowledge management",
"AI document assistant",
"customizable AI assistant",
"notion integration",
"slack integration",
"github integration",
"hybrid search",
"vector search",
"RAG",
"LangChain",
"FastAPI",
"LLM apps",
"AI document chat",
"knowledge management AI",
"AI-powered document search",
"personal AI assistant",
"AI research tools",
"AI podcast generator",
"AI knowledge base",
"AI document assistant tools",
"AI-powered search assistant",
],
openGraph: {
title: "SurfSense AI Research & Knowledge Management Assistant",
description:
"Connect your documents and tools like Notion, Slack, GitHub, and more to your private AI assistant. SurfSense offers powerful search, document chat, podcast generation, and RAG APIs to enhance your workflow.",
url: "https://surfsense.net",
siteName: "SurfSense",
type: "website",
images: [
{
url: "https://surfsense.net/og-image.png",
width: 1200,
height: 630,
alt: "SurfSense AI Research Assistant",
},
],
locale: "en_US",
},
twitter: {
card: "summary_large_image",
title: "SurfSense AI Assistant for Research & Knowledge Management",
description:
"Have your own NotebookLM or Perplexity, but better. SurfSense connects external tools, allows chat with your documents, and generates fast, high-quality podcasts.",
creator: "https://surfsense.net",
site: "https://surfsense.net",
images: [
{
url: "https://surfsense.net/og-image-twitter.png",
width: 1200,
height: 630,
alt: "SurfSense AI Assistant Preview",
},
],
},
};
export default async function RootLayout({
children,
children,
}: Readonly<{
children: React.ReactNode;
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"
>
<RootProvider>
{children}
<Toaster />
</RootProvider>
</ThemeProvider>
</body>
</html>
);
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"
>
<RootProvider>
{children}
<Toaster />
</RootProvider>
</ThemeProvider>
</body>
</html>
);
}

View file

@ -1,43 +1,42 @@
"use client";
import React from "react";
export 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>
);
};
return (
<div className="pointer-events-none absolute left-0 top-0 z-0 h-screen w-screen">
<div
style={{
transform: "translateY(-350px) rotate(-45deg)",
width: "560px",
height: "1380px",
background:
"radial-gradient(68.54% 68.72% at 55.02% 31.46%, rgba(59, 130, 246, 0.08) 0%, rgba(59, 130, 246, 0.02) 50%, rgba(59, 130, 246, 0) 100%)",
}}
className="absolute left-0 top-0"
/>
<div
style={{
transform: "rotate(-45deg) translate(5%, -50%)",
transformOrigin: "top left",
width: "240px",
height: "1380px",
background:
"radial-gradient(50% 50% at 50% 50%, rgba(59, 130, 246, 0.06) 0%, rgba(59, 130, 246, 0.02) 80%, transparent 100%)",
}}
className="absolute left-0 top-0"
/>
<div
style={{
position: "absolute",
borderRadius: "20px",
transform: "rotate(-45deg) translate(-180%, -70%)",
transformOrigin: "top left",
width: "240px",
height: "1380px",
background:
"radial-gradient(50% 50% at 50% 50%, rgba(59, 130, 246, 0.04) 0%, rgba(59, 130, 246, 0.02) 80%, transparent 100%)",
}}
className="absolute left-0 top-0"
/>
</div>
);
};

View file

@ -1,92 +1,99 @@
"use client";
import React from "react";
import { IconBrandGoogleFilled } from "@tabler/icons-react";
import { motion } from "framer-motion";
import { Logo } from "@/components/Logo";
import { AmbientBackground } from "./AmbientBackground";
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.div
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="mb-4 w-full overflow-hidden rounded-lg border border-yellow-200 bg-yellow-50 text-yellow-900 shadow-sm dark:border-yellow-900/30 dark:bg-yellow-900/20 dark:text-yellow-200"
>
<motion.div
className="flex items-center gap-2 p-4"
initial={{ x: -5 }}
animate={{ x: 0 }}
transition={{ delay: 0.1, duration: 0.2 }}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="flex-shrink-0"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<div className="ml-1">
<p className="text-sm font-medium">
SurfSense Cloud is currently in development. Check <a href="/docs" className="text-blue-600 underline dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">Docs</a> for more information on Self-Hosted version.
</p>
</div>
</motion.div>
</motion.div>
<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 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.div
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="mb-4 w-full overflow-hidden rounded-lg border border-yellow-200 bg-yellow-50 text-yellow-900 shadow-sm dark:border-yellow-900/30 dark:bg-yellow-900/20 dark:text-yellow-200"
>
<motion.div
className="flex items-center gap-2 p-4"
initial={{ x: -5 }}
animate={{ x: 0 }}
transition={{ delay: 0.1, duration: 0.2 }}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="flex-shrink-0"
>
<title>Google Logo</title>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<div className="ml-1">
<p className="text-sm font-medium">
SurfSense Cloud is currently in development. Check{" "}
<a
href="/docs"
className="text-blue-600 underline dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
Docs
</a>{" "}
for more information on Self-Hosted version.
</p>
</div>
</motion.div>
</motion.div>
<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>
);
}

View file

@ -1,114 +1,124 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
export function LocalLoginForm() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [authType, setAuthType] = useState<string | null>(null);
const router = useRouter();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [authType, setAuthType] = useState<string | null>(null);
const router = useRouter();
useEffect(() => {
// Get the auth type from environment variables
setAuthType(process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE");
}, []);
useEffect(() => {
// Get the auth type from environment variables
setAuthType(process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE");
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError("");
try {
// Create form data for the API request
const formData = new URLSearchParams();
formData.append("username", username);
formData.append("password", password);
formData.append("grant_type", "password");
try {
// Create form data for the API request
const formData = new URLSearchParams();
formData.append("username", username);
formData.append("password", password);
formData.append("grant_type", "password");
const response = await fetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/jwt/login`,
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: formData.toString(),
}
);
const response = await fetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/jwt/login`,
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: formData.toString(),
}
);
const data = await response.json();
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || "Failed to login");
}
if (!response.ok) {
throw new Error(data.detail || "Failed to login");
}
router.push("/auth/callback?token=" + data.access_token);
} catch (err: any) {
setError(err.message || "An error occurred during login");
} finally {
setIsLoading(false);
}
};
router.push(`/auth/callback?token=${data.access_token}`);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "An error occurred during login";
setError(errorMessage);
} finally {
setIsLoading(false);
}
};
return (
<div className="w-full max-w-md">
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-4 text-sm text-red-500 dark:bg-red-900/20 dark:text-red-200">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Email
</label>
<input
id="email"
type="email"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
/>
</div>
return (
<div className="w-full max-w-md">
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-4 text-sm text-red-500 dark:bg-red-900/20 dark:text-red-200">
{error}
</div>
)}
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Password
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
/>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Email
</label>
<input
id="email"
type="email"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoading ? "Signing in..." : "Sign in"}
</button>
</form>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Password
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
/>
</div>
{authType === "LOCAL" && (
<div className="mt-4 text-center text-sm">
<p className="text-gray-600 dark:text-gray-400">
Don&apos;t have an account?{" "}
<Link href="/register" className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400">
Register here
</Link>
</p>
</div>
)}
</div>
);
}
<button
type="submit"
disabled={isLoading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoading ? "Signing in..." : "Sign in"}
</button>
</form>
{authType === "LOCAL" && (
<div className="mt-4 text-center text-sm">
<p className="text-gray-600 dark:text-gray-400">
Don&apos;t have an account?{" "}
<Link
href="/register"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
>
Register here
</Link>
</p>
</div>
)}
</div>
);
}

View file

@ -1,89 +1,89 @@
"use client";
import { useState, useEffect, Suspense } from "react";
import { GoogleLoginButton } from "./GoogleLoginButton";
import { LocalLoginForm } from "./LocalLoginForm";
import { Loader2 } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react";
import { Logo } from "@/components/Logo";
import { AmbientBackground } from "./AmbientBackground";
import { useSearchParams } from "next/navigation";
import { Loader2 } from "lucide-react";
import { GoogleLoginButton } from "./GoogleLoginButton";
import { LocalLoginForm } from "./LocalLoginForm";
function LoginContent() {
const [authType, setAuthType] = useState<string | null>(null);
const [registrationSuccess, setRegistrationSuccess] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const searchParams = useSearchParams();
const [authType, setAuthType] = useState<string | null>(null);
const [registrationSuccess, setRegistrationSuccess] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const searchParams = useSearchParams();
useEffect(() => {
// Check if the user was redirected from registration
if (searchParams.get("registered") === "true") {
setRegistrationSuccess(true);
}
useEffect(() => {
// Check if the user was redirected from registration
if (searchParams.get("registered") === "true") {
setRegistrationSuccess(true);
}
// Get the auth type from environment variables
setAuthType(process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE");
setIsLoading(false);
}, [searchParams]);
// Get the auth type from environment variables
setAuthType(process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE");
setIsLoading(false);
}, [searchParams]);
// Show loading state while determining auth type
if (isLoading) {
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" />
<div className="mt-8 flex items-center space-x-2">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">Loading...</span>
</div>
</div>
</div>
);
}
// Show loading state while determining auth type
if (isLoading) {
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" />
<div className="mt-8 flex items-center space-x-2">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">Loading...</span>
</div>
</div>
</div>
);
}
if (authType === "GOOGLE") {
return <GoogleLoginButton />;
}
if (authType === "GOOGLE") {
return <GoogleLoginButton />;
}
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">
Sign In
</h1>
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">
Sign In
</h1>
{registrationSuccess && (
<div className="mb-4 w-full rounded-md bg-green-50 p-4 text-sm text-green-500 dark:bg-green-900/20 dark:text-green-200">
Registration successful! You can now sign in with your credentials.
</div>
)}
{registrationSuccess && (
<div className="mb-4 w-full rounded-md bg-green-50 p-4 text-sm text-green-500 dark:bg-green-900/20 dark:text-green-200">
Registration successful! You can now sign in with your credentials.
</div>
)}
<LocalLoginForm />
</div>
</div>
);
<LocalLoginForm />
</div>
</div>
);
}
// Loading fallback for Suspense
const LoadingFallback = () => (
<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" />
<div className="mt-8 flex items-center space-x-2">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">Loading...</span>
</div>
</div>
</div>
<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" />
<div className="mt-8 flex items-center space-x-2">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">Loading...</span>
</div>
</div>
</div>
);
export default function LoginPage() {
return (
<Suspense fallback={<LoadingFallback />}>
<LoginContent />
</Suspense>
);
}
return (
<Suspense fallback={<LoadingFallback />}>
<LoginContent />
</Suspense>
);
}

View file

@ -1,227 +1,238 @@
"use client";
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { CheckCircle, ArrowRight, ArrowLeft, Bot, Sparkles, Zap, Brain } from 'lucide-react';
import { Logo } from '@/components/Logo';
import { useLLMConfigs, useLLMPreferences } from '@/hooks/use-llm-configs';
import { AddProviderStep } from '@/components/onboard/add-provider-step';
import { AssignRolesStep } from '@/components/onboard/assign-roles-step';
import { CompletionStep } from '@/components/onboard/completion-step';
import { AnimatePresence, motion } from "framer-motion";
import { ArrowLeft, ArrowRight, Bot, CheckCircle, Sparkles } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Logo } from "@/components/Logo";
import { AddProviderStep } from "@/components/onboard/add-provider-step";
import { AssignRolesStep } from "@/components/onboard/assign-roles-step";
import { CompletionStep } from "@/components/onboard/completion-step";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
const TOTAL_STEPS = 3;
const OnboardPage = () => {
const router = useRouter();
const { llmConfigs, loading: configsLoading, refreshConfigs } = useLLMConfigs();
const { preferences, loading: preferencesLoading, isOnboardingComplete, refreshPreferences } = useLLMPreferences();
const [currentStep, setCurrentStep] = useState(1);
const [hasUserProgressed, setHasUserProgressed] = useState(false);
const router = useRouter();
const { llmConfigs, loading: configsLoading, refreshConfigs } = useLLMConfigs();
const {
preferences,
loading: preferencesLoading,
isOnboardingComplete,
refreshPreferences,
} = useLLMPreferences();
const [currentStep, setCurrentStep] = useState(1);
const [hasUserProgressed, setHasUserProgressed] = useState(false);
// Check if user is authenticated
useEffect(() => {
const token = localStorage.getItem('surfsense_bearer_token');
if (!token) {
router.push('/login');
return;
}
}, [router]);
// Check if user is authenticated
useEffect(() => {
const token = localStorage.getItem("surfsense_bearer_token");
if (!token) {
router.push("/login");
return;
}
}, [router]);
// Track if user has progressed beyond step 1
useEffect(() => {
if (currentStep > 1) {
setHasUserProgressed(true);
}
}, [currentStep]);
// Track if user has progressed beyond step 1
useEffect(() => {
if (currentStep > 1) {
setHasUserProgressed(true);
}
}, [currentStep]);
// Redirect to dashboard if onboarding is already complete and user hasn't progressed (fresh page load)
useEffect(() => {
if (!preferencesLoading && isOnboardingComplete() && !hasUserProgressed) {
router.push('/dashboard');
}
}, [preferencesLoading, isOnboardingComplete, hasUserProgressed, router]);
// Redirect to dashboard if onboarding is already complete and user hasn't progressed (fresh page load)
useEffect(() => {
if (!preferencesLoading && isOnboardingComplete() && !hasUserProgressed) {
router.push("/dashboard");
}
}, [preferencesLoading, isOnboardingComplete, hasUserProgressed, router]);
const progress = (currentStep / TOTAL_STEPS) * 100;
const stepTitles = ["Add LLM Provider", "Assign LLM Roles", "Setup Complete"];
const progress = (currentStep / TOTAL_STEPS) * 100;
const stepDescriptions = [
"Configure your first model provider",
"Assign specific roles to your LLM configurations",
"You're all set to start using SurfSense!",
];
const stepTitles = [
"Add LLM Provider",
"Assign LLM Roles",
"Setup Complete"
];
const canProceedToStep2 = !configsLoading && llmConfigs.length > 0;
const canProceedToStep3 =
!preferencesLoading &&
preferences.long_context_llm_id &&
preferences.fast_llm_id &&
preferences.strategic_llm_id;
const stepDescriptions = [
"Configure your first model provider",
"Assign specific roles to your LLM configurations",
"You're all set to start using SurfSense!"
];
const handleNext = () => {
if (currentStep < TOTAL_STEPS) {
setCurrentStep(currentStep + 1);
}
};
const canProceedToStep2 = !configsLoading && llmConfigs.length > 0;
const canProceedToStep3 = !preferencesLoading && preferences.long_context_llm_id && preferences.fast_llm_id && preferences.strategic_llm_id;
const handlePrevious = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
}
};
const handleComplete = () => {
router.push("/dashboard");
};
const handleNext = () => {
if (currentStep < TOTAL_STEPS) {
setCurrentStep(currentStep + 1);
}
};
if (configsLoading || preferencesLoading) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
<CardContent className="flex flex-col items-center justify-center py-12">
<Bot className="h-12 w-12 text-primary animate-pulse mb-4" />
<p className="text-sm text-muted-foreground">Loading your configuration...</p>
</CardContent>
</Card>
</div>
);
}
const handlePrevious = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-background via-background to-muted/20 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="w-full max-w-4xl"
>
{/* Header */}
<div className="text-center mb-8">
<div className="flex items-center justify-center mb-4">
<Logo className="w-12 h-12 mr-3" />
<h1 className="text-3xl font-bold">Welcome to SurfSense</h1>
</div>
<p className="text-muted-foreground text-lg">
Let's configure your SurfSense to get started
</p>
</div>
const handleComplete = () => {
router.push('/dashboard');
};
{/* Progress */}
<Card className="mb-8 bg-background/60 backdrop-blur-sm">
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<div className="text-sm font-medium">
Step {currentStep} of {TOTAL_STEPS}
</div>
<div className="text-sm text-muted-foreground">{Math.round(progress)}% Complete</div>
</div>
<Progress value={progress} className="mb-4" />
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: TOTAL_STEPS }, (_, i) => {
const stepNum = i + 1;
const isCompleted = stepNum < currentStep;
const isCurrent = stepNum === currentStep;
if (configsLoading || preferencesLoading) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
<CardContent className="flex flex-col items-center justify-center py-12">
<Bot className="h-12 w-12 text-primary animate-pulse mb-4" />
<p className="text-sm text-muted-foreground">Loading your configuration...</p>
</CardContent>
</Card>
</div>
);
}
return (
<div key={stepNum} className="flex items-center space-x-2">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
isCompleted
? "bg-primary text-primary-foreground"
: isCurrent
? "bg-primary/20 text-primary border-2 border-primary"
: "bg-muted text-muted-foreground"
}`}
>
{isCompleted ? <CheckCircle className="w-4 h-4" /> : stepNum}
</div>
<div className="flex-1 min-w-0">
<p
className={`text-sm font-medium truncate ${
isCurrent ? "text-foreground" : "text-muted-foreground"
}`}
>
{stepTitles[i]}
</p>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
return (
<div className="min-h-screen bg-gradient-to-br from-background via-background to-muted/20 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="w-full max-w-4xl"
>
{/* Header */}
<div className="text-center mb-8">
<div className="flex items-center justify-center mb-4">
<Logo className="w-12 h-12 mr-3" />
<h1 className="text-3xl font-bold">Welcome to SurfSense</h1>
</div>
<p className="text-muted-foreground text-lg">Let's configure your SurfSense to get started</p>
</div>
{/* Step Content */}
<Card className="min-h-[500px] bg-background/60 backdrop-blur-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl flex items-center justify-center gap-2">
{currentStep === 1 && <Bot className="w-6 h-6" />}
{currentStep === 2 && <Sparkles className="w-6 h-6" />}
{currentStep === 3 && <CheckCircle className="w-6 h-6" />}
{stepTitles[currentStep - 1]}
</CardTitle>
<CardDescription className="text-base">
{stepDescriptions[currentStep - 1]}
</CardDescription>
</CardHeader>
<CardContent>
<AnimatePresence mode="wait">
<motion.div
key={currentStep}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
{currentStep === 1 && (
<AddProviderStep
onConfigCreated={refreshConfigs}
onConfigDeleted={refreshConfigs}
/>
)}
{currentStep === 2 && <AssignRolesStep onPreferencesUpdated={refreshPreferences} />}
{currentStep === 3 && <CompletionStep />}
</motion.div>
</AnimatePresence>
</CardContent>
</Card>
{/* Progress */}
<Card className="mb-8 bg-background/60 backdrop-blur-sm">
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<div className="text-sm font-medium">Step {currentStep} of {TOTAL_STEPS}</div>
<div className="text-sm text-muted-foreground">{Math.round(progress)}% Complete</div>
</div>
<Progress value={progress} className="mb-4" />
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: TOTAL_STEPS }, (_, i) => {
const stepNum = i + 1;
const isCompleted = stepNum < currentStep;
const isCurrent = stepNum === currentStep;
return (
<div key={stepNum} className="flex items-center space-x-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
isCompleted
? 'bg-primary text-primary-foreground'
: isCurrent
? 'bg-primary/20 text-primary border-2 border-primary'
: 'bg-muted text-muted-foreground'
}`}>
{isCompleted ? <CheckCircle className="w-4 h-4" /> : stepNum}
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium truncate ${
isCurrent ? 'text-foreground' : 'text-muted-foreground'
}`}>
{stepTitles[i]}
</p>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Navigation */}
<div className="flex justify-between mt-8">
<Button
variant="outline"
onClick={handlePrevious}
disabled={currentStep === 1}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Previous
</Button>
{/* Step Content */}
<Card className="min-h-[500px] bg-background/60 backdrop-blur-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl flex items-center justify-center gap-2">
{currentStep === 1 && <Bot className="w-6 h-6" />}
{currentStep === 2 && <Sparkles className="w-6 h-6" />}
{currentStep === 3 && <CheckCircle className="w-6 h-6" />}
{stepTitles[currentStep - 1]}
</CardTitle>
<CardDescription className="text-base">
{stepDescriptions[currentStep - 1]}
</CardDescription>
</CardHeader>
<CardContent>
<AnimatePresence mode="wait">
<motion.div
key={currentStep}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
{currentStep === 1 && <AddProviderStep onConfigCreated={refreshConfigs} onConfigDeleted={refreshConfigs} />}
{currentStep === 2 && <AssignRolesStep onPreferencesUpdated={refreshPreferences} />}
{currentStep === 3 && <CompletionStep />}
</motion.div>
</AnimatePresence>
</CardContent>
</Card>
<div className="flex gap-2">
{currentStep < TOTAL_STEPS && (
<Button
onClick={handleNext}
disabled={
(currentStep === 1 && !canProceedToStep2) ||
(currentStep === 2 && !canProceedToStep3)
}
className="flex items-center gap-2"
>
Next
<ArrowRight className="w-4 h-4" />
</Button>
)}
{/* Navigation */}
<div className="flex justify-between mt-8">
<Button
variant="outline"
onClick={handlePrevious}
disabled={currentStep === 1}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Previous
</Button>
<div className="flex gap-2">
{currentStep < TOTAL_STEPS && (
<Button
onClick={handleNext}
disabled={
(currentStep === 1 && !canProceedToStep2) ||
(currentStep === 2 && !canProceedToStep3)
}
className="flex items-center gap-2"
>
Next
<ArrowRight className="w-4 h-4" />
</Button>
)}
{currentStep === TOTAL_STEPS && (
<Button
onClick={handleComplete}
className="flex items-center gap-2"
>
Complete Setup
<CheckCircle className="w-4 h-4" />
</Button>
)}
</div>
</div>
</motion.div>
</div>
);
{currentStep === TOTAL_STEPS && (
<Button onClick={handleComplete} className="flex items-center gap-2">
Complete Setup
<CheckCircle className="w-4 h-4" />
</Button>
)}
</div>
</div>
</motion.div>
</div>
);
};
export default OnboardPage;
export default OnboardPage;

View file

@ -1,16 +1,15 @@
"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";
import { ModernHeroWithGradients } from "@/components/ModernHeroWithGradients";
import { Navbar } from "@/components/Navbar";
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>
);
}
return (
<main className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white">
<Navbar />
<ModernHeroWithGradients />
<Footer />
</main>
);
}

View file

@ -1,146 +1,190 @@
import { Metadata } from "next";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Privacy Policy | SurfSense",
description: "Privacy Policy for SurfSense application",
title: "Privacy Policy | SurfSense",
description: "Privacy Policy for SurfSense application",
};
export default function PrivacyPolicy() {
return (
<div className="container max-w-4xl mx-auto py-12 px-4">
<h1 className="text-4xl font-bold mb-8">Privacy Policy</h1>
<div className="prose dark:prose-invert max-w-none">
<p className="text-lg mb-6">Last updated: {new Date().toLocaleDateString()}</p>
return (
<div className="container max-w-4xl mx-auto py-12 px-4">
<h1 className="text-4xl font-bold mb-8">Privacy Policy</h1>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">1. Introduction</h2>
<p>
Welcome to SurfSense. We respect your privacy and are committed to protecting your personal data.
This privacy policy will inform you about how we look after your personal data when you visit our
website and tell you about your privacy rights and how the law protects you.
</p>
<p className="mt-4">
By using our services, you acknowledge that you have read and understood this Privacy Policy. We reserve
the right to modify this policy at any time, and such modifications shall be effective immediately upon
posting the modified policy on this website.
</p>
</section>
<div className="prose dark:prose-invert max-w-none">
<p className="text-lg mb-6">Last updated: {new Date().toLocaleDateString()}</p>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">2. Data We Collect</h2>
<p>
We may collect, use, store and transfer different kinds of personal data about you which we have
grouped together as follows:
</p>
<ul className="list-disc pl-6 my-4 space-y-2">
<li><strong>Identity Data</strong> includes first name, last name, username or similar identifier.</li>
<li><strong>Contact Data</strong> includes email address and telephone numbers.</li>
<li><strong>Technical Data</strong> includes internet protocol (IP) address, your login data, browser type and version,
time zone setting and location, browser plug-in types and versions, operating system and platform,
and other technology on the devices you use to access this website.</li>
<li><strong>Usage Data</strong> includes information about how you use our website and services.</li>
<li><strong>Surf Data</strong> includes information about surf sessions, preferences, and equipment settings.</li>
<li><strong>Marketing and Communications Data</strong> includes your preferences in receiving marketing from us and your communication preferences.</li>
<li><strong>Aggregated Data</strong> which may be derived from your personal data but is not considered personal data as it does not directly or indirectly reveal your identity.</li>
</ul>
<p className="mt-4">
We may also collect, use and share Aggregated Data such as statistical or demographic data for any purpose.
Aggregated Data may be derived from your personal data but is not considered personal data in law as this data
does not directly or indirectly reveal your identity.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">1. Introduction</h2>
<p>
Welcome to SurfSense. We respect your privacy and are committed to protecting your
personal data. This privacy policy will inform you about how we look after your personal
data when you visit our website and tell you about your privacy rights and how the law
protects you.
</p>
<p className="mt-4">
By using our services, you acknowledge that you have read and understood this Privacy
Policy. We reserve the right to modify this policy at any time, and such modifications
shall be effective immediately upon posting the modified policy on this website.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">3. How We Use Your Data</h2>
<p>
We will only use your personal data when the law allows us to. Most commonly, we will use your
personal data in the following circumstances:
</p>
<ul className="list-disc pl-6 my-4 space-y-2">
<li>Where we need to perform the contract we are about to enter into or have entered into with you.</li>
<li>Where it is necessary for our legitimate interests (or those of a third party) and your interests
and fundamental rights do not override those interests.</li>
<li>Where we need to comply with a legal obligation.</li>
<li>To provide and maintain our services, including to monitor the usage of our service.</li>
<li>To improve our services, products, marketing, and customer relationships and experiences.</li>
<li>To communicate with you about updates, security alerts, and support messages.</li>
<li>To provide customer support and respond to your requests or inquiries.</li>
<li>For business transfers, such as in connection with a merger, sale of company assets, financing, or acquisition.</li>
</ul>
<p className="mt-4">
We may use your information for marketing purposes, such as sending you information about our products, services,
promotions, and events. You can opt-out of receiving these communications at any time.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">2. Data We Collect</h2>
<p>
We may collect, use, store and transfer different kinds of personal data about you which
we have grouped together as follows:
</p>
<ul className="list-disc pl-6 my-4 space-y-2">
<li>
<strong>Identity Data</strong> includes first name, last name, username or similar
identifier.
</li>
<li>
<strong>Contact Data</strong> includes email address and telephone numbers.
</li>
<li>
<strong>Technical Data</strong> includes internet protocol (IP) address, your login
data, browser type and version, time zone setting and location, browser plug-in types
and versions, operating system and platform, and other technology on the devices you
use to access this website.
</li>
<li>
<strong>Usage Data</strong> includes information about how you use our website and
services.
</li>
<li>
<strong>Surf Data</strong> includes information about surf sessions, preferences, and
equipment settings.
</li>
<li>
<strong>Marketing and Communications Data</strong> includes your preferences in
receiving marketing from us and your communication preferences.
</li>
<li>
<strong>Aggregated Data</strong> which may be derived from your personal data but is
not considered personal data as it does not directly or indirectly reveal your
identity.
</li>
</ul>
<p className="mt-4">
We may also collect, use and share Aggregated Data such as statistical or demographic
data for any purpose. Aggregated Data may be derived from your personal data but is not
considered personal data in law as this data does not directly or indirectly reveal your
identity.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">4. Data Security</h2>
<p>
We have put in place appropriate security measures to prevent your personal data from being
accidentally lost, used or accessed in an unauthorized way, altered or disclosed. In addition,
we limit access to your personal data to those employees, agents, contractors and other third
parties who have a business need to know.
</p>
<p className="mt-4">
While we implement safeguards designed to protect your information, no security system is impenetrable
and due to the inherent nature of the Internet, we cannot guarantee that information, during transmission
through the Internet or while stored on our systems, is absolutely safe from intrusion by others.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">3. How We Use Your Data</h2>
<p>
We will only use your personal data when the law allows us to. Most commonly, we will
use your personal data in the following circumstances:
</p>
<ul className="list-disc pl-6 my-4 space-y-2">
<li>
Where we need to perform the contract we are about to enter into or have entered into
with you.
</li>
<li>
Where it is necessary for our legitimate interests (or those of a third party) and
your interests and fundamental rights do not override those interests.
</li>
<li>Where we need to comply with a legal obligation.</li>
<li>
To provide and maintain our services, including to monitor the usage of our service.
</li>
<li>
To improve our services, products, marketing, and customer relationships and
experiences.
</li>
<li>To communicate with you about updates, security alerts, and support messages.</li>
<li>To provide customer support and respond to your requests or inquiries.</li>
<li>
For business transfers, such as in connection with a merger, sale of company assets,
financing, or acquisition.
</li>
</ul>
<p className="mt-4">
We may use your information for marketing purposes, such as sending you information
about our products, services, promotions, and events. You can opt-out of receiving these
communications at any time.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">5. Data Retention</h2>
<p>
We will only retain your personal data for as long as necessary to fulfill the purposes we collected it for,
including for the purposes of satisfying any legal, accounting, or reporting requirements. To determine the appropriate
retention period for personal data, we consider the amount, nature, and sensitivity of the personal data, the
potential risk of harm from unauthorized use or disclosure of your personal data, the purposes for which we process
your personal data and whether we can achieve those purposes through other means, and the applicable legal requirements.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">4. Data Security</h2>
<p>
We have put in place appropriate security measures to prevent your personal data from
being accidentally lost, used or accessed in an unauthorized way, altered or disclosed.
In addition, we limit access to your personal data to those employees, agents,
contractors and other third parties who have a business need to know.
</p>
<p className="mt-4">
While we implement safeguards designed to protect your information, no security system
is impenetrable and due to the inherent nature of the Internet, we cannot guarantee that
information, during transmission through the Internet or while stored on our systems, is
absolutely safe from intrusion by others.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">6. Your Legal Rights</h2>
<p>
Under certain circumstances, you have rights under data protection laws in relation to your personal data, including:
</p>
<ul className="list-disc pl-6 my-4 space-y-2">
<li>The right to request access to your personal data.</li>
<li>The right to request correction of your personal data.</li>
<li>The right to request erasure of your personal data.</li>
<li>The right to object to processing of your personal data.</li>
<li>The right to request restriction of processing your personal data.</li>
<li>The right to request transfer of your personal data.</li>
<li>The right to withdraw consent.</li>
</ul>
<p className="mt-4">
Please note that these rights are not absolute, and we may be entitled to refuse requests where exceptions apply.
If you wish to exercise any of the rights set out above, please contact us. We may need to request specific
information from you to help us confirm your identity and ensure your right to access your personal data.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">5. Data Retention</h2>
<p>
We will only retain your personal data for as long as necessary to fulfill the purposes
we collected it for, including for the purposes of satisfying any legal, accounting, or
reporting requirements. To determine the appropriate retention period for personal data,
we consider the amount, nature, and sensitivity of the personal data, the potential risk
of harm from unauthorized use or disclosure of your personal data, the purposes for
which we process your personal data and whether we can achieve those purposes through
other means, and the applicable legal requirements.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">7. Third-Party Services</h2>
<p>
Our service may contain links to other websites that are not operated by us. If you click on a third-party link,
you will be directed to that third party's site. We strongly advise you to review the Privacy Policy of every site you visit.
We have no control over and assume no responsibility for the content, privacy policies, or practices of any third-party
sites or services.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">6. Your Legal Rights</h2>
<p>
Under certain circumstances, you have rights under data protection laws in relation to
your personal data, including:
</p>
<ul className="list-disc pl-6 my-4 space-y-2">
<li>The right to request access to your personal data.</li>
<li>The right to request correction of your personal data.</li>
<li>The right to request erasure of your personal data.</li>
<li>The right to object to processing of your personal data.</li>
<li>The right to request restriction of processing your personal data.</li>
<li>The right to request transfer of your personal data.</li>
<li>The right to withdraw consent.</li>
</ul>
<p className="mt-4">
Please note that these rights are not absolute, and we may be entitled to refuse
requests where exceptions apply. If you wish to exercise any of the rights set out
above, please contact us. We may need to request specific information from you to help
us confirm your identity and ensure your right to access your personal data.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">8. Contact Us</h2>
<p>
If you have any questions about this privacy policy or our privacy practices, please contact us at:
</p>
<p className="mt-2">
<strong>Email:</strong> vermarohanfinal@gmail.com
</p>
</section>
</div>
</div>
);
}
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">7. Third-Party Services</h2>
<p>
Our service may contain links to other websites that are not operated by us. If you
click on a third-party link, you will be directed to that third party's site. We
strongly advise you to review the Privacy Policy of every site you visit. We have no
control over and assume no responsibility for the content, privacy policies, or
practices of any third-party sites or services.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">8. Contact Us</h2>
<p>
If you have any questions about this privacy policy or our privacy practices, please
contact us at:
</p>
<p className="mt-2">
<strong>Email:</strong> vermarohanfinal@gmail.com
</p>
</section>
</div>
</div>
);
}

View file

@ -1,149 +1,161 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Logo } from "@/components/Logo";
import { AmbientBackground } from "../login/AmbientBackground";
export default function RegisterPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
// Check authentication type and redirect if not LOCAL
useEffect(() => {
const authType = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE";
if (authType !== "LOCAL") {
router.push("/login");
}
}, [router]);
// Check authentication type and redirect if not LOCAL
useEffect(() => {
const authType = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE";
if (authType !== "LOCAL") {
router.push("/login");
}
}, [router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Form validation
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError("");
// Form validation
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/register`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email,
password,
is_active: true,
is_superuser: false,
is_verified: false,
}),
}
);
setIsLoading(true);
setError("");
const data = await response.json();
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/register`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email,
password,
is_active: true,
is_superuser: false,
is_verified: false,
}),
});
if (!response.ok) {
throw new Error(data.detail || "Registration failed");
}
const data = await response.json();
// Redirect to login page after successful registration
router.push("/login?registered=true");
} catch (err: any) {
setError(err.message || "An error occurred during registration");
} finally {
setIsLoading(false);
}
};
if (!response.ok) {
throw new Error(data.detail || "Registration failed");
}
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">
Create an Account
</h1>
// Redirect to login page after successful registration
router.push("/login?registered=true");
} catch (err: unknown) {
const errorMessage =
err instanceof Error ? err.message : "An error occurred during registration";
setError(errorMessage);
} finally {
setIsLoading(false);
}
};
<div className="w-full max-w-md">
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-4 text-sm text-red-500 dark:bg-red-900/20 dark:text-red-200">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
/>
</div>
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">
Create an Account
</h1>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Password
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
/>
</div>
<div className="w-full max-w-md">
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-4 text-sm text-red-500 dark:bg-red-900/20 dark:text-red-200">
{error}
</div>
)}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Confirm Password
</label>
<input
id="confirmPassword"
type="password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
/>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoading ? "Creating account..." : "Register"}
</button>
</form>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Password
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
/>
</div>
<div className="mt-4 text-center text-sm">
<p className="text-gray-600 dark:text-gray-400">
Already have an account?{" "}
<Link href="/login" className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400">
Sign in
</Link>
</p>
</div>
</div>
</div>
</div>
);
}
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Confirm Password
</label>
<input
id="confirmPassword"
type="password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoading ? "Creating account..." : "Register"}
</button>
</form>
<div className="mt-4 text-center text-sm">
<p className="text-gray-600 dark:text-gray-400">
Already have an account?{" "}
<Link
href="/login"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
>
Sign in
</Link>
</p>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,72 +1,71 @@
"use client";
import React from 'react';
import { useRouter } from 'next/navigation'; // Add this import
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Separator } from '@/components/ui/separator';
import { Bot, Settings, Brain, ArrowLeft } from 'lucide-react'; // Import ArrowLeft icon
import { ModelConfigManager } from '@/components/settings/model-config-manager';
import { LLMRoleManager } from '@/components/settings/llm-role-manager';
import { ArrowLeft, Bot, Brain, Settings } from "lucide-react"; // Import ArrowLeft icon
import { useRouter } from "next/navigation"; // Add this import
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
import { ModelConfigManager } from "@/components/settings/model-config-manager";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
export default function SettingsPage() {
const router = useRouter(); // Initialize router
const router = useRouter(); // Initialize router
return (
<div className="min-h-screen bg-background">
<div className="container max-w-7xl mx-auto p-6 lg:p-8">
<div className="space-y-8">
{/* Header Section */}
<div className="space-y-4">
<div className="flex items-center space-x-4">
{/* Back Button */}
<button
onClick={() => router.push('/dashboard')}
className="flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 hover:bg-primary/20 transition-colors"
aria-label="Back to Dashboard"
type="button"
>
<ArrowLeft className="h-5 w-5 text-primary" />
</button>
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<Settings className="h-6 w-6 text-primary" />
</div>
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
<p className="text-lg text-muted-foreground">
Manage your LLM configurations and role assignments.
</p>
</div>
</div>
<Separator className="my-6" />
</div>
return (
<div className="min-h-screen bg-background">
<div className="container max-w-7xl mx-auto p-6 lg:p-8">
<div className="space-y-8">
{/* Header Section */}
<div className="space-y-4">
<div className="flex items-center space-x-4">
{/* Back Button */}
<button
onClick={() => router.push("/dashboard")}
className="flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 hover:bg-primary/20 transition-colors"
aria-label="Back to Dashboard"
type="button"
>
<ArrowLeft className="h-5 w-5 text-primary" />
</button>
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<Settings className="h-6 w-6 text-primary" />
</div>
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
<p className="text-lg text-muted-foreground">
Manage your LLM configurations and role assignments.
</p>
</div>
</div>
<Separator className="my-6" />
</div>
{/* Settings Content */}
<Tabs defaultValue="models" className="space-y-8">
<div className="overflow-x-auto">
<TabsList className="grid w-full min-w-fit grid-cols-2 lg:w-auto lg:inline-grid">
<TabsTrigger value="models" className="flex items-center gap-2 text-sm">
<Bot className="h-4 w-4" />
<span className="hidden sm:inline">Model Configs</span>
<span className="sm:hidden">Models</span>
</TabsTrigger>
<TabsTrigger value="roles" className="flex items-center gap-2 text-sm">
<Brain className="h-4 w-4" />
<span className="hidden sm:inline">LLM Roles</span>
<span className="sm:hidden">Roles</span>
</TabsTrigger>
</TabsList>
</div>
{/* Settings Content */}
<Tabs defaultValue="models" className="space-y-8">
<div className="overflow-x-auto">
<TabsList className="grid w-full min-w-fit grid-cols-2 lg:w-auto lg:inline-grid">
<TabsTrigger value="models" className="flex items-center gap-2 text-sm">
<Bot className="h-4 w-4" />
<span className="hidden sm:inline">Model Configs</span>
<span className="sm:hidden">Models</span>
</TabsTrigger>
<TabsTrigger value="roles" className="flex items-center gap-2 text-sm">
<Brain className="h-4 w-4" />
<span className="hidden sm:inline">LLM Roles</span>
<span className="sm:hidden">Roles</span>
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="models" className="space-y-6">
<ModelConfigManager />
</TabsContent>
<TabsContent value="models" className="space-y-6">
<ModelConfigManager />
</TabsContent>
<TabsContent value="roles" className="space-y-6">
<LLMRoleManager />
</TabsContent>
</Tabs>
</div>
</div>
</div>
);
}
<TabsContent value="roles" className="space-y-6">
<LLMRoleManager />
</TabsContent>
</Tabs>
</div>
</div>
</div>
);
}

View file

@ -1,48 +1,48 @@
import type { MetadataRoute } from 'next'
import type { MetadataRoute } from "next";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: 'https://www.surfsense.net/',
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 1,
},
{
url: 'https://www.surfsense.net/privacy',
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.9,
},
{
url: 'https://www.surfsense.net/terms',
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.9,
},
{
url: 'https://www.surfsense.net/docs',
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.9,
},
{
url: 'https://www.surfsense.net/docs/installation',
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.9,
},
{
url: 'https://www.surfsense.net/docs/docker-installation',
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.9,
},
{
url: 'https://www.surfsense.net/docs/manual-installation',
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.9,
},
]
return [
{
url: "https://www.surfsense.net/",
lastModified: new Date(),
changeFrequency: "yearly",
priority: 1,
},
{
url: "https://www.surfsense.net/privacy",
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.9,
},
{
url: "https://www.surfsense.net/terms",
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.9,
},
{
url: "https://www.surfsense.net/docs",
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.9,
},
{
url: "https://www.surfsense.net/docs/installation",
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.9,
},
{
url: "https://www.surfsense.net/docs/docker-installation",
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.9,
},
{
url: "https://www.surfsense.net/docs/manual-installation",
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.9,
},
];
}

View file

@ -1,204 +1,225 @@
import { Metadata } from "next";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Terms of Service | SurfSense",
description: "Terms of Service for SurfSense application",
title: "Terms of Service | SurfSense",
description: "Terms of Service for SurfSense application",
};
export default function TermsOfService() {
return (
<div className="container max-w-4xl mx-auto py-12 px-4">
<h1 className="text-4xl font-bold mb-8">Terms of Service</h1>
<div className="prose dark:prose-invert max-w-none">
<p className="text-lg mb-6">Last updated: {new Date().toLocaleDateString()}</p>
return (
<div className="container max-w-4xl mx-auto py-12 px-4">
<h1 className="text-4xl font-bold mb-8">Terms of Service</h1>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">1. Introduction</h2>
<p>
Welcome to SurfSense. These Terms of Service govern your access to and use of the SurfSense website and services.
By accessing or using our services, you agree to be bound by these Terms.
</p>
<p className="mt-4">
Please read these Terms carefully before using our Services. By using our Services, you agree that these Terms
will govern your relationship with us. If you do not agree to these Terms, please refrain from using our Services.
</p>
</section>
<div className="prose dark:prose-invert max-w-none">
<p className="text-lg mb-6">Last updated: {new Date().toLocaleDateString()}</p>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">2. Using Our Services</h2>
<p>
You must follow any policies made available to you within the Services. You may use our Services only as
permitted by law. We may suspend or stop providing our Services to you if you do not comply with our terms or
policies or if we are investigating suspected misconduct.
</p>
<p className="mt-4">
Using our Services does not give you ownership of any intellectual property rights in our Services or the
content you access. You may not use content from our Services unless you obtain permission from its owner or
are otherwise permitted by law.
</p>
<p className="mt-4">
We reserve the right to remove any content that we reasonably believe violates these Terms, infringes any
intellectual property right, is abusive, illegal, or otherwise objectionable.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">1. Introduction</h2>
<p>
Welcome to SurfSense. These Terms of Service govern your access to and use of the
SurfSense website and services. By accessing or using our services, you agree to be
bound by these Terms.
</p>
<p className="mt-4">
Please read these Terms carefully before using our Services. By using our Services, you
agree that these Terms will govern your relationship with us. If you do not agree to
these Terms, please refrain from using our Services.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">3. Your Account</h2>
<p>
To use some of our services, you may need to create an account. You are responsible for safeguarding the
password that you use to access the services and for any activities or actions under your password.
</p>
<p className="mt-4">
You must provide accurate and complete information when creating your account. You agree to update your
information to keep it accurate and complete. You are responsible for maintaining the confidentiality of your
account and password, including restricting access to your computer and/or account.
</p>
<p className="mt-4">
We reserve the right to refuse service, terminate accounts, remove or edit content, or cancel orders at
our sole discretion.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">2. Using Our Services</h2>
<p>
You must follow any policies made available to you within the Services. You may use our
Services only as permitted by law. We may suspend or stop providing our Services to you
if you do not comply with our terms or policies or if we are investigating suspected
misconduct.
</p>
<p className="mt-4">
Using our Services does not give you ownership of any intellectual property rights in
our Services or the content you access. You may not use content from our Services unless
you obtain permission from its owner or are otherwise permitted by law.
</p>
<p className="mt-4">
We reserve the right to remove any content that we reasonably believe violates these
Terms, infringes any intellectual property right, is abusive, illegal, or otherwise
objectionable.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">4. Privacy and Copyright Protection</h2>
<p>
Our privacy policies explain how we treat your personal data and protect your privacy when you use our
Services. By using our Services, you agree that SurfSense can use such data in accordance with our privacy policies.
</p>
<p className="mt-4">
We respond to notices of alleged copyright infringement and terminate accounts of repeat infringers according
to the process set out in applicable copyright laws.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">3. Your Account</h2>
<p>
To use some of our services, you may need to create an account. You are responsible for
safeguarding the password that you use to access the services and for any activities or
actions under your password.
</p>
<p className="mt-4">
You must provide accurate and complete information when creating your account. You agree
to update your information to keep it accurate and complete. You are responsible for
maintaining the confidentiality of your account and password, including restricting
access to your computer and/or account.
</p>
<p className="mt-4">
We reserve the right to refuse service, terminate accounts, remove or edit content, or
cancel orders at our sole discretion.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">5. License and Intellectual Property</h2>
<p>
SurfSense gives you a personal, worldwide, royalty-free, non-assignable and non-exclusive license to use the
software provided to you as part of the Services. This license is for the sole purpose of enabling you to use
and enjoy the benefit of the Services as provided by SurfSense, in the manner permitted by these terms.
</p>
<p className="mt-4">
All content included in or made available through our Servicessuch as text, graphics, logos, button icons,
images, audio clips, digital downloads, data compilations, and softwareis the property of SurfSense or its
content suppliers and is protected by international copyright, trademark, and other intellectual property laws.
</p>
<p className="mt-4">
By submitting, posting, or displaying content on or through our Services, you grant us a worldwide, non-exclusive,
royalty-free license to use, reproduce, modify, adapt, publish, translate, create derivative works from, distribute,
and display such content in any media for the purpose of providing and improving our Services.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">4. Privacy and Copyright Protection</h2>
<p>
Our privacy policies explain how we treat your personal data and protect your privacy
when you use our Services. By using our Services, you agree that SurfSense can use such
data in accordance with our privacy policies.
</p>
<p className="mt-4">
We respond to notices of alleged copyright infringement and terminate accounts of repeat
infringers according to the process set out in applicable copyright laws.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">6. Modifying and Terminating our Services</h2>
<p>
We are constantly changing and improving our Services. We may add or remove functionalities or features, and
we may suspend or stop a Service altogether. You can stop using our Services at any time. SurfSense may also
stop providing Services to you, or add or create new limits on our Services at any time.
</p>
<p className="mt-4">
We believe that you own your data and preserving your access to such data is important. If we discontinue a Service,
where reasonably possible, we will give you reasonable advance notice and a chance to get information out of that Service.
</p>
<p className="mt-4">
We reserve the right to modify these Terms at any time. If we make material changes to these Terms, we will notify
you by email or by posting a notice on our website before the changes become effective. Your continued use of our
Services after the effective date of such changes constitutes your acceptance of the modified Terms.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">5. License and Intellectual Property</h2>
<p>
SurfSense gives you a personal, worldwide, royalty-free, non-assignable and
non-exclusive license to use the software provided to you as part of the Services. This
license is for the sole purpose of enabling you to use and enjoy the benefit of the
Services as provided by SurfSense, in the manner permitted by these terms.
</p>
<p className="mt-4">
All content included in or made available through our Servicessuch as text, graphics,
logos, button icons, images, audio clips, digital downloads, data compilations, and
softwareis the property of SurfSense or its content suppliers and is protected by
international copyright, trademark, and other intellectual property laws.
</p>
<p className="mt-4">
By submitting, posting, or displaying content on or through our Services, you grant us a
worldwide, non-exclusive, royalty-free license to use, reproduce, modify, adapt,
publish, translate, create derivative works from, distribute, and display such content
in any media for the purpose of providing and improving our Services.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">7. Warranties and Disclaimers</h2>
<p>
We provide our Services using a commercially reasonable level of skill and care and we hope that you will
enjoy using them. But there are certain things that we don't promise about our Services.
</p>
<p className="mt-4 uppercase font-bold">
OTHER THAN AS EXPRESSLY SET OUT IN THESE TERMS OR ADDITIONAL TERMS, NEITHER SURFSENSE NOR ITS SUPPLIERS OR DISTRIBUTORS
MAKE ANY SPECIFIC PROMISES ABOUT THE SERVICES. FOR EXAMPLE, WE DON'T MAKE ANY COMMITMENTS ABOUT THE CONTENT WITHIN THE
SERVICES, THE SPECIFIC FUNCTIONS OF THE SERVICES, OR THEIR RELIABILITY, AVAILABILITY, OR ABILITY TO MEET YOUR NEEDS.
WE PROVIDE THE SERVICES "AS IS".
</p>
<p className="mt-4 uppercase font-bold">
SOME JURISDICTIONS PROVIDE FOR CERTAIN WARRANTIES, LIKE THE IMPLIED WARRANTY OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE, AND NON-INFRINGEMENT. TO THE EXTENT PERMITTED BY LAW, WE EXCLUDE ALL WARRANTIES.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">6. Modifying and Terminating our Services</h2>
<p>
We are constantly changing and improving our Services. We may add or remove
functionalities or features, and we may suspend or stop a Service altogether. You can
stop using our Services at any time. SurfSense may also stop providing Services to you,
or add or create new limits on our Services at any time.
</p>
<p className="mt-4">
We believe that you own your data and preserving your access to such data is important.
If we discontinue a Service, where reasonably possible, we will give you reasonable
advance notice and a chance to get information out of that Service.
</p>
<p className="mt-4">
We reserve the right to modify these Terms at any time. If we make material changes to
these Terms, we will notify you by email or by posting a notice on our website before
the changes become effective. Your continued use of our Services after the effective
date of such changes constitutes your acceptance of the modified Terms.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">8. Liability for our Services</h2>
<p className="uppercase font-bold">
WHEN PERMITTED BY LAW, SURFSENSE, AND SURFSENSE'S SUPPLIERS AND DISTRIBUTORS, WILL NOT BE RESPONSIBLE FOR
LOST PROFITS, REVENUES, OR DATA, FINANCIAL LOSSES OR INDIRECT, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR
PUNITIVE DAMAGES.
</p>
<p className="mt-4 uppercase font-bold">
TO THE EXTENT PERMITTED BY LAW, THE TOTAL LIABILITY OF SURFSENSE, AND ITS SUPPLIERS AND DISTRIBUTORS, FOR ANY
CLAIMS UNDER THESE TERMS, INCLUDING FOR ANY IMPLIED WARRANTIES, IS LIMITED TO THE AMOUNT YOU PAID US TO USE THE
SERVICES (OR, IF WE CHOOSE, TO SUPPLYING YOU THE SERVICES AGAIN).
</p>
<p className="mt-4 uppercase font-bold">
IN ALL CASES, SURFSENSE, AND ITS SUPPLIERS AND DISTRIBUTORS, WILL NOT BE LIABLE FOR ANY LOSS OR DAMAGE THAT IS
NOT REASONABLY FORESEEABLE.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">7. Warranties and Disclaimers</h2>
<p>
We provide our Services using a commercially reasonable level of skill and care and we
hope that you will enjoy using them. But there are certain things that we don't promise
about our Services.
</p>
<p className="mt-4 uppercase font-bold">
OTHER THAN AS EXPRESSLY SET OUT IN THESE TERMS OR ADDITIONAL TERMS, NEITHER SURFSENSE
NOR ITS SUPPLIERS OR DISTRIBUTORS MAKE ANY SPECIFIC PROMISES ABOUT THE SERVICES. FOR
EXAMPLE, WE DON'T MAKE ANY COMMITMENTS ABOUT THE CONTENT WITHIN THE SERVICES, THE
SPECIFIC FUNCTIONS OF THE SERVICES, OR THEIR RELIABILITY, AVAILABILITY, OR ABILITY TO
MEET YOUR NEEDS. WE PROVIDE THE SERVICES "AS IS".
</p>
<p className="mt-4 uppercase font-bold">
SOME JURISDICTIONS PROVIDE FOR CERTAIN WARRANTIES, LIKE THE IMPLIED WARRANTY OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. TO THE EXTENT
PERMITTED BY LAW, WE EXCLUDE ALL WARRANTIES.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">9. Indemnification</h2>
<p>
You agree to defend, indemnify, and hold harmless SurfSense, its affiliates, and their respective officers, directors,
employees, and agents from and against any claims, liabilities, damages, judgments, awards, losses, costs, expenses, or
fees (including reasonable attorneys' fees) arising out of or relating to your violation of these Terms or your use of
the Services, including, but not limited to, any use of the Services' content, services, and products other than as
expressly authorized in these Terms.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">8. Liability for our Services</h2>
<p className="uppercase font-bold">
WHEN PERMITTED BY LAW, SURFSENSE, AND SURFSENSE'S SUPPLIERS AND DISTRIBUTORS, WILL NOT
BE RESPONSIBLE FOR LOST PROFITS, REVENUES, OR DATA, FINANCIAL LOSSES OR INDIRECT,
SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES.
</p>
<p className="mt-4 uppercase font-bold">
TO THE EXTENT PERMITTED BY LAW, THE TOTAL LIABILITY OF SURFSENSE, AND ITS SUPPLIERS AND
DISTRIBUTORS, FOR ANY CLAIMS UNDER THESE TERMS, INCLUDING FOR ANY IMPLIED WARRANTIES, IS
LIMITED TO THE AMOUNT YOU PAID US TO USE THE SERVICES (OR, IF WE CHOOSE, TO SUPPLYING
YOU THE SERVICES AGAIN).
</p>
<p className="mt-4 uppercase font-bold">
IN ALL CASES, SURFSENSE, AND ITS SUPPLIERS AND DISTRIBUTORS, WILL NOT BE LIABLE FOR ANY
LOSS OR DAMAGE THAT IS NOT REASONABLY FORESEEABLE.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">10. Dispute Resolution</h2>
<p>
Any dispute arising out of or relating to these Terms, including the validity, interpretation, breach, or termination
thereof, shall be resolved by arbitration in accordance with the rules of the arbitration authority in the jurisdiction
where SurfSense operates. The arbitration shall be conducted by one arbitrator, in the English language, and the
decision of the arbitrator shall be final and binding on the parties.
</p>
<p className="mt-4">
You agree that any dispute resolution proceedings will be conducted only on an individual basis and not in a class,
consolidated, or representative action. If for any reason a claim proceeds in court rather than in arbitration, you
waive any right to a jury trial.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">9. Indemnification</h2>
<p>
You agree to defend, indemnify, and hold harmless SurfSense, its affiliates, and their
respective officers, directors, employees, and agents from and against any claims,
liabilities, damages, judgments, awards, losses, costs, expenses, or fees (including
reasonable attorneys' fees) arising out of or relating to your violation of these Terms
or your use of the Services, including, but not limited to, any use of the Services'
content, services, and products other than as expressly authorized in these Terms.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">11. About these Terms</h2>
<p>
We may modify these terms or any additional terms that apply to a Service to, for example, reflect changes to
the law or changes to our Services. You should look at the terms regularly. If you do not agree to the modified
terms for a Service, you should discontinue your use of that Service.
</p>
<p className="mt-4">
If there is a conflict between these terms and the additional terms, the additional terms will control for that conflict.
These terms control the relationship between SurfSense and you. They do not create any third-party beneficiary rights.
</p>
<p className="mt-4">
If you do not comply with these terms, and we don't take action right away, this doesn't mean that we are giving up
any rights that we may have (such as taking action in the future). If it turns out that a particular term is not
enforceable, this will not affect any other terms.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">10. Dispute Resolution</h2>
<p>
Any dispute arising out of or relating to these Terms, including the validity,
interpretation, breach, or termination thereof, shall be resolved by arbitration in
accordance with the rules of the arbitration authority in the jurisdiction where
SurfSense operates. The arbitration shall be conducted by one arbitrator, in the English
language, and the decision of the arbitrator shall be final and binding on the parties.
</p>
<p className="mt-4">
You agree that any dispute resolution proceedings will be conducted only on an
individual basis and not in a class, consolidated, or representative action. If for any
reason a claim proceeds in court rather than in arbitration, you waive any right to a
jury trial.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">12. Contact Us</h2>
<p>
If you have any questions about these Terms, please contact us at:
</p>
<p className="mt-2">
<strong>Email:</strong> vermarohanfinal@gmail.com
</p>
</section>
</div>
</div>
);
}
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">11. About these Terms</h2>
<p>
We may modify these terms or any additional terms that apply to a Service to, for
example, reflect changes to the law or changes to our Services. You should look at the
terms regularly. If you do not agree to the modified terms for a Service, you should
discontinue your use of that Service.
</p>
<p className="mt-4">
If there is a conflict between these terms and the additional terms, the additional
terms will control for that conflict. These terms control the relationship between
SurfSense and you. They do not create any third-party beneficiary rights.
</p>
<p className="mt-4">
If you do not comply with these terms, and we don't take action right away, this doesn't
mean that we are giving up any rights that we may have (such as taking action in the
future). If it turns out that a particular term is not enforceable, this will not affect
any other terms.
</p>
</section>
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">12. Contact Us</h2>
<p>If you have any questions about these Terms, please contact us at:</p>
<p className="mt-2">
<strong>Email:</strong> vermarohanfinal@gmail.com
</p>
</section>
</div>
</div>
);
}

115
surfsense_web/biome.json Normal file
View file

@ -0,0 +1,115 @@
{
"$schema": "https://biomejs.dev/schemas/2.1.2/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"experimentalScannerIgnores": ["node_modules", ".git", ".next", "dist", "build", "coverage"],
"maxSize": 1048576
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"indentWidth": 2,
"lineWidth": 100,
"lineEnding": "lf",
"formatWithErrors": false
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "warn",
"noArrayIndexKey": "warn"
},
"style": {
"useConst": "error",
"useTemplate": "warn"
},
"correctness": {
"useExhaustiveDependencies": "warn"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "es5",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSameLine": false,
"bracketSpacing": true
},
"linter": {
"enabled": true
},
"assist": {
"enabled": true
}
},
"json": {
"formatter": {
"enabled": true,
"indentStyle": "tab",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"enabled": true
}
},
"css": {
"formatter": {
"enabled": true,
"indentStyle": "tab",
"indentWidth": 2,
"lineWidth": 100,
"quoteStyle": "double"
},
"linter": {
"enabled": true
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"overrides": [
{
"includes": ["*.json", "*.jsonc"],
"json": {
"parser": {
"allowComments": true,
"allowTrailingCommas": false
}
}
},
{
"includes": [".vscode/**/*.json"],
"json": {
"parser": {
"allowComments": true,
"allowTrailingCommas": true
}
}
},
{
"includes": ["**/*.config.*", "**/next.config.*"],
"javascript": {
"formatter": {
"semicolons": "always"
}
}
}
]
}

View file

@ -1,21 +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"
}
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View file

@ -1,106 +1,97 @@
"use client";
import { cn } from "@/lib/utils";
import {
IconBrandDiscord,
IconBrandGithub,
IconBrandLinkedin,
IconBrandTwitter,
IconBrandDiscord,
IconBrandGithub,
IconBrandLinkedin,
IconBrandTwitter,
} from "@tabler/icons-react";
import Link from "next/link";
import React from "react";
import type React from "react";
import { cn } from "@/lib/utils";
export function Footer() {
const pages = [
{
title: "Privacy",
href: "/privacy",
},
{
title: "Terms",
href: "/terms",
},
];
const pages = [
{
title: "Privacy",
href: "/privacy",
},
{
title: "Terms",
href: "/terms",
},
];
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>
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>
<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) => (
<li key={`pages-${page.title}`} className="list-none">
<Link className="transition-colors hover:text-text-neutral-800" href={page.href}>
{page.title}
</Link>
</li>
))}
</ul>
<GridLineHorizontal className="max-w-7xl mx-auto mt-8" />
</div>
<div className="flex sm:flex-row flex-col justify-between mt-8 items-center w-full">
<p className="text-neutral-500 dark:text-neutral-400 mb-8 sm:mb-0">
&copy; SurfSense 2025
</p>
<div className="flex gap-4">
<Link href="https://x.com/mod_setter">
<IconBrandTwitter className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
</Link>
<Link href="https://www.linkedin.com/in/rohan-verma-sde/">
<IconBrandLinkedin className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
</Link>
<Link href="https://github.com/MODSetter">
<IconBrandGithub className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
</Link>
<Link href="https://discord.gg/ejRNvftDp9">
<IconBrandDiscord className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
</Link>
</div>
</div>
</div>
</div>
);
<GridLineHorizontal className="max-w-7xl mx-auto mt-8" />
</div>
<div className="flex sm:flex-row flex-col justify-between mt-8 items-center w-full">
<p className="text-neutral-500 dark:text-neutral-400 mb-8 sm:mb-0">
&copy; SurfSense 2025
</p>
<div className="flex gap-4">
<Link href="https://x.com/mod_setter">
<IconBrandTwitter className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
</Link>
<Link href="https://www.linkedin.com/in/rohan-verma-sde/">
<IconBrandLinkedin className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
</Link>
<Link href="https://github.com/MODSetter">
<IconBrandGithub className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
</Link>
<Link href="https://discord.gg/ejRNvftDp9">
<IconBrandDiscord 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>
);
};
const GridLineHorizontal = ({ className, offset }: { className?: string; offset?: string }) => {
return (
<div
style={
{
"--background": "#ffffff",
"--color": "rgba(0, 0, 0, 0.2)",
"--height": "1px",
"--width": "5px",
"--fade-stop": "90%",
"--offset": offset || "200px", //-100px if you want to keep the line inside
"--color-dark": "rgba(255, 255, 255, 0.2)",
maskComposite: "exclude",
} as React.CSSProperties
}
className={cn(
"w-[calc(100%+var(--offset))] h-[var(--height)]",
"bg-[linear-gradient(to_right,var(--color),var(--color)_50%,transparent_0,transparent)]",
"[background-size:var(--width)_var(--height)]",
"[mask:linear-gradient(to_left,var(--background)_var(--fade-stop),transparent),_linear-gradient(to_right,var(--background)_var(--fade-stop),transparent),_linear-gradient(black,black)]",
"[mask-composite:exclude]",
"z-30",
"dark:bg-[linear-gradient(to_right,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
className
)}
></div>
);
};

View file

@ -1,22 +1,13 @@
"use client";
import Link from "next/link";
import React from "react";
import Image from "next/image";
import Link from "next/link";
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>
);
return (
<Link href="/">
<Image src="/icon-128.png" className={cn(className)} alt="logo" width={128} height={128} />
</Link>
);
};

File diff suppressed because it is too large Load diff

View file

@ -1,290 +1,286 @@
"use client";
import { cn } from "@/lib/utils";
import { IconMenu2, IconX, IconBrandGoogleFilled, IconUser } from "@tabler/icons-react";
import {
motion,
AnimatePresence,
useScroll,
useMotionValueEvent,
} from "framer-motion";
import { IconMenu2, IconUser, IconX } from "@tabler/icons-react";
import { AnimatePresence, motion, useMotionValueEvent, useScroll } from "framer-motion";
import Link from "next/link";
import React, { useRef, useState } from "react";
import { Button } from "./ui/button";
import { useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { Logo } from "./Logo";
import { ThemeTogglerComponent } from "./theme/theme-toggle";
import { Button } from "./ui/button";
interface NavbarProps {
navItems: {
name: string;
link: string;
}[];
visible: boolean;
navItems: {
name: string;
link: string;
}[];
visible: boolean;
}
export const Navbar = () => {
const navItems = [
{
name: "Docs",
link: "/docs",
},
// {
// name: "Product",
// link: "/#product",
// },
// {
// name: "Pricing",
// link: "/#pricing",
// },
];
const navItems = [
{
name: "Docs",
link: "/docs",
},
// {
// 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);
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);
}
});
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>
);
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 [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const handleGoogleLogin = () => {
// Redirect to the login page
window.location.href = '/login';
};
const handleGoogleLogin = () => {
// Redirect to the login page
window.location.href = "/login";
};
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>
<div className="flex items-center gap-4">
<motion.div
className="lg:flex flex-row items-center justify-end space-x-1 text-sm"
animate={{
scale: visible ? 0.9 : 1,
}}
>
{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>
<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 cursor-pointer 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"
>
<IconUser className="h-4 w-4" />
<span>Sign in</span>
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
);
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>
<div className="flex items-center gap-4">
<motion.div
className="lg:flex flex-row items-center justify-end space-x-1 text-sm"
animate={{
scale: visible ? 0.9 : 1,
}}
>
{navItems.map((navItem, idx) => (
<motion.div
key={`nav-item-${navItem.name}`}
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>
<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 cursor-pointer 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"
>
<IconUser className="h-4 w-4" />
<span>Sign in</span>
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
);
};
const MobileNav = ({ navItems, visible }: NavbarProps) => {
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(false);
const handleGoogleLogin = () => {
// Redirect to the login page
window.location.href = "./login";
};
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>
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 cursor-pointer 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"
>
<IconUser className="h-4 w-4" />
<span>Sign in</span>
</Button>
</motion.div>
)}
</AnimatePresence>
</motion.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 }) => (
<Link
key={`link-${navItem.name}`}
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 cursor-pointer 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"
>
<IconUser className="h-4 w-4" />
<span>Sign in</span>
</Button>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
};

View file

@ -1,55 +1,55 @@
'use client';
"use client";
import { useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
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
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'
redirectPath = "/",
tokenParamName = "token",
storageKey = "surfsense_bearer_token",
}: TokenHandlerProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
// Only run on client-side
if (typeof window === 'undefined') return;
useEffect(() => {
// Only run on client-side
if (typeof window === "undefined") return;
// Get token from URL parameters
const token = searchParams.get(tokenParamName);
// 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]);
if (token) {
try {
// Store token in localStorage
localStorage.setItem(storageKey, token);
// console.log(`Token stored in localStorage with key: ${storageKey}`);
return (
<div className="flex items-center justify-center min-h-[200px]">
<p className="text-gray-500">Processing authentication...</p>
</div>
);
// 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;
export default TokenHandler;

View file

@ -1,101 +1,80 @@
"use client"
"use client";
import { BadgeCheck, LogOut, Settings } from "lucide-react";
import { useRouter } from "next/navigation";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
BadgeCheck,
ChevronsUpDown,
LogOut,
Settings,
} 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 { Button } from "@/components/ui/button"
import { useRouter, useParams } from "next/navigation"
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function UserDropdown({
user,
user,
}: {
user: {
name: string
email: string
avatar: string
}
user: {
name: string;
email: string;
avatar: string;
};
}) {
const router = useRouter()
const router = useRouter();
const handleLogout = () => {
try {
if (typeof window !== 'undefined') {
localStorage.removeItem('surfsense_bearer_token');
router.push('/');
}
} catch (error) {
console.error('Error during logout:', error);
// Optionally, provide user feedback
if (typeof window !== 'undefined') {
alert('Logout failed. Please try again.');
router.push('/');
}
}
};
const handleLogout = () => {
try {
if (typeof window !== "undefined") {
localStorage.removeItem("surfsense_bearer_token");
router.push("/");
}
} catch (error) {
console.error("Error during logout:", error);
// Optionally, provide user feedback
if (typeof window !== "undefined") {
alert("Logout failed. Please try again.");
router.push("/");
}
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="relative h-10 w-10 rounded-full"
>
<Avatar className="h-8 w-8">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback>{user.name.charAt(0)?.toUpperCase() || '?'}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-56"
align="end"
forceMount
>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{user.name}</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => router.push(`/dashboard/api-key`)}>
<BadgeCheck className="mr-2 h-4 w-4" />
API Key
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push(`/settings`)}>
<Settings className="mr-2 h-4 w-4" />
Settings
</DropdownMenuItem>
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
<Avatar className="h-8 w-8">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback>{user.name.charAt(0)?.toUpperCase() || "?"}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{user.name}</p>
<p className="text-xs leading-none text-muted-foreground">{user.email}</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => router.push(`/dashboard/api-key`)}>
<BadgeCheck className="mr-2 h-4 w-4" />
API Key
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push(`/settings`)}>
<Settings className="mr-2 h-4 w-4" />
Settings
</DropdownMenuItem>
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -1,160 +1,152 @@
"use client";
import { cn } from "@/lib/utils";
import { Manrope } from "next/font/google";
import React, {
useRef,
useEffect,
useReducer,
useMemo
} from "react";
import { RoughNotation, RoughNotationGroup } from "react-rough-notation";
import { useInView } from "framer-motion";
import { Manrope } from "next/font/google";
import { useEffect, useMemo, useReducer, useRef } from "react";
import { RoughNotation, RoughNotationGroup } from "react-rough-notation";
import { useSidebar } from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
// Font configuration - could be moved to a global font config file
const manrope = Manrope({
subsets: ["latin"],
weight: ["400", "700"],
display: "swap", // Optimize font loading
variable: "--font-manrope"
const manrope = Manrope({
subsets: ["latin"],
weight: ["400", "700"],
display: "swap", // Optimize font loading
variable: "--font-manrope",
});
// Constants for timing - makes it easier to adjust and more maintainable
const TIMING = {
SIDEBAR_TRANSITION: 300, // Wait for sidebar transition + buffer
LAYOUT_SETTLE: 100, // Small delay to ensure layout is fully settled
SIDEBAR_TRANSITION: 300, // Wait for sidebar transition + buffer
LAYOUT_SETTLE: 100, // Small delay to ensure layout is fully settled
} as const;
// Animation configuration
const ANIMATION_CONFIG = {
HIGHLIGHT: {
type: "highlight" as const,
animationDuration: 2000,
iterations: 3,
color: "#3b82f680",
multiline: true,
},
UNDERLINE: {
type: "underline" as const,
animationDuration: 2000,
iterations: 3,
color: "#10b981",
},
HIGHLIGHT: {
type: "highlight" as const,
animationDuration: 2000,
iterations: 3,
color: "#3b82f680",
multiline: true,
},
UNDERLINE: {
type: "underline" as const,
animationDuration: 2000,
iterations: 3,
color: "#10b981",
},
} as const;
// State management with useReducer for better organization
interface HighlightState {
shouldShowHighlight: boolean;
layoutStable: boolean;
shouldShowHighlight: boolean;
layoutStable: boolean;
}
type HighlightAction =
| { type: "SIDEBAR_CHANGED" }
| { type: "LAYOUT_STABILIZED" }
| { type: "SHOW_HIGHLIGHT" }
| { type: "HIDE_HIGHLIGHT" };
type HighlightAction =
| { type: "SIDEBAR_CHANGED" }
| { type: "LAYOUT_STABILIZED" }
| { type: "SHOW_HIGHLIGHT" }
| { type: "HIDE_HIGHLIGHT" };
const highlightReducer = (
state: HighlightState,
action: HighlightAction
): HighlightState => {
switch (action.type) {
case "SIDEBAR_CHANGED":
return {
shouldShowHighlight: false,
layoutStable: false,
};
case "LAYOUT_STABILIZED":
return {
...state,
layoutStable: true,
};
case "SHOW_HIGHLIGHT":
return {
...state,
shouldShowHighlight: true,
};
case "HIDE_HIGHLIGHT":
return {
...state,
shouldShowHighlight: false,
};
default:
return state;
}
const highlightReducer = (state: HighlightState, action: HighlightAction): HighlightState => {
switch (action.type) {
case "SIDEBAR_CHANGED":
return {
shouldShowHighlight: false,
layoutStable: false,
};
case "LAYOUT_STABILIZED":
return {
...state,
layoutStable: true,
};
case "SHOW_HIGHLIGHT":
return {
...state,
shouldShowHighlight: true,
};
case "HIDE_HIGHLIGHT":
return {
...state,
shouldShowHighlight: false,
};
default:
return state;
}
};
const initialState: HighlightState = {
shouldShowHighlight: false,
layoutStable: true,
shouldShowHighlight: false,
layoutStable: true,
};
export function AnimatedEmptyState() {
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref);
const { state: sidebarState } = useSidebar();
const [{ shouldShowHighlight, layoutStable }, dispatch] = useReducer(
highlightReducer,
initialState
);
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref);
const { state: sidebarState } = useSidebar();
const [{ shouldShowHighlight, layoutStable }, dispatch] = useReducer(
highlightReducer,
initialState
);
// Memoize class names to prevent unnecessary recalculations
const headingClassName = useMemo(() => cn(
"text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight text-neutral-900 dark:text-neutral-50 mb-6",
manrope.className,
), []);
// Memoize class names to prevent unnecessary recalculations
const headingClassName = useMemo(
() =>
cn(
"text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight text-neutral-900 dark:text-neutral-50 mb-6",
manrope.className
),
[]
);
const paragraphClassName = useMemo(() =>
"text-lg sm:text-xl text-neutral-600 dark:text-neutral-300 mb-8 max-w-2xl mx-auto",
[]);
const paragraphClassName = useMemo(
() => "text-lg sm:text-xl text-neutral-600 dark:text-neutral-300 mb-8 max-w-2xl mx-auto",
[]
);
// Handle sidebar state changes
useEffect(() => {
dispatch({ type: "SIDEBAR_CHANGED" });
// Handle sidebar state changes
useEffect(() => {
dispatch({ type: "SIDEBAR_CHANGED" });
const stabilizeTimer = setTimeout(() => {
dispatch({ type: "LAYOUT_STABILIZED" });
}, TIMING.SIDEBAR_TRANSITION);
const stabilizeTimer = setTimeout(() => {
dispatch({ type: "LAYOUT_STABILIZED" });
}, TIMING.SIDEBAR_TRANSITION);
return () => clearTimeout(stabilizeTimer);
}, [sidebarState]);
return () => clearTimeout(stabilizeTimer);
}, []);
// Handle highlight visibility based on layout stability and viewport visibility
useEffect(() => {
if (!layoutStable || !isInView) {
dispatch({ type: "HIDE_HIGHLIGHT" });
return;
}
// Handle highlight visibility based on layout stability and viewport visibility
useEffect(() => {
if (!layoutStable || !isInView) {
dispatch({ type: "HIDE_HIGHLIGHT" });
return;
}
const showTimer = setTimeout(() => {
dispatch({ type: "SHOW_HIGHLIGHT" });
}, TIMING.LAYOUT_SETTLE);
const showTimer = setTimeout(() => {
dispatch({ type: "SHOW_HIGHLIGHT" });
}, TIMING.LAYOUT_SETTLE);
return () => clearTimeout(showTimer);
}, [layoutStable, isInView]);
return () => clearTimeout(showTimer);
}, [layoutStable, isInView]);
return (
<div
ref={ref}
className="flex-1 flex items-center justify-center w-full min-h-[400px]"
>
<div className="max-w-4xl mx-auto px-4 py-10 text-center">
<RoughNotationGroup show={shouldShowHighlight}>
<h1 className={headingClassName}>
<RoughNotation {...ANIMATION_CONFIG.HIGHLIGHT}>
<span>SurfSense</span>
</RoughNotation>
</h1>
return (
<div ref={ref} className="flex-1 flex items-center justify-center w-full min-h-[400px]">
<div className="max-w-4xl mx-auto px-4 py-10 text-center">
<RoughNotationGroup show={shouldShowHighlight}>
<h1 className={headingClassName}>
<RoughNotation {...ANIMATION_CONFIG.HIGHLIGHT}>
<span>SurfSense</span>
</RoughNotation>
</h1>
<p className={paragraphClassName}>
<RoughNotation {...ANIMATION_CONFIG.UNDERLINE}>
Let's Start Surfing
</RoughNotation>{" "}
through your knowledge base.
</p>
</RoughNotationGroup>
</div>
</div>
);
<p className={paragraphClassName}>
<RoughNotation {...ANIMATION_CONFIG.UNDERLINE}>Let's Start Surfing</RoughNotation>{" "}
through your knowledge base.
</p>
</RoughNotationGroup>
</div>
</div>
);
}

View file

@ -1,20 +1,14 @@
"use client";
import React from "react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ExternalLink } from "lucide-react";
import type React from "react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
export const CitationDisplay: React.FC<{ index: number; node: any }> = ({
index,
node,
}) => {
export const CitationDisplay: React.FC<{ index: number; node: any }> = ({ index, node }) => {
const truncateText = (text: string, maxLength: number = 200) => {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + "...";
return `${text.substring(0, maxLength)}...`;
};
const handleUrlClick = (e: React.MouseEvent, url: string) => {
@ -33,13 +27,15 @@ export const CitationDisplay: React.FC<{ index: number; node: any }> = ({
<PopoverContent className="w-80 p-4 space-y-3 relative" align="start">
{/* External Link Button - Top Right */}
{node?.url && (
<button
<Button
size="icon"
variant="ghost"
onClick={(e) => handleUrlClick(e, node.url)}
className="absolute top-3 right-3 inline-flex items-center justify-center w-6 h-6 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors"
title="Open in new tab"
>
<ExternalLink size={14} />
</button>
</Button>
)}
{/* Heading */}

View file

@ -1,45 +1,36 @@
"use client";
import { getAnnotationData, type Message, useChatUI } from "@llamaindex/chat-ui";
import { SuggestedQuestions } from "@llamaindex/chat-ui/widgets";
import { getAnnotationData, Message, useChatUI } from "@llamaindex/chat-ui";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
export const ChatFurtherQuestions: React.FC<{ message: Message }> = ({
message,
}) => {
const annotations: string[][] = getAnnotationData(
message,
"FURTHER_QUESTIONS",
);
const { append, requestData } = useChatUI();
export const ChatFurtherQuestions: React.FC<{ message: Message }> = ({ message }) => {
const annotations: string[][] = getAnnotationData(message, "FURTHER_QUESTIONS");
const { append, requestData } = useChatUI();
if (annotations.length !== 1 || annotations[0].length === 0) {
return <></>;
}
if (annotations.length !== 1 || annotations[0].length === 0) {
return null;
}
return (
<Accordion
type="single"
collapsible
className="w-full border rounded-md bg-card shadow-sm"
>
<AccordionItem value="suggested-questions" className="border-0">
<AccordionTrigger className="px-4 py-3 text-sm font-medium text-foreground transition-colors">
Further Suggested Questions
</AccordionTrigger>
<AccordionContent className="px-4 pb-4 pt-0">
<SuggestedQuestions
questions={annotations[0]}
append={append}
requestData={requestData}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
);
return (
<Accordion type="single" collapsible className="w-full border rounded-md bg-card shadow-sm">
<AccordionItem value="suggested-questions" className="border-0">
<AccordionTrigger className="px-4 py-3 text-sm font-medium text-foreground transition-colors">
Further Suggested Questions
</AccordionTrigger>
<AccordionContent className="px-4 pb-4 pt-0">
<SuggestedQuestions
questions={annotations[0]}
append={append}
requestData={requestData}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
);
};

View file

@ -1,15 +1,24 @@
"use client";
import { ChatInput } from "@llamaindex/chat-ui";
import { FolderOpen, Check, Zap, Brain } from "lucide-react";
import { Brain, Check, FolderOpen, Zap } from "lucide-react";
import { useParams } from "next/navigation";
import React, { Suspense, useCallback, useState } from "react";
import type { ResearchMode } from "@/components/chat";
import {
ConnectorButton as ConnectorButtonComponent,
getConnectorIcon,
} from "@/components/chat/ConnectorComponents";
import { DocumentsDataTable } from "@/components/chat/DocumentsDataTable";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
DialogTrigger,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
@ -18,19 +27,9 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Suspense, useState, useCallback } from "react";
import { useParams } from "next/navigation";
import { useDocuments, Document } from "@/hooks/use-documents";
import { DocumentsDataTable } from "@/components/chat/DocumentsDataTable";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
import {
getConnectorIcon,
ConnectorButton as ConnectorButtonComponent,
} from "@/components/chat/ConnectorComponents";
import { ResearchMode } from "@/components/chat";
import { type Document, useDocuments } from "@/hooks/use-documents";
import { useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
import React from "react";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
const DocumentSelector = React.memo(
({
@ -45,7 +44,7 @@ const DocumentSelector = React.memo(
const { documents, loading, isLoaded, fetchDocuments } = useDocuments(
Number(search_space_id),
true,
true
);
const handleOpenChange = useCallback(
@ -55,24 +54,21 @@ const DocumentSelector = React.memo(
fetchDocuments();
}
},
[fetchDocuments, isLoaded],
[fetchDocuments, isLoaded]
);
const handleSelectionChange = useCallback(
(documents: Document[]) => {
onSelectionChange?.(documents);
},
[onSelectionChange],
[onSelectionChange]
);
const handleDone = useCallback(() => {
setIsOpen(false);
}, []);
const selectedCount = React.useMemo(
() => selectedDocuments.length,
[selectedDocuments.length],
);
const selectedCount = React.useMemo(() => selectedDocuments.length, [selectedDocuments.length]);
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
@ -90,9 +86,7 @@ const DocumentSelector = React.memo(
<DialogContent className="max-w-[95vw] md:max-w-5xl h-[90vh] md:h-[85vh] p-0 flex flex-col">
<div className="flex flex-col h-full">
<div className="px-4 md:px-6 py-4 border-b flex-shrink-0">
<DialogTitle className="text-lg md:text-xl">
Select Documents
</DialogTitle>
<DialogTitle className="text-lg md:text-xl">Select Documents</DialogTitle>
<DialogDescription className="mt-1 text-sm">
Choose documents to include in your research context
</DialogDescription>
@ -103,9 +97,7 @@ const DocumentSelector = React.memo(
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full mx-auto" />
<p className="text-sm text-muted-foreground">
Loading documents...
</p>
<p className="text-sm text-muted-foreground">Loading documents...</p>
</div>
</div>
) : isLoaded ? (
@ -121,7 +113,7 @@ const DocumentSelector = React.memo(
</DialogContent>
</Dialog>
);
},
}
);
DocumentSelector.displayName = "DocumentSelector";
@ -146,7 +138,7 @@ const ConnectorSelector = React.memo(
fetchConnectors();
}
},
[fetchConnectors, isLoaded],
[fetchConnectors, isLoaded]
);
const handleConnectorToggle = useCallback(
@ -157,7 +149,7 @@ const ConnectorSelector = React.memo(
: [...selectedConnectors, connectorType];
onSelectionChange?.(newSelection);
},
[selectedConnectors, onSelectionChange],
[selectedConnectors, onSelectionChange]
);
const handleSelectAll = useCallback(() => {
@ -195,26 +187,17 @@ const ConnectorSelector = React.memo(
const isSelected = selectedConnectors.includes(connector.type);
return (
<div
<Button
key={connector.id}
className={`flex items-center gap-2 p-2 rounded-md border cursor-pointer transition-colors ${
isSelected
? "border-primary bg-primary/10"
: "border-border hover:border-primary/50 hover:bg-muted"
}`}
className={`flex items-center gap-2 p-2 rounded-md border cursor-pointer transition-colors`}
onClick={() => handleConnectorToggle(connector.type)}
role="checkbox"
aria-checked={isSelected}
tabIndex={0}
variant={isSelected ? "default" : "outline"}
size="sm"
type="button"
>
<div className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded-full bg-muted">
{getConnectorIcon(connector.type)}
</div>
<span className="flex-1 text-sm font-medium">
{connector.name}
</span>
{isSelected && <Check className="h-4 w-4 text-primary" />}
</div>
{getConnectorIcon(connector.type)}
<span className="flex-1 text-sm font-medium">{connector.name}</span>
</Button>
);
})
)}
@ -231,7 +214,7 @@ const ConnectorSelector = React.memo(
</DialogContent>
</Dialog>
);
},
}
);
ConnectorSelector.displayName = "ConnectorSelector";
@ -254,9 +237,7 @@ const SearchModeSelector = React.memo(
return (
<div className="flex items-center gap-1 sm:gap-2">
<span className="text-xs text-muted-foreground hidden sm:block">
Scope:
</span>
<span className="text-xs text-muted-foreground hidden sm:block">Scope:</span>
<div className="flex rounded-md border border-border overflow-hidden">
<Button
variant={searchMode === "DOCUMENTS" ? "default" : "ghost"}
@ -278,7 +259,7 @@ const SearchModeSelector = React.memo(
</div>
</div>
);
},
}
);
SearchModeSelector.displayName = "SearchModeSelector";
@ -295,7 +276,7 @@ const ResearchModeSelector = React.memo(
(value: string) => {
onResearchModeChange?.(value as ResearchMode);
},
[onResearchModeChange],
[onResearchModeChange]
);
// Memoize mode options to prevent recreation
@ -318,14 +299,12 @@ const ResearchModeSelector = React.memo(
shortLabel: "Deeper",
},
],
[],
[]
);
return (
<div className="flex items-center gap-1 sm:gap-2">
<span className="text-xs text-muted-foreground hidden sm:block">
Mode:
</span>
<span className="text-xs text-muted-foreground hidden sm:block">Mode:</span>
<Select value={researchMode} onValueChange={handleValueChange}>
<SelectTrigger className="w-auto min-w-[80px] sm:min-w-[120px] h-8 text-xs border-border bg-background hover:bg-muted/50 transition-colors duration-200 focus:ring-2 focus:ring-primary/20">
<SelectValue placeholder="Mode" className="text-xs" />
@ -348,27 +327,21 @@ const ResearchModeSelector = React.memo(
</Select>
</div>
);
},
}
);
ResearchModeSelector.displayName = "ResearchModeSelector";
const LLMSelector = React.memo(() => {
const { llmConfigs, loading: llmLoading, error } = useLLMConfigs();
const {
preferences,
updatePreferences,
loading: preferencesLoading,
} = useLLMPreferences();
const { preferences, updatePreferences, loading: preferencesLoading } = useLLMPreferences();
const isLoading = llmLoading || preferencesLoading;
// Memoize the selected config to avoid repeated lookups
const selectedConfig = React.useMemo(() => {
if (!preferences.fast_llm_id || !llmConfigs.length) return null;
return (
llmConfigs.find((config) => config.id === preferences.fast_llm_id) || null
);
return llmConfigs.find((config) => config.id === preferences.fast_llm_id) || null;
}, [preferences.fast_llm_id, llmConfigs]);
// Memoize the display value for the trigger
@ -390,7 +363,7 @@ const LLMSelector = React.memo(() => {
const llmId = value ? parseInt(value, 10) : undefined;
updatePreferences({ fast_llm_id: llmId });
},
[updatePreferences],
[updatePreferences]
);
// Loading skeleton
@ -432,9 +405,7 @@ const LLMSelector = React.memo(() => {
<div className="flex items-center gap-2 min-w-0">
<Zap className="h-3 w-3 text-primary flex-shrink-0" />
<SelectValue placeholder="Fast LLM" className="text-xs">
{displayValue || (
<span className="text-muted-foreground">Select LLM</span>
)}
{displayValue || <span className="text-muted-foreground">Select LLM</span>}
</SelectValue>
</div>
</SelectTrigger>
@ -452,9 +423,7 @@ const LLMSelector = React.memo(() => {
<div className="mx-auto w-12 h-12 rounded-full bg-muted flex items-center justify-center mb-3">
<Brain className="h-5 w-5 text-muted-foreground" />
</div>
<h4 className="text-sm font-medium mb-1">
No LLM configurations
</h4>
<h4 className="text-sm font-medium mb-1">No LLM configurations</h4>
<p className="text-xs text-muted-foreground mb-3">
Configure AI models to get started
</p>
@ -482,13 +451,8 @@ const LLMSelector = React.memo(() => {
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm truncate">
{config.name}
</span>
<Badge
variant="outline"
className="text-xs px-1.5 py-0.5 flex-shrink-0"
>
<span className="font-medium text-sm truncate">{config.name}</span>
<Badge variant="outline" className="text-xs px-1.5 py-0.5 flex-shrink-0">
{config.provider}
</Badge>
</div>
@ -537,10 +501,8 @@ const CustomChatInputOptions = React.memo(
}) => {
// Memoize the loading fallback to prevent recreation
const loadingFallback = React.useMemo(
() => (
<div className="h-8 min-w-[100px] animate-pulse bg-muted rounded-md" />
),
[],
() => <div className="h-8 min-w-[100px] animate-pulse bg-muted rounded-md" />,
[]
);
return (
@ -557,10 +519,7 @@ const CustomChatInputOptions = React.memo(
selectedConnectors={selectedConnectors}
/>
</Suspense>
<SearchModeSelector
searchMode={searchMode}
onSearchModeChange={onSearchModeChange}
/>
<SearchModeSelector searchMode={searchMode} onSearchModeChange={onSearchModeChange} />
<ResearchModeSelector
researchMode={researchMode}
onResearchModeChange={onResearchModeChange}
@ -568,7 +527,7 @@ const CustomChatInputOptions = React.memo(
<LLMSelector />
</div>
);
},
}
);
CustomChatInputOptions.displayName = "CustomChatInputOptions";
@ -611,7 +570,7 @@ export const ChatInputUI = React.memo(
/>
</ChatInput>
);
},
}
);
ChatInputUI.displayName = "ChatInputUI";

View file

@ -1,14 +1,10 @@
"use client";
import React from "react";
import {
ChatSection as LlamaIndexChatSection,
ChatHandler,
} from "@llamaindex/chat-ui";
import { Document } from "@/hooks/use-documents";
import { type ChatHandler, ChatSection as LlamaIndexChatSection } from "@llamaindex/chat-ui";
import type { ResearchMode } from "@/components/chat";
import { ChatInputUI } from "@/components/chat/ChatInputGroup";
import { ResearchMode } from "@/components/chat";
import { ChatMessagesUI } from "@/components/chat/ChatMessages";
import type { Document } from "@/hooks/use-documents";
interface ChatInterfaceProps {
handler: ChatHandler;

View file

@ -1,87 +1,73 @@
"use client";
import React from "react";
import {
ChatMessage as LlamaIndexChatMessage,
ChatMessages as LlamaIndexChatMessages,
Message,
useChatUI,
ChatMessage as LlamaIndexChatMessage,
ChatMessages as LlamaIndexChatMessages,
type Message,
useChatUI,
} from "@llamaindex/chat-ui";
import TerminalDisplay from "@/components/chat/ChatTerminal";
import ChatSourcesDisplay from "@/components/chat/ChatSources";
import { useEffect, useRef } from "react";
import { AnimatedEmptyState } from "@/components/chat/AnimatedEmptyState";
import { CitationDisplay } from "@/components/chat/ChatCitation";
import { ChatFurtherQuestions } from "@/components/chat/ChatFurtherQuestions";
import { AnimatedEmptyState } from "@/components/chat/AnimatedEmptyState";
import ChatSourcesDisplay from "@/components/chat/ChatSources";
import TerminalDisplay from "@/components/chat/ChatTerminal";
import { languageRenderers } from "@/components/chat/CodeBlock";
export function ChatMessagesUI() {
const { messages } = useChatUI();
const { messages } = useChatUI();
return (
<LlamaIndexChatMessages className="flex-1">
<LlamaIndexChatMessages.Empty>
<AnimatedEmptyState />
</LlamaIndexChatMessages.Empty>
<LlamaIndexChatMessages.List className="p-4">
{messages.map((message, index) => (
<ChatMessageUI
key={`Message-${index}`}
message={message}
isLast={index === messages.length - 1}
/>
))}
</LlamaIndexChatMessages.List>
<LlamaIndexChatMessages.Loading />
</LlamaIndexChatMessages>
);
return (
<LlamaIndexChatMessages className="flex-1">
<LlamaIndexChatMessages.Empty>
<AnimatedEmptyState />
</LlamaIndexChatMessages.Empty>
<LlamaIndexChatMessages.List className="p-4">
{messages.map((message, index) => (
<ChatMessageUI
key={`Message-${index}`}
message={message}
isLast={index === messages.length - 1}
/>
))}
</LlamaIndexChatMessages.List>
<LlamaIndexChatMessages.Loading />
</LlamaIndexChatMessages>
);
}
function ChatMessageUI({
message,
isLast,
}: {
message: Message;
isLast: boolean;
}) {
const bottomRef = React.useRef<HTMLDivElement>(null);
function ChatMessageUI({ message, isLast }: { message: Message; isLast: boolean }) {
const bottomRef = useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (isLast && bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [message]);
useEffect(() => {
if (isLast && bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [isLast]);
return (
<LlamaIndexChatMessage
message={message}
isLast={isLast}
className="flex flex-col "
>
{message.role === "assistant" ? (
<div className="flex-1 flex flex-col space-y-4">
<TerminalDisplay message={message} open={isLast} />
<ChatSourcesDisplay message={message} />
<LlamaIndexChatMessage.Content className="flex-1">
<LlamaIndexChatMessage.Content.Markdown
citationComponent={CitationDisplay}
languageRenderers={languageRenderers}
/>
</LlamaIndexChatMessage.Content>
<div ref={bottomRef} />
<div className="flex flex-row justify-end gap-2">
{isLast && <ChatFurtherQuestions message={message} />}
<LlamaIndexChatMessage.Actions className="flex-1 flex-col" />
</div>
</div>
) : (
<LlamaIndexChatMessage.Content className="flex-1">
<LlamaIndexChatMessage.Content.Markdown
languageRenderers={languageRenderers}
/>
</LlamaIndexChatMessage.Content>
)}
</LlamaIndexChatMessage>
);
return (
<LlamaIndexChatMessage message={message} isLast={isLast} className="flex flex-col ">
{message.role === "assistant" ? (
<div className="flex-1 flex flex-col space-y-4">
<TerminalDisplay message={message} open={isLast} />
<ChatSourcesDisplay message={message} />
<LlamaIndexChatMessage.Content className="flex-1">
<LlamaIndexChatMessage.Content.Markdown
citationComponent={CitationDisplay}
languageRenderers={languageRenderers}
/>
</LlamaIndexChatMessage.Content>
<div ref={bottomRef} />
<div className="flex flex-row justify-end gap-2">
{isLast && <ChatFurtherQuestions message={message} />}
<LlamaIndexChatMessage.Actions className="flex-1 flex-col" />
</div>
</div>
) : (
<LlamaIndexChatMessage.Content className="flex-1">
<LlamaIndexChatMessage.Content.Markdown languageRenderers={languageRenderers} />
</LlamaIndexChatMessage.Content>
)}
</LlamaIndexChatMessage>
);
}

View file

@ -1,8 +1,12 @@
"use client";
import { getAnnotationData, type Message } from "@llamaindex/chat-ui";
import { IconBrandGithub } from "@tabler/icons-react";
import { ExternalLink, FileText, Globe } from "lucide-react";
import { useState } from "react";
import { getAnnotationData, Message } from "@llamaindex/chat-ui";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
@ -11,16 +15,6 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ExternalLink, FileText, Globe } from "lucide-react";
import { IconBrandGithub } from "@tabler/icons-react";
interface Source {
id: string;
@ -50,10 +44,6 @@ interface SourceNode {
metadata: NodeMetadata;
}
interface NodesResponse {
nodes: SourceNode[];
}
function getSourceIcon(type: string) {
switch (type) {
case "USER_SELECTED_GITHUB_CONNECTOR":
@ -113,12 +103,7 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) {
const allNodes: SourceNode[] = [];
annotations.forEach((item) => {
if (
item &&
typeof item === "object" &&
"nodes" in item &&
Array.isArray(item.nodes)
) {
if (item && typeof item === "object" && "nodes" in item && Array.isArray(item.nodes)) {
allNodes.push(...item.nodes);
}
});
@ -133,7 +118,7 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) {
acc[sourceType].push(node);
return acc;
},
{} as Record<string, SourceNode[]>,
{} as Record<string, SourceNode[]>
);
// Convert grouped nodes to SourceGroup format
@ -159,10 +144,7 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) {
return null;
}
const totalSources = sourceGroups.reduce(
(acc, group) => acc + group.sources.length,
0,
);
const totalSources = sourceGroups.reduce((acc, group) => acc + group.sources.length, 0);
return (
<Dialog open={open} onOpenChange={setOpen}>
@ -176,10 +158,7 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) {
<DialogHeader className="flex-shrink-0">
<DialogTitle>Sources</DialogTitle>
</DialogHeader>
<Tabs
defaultValue={sourceGroups[0]?.type}
className="flex-1 flex flex-col min-h-0"
>
<Tabs defaultValue={sourceGroups[0]?.type} className="flex-1 flex flex-col min-h-0">
<div className="flex-shrink-0 w-full overflow-x-auto">
<TabsList className="flex w-max min-w-full">
{sourceGroups.map((group) => (
@ -189,13 +168,8 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) {
className="flex items-center gap-2 whitespace-nowrap px-3 md:px-4"
>
{getSourceIcon(group.type)}
<span className="truncate max-w-[100px] md:max-w-none">
{group.name}
</span>
<Badge
variant="secondary"
className="ml-1 h-5 text-xs flex-shrink-0"
>
<span className="truncate max-w-[100px] md:max-w-none">{group.name}</span>
<Badge variant="secondary" className="ml-1 h-5 text-xs flex-shrink-0">
{group.sources.length}
</Badge>
</TabsTrigger>
@ -203,11 +177,7 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) {
</TabsList>
</div>
{sourceGroups.map((group) => (
<TabsContent
key={group.type}
value={group.type}
className="flex-1 min-h-0 mt-4"
>
<TabsContent key={group.type} value={group.type} className="flex-1 min-h-0 mt-4">
<div className="h-full overflow-y-auto pr-2">
<div className="space-y-3">
{group.sources.map((source) => (

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