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" echo "Running file quality hooks on changed files against $BASE_REF"
# Run each hook individually on changed files # 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=$? pre-commit run --from-ref $BASE_REF --to-ref HEAD || exit_code=$?
# Exit with the same code as pre-commit # Exit with the same code as pre-commit
@ -118,7 +118,7 @@ jobs:
echo "Running security scans on changed files against $BASE_REF" echo "Running security scans on changed files against $BASE_REF"
# Run only security hooks on changed files # 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=$? pre-commit run --from-ref $BASE_REF --to-ref HEAD || exit_code=$?
# Exit with the same code as pre-commit # Exit with the same code as pre-commit
@ -199,7 +199,89 @@ jobs:
echo "Running Python backend checks on changed files against $BASE_REF" echo "Running Python backend checks on changed files against $BASE_REF"
# Run only ruff hooks on changed Python files # 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=$? pre-commit run --from-ref $BASE_REF --to-ref HEAD || exit_code=$?
# Exit with the same code as pre-commit # Exit with the same code as pre-commit
@ -208,7 +290,7 @@ jobs:
quality-gate: quality-gate:
name: Quality Gate name: Quality Gate
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [file-quality, security-scan, python-backend] needs: [file-quality, security-scan, python-backend, typescript-frontend]
if: always() if: always()
steps: steps:
@ -216,7 +298,8 @@ jobs:
run: | run: |
if [[ "${{ needs.file-quality.result }}" == "failure" || if [[ "${{ needs.file-quality.result }}" == "failure" ||
"${{ needs.security-scan.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" echo "❌ Code quality checks failed"
exit 1 exit 1
else else

View file

@ -60,45 +60,28 @@ repos:
args: ['-f', 'json', '--severity-level', 'high', '--confidence-level', 'high'] args: ['-f', 'json', '--severity-level', 'high', '--confidence-level', 'high']
exclude: ^surfsense_backend/(tests/|test_.*\.py|.*test.*\.py|alembic/) exclude: ^surfsense_backend/(tests/|test_.*\.py|.*test.*\.py|alembic/)
# Frontend/Extension Hooks (TypeScript/JavaScript) # Biome hooks for TypeScript/JavaScript projects
- 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
- repo: local - repo: local
hooks: hooks:
- id: typescript-check-web # Biome check for surfsense_web
name: TypeScript Check (Web) - id: biome-check-web
entry: bash -c 'cd surfsense_web && (command -v pnpm >/dev/null 2>&1 && pnpm build --dry-run || npx next build --dry-run)' name: biome-check-web
entry: bash -c 'cd surfsense_web && npx @biomejs/biome check --diagnostic-level=error .'
language: system language: system
files: ^surfsense_web/.*\.(ts|tsx)$ files: ^surfsense_web/
pass_filenames: false pass_filenames: false
always_run: true
stages: [pre-commit]
- id: typescript-check-extension # Biome check for surfsense_browser_extension
name: TypeScript Check (Browser Extension) - id: biome-check-extension
entry: bash -c 'cd surfsense_browser_extension && npx tsc --noEmit' name: biome-check-extension
entry: bash -c 'cd surfsense_browser_extension && npx @biomejs/biome check --diagnostic-level=error .'
language: system language: system
files: ^surfsense_browser_extension/.*\.(ts|tsx)$ files: ^surfsense_browser_extension/
pass_filenames: false pass_filenames: false
always_run: true
stages: [pre-commit]
# Commit message linting # Commit message linting
- repo: https://github.com/commitizen-tools/commitizen - 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>" chat_history_str += f"<user_message>{message['content']}</user_message>"
processed_messages += 1 processed_messages += 1
elif message["role"] == "assistant": elif message["role"] == "assistant":
# Last annotation type will always be "ANSWER" here chat_history_str += (
answer_annotation = message["annotations"][-1] f"<assistant_message>{message['content']}</assistant_message>"
answer_text = "" )
if answer_annotation["type"] == "ANSWER": processed_messages += 1
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 += "</chat_history>" 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 { Storage } from "@plasmohq/storage";
import type { WebHistory } from "~utils/interfaces" import { getRenderedHtml, initQueues, initWebHistory } from "~utils/commons";
import { Storage } from "@plasmohq/storage" import type { WebHistory } from "~utils/interfaces";
import {getRenderedHtml} from '~utils/commons'
chrome.tabs.onCreated.addListener(async (tab: any) => { chrome.tabs.onCreated.addListener(async (tab: any) => {
try { try {
await initWebHistory(tab.id) await initWebHistory(tab.id);
await initQueues(tab.id) await initQueues(tab.id);
} catch (error) { } catch (error) {
console.log(error) console.log(error);
} }
}) });
chrome.tabs.onUpdated.addListener( chrome.tabs.onUpdated.addListener(async (tabId: number, changeInfo: any, tab: any) => {
async (tabId: number, changeInfo: any, tab: any) => { if (changeInfo.status === "complete" && tab.url) {
if (changeInfo.status === "complete" && tab.url) { const storage = new Storage({ area: "local" });
const storage = new Storage({ area: "local" }) await initWebHistory(tab.id);
await initWebHistory(tab.id) await initQueues(tab.id);
await initQueues(tab.id)
const result = await chrome.scripting.executeScript({ const result = await chrome.scripting.executeScript({
// @ts-ignore // @ts-ignore
target: { tabId: tab.id }, target: { tabId: tab.id },
// @ts-ignore // @ts-ignore
func: getRenderedHtml 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") const urlQueueListObj: any = await storage.get("urlQueueList");
let timeQueueListObj: any = await storage.get("timeQueueList") const timeQueueListObj: any = await storage.get("timeQueueList");
urlQueueListObj.urlQueueList urlQueueListObj.urlQueueList
.find((data: WebHistory) => data.tabsessionId === tabId) .find((data: WebHistory) => data.tabsessionId === tabId)
.urlQueue.push(toPushInTabHistory.url) .urlQueue.push(toPushInTabHistory.url);
timeQueueListObj.timeQueueList timeQueueListObj.timeQueueList
.find((data: WebHistory) => data.tabsessionId === tabId) .find((data: WebHistory) => data.tabsessionId === tabId)
.timeQueue.push(toPushInTabHistory.entryTime) .timeQueue.push(toPushInTabHistory.entryTime);
await storage.set("urlQueueList", { await storage.set("urlQueueList", {
urlQueueList: urlQueueListObj.urlQueueList urlQueueList: urlQueueListObj.urlQueueList,
}) });
await storage.set("timeQueueList", { await storage.set("timeQueueList", {
timeQueueList: timeQueueListObj.timeQueueList timeQueueList: timeQueueListObj.timeQueueList,
}) });
} }
} });
)
chrome.tabs.onRemoved.addListener(async (tabId: number, removeInfo: object) => { chrome.tabs.onRemoved.addListener(async (tabId: number, removeInfo: object) => {
const storage = new Storage({ area: "local" }) const storage = new Storage({ area: "local" });
let urlQueueListObj: any = await storage.get("urlQueueList") const urlQueueListObj: any = await storage.get("urlQueueList");
let timeQueueListObj: any = await storage.get("timeQueueList") const timeQueueListObj: any = await storage.get("timeQueueList");
if (urlQueueListObj.urlQueueList && timeQueueListObj.timeQueueList) { if (urlQueueListObj.urlQueueList && timeQueueListObj.timeQueueList) {
const urlQueueListToSave = urlQueueListObj.urlQueueList.map( const urlQueueListToSave = urlQueueListObj.urlQueueList.map((element: WebHistory) => {
(element: WebHistory) => { if (element.tabsessionId !== tabId) {
if (element.tabsessionId !== tabId) { return element;
return element }
} });
} const timeQueueListSave = timeQueueListObj.timeQueueList.map((element: WebHistory) => {
) if (element.tabsessionId !== tabId) {
const timeQueueListSave = timeQueueListObj.timeQueueList.map( return element;
(element: WebHistory) => { }
if (element.tabsessionId !== tabId) { });
return element await storage.set("urlQueueList", {
} urlQueueList: urlQueueListToSave.filter((item: any) => item),
} });
) await storage.set("timeQueueList", {
await storage.set("urlQueueList", { timeQueueList: timeQueueListSave.filter((item: any) => item),
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 type { PlasmoMessaging } from "@plasmohq/messaging";
import { Storage } from "@plasmohq/storage" import { Storage } from "@plasmohq/storage";
import { import { emptyArr, webhistoryToLangChainDocument } from "~utils/commons";
emptyArr,
webhistoryToLangChainDocument
} from "~utils/commons"
const clearMemory = async () => { const clearMemory = async () => {
try { try {
const storage = new Storage({ area: "local" }) const storage = new Storage({ area: "local" });
let webHistory: any = await storage.get("webhistory") const webHistory: any = await storage.get("webhistory");
let urlQueue: any = await storage.get("urlQueueList") const urlQueue: any = await storage.get("urlQueueList");
let timeQueue: any = await storage.get("timeQueueList") const timeQueue: any = await storage.get("timeQueueList");
if (!webHistory.webhistory) { if (!webHistory.webhistory) {
return return;
} }
//Main Cleanup COde //Main Cleanup COde
chrome.tabs.query({}, async (tabs) => { chrome.tabs.query({}, async (tabs) => {
//Get Active Tabs Ids //Get Active Tabs Ids
// console.log("Event Tabs",tabs) // console.log("Event Tabs",tabs)
let actives = tabs.map((tab) => { let actives = tabs.map((tab) => {
if (tab.id) { if (tab.id) {
return tab.id return tab.id;
} }
}) });
actives = actives.filter((item: any) => item) actives = actives.filter((item: any) => item);
//Only retain which is still active //Only retain which is still active
const newHistory = webHistory.webhistory.map((element: any) => { const newHistory = webHistory.webhistory.map((element: any) => {
//@ts-ignore //@ts-ignore
if (actives.includes(element.tabsessionId)) { if (actives.includes(element.tabsessionId)) {
return element return element;
} }
}) });
const newUrlQueue = urlQueue.urlQueueList.map((element: any) => { const newUrlQueue = urlQueue.urlQueueList.map((element: any) => {
//@ts-ignore //@ts-ignore
if (actives.includes(element.tabsessionId)) { if (actives.includes(element.tabsessionId)) {
return element return element;
} }
}) });
const newTimeQueue = timeQueue.timeQueueList.map((element: any) => { const newTimeQueue = timeQueue.timeQueueList.map((element: any) => {
//@ts-ignore //@ts-ignore
if (actives.includes(element.tabsessionId)) { if (actives.includes(element.tabsessionId)) {
return element return element;
} }
}) });
await storage.set("webhistory", { await storage.set("webhistory", {
webhistory: newHistory.filter((item: any) => item) webhistory: newHistory.filter((item: any) => item),
}) });
await storage.set("urlQueueList", { await storage.set("urlQueueList", {
urlQueueList: newUrlQueue.filter((item: any) => item) urlQueueList: newUrlQueue.filter((item: any) => item),
}) });
await storage.set("timeQueueList", { await storage.set("timeQueueList", {
timeQueueList: newTimeQueue.filter((item: any) => item) timeQueueList: newTimeQueue.filter((item: any) => item),
}) });
}) });
} catch (error) { } catch (error) {
console.log(error) console.log(error);
} }
} };
const handler: PlasmoMessaging.MessageHandler = async (req, res) => { const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
try { try {
const storage = new Storage({ area: "local" }) const storage = new Storage({ area: "local" });
const webhistoryObj: any = await storage.get("webhistory") const webhistoryObj: any = await storage.get("webhistory");
const webhistory = webhistoryObj.webhistory const webhistory = webhistoryObj.webhistory;
if (webhistory) { if (webhistory) {
let toSaveFinally: any[] = [] const toSaveFinally: any[] = [];
let newHistoryAfterCleanup: any[] = [] const newHistoryAfterCleanup: any[] = [];
for (let i = 0; i < webhistory.length; i++) { for (let i = 0; i < webhistory.length; i++) {
const markdownFormat = webhistoryToLangChainDocument( const markdownFormat = webhistoryToLangChainDocument(
webhistory[i].tabsessionId, webhistory[i].tabsessionId,
webhistory[i].tabHistory webhistory[i].tabHistory
) );
toSaveFinally.push(...markdownFormat) toSaveFinally.push(...markdownFormat);
newHistoryAfterCleanup.push({ newHistoryAfterCleanup.push({
tabsessionId: webhistory[i].tabsessionId, tabsessionId: webhistory[i].tabsessionId,
tabHistory: emptyArr tabHistory: emptyArr,
}) });
} }
await storage.set("webhistory",{ webhistory: newHistoryAfterCleanup }); await storage.set("webhistory", { webhistory: newHistoryAfterCleanup });
// Log first item to debug metadata structure // Log first item to debug metadata structure
if (toSaveFinally.length > 0) { if (toSaveFinally.length > 0) {
console.log("First item metadata:", toSaveFinally[0].metadata); console.log("First item metadata:", toSaveFinally[0].metadata);
} }
// Create content array for documents in the format expected by the new API // Create content array for documents in the format expected by the new API
const content = toSaveFinally.map(item => ({ const content = toSaveFinally.map((item) => ({
metadata: { metadata: {
BrowsingSessionId: String(item.metadata.BrowsingSessionId || ""), BrowsingSessionId: String(item.metadata.BrowsingSessionId || ""),
VisitedWebPageURL: String(item.metadata.VisitedWebPageURL || ""), VisitedWebPageURL: String(item.metadata.VisitedWebPageURL || ""),
VisitedWebPageTitle: String(item.metadata.VisitedWebPageTitle || "No Title"), VisitedWebPageTitle: String(item.metadata.VisitedWebPageTitle || "No Title"),
VisitedWebPageDateWithTimeInISOString: String(item.metadata.VisitedWebPageDateWithTimeInISOString || ""), VisitedWebPageDateWithTimeInISOString: String(
VisitedWebPageReffererURL: String(item.metadata.VisitedWebPageReffererURL || ""), item.metadata.VisitedWebPageDateWithTimeInISOString || ""
VisitedWebPageVisitDurationInMilliseconds: String(item.metadata.VisitedWebPageVisitDurationInMilliseconds || "0") ),
}, VisitedWebPageReffererURL: String(item.metadata.VisitedWebPageReffererURL || ""),
pageContent: String(item.pageContent || "") VisitedWebPageVisitDurationInMilliseconds: String(
})); item.metadata.VisitedWebPageVisitDurationInMilliseconds || "0"
),
},
pageContent: String(item.pageContent || ""),
}));
const token = await storage.get("token"); const token = await storage.get("token");
const search_space_id = parseInt(await storage.get("search_space_id"), 10); const search_space_id = parseInt(await storage.get("search_space_id"), 10);
const toSend = { const toSend = {
document_type: "EXTENSION", document_type: "EXTENSION",
content: content, content: content,
search_space_id: search_space_id search_space_id: search_space_id,
} };
console.log("toSend", toSend) console.log("toSend", toSend);
const requestOptions = { const requestOptions = {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": `Bearer ${token}` Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify(toSend) body: JSON.stringify(toSend),
} };
const response = await fetch( const response = await fetch(
`${process.env.PLASMO_PUBLIC_BACKEND_URL}/api/v1/documents/`, `${process.env.PLASMO_PUBLIC_BACKEND_URL}/api/v1/documents/`,
requestOptions requestOptions
) );
const resp = await response.json() const resp = await response.json();
if (resp) { if (resp) {
await clearMemory() await clearMemory();
res.send({ res.send({
message: "Save Job Started" message: "Save Job Started",
}) });
} }
} }
} catch (error) { } catch (error) {
console.log(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 { Storage } from "@plasmohq/storage";
import type { PlasmoMessaging } from "@plasmohq/messaging" import { convertHtmlToMarkdown } from "dom-to-semantic-markdown";
import { DOMParser } from "linkedom";
import type { WebHistory } from "~utils/interfaces" import { getRenderedHtml, webhistoryToLangChainDocument } from "~utils/commons";
import { webhistoryToLangChainDocument, getRenderedHtml } from "~utils/commons" import type { WebHistory } from "~utils/interfaces";
import { convertHtmlToMarkdown } from "dom-to-semantic-markdown"
// @ts-ignore // @ts-ignore
global.Node = { global.Node = {
ELEMENT_NODE: 1, ELEMENT_NODE: 1,
ATTRIBUTE_NODE: 2, ATTRIBUTE_NODE: 2,
TEXT_NODE: 3, TEXT_NODE: 3,
CDATA_SECTION_NODE: 4, CDATA_SECTION_NODE: 4,
PROCESSING_INSTRUCTION_NODE: 7, PROCESSING_INSTRUCTION_NODE: 7,
COMMENT_NODE: 8, COMMENT_NODE: 8,
DOCUMENT_NODE: 9, DOCUMENT_NODE: 9,
DOCUMENT_TYPE_NODE: 10, DOCUMENT_TYPE_NODE: 10,
DOCUMENT_FRAGMENT_NODE: 11, DOCUMENT_FRAGMENT_NODE: 11,
}; };
const handler: PlasmoMessaging.MessageHandler = async (req, res) => { const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
try { try {
chrome.tabs.query( chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
{ active: true, currentWindow: true }, const storage = new Storage({ area: "local" });
async function (tabs) { const tab = tabs[0];
const storage = new Storage({ area: "local" }) if (tab.id) {
const tab = tabs[0] const tabId: number = tab.id;
if (tab.id) { console.log("tabs", tabs);
const tabId: number = tab.id const result = await chrome.scripting.executeScript({
console.log("tabs", tabs) // @ts-ignore
const result = await chrome.scripting.executeScript({ target: { tabId: tab.id },
// @ts-ignore // @ts-ignore
target: { tabId: tab.id }, func: getRenderedHtml,
// @ts-ignore // world: "MAIN"
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.pageContentMarkdown = convertHtmlToMarkdown(
toPushInTabHistory.renderedHtml, toPushInTabHistory.renderedHtml,
{ {
extractMainContent: true, extractMainContent: true,
enableTableColumnTracking: true, enableTableColumnTracking: true,
includeMetaData: false, includeMetaData: false,
overrideDOMParser: new DOMParser() 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 urlQueueListObj: any = await storage.get("urlQueueList");
const timeQueueListObj: any = await storage.get("timeQueueList") const timeQueueListObj: any = await storage.get("timeQueueList");
const isUrlQueueThere = urlQueueListObj.urlQueueList.find( const isUrlQueueThere = urlQueueListObj.urlQueueList.find(
(data: WebHistory) => data.tabsessionId === tabId (data: WebHistory) => data.tabsessionId === tabId
) );
const isTimeQueueThere = timeQueueListObj.timeQueueList.find( const isTimeQueueThere = timeQueueListObj.timeQueueList.find(
(data: WebHistory) => data.tabsessionId === tabId (data: WebHistory) => data.tabsessionId === tabId
) );
toPushInTabHistory.duration = toPushInTabHistory.duration =
toPushInTabHistory.entryTime - toPushInTabHistory.entryTime -
isTimeQueueThere.timeQueue[isTimeQueueThere.timeQueue.length - 1] isTimeQueueThere.timeQueue[isTimeQueueThere.timeQueue.length - 1];
if (isUrlQueueThere.urlQueue.length == 1) { if (isUrlQueueThere.urlQueue.length === 1) {
toPushInTabHistory.reffererUrl = "START" toPushInTabHistory.reffererUrl = "START";
} }
if (isUrlQueueThere.urlQueue.length > 1) { if (isUrlQueueThere.urlQueue.length > 1) {
toPushInTabHistory.reffererUrl = toPushInTabHistory.reffererUrl =
isUrlQueueThere.urlQueue[isUrlQueueThere.urlQueue.length - 2] isUrlQueueThere.urlQueue[isUrlQueueThere.urlQueue.length - 2];
} }
let toSaveFinally: any[] = [] const toSaveFinally: any[] = [];
const markdownFormat = webhistoryToLangChainDocument( const markdownFormat = webhistoryToLangChainDocument(tab.id, [toPushInTabHistory]);
tab.id, toSaveFinally.push(...markdownFormat);
[toPushInTabHistory]
)
toSaveFinally.push(...markdownFormat)
console.log("toSaveFinally", toSaveFinally)
// Log first item to debug metadata structure console.log("toSaveFinally", toSaveFinally);
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 // Log first item to debug metadata structure
// The metadata is already in the correct format in toSaveFinally if (toSaveFinally.length > 0) {
const content = toSaveFinally.map(item => ({ console.log("First item metadata:", toSaveFinally[0].metadata);
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"); // Create content array for documents in the format expected by the new API
const search_space_id = parseInt(await storage.get("search_space_id"), 10); // 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 = { const token = await storage.get("token");
document_type: "EXTENSION", const search_space_id = parseInt(await storage.get("search_space_id"), 10);
content: content,
search_space_id: search_space_id
}
const requestOptions = { const toSend = {
method: "POST", document_type: "EXTENSION",
headers: { content: content,
"Content-Type": "application/json", search_space_id: search_space_id,
"Authorization": `Bearer ${token}` };
},
body: JSON.stringify(toSend)
}
const response = await fetch( const requestOptions = {
`${process.env.PLASMO_PUBLIC_BACKEND_URL}/api/v1/documents/`, method: "POST",
requestOptions headers: {
) "Content-Type": "application/json",
const resp = await response.json() Authorization: `Bearer ${token}`,
if (resp) { },
res.send({ body: JSON.stringify(toSend),
message: "Snapshot Saved Successfully" };
})
}
}
}
)
} catch (error) {
console.log(error)
}
}
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" import type { PlasmoCSConfig } from "plasmo";
export const config: PlasmoCSConfig = {
matches: ["<all_urls>"],
all_frames: true,
world: "MAIN"
}
export const config: PlasmoCSConfig = {
matches: ["<all_urls>"],
all_frames: true,
world: "MAIN",
};

View file

@ -1,10 +1,11 @@
@font-face { @font-face {
font-family: "Fascinate"; font-family: "Fascinate";
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url(data-base64:~assets/Fascinate.woff2) format("woff2"); 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, unicode-range:
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+FEFF, U+FFFD; 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 { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

@ -1,476 +1,478 @@
import React, { useEffect, useState } from "react"; import brain from "data-base64:~assets/brain.png";
import { useNavigate } from "react-router-dom" import icon from "data-base64:~assets/icon.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 { convertHtmlToMarkdown } from "dom-to-semantic-markdown";
import type { WebHistory } from "~utils/interfaces"; import { Check, ChevronsUpDown } from "lucide-react";
import { getRenderedHtml } from "~utils/commons"; import React, { useEffect, useState } from "react";
import Loading from "./Loading"; import { useNavigate } from "react-router-dom";
import brain from "data-base64:~assets/brain.png" import { cn } from "~/lib/utils";
import { Storage } from "@plasmohq/storage" import { Button } from "~/routes/ui/button";
import { sendToBackground } from "@plasmohq/messaging"
import { Check, ChevronsUpDown } from "lucide-react"
import { cn } from "~/lib/utils"
import { Button } from "~/routes/ui/button"
import { import {
Command, Command,
CommandEmpty, CommandEmpty,
CommandGroup, CommandGroup,
CommandInput, CommandInput,
CommandItem, CommandItem,
CommandList, CommandList,
} from "~/routes/ui/command" } from "~/routes/ui/command";
import { import { Popover, PopoverContent, PopoverTrigger } from "~/routes/ui/popover";
Popover, import { Label } from "~routes/ui/label";
PopoverContent,
PopoverTrigger,
} from "~/routes/ui/popover"
import { useToast } from "~routes/ui/use-toast"; import { useToast } from "~routes/ui/use-toast";
import { import { getRenderedHtml } from "~utils/commons";
CircleIcon, import type { WebHistory } from "~utils/interfaces";
CrossCircledIcon, import Loading from "./Loading";
DiscIcon,
ExitIcon,
FileIcon,
ReloadIcon,
ResetIcon,
UploadIcon
} from "@radix-ui/react-icons"
const HomePage = () => { const HomePage = () => {
const { toast } = useToast() const { toast } = useToast();
const navigation = useNavigate() const navigation = useNavigate();
const [noOfWebPages, setNoOfWebPages] = useState<number>(0); const [noOfWebPages, setNoOfWebPages] = useState<number>(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [open, setOpen] = React.useState(false) const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState<string>("") const [value, setValue] = React.useState<string>("");
const [searchspaces, setSearchSpaces] = useState([]) const [searchspaces, setSearchSpaces] = useState([]);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
useEffect(() => { useEffect(() => {
const checkSearchSpaces = async () => { const checkSearchSpaces = async () => {
const storage = new Storage({ area: "local" }) const storage = new Storage({ area: "local" });
const token = await storage.get('token'); const token = await storage.get("token");
try { try {
const response = await fetch( const response = await fetch(
`${process.env.PLASMO_PUBLIC_BACKEND_URL}/api/v1/searchspaces/`, `${process.env.PLASMO_PUBLIC_BACKEND_URL}/api/v1/searchspaces/`,
{ {
headers: { headers: {
'Authorization': `Bearer ${token}` Authorization: `Bearer ${token}`,
} },
} }
); );
if (!response.ok) { if (!response.ok) {
throw new Error("Token verification failed"); throw new Error("Token verification failed");
} else { } else {
const res = await response.json() const res = await response.json();
console.log(res) console.log(res);
setSearchSpaces(res) setSearchSpaces(res);
} }
} catch (error) { } catch (error) {
await storage.remove('token'); await storage.remove("token");
await storage.remove('showShadowDom'); await storage.remove("showShadowDom");
navigation("/login") navigation("/login");
} }
}; };
checkSearchSpaces(); checkSearchSpaces();
setLoading(false); 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(() => { let sum = 0;
async function onLoad() { webhistory.webhistory.forEach((element: any) => {
try { sum = sum + element.tabHistory.length;
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 setNoOfWebPages(sum);
webhistory.webhistory.forEach((element: any) => { }
sum = sum + element.tabHistory.length });
});
setNoOfWebPages(sum) const storage = new Storage({ area: "local" });
} const searchspace = await storage.get("search_space");
}
);
const storage = new Storage({ area: "local" }) if (searchspace) {
const searchspace = await storage.get("search_space"); setValue(searchspace);
}
if(searchspace){ await storage.set("showShadowDom", true);
setValue(searchspace)
}
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) {
if (webhistoryObj.webhistory.length) { let sum = 0;
const webhistory = webhistoryObj.webhistory; webhistory.forEach((element: any) => {
sum = sum + element.tabHistory.length;
});
setNoOfWebPages(sum);
}
} else {
setNoOfWebPages(0);
}
} catch (error) {
console.log(error);
}
}
if (webhistoryObj) { onLoad();
let sum = 0 }, []);
webhistory.forEach((element: any) => {
sum = sum + element.tabHistory.length
});
setNoOfWebPages(sum)
}
} else {
setNoOfWebPages(0)
}
} catch (error) {
console.log(error);
}
}
onLoad() async function clearMem(): Promise<void> {
}, []); try {
const storage = new Storage({ area: "local" });
async function clearMem(): Promise<void> { const webHistory: any = await storage.get("webhistory");
try { const urlQueue: any = await storage.get("urlQueueList");
const storage = new Storage({ area: "local" }) const timeQueue: any = await storage.get("timeQueueList");
let webHistory: any = await storage.get("webhistory");
let urlQueue: any = await storage.get("urlQueueList");
let timeQueue: any = await storage.get("timeQueueList");
if (!webHistory.webhistory) {
return
}
//Main Cleanup COde
chrome.tabs.query({}, async (tabs) => {
//Get Active Tabs Ids
let actives = tabs.map((tab) => {
if (tab.id) {
return tab.id
}
})
actives = actives.filter((item: any) => item)
//Only retain which is still active
const newHistory = webHistory.webhistory.map((element: any) => {
//@ts-ignore
if (actives.includes(element.tabsessionId)) {
return element
}
})
const newUrlQueue = urlQueue.urlQueueList.map((element: any) => {
//@ts-ignore
if (actives.includes(element.tabsessionId)) {
return element
}
})
const newTimeQueue = timeQueue.timeQueueList.map((element: any) => {
//@ts-ignore
if (actives.includes(element.tabsessionId)) {
return element
}
})
await storage.set("webhistory", { webhistory: newHistory.filter((item: any) => item) });
await storage.set("urlQueueList", { urlQueueList: newUrlQueue.filter((item: any) => item) });
await storage.set("timeQueueList", { timeQueueList: newTimeQueue.filter((item: any) => item) });
toast({
title: "History store cleared",
description: "Inactive history sessions have been removed",
variant: "destructive",
})
});
} catch (error) {
console.log(error);
}
}
async function saveCurrSnapShot(): Promise<void> { if (!webHistory.webhistory) {
chrome.tabs.query({ active: true, currentWindow: true }, async function (tabs) { return;
const storage = new Storage({ area: "local" }) }
const tab = tabs[0];
if (tab.id) {
const tabId: number = tab.id
const result = await chrome.scripting.executeScript({
// @ts-ignore
target: { tabId: tab.id },
// @ts-ignore
func: getRenderedHtml,
});
let toPushInTabHistory: any = result[0].result; //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' actives = actives.filter((item: any) => item);
let webhistoryObj: any = await storage.get("webhistory");
const webHistoryOfTabId = webhistoryObj.webhistory.filter( //Only retain which is still active
(data: WebHistory) => { const newHistory = webHistory.webhistory.map((element: any) => {
return data.tabsessionId === tab.id; //@ts-ignore
} if (actives.includes(element.tabsessionId)) {
); return element;
}
});
toPushInTabHistory.pageContentMarkdown = convertHtmlToMarkdown( const newUrlQueue = urlQueue.urlQueueList.map((element: any) => {
toPushInTabHistory.renderedHtml, //@ts-ignore
{ if (actives.includes(element.tabsessionId)) {
extractMainContent: true, return element;
includeMetaData: false, }
enableTableColumnTracking: true });
}
)
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"); async function saveCurrSnapShot(): Promise<void> {
const timeQueueListObj: any = await storage.get("timeQueueList"); 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 toPushInTabHistory: any = result[0].result;
const isTimeQueueThere = timeQueueListObj.timeQueueList.find((data: WebHistory) => data.tabsessionId === tabId)
toPushInTabHistory.duration = toPushInTabHistory.entryTime - isTimeQueueThere.timeQueue[isTimeQueueThere.timeQueue.length - 1] //Updates 'tabhistory'
if (isUrlQueueThere.urlQueue.length == 1) { const webhistoryObj: any = await storage.get("webhistory");
toPushInTabHistory.reffererUrl = 'START'
}
if (isUrlQueueThere.urlQueue.length > 1) {
toPushInTabHistory.reffererUrl = isUrlQueueThere.urlQueue[isUrlQueueThere.urlQueue.length - 2];
}
webHistoryOfTabId[0].tabHistory.push(toPushInTabHistory); const webHistoryOfTabId = webhistoryObj.webhistory.filter((data: WebHistory) => {
return data.tabsessionId === tab.id;
await storage.set("webhistory", webhistoryObj); });
toast({
title: "Snapshot saved",
description: `Captured: ${toPushInTabHistory.title}`,
})
}
}); toPushInTabHistory.pageContentMarkdown = convertHtmlToMarkdown(
} toPushInTabHistory.renderedHtml,
{
extractMainContent: true,
includeMetaData: false,
enableTableColumnTracking: true,
}
);
const saveDatamessage = async () => { delete toPushInTabHistory.renderedHtml;
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); const tabhistory = webHistoryOfTabId[0].tabHistory;
toast({
title: "Save job running",
description: "Saving captured content to SurfSense",
})
try { const urlQueueListObj: any = await storage.get("urlQueueList");
const resp = await sendToBackground({ const timeQueueListObj: any = await storage.get("timeQueueList");
// @ts-ignore
name: "savedata",
})
toast({ const isUrlQueueThere = urlQueueListObj.urlQueueList.find(
title: resp.message, (data: WebHistory) => data.tabsessionId === tabId
}) );
} catch (error) { const isTimeQueueThere = timeQueueListObj.timeQueueList.find(
toast({ (data: WebHistory) => data.tabsessionId === tabId
title: "Error saving data", );
description: "Please try again",
variant: "destructive",
})
} finally {
setIsSaving(false);
}
}
async function logOut(): Promise<void> { toPushInTabHistory.duration =
const storage = new Storage({ area: "local" }) toPushInTabHistory.entryTime -
await storage.remove('token'); isTimeQueueThere.timeQueue[isTimeQueueThere.timeQueue.length - 1];
await storage.remove('showShadowDom'); if (isUrlQueueThere.urlQueue.length === 1) {
navigation("/login") toPushInTabHistory.reffererUrl = "START";
} }
if (isUrlQueueThere.urlQueue.length > 1) {
toPushInTabHistory.reffererUrl =
isUrlQueueThere.urlQueue[isUrlQueueThere.urlQueue.length - 2];
}
if (loading) { webHistoryOfTabId[0].tabHistory.push(toPushInTabHistory);
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"> await storage.set("webhistory", webhistoryObj);
<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"> toast({
<label className="mb-2 block text-sm font-medium text-gray-300"> title: "Snapshot saved",
Search Space description: `Captured: ${toPushInTabHistory.title}`,
</label> });
<Popover open={open} onOpenChange={setOpen}> }
<PopoverTrigger asChild> });
<Button }
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between border-gray-700 bg-gray-900 text-white hover:bg-gray-700"
>
{value
? searchspaces.find((space) => space.name === value)?.name
: "Select Search Space..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full border-gray-700 bg-gray-800/90 p-0 backdrop-blur-sm">
<Command className="bg-transparent">
<CommandInput placeholder="Search spaces..." className="border-gray-700 bg-gray-900 text-gray-200" />
<CommandList>
<CommandEmpty>No search spaces found.</CommandEmpty>
<CommandGroup>
{searchspaces.map((space) => (
<CommandItem
key={space.name}
value={space.name}
onSelect={async (currentValue) => {
const storage = new Storage({ area: "local" })
if (currentValue === value) {
await storage.set("search_space", "");
await storage.set("search_space_id", 0);
} else {
const selectedSpace = searchspaces.find((space) => space.name === currentValue);
await storage.set("search_space", currentValue);
await storage.set("search_space_id", selectedSpace.id);
}
setValue(currentValue === value ? "" : currentValue)
setOpen(false)
}}
className="aria-selected:bg-gray-700"
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === space.name ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex items-center">
<DiscIcon className="mr-2 h-4 w-4 text-teal-400" />
{space.name}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="grid gap-3"> const saveDatamessage = async () => {
<Button if (value === "") {
variant="destructive" toast({
className="group flex w-full items-center justify-center space-x-2 bg-red-500/90 text-white hover:bg-red-600" title: "Select a SearchSpace !",
onClick={() => clearMem()} });
> return;
<CrossCircledIcon className="h-4 w-4 transition-transform group-hover:scale-110" /> }
<span>Clear Inactive History</span>
</Button> const storage = new Storage({ area: "local" });
const search_space_id = await storage.get("search_space_id");
<Button
variant="outline" if (!search_space_id) {
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" toast({
onClick={() => saveCurrSnapShot()} title: "Invalid SearchSpace selected!",
> variant: "destructive",
<FileIcon className="h-4 w-4 transition-transform group-hover:scale-110" /> });
<span>Save Current Page</span> return;
</Button> }
<Button setIsSaving(true);
variant="default" toast({
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" title: "Save job running",
onClick={() => saveDatamessage()} description: "Saving captured content to SurfSense",
disabled={isSaving} });
>
{isSaving ? ( try {
<> const resp = await sendToBackground({
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" /> // @ts-ignore
<span>Saving to SurfSense...</span> name: "savedata",
</> });
) : (
<> toast({
<UploadIcon className="h-4 w-4 transition-transform group-hover:scale-110" /> title: resp.message,
<span>Save to SurfSense</span> });
</> } catch (error) {
)} toast({
</Button> title: "Error saving data",
</div> description: "Please try again",
</div> variant: "destructive",
</div> });
</div> } 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 icon from "data-base64:~assets/icon.png" import { ReloadIcon } from "@radix-ui/react-icons";
import { ReloadIcon } from "@radix-ui/react-icons"
const Loading = () => { const Loading = () => {
return ( 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="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="w-full max-w-md mx-auto space-y-8">
<div className="flex flex-col items-center space-y-2"> <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"> <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" /> <img className="w-12 h-12" src={icon} alt="SurfSense" />
</div> </div>
<h1 className="text-3xl font-semibold tracking-tight text-white mt-4">SurfSense</h1> <h1 className="text-3xl font-semibold tracking-tight text-white mt-4">SurfSense</h1>
</div> </div>
<div className="flex flex-col items-center mt-8">
<ReloadIcon className="h-10 w-10 text-teal-400 animate-spin" />
<div className="mt-6 text-lg text-gray-300 flex space-x-1">
{Array.from("LOADING").map((letter, i) => (
<span
key={i}
className="inline-block animate-pulse text-teal-400"
style={{
animationDelay: `${i * 0.1}s`,
animationDuration: '1.5s'
}}
>
{letter}
</span>
))}
</div>
</div>
</div>
</div>
)
}
export default Loading <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 { Slot } from "@radix-ui/react-slot" import { cva, type VariantProps } from "class-variance-authority";
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( 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", "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: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
"bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
outline: secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
"border border-input bg-background hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground",
secondary: link: "text-primary underline-offset-4 hover:underline",
"bg-secondary text-secondary-foreground hover:bg-secondary/80", },
ghost: "hover:bg-accent hover:text-accent-foreground", size: {
link: "text-primary underline-offset-4 hover:underline", default: "h-10 px-4 py-2",
}, sm: "h-9 rounded-md px-3",
size: { lg: "h-11 rounded-md px-8",
default: "h-10 px-4 py-2", icon: "h-10 w-10",
sm: "h-9 rounded-md px-3", },
lg: "h-11 rounded-md px-8", },
icon: "h-10 w-10", defaultVariants: {
}, variant: "default",
}, size: "default",
defaultVariants: { },
variant: "default", }
size: "default", );
},
}
)
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean asChild?: boolean;
} }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => { ({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button";
return ( return (
<Comp <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
className={cn(buttonVariants({ variant, size, className }))} );
ref={ref} }
{...props} );
/> Button.displayName = "Button";
)
}
)
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 { type DialogProps } from "@radix-ui/react-dialog" import { Command as CommandPrimitive } from "cmdk";
import { Command as CommandPrimitive } from "cmdk" import { Search } from "lucide-react";
import { Search } from "lucide-react" import * as React from "react";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
import { Dialog, DialogContent } from "~/routes/ui/dialog" import { Dialog, DialogContent } from "~/routes/ui/dialog";
const Command = React.forwardRef< const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>, React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive> React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<CommandPrimitive <CommandPrimitive
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className className
)} )}
{...props} {...props}
/> />
)) ));
Command.displayName = CommandPrimitive.displayName Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {} interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => { const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return ( return (
<Dialog {...props}> <Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg"> <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"> <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} {children}
</Command> </Command>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) );
} };
const CommandInput = React.forwardRef< const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>, React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper=""> <div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input <CommandPrimitive.Input
ref={ref} ref={ref}
className={cn( 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", "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 className
)} )}
{...props} {...props}
/> />
</div> </div>
)) ));
CommandInput.displayName = CommandPrimitive.Input.displayName CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef< const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>, React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<CommandPrimitive.List <CommandPrimitive.List
ref={ref} ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)} className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props} {...props}
/> />
)) ));
CommandList.displayName = CommandPrimitive.List.displayName CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef< const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>, React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty> React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => ( >((props, ref) => (
<CommandPrimitive.Empty <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
ref={ref} ));
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef< const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>, React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group> React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<CommandPrimitive.Group <CommandPrimitive.Group
ref={ref} ref={ref}
className={cn( 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", "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 className
)} )}
{...props} {...props}
/> />
)) ));
CommandGroup.displayName = CommandPrimitive.Group.displayName CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef< const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>, React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator> React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<CommandPrimitive.Separator <CommandPrimitive.Separator
ref={ref} ref={ref}
className={cn("-mx-1 h-px bg-border", className)} className={cn("-mx-1 h-px bg-border", className)}
{...props} {...props}
/> />
)) ));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef< const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>, React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item> React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<CommandPrimitive.Item <CommandPrimitive.Item
ref={ref} ref={ref}
className={cn( 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", "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 className
)} )}
{...props} {...props}
/> />
)) ));
CommandItem.displayName = CommandPrimitive.Item.displayName CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
className, return (
...props <span
}: React.HTMLAttributes<HTMLSpanElement>) => { className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
return ( {...props}
<span />
className={cn( );
"ml-auto text-xs tracking-widest text-muted-foreground", };
className CommandShortcut.displayName = "CommandShortcut";
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export { export {
Command, Command,
CommandDialog, CommandDialog,
CommandInput, CommandInput,
CommandList, CommandList,
CommandEmpty, CommandEmpty,
CommandGroup, CommandGroup,
CommandItem, CommandItem,
CommandShortcut, CommandShortcut,
CommandSeparator, 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 * as DialogPrimitive from "@radix-ui/react-dialog" import { X } from "lucide-react";
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< const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>, React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( 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", "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 className
)} )}
{...props} {...props}
/> />
)) ));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => (
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( 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", "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 className
)} )}
{...props} {...props}
> >
{children} {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"> <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" /> <X className="h-4 w-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
)) ));
DialogContent.displayName = DialogPrimitive.Content.displayName DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
className, <div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
...props );
}: React.HTMLAttributes<HTMLDivElement>) => ( DialogHeader.displayName = "DialogHeader";
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({ const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
className, <div
...props className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
}: React.HTMLAttributes<HTMLDivElement>) => ( {...props}
<div />
className={cn( );
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", DialogFooter.displayName = "DialogFooter";
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef< const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>, React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Title <DialogPrimitive.Title
ref={ref} ref={ref}
className={cn( className={cn("text-lg font-semibold leading-none tracking-tight", className)}
"text-lg font-semibold leading-none tracking-tight", {...props}
className />
)} ));
{...props} DialogTitle.displayName = DialogPrimitive.Title.displayName;
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef< const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>, React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Description <DialogPrimitive.Description
ref={ref} ref={ref}
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)) ));
DialogDescription.displayName = DialogPrimitive.Description.displayName DialogDescription.displayName = DialogPrimitive.Description.displayName;
export { export {
Dialog, Dialog,
DialogPortal, DialogPortal,
DialogOverlay, DialogOverlay,
DialogClose, DialogClose,
DialogTrigger, DialogTrigger,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogFooter, DialogFooter,
DialogTitle, DialogTitle,
DialogDescription, 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< const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>, React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal> <PopoverPrimitive.Portal>
<PopoverPrimitive.Content <PopoverPrimitive.Content
ref={ref} ref={ref}
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( 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", "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 className
)} )}
{...props} {...props}
/> />
</PopoverPrimitive.Portal> </PopoverPrimitive.Portal>
)) ));
PopoverContent.displayName = PopoverPrimitive.Content.displayName 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 * as ToastPrimitives from "@radix-ui/react-toast" import { cva, type VariantProps } from "class-variance-authority";
import { cva, type VariantProps } from "class-variance-authority" import { X } from "lucide-react";
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< const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>, React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport <ToastPrimitives.Viewport
ref={ref} ref={ref}
className={cn( 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]", "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 className
)} )}
{...props} {...props}
/> />
)) ));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva( 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", "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: { variants: {
variant: { variant: {
default: "border bg-background text-foreground", default: "border bg-background text-foreground",
destructive: destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground", "destructive group border-destructive bg-destructive text-destructive-foreground",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} }
) );
const Toast = React.forwardRef< const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>, React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => { >(({ className, variant, ...props }, ref) => {
return ( return (
<ToastPrimitives.Root <ToastPrimitives.Root
ref={ref} ref={ref}
className={cn(toastVariants({ variant }), className)} className={cn(toastVariants({ variant }), className)}
{...props} {...props}
/> />
) );
}) });
Toast.displayName = ToastPrimitives.Root.displayName Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef< const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>, React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Action <ToastPrimitives.Action
ref={ref} ref={ref}
className={cn( 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", "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 className
)} )}
{...props} {...props}
/> />
)) ));
ToastAction.displayName = ToastPrimitives.Action.displayName ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef< const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>, React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Close <ToastPrimitives.Close
ref={ref} ref={ref}
className={cn( 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", "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 className
)} )}
toast-close="" toast-close=""
{...props} {...props}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</ToastPrimitives.Close> </ToastPrimitives.Close>
)) ));
ToastClose.displayName = ToastPrimitives.Close.displayName ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef< const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>, React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Title <ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
ref={ref} ));
className={cn("text-sm font-semibold", className)} ToastTitle.displayName = ToastPrimitives.Title.displayName;
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef< const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>, React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Description <ToastPrimitives.Description
ref={ref} ref={ref}
className={cn("text-sm opacity-90", className)} className={cn("text-sm opacity-90", className)}
{...props} {...props}
/> />
)) ));
ToastDescription.displayName = ToastPrimitives.Description.displayName 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 { export {
type ToastProps, type ToastProps,
type ToastActionElement, type ToastActionElement,
ToastProvider, ToastProvider,
ToastViewport, ToastViewport,
Toast, Toast,
ToastTitle, ToastTitle,
ToastDescription, ToastDescription,
ToastClose, ToastClose,
ToastAction, ToastAction,
} };

View file

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

View file

@ -1,194 +1,189 @@
"use client" "use client";
// Inspired by react-hot-toast library // Inspired by react-hot-toast library
import * as React from "react" import * as React from "react";
import type { import type { ToastActionElement, ToastProps } from "@/routes/ui/toast";
ToastActionElement,
ToastProps,
} from "@/routes/ui/toast"
const TOAST_LIMIT = 1 const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000 const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & { type ToasterToast = ToastProps & {
id: string id: string;
title?: React.ReactNode title?: React.ReactNode;
description?: React.ReactNode description?: React.ReactNode;
action?: ToastActionElement action?: ToastActionElement;
} };
const actionTypes = { const actionTypes = {
ADD_TOAST: "ADD_TOAST", ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST", UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST", DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST", REMOVE_TOAST: "REMOVE_TOAST",
} as const } as const;
let count = 0 let count = 0;
function genId() { function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString() return count.toString();
} }
type ActionType = typeof actionTypes type ActionType = typeof actionTypes;
type Action = type Action =
| { | {
type: ActionType["ADD_TOAST"] type: ActionType["ADD_TOAST"];
toast: ToasterToast toast: ToasterToast;
} }
| { | {
type: ActionType["UPDATE_TOAST"] type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast> toast: Partial<ToasterToast>;
} }
| { | {
type: ActionType["DISMISS_TOAST"] type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"] toastId?: ToasterToast["id"];
} }
| { | {
type: ActionType["REMOVE_TOAST"] type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"] toastId?: ToasterToast["id"];
} };
interface State { 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) => { const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) { if (toastTimeouts.has(toastId)) {
return return;
} }
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
toastTimeouts.delete(toastId) toastTimeouts.delete(toastId);
dispatch({ dispatch({
type: "REMOVE_TOAST", type: "REMOVE_TOAST",
toastId: toastId, toastId: toastId,
}) });
}, TOAST_REMOVE_DELAY) }, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout) toastTimeouts.set(toastId, timeout);
} };
export const reducer = (state: State, action: Action): State => { export const reducer = (state: State, action: Action): State => {
switch (action.type) { switch (action.type) {
case "ADD_TOAST": case "ADD_TOAST":
return { return {
...state, ...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
} };
case "UPDATE_TOAST": case "UPDATE_TOAST":
return { return {
...state, ...state,
toasts: state.toasts.map((t) => toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
t.id === action.toast.id ? { ...t, ...action.toast } : t };
),
}
case "DISMISS_TOAST": { case "DISMISS_TOAST": {
const { toastId } = action const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action, // ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity // but I'll keep it here for simplicity
if (toastId) { if (toastId) {
addToRemoveQueue(toastId) addToRemoveQueue(toastId);
} else { } else {
state.toasts.forEach((toast) => { state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id) addToRemoveQueue(toast.id);
}) });
} }
return { return {
...state, ...state,
toasts: state.toasts.map((t) => toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined t.id === toastId || toastId === undefined
? { ? {
...t, ...t,
open: false, open: false,
} }
: t : t
), ),
} };
} }
case "REMOVE_TOAST": case "REMOVE_TOAST":
if (action.toastId === undefined) { if (action.toastId === undefined) {
return { return {
...state, ...state,
toasts: [], toasts: [],
} };
} }
return { return {
...state, ...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId), 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) { function dispatch(action: Action) {
memoryState = reducer(memoryState, action) memoryState = reducer(memoryState, action);
listeners.forEach((listener) => { listeners.forEach((listener) => {
listener(memoryState) listener(memoryState);
}) });
} }
type Toast = Omit<ToasterToast, "id"> type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) { function toast({ ...props }: Toast) {
const id = genId() const id = genId();
const update = (props: ToasterToast) => const update = (props: ToasterToast) =>
dispatch({ dispatch({
type: "UPDATE_TOAST", type: "UPDATE_TOAST",
toast: { ...props, id }, toast: { ...props, id },
}) });
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({ dispatch({
type: "ADD_TOAST", type: "ADD_TOAST",
toast: { toast: {
...props, ...props,
id, id,
open: true, open: true,
onOpenChange: (open) => { onOpenChange: (open) => {
if (!open) dismiss() if (!open) dismiss();
}, },
}, },
}) });
return { return {
id: id, id: id,
dismiss, dismiss,
update, update,
} };
} }
function useToast() { function useToast() {
const [state, setState] = React.useState<State>(memoryState) const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => { React.useEffect(() => {
listeners.push(setState) listeners.push(setState);
return () => { return () => {
const index = listeners.indexOf(setState) const index = listeners.indexOf(setState);
if (index > -1) { if (index > -1) {
listeners.splice(index, 1) listeners.splice(index, 1);
} }
} };
}, [state]) }, [state]);
return { return {
...state, ...state,
toast, toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), 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} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
darkMode: ["class"], darkMode: ["class"],
content: ["./*.{js,jsx,ts,tsx}","./routes/*.tsx","./routes/**/*.tsx"], content: ["./*.{js,jsx,ts,tsx}", "./routes/*.tsx", "./routes/**/*.tsx"],
theme: { theme: {
container: { container: {
center: true, center: true,
padding: "2rem", padding: "2rem",
screens: { screens: {
"2xl": "1400px", "2xl": "1400px",
}, },
}, },
extend: { extend: {
colors: { colors: {
border: "hsl(var(--border))", border: "hsl(var(--border))",
input: "hsl(var(--input))", input: "hsl(var(--input))",
ring: "hsl(var(--ring))", ring: "hsl(var(--ring))",
background: "hsl(var(--background))", background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))", foreground: "hsl(var(--foreground))",
primary: { primary: {
DEFAULT: "hsl(var(--primary))", DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))", foreground: "hsl(var(--primary-foreground))",
}, },
secondary: { secondary: {
DEFAULT: "hsl(var(--secondary))", DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))", foreground: "hsl(var(--secondary-foreground))",
}, },
destructive: { destructive: {
DEFAULT: "hsl(var(--destructive))", DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))", foreground: "hsl(var(--destructive-foreground))",
}, },
muted: { muted: {
DEFAULT: "hsl(var(--muted))", DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))", foreground: "hsl(var(--muted-foreground))",
}, },
accent: { accent: {
DEFAULT: "hsl(var(--accent))", DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))", foreground: "hsl(var(--accent-foreground))",
}, },
popover: { popover: {
DEFAULT: "hsl(var(--popover))", DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))", foreground: "hsl(var(--popover-foreground))",
}, },
card: { card: {
DEFAULT: "hsl(var(--card))", DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))", foreground: "hsl(var(--card-foreground))",
}, },
}, },
borderRadius: { borderRadius: {
lg: `var(--radius)`, lg: `var(--radius)`,
md: `calc(var(--radius) - 2px)`, md: `calc(var(--radius) - 2px)`,
sm: "calc(var(--radius) - 4px)", sm: "calc(var(--radius) - 4px)",
}, },
fontFamily: { fontFamily: {
sans: ["var(--font-sans)", ...fontFamily.sans], sans: ["var(--font-sans)", ...fontFamily.sans],
}, },
keyframes: { keyframes: {
"accordion-down": { "accordion-down": {
from: { height: "0" }, from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" }, to: { height: "var(--radix-accordion-content-height)" },
}, },
"accordion-up": { "accordion-up": {
from: { height: "var(--radix-accordion-content-height)" }, from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" }, to: { height: "0" },
}, },
}, },
animation: { animation: {
"accordion-down": "accordion-down 0.2s ease-out", "accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out",
}, },
}, },
}, },
plugins: [require("tailwindcss-animate")], plugins: [require("tailwindcss-animate")],
} };

View file

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

View file

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

View file

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

View file

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

View file

@ -1,31 +1,31 @@
{ {
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Next.js: debug client-side", "name": "Next.js: debug client-side",
"type": "chrome", "type": "chrome",
"request": "launch", "request": "launch",
"url": "http://localhost:3000", "url": "http://localhost:3000",
"webRoot": "${workspaceFolder}" "webRoot": "${workspaceFolder}"
}, },
{ {
"name": "Next.js: debug server-side", "name": "Next.js: debug server-side",
"type": "node-terminal", "type": "node-terminal",
"request": "launch", "request": "launch",
"command": "pnpm run debug:server", "command": "pnpm run debug:server",
"skipFiles": ["<node_internals>/**"] "skipFiles": ["<node_internals>/**"]
}, },
{ {
"name": "Next.js: debug full stack", "name": "Next.js: debug full stack",
"type": "node-terminal", "type": "node-terminal",
"request": "launch", "request": "launch",
"command": "pnpm run debug", "command": "pnpm run debug",
"serverReadyAction": { "serverReadyAction": {
"pattern": "- Local:.+(https?://.+)", "pattern": "- Local:.+(https?://.+)",
"uriFormat": "%s", "uriFormat": "%s",
"action": "debugWithChrome" "action": "debugWithChrome"
}, },
"skipFiles": ["<node_internals>/**"] "skipFiles": ["<node_internals>/**"]
} }
] ]
} }

View file

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

View file

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

View file

@ -1,21 +1,25 @@
import { Suspense } from 'react'; import { Suspense } from "react";
import ChatsPageClient from './chats-client'; import ChatsPageClient from "./chats-client";
interface PageProps { interface PageProps {
params: { params: {
search_space_id: string; search_space_id: string;
}; };
} }
export default async function ChatsPage({ params }: PageProps) { export default async function ChatsPage({ params }: PageProps) {
// Get search space ID from the route parameter // Get search space ID from the route parameter
const { search_space_id: searchSpaceId } = await Promise.resolve(params); const { search_space_id: searchSpaceId } = await Promise.resolve(params);
return ( return (
<Suspense fallback={<div className="flex items-center justify-center h-[60vh]"> <Suspense
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div> fallback={
</div>}> <div className="flex items-center justify-center h-[60vh]">
<ChatsPageClient searchSpaceId={searchSpaceId} /> <div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
</Suspense> </div>
); }
} >
<ChatsPageClient searchSpaceId={searchSpaceId} />
</Suspense>
);
}

View file

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

View file

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

View file

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

View file

@ -1,311 +1,286 @@
"use client"; "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 { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { motion } from "framer-motion";
import * as z from "zod";
import { toast } from "sonner";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react"; 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 { import {
useSearchSourceConnectors, Form,
SearchSourceConnector, FormControl,
} from "@/hooks/useSearchSourceConnectors"; FormDescription,
import { FormField,
Form, FormItem,
FormControl, FormLabel,
FormDescription, FormMessage,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { import {
Card, type SearchSourceConnector,
CardContent, useSearchSourceConnectors,
CardDescription, } from "@/hooks/useSearchSourceConnectors";
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
// Define the form schema with Zod // Define the form schema with Zod
const apiConnectorFormSchema = z.object({ const apiConnectorFormSchema = z.object({
name: z.string().min(3, { name: z.string().min(3, {
message: "Connector name must be at least 3 characters.", message: "Connector name must be at least 3 characters.",
}), }),
api_key: z.string().min(10, { api_key: z.string().min(10, {
message: "API key is required and must be valid.", message: "API key is required and must be valid.",
}), }),
}); });
// Helper function to get connector type display name // Helper function to get connector type display name
const getConnectorTypeDisplay = (type: string): string => { const getConnectorTypeDisplay = (type: string): string => {
const typeMap: Record<string, string> = { const typeMap: Record<string, string> = {
SERPER_API: "Serper API", SERPER_API: "Serper API",
TAVILY_API: "Tavily API", TAVILY_API: "Tavily API",
SLACK_CONNECTOR: "Slack Connector", SLACK_CONNECTOR: "Slack Connector",
NOTION_CONNECTOR: "Notion Connector", NOTION_CONNECTOR: "Notion Connector",
GITHUB_CONNECTOR: "GitHub Connector", GITHUB_CONNECTOR: "GitHub Connector",
LINEAR_CONNECTOR: "Linear Connector", LINEAR_CONNECTOR: "Linear Connector",
JIRA_CONNECTOR: "Jira Connector", JIRA_CONNECTOR: "Jira Connector",
DISCORD_CONNECTOR: "Discord Connector", DISCORD_CONNECTOR: "Discord Connector",
LINKUP_API: "Linkup", LINKUP_API: "Linkup",
// Add other connector types here as needed // Add other connector types here as needed
}; };
return typeMap[type] || type; return typeMap[type] || type;
}; };
// Define the type for the form values // Define the type for the form values
type ApiConnectorFormValues = z.infer<typeof apiConnectorFormSchema>; 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() { export default function EditConnectorPage() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const searchSpaceId = params.search_space_id as string; const searchSpaceId = params.search_space_id as string;
const connectorId = parseInt(params.connector_id as string, 10); const connectorId = parseInt(params.connector_id as string, 10);
const { connectors, updateConnector } = useSearchSourceConnectors(); const { connectors, updateConnector } = useSearchSourceConnectors();
const [connector, setConnector] = useState<SearchSourceConnector | null>( const [connector, setConnector] = useState<SearchSourceConnector | null>(null);
null, const [isLoading, setIsLoading] = useState(true);
); const [isSubmitting, setIsSubmitting] = useState(false);
const [isLoading, setIsLoading] = useState(true); // console.log("connector", connector);
const [isSubmitting, setIsSubmitting] = useState(false); // Initialize the form
// console.log("connector", connector); const form = useForm<ApiConnectorFormValues>({
// Initialize the form resolver: zodResolver(apiConnectorFormSchema),
const form = useForm<ApiConnectorFormValues>({ defaultValues: {
resolver: zodResolver(apiConnectorFormSchema), name: "",
defaultValues: { api_key: "",
name: "", },
api_key: "", });
},
});
// Get API key field name based on connector type // Find connector in the list
const getApiKeyFieldName = (connectorType: string): string => { useEffect(() => {
const fieldMap: Record<string, string> = { const currentConnector = connectors.find((c) => c.id === connectorId);
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 if (currentConnector) {
useEffect(() => { setConnector(currentConnector);
const currentConnector = connectors.find((c) => c.id === connectorId);
if (currentConnector) { // Check if connector type is supported
setConnector(currentConnector); 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 setIsLoading(false);
const apiKeyField = getApiKeyFieldName(currentConnector.connector_type); } else if (!isLoading && connectors.length > 0) {
if (apiKeyField) { // If connectors are loaded but this one isn't found
form.reset({ toast.error("Connector not found");
name: currentConnector.name, router.push(`/dashboard/${searchSpaceId}/connectors`);
api_key: currentConnector.config[apiKeyField] || "", }
}); }, [connectors, connectorId, form, router, searchSpaceId, isLoading]);
} 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); // Handle form submission
} else if (!isLoading && connectors.length > 0) { const onSubmit = async (values: ApiConnectorFormValues) => {
// If connectors are loaded but this one isn't found if (!connector) return;
toast.error("Connector not found");
router.push(`/dashboard/${searchSpaceId}/connectors`);
}
}, [connectors, connectorId, form, router, searchSpaceId, isLoading]);
// Handle form submission setIsSubmitting(true);
const onSubmit = async (values: ApiConnectorFormValues) => { try {
if (!connector) return; const apiKeyField = getApiKeyFieldName(connector.connector_type);
setIsSubmitting(true); // Only update the API key if a new one was provided
try { const updatedConfig = { ...connector.config };
const apiKeyField = getApiKeyFieldName(connector.connector_type); if (values.api_key) {
updatedConfig[apiKeyField] = values.api_key;
}
// Only update the API key if a new one was provided await updateConnector(connectorId, {
const updatedConfig = { ...connector.config }; name: values.name,
if (values.api_key) { connector_type: connector.connector_type,
updatedConfig[apiKeyField] = values.api_key; config: updatedConfig,
} is_indexable: connector.is_indexable,
last_indexed_at: connector.last_indexed_at,
});
await updateConnector(connectorId, { toast.success("Connector updated successfully!");
name: values.name, router.push(`/dashboard/${searchSpaceId}/connectors`);
connector_type: connector.connector_type, } catch (error) {
config: updatedConfig, console.error("Error updating connector:", error);
is_indexable: connector.is_indexable, toast.error(error instanceof Error ? error.message : "Failed to update connector");
last_indexed_at: connector.last_indexed_at, } finally {
}); setIsSubmitting(false);
}
};
toast.success("Connector updated successfully!"); if (isLoading) {
router.push(`/dashboard/${searchSpaceId}/connectors`); return (
} catch (error) { <div className="container mx-auto py-8 max-w-3xl flex justify-center items-center min-h-[60vh]">
console.error("Error updating connector:", error); <div className="animate-pulse text-center">
toast.error( <div className="h-8 w-48 bg-muted rounded mx-auto mb-4"></div>
error instanceof Error ? error.message : "Failed to update connector", <div className="h-4 w-64 bg-muted rounded mx-auto"></div>
); </div>
} finally { </div>
setIsSubmitting(false); );
} }
};
if (isLoading) { return (
return ( <div className="container mx-auto py-8 max-w-3xl">
<div className="container mx-auto py-8 max-w-3xl flex justify-center items-center min-h-[60vh]"> <Button
<div className="animate-pulse text-center"> variant="ghost"
<div className="h-8 w-48 bg-muted rounded mx-auto mb-4"></div> className="mb-6"
<div className="h-4 w-64 bg-muted rounded mx-auto"></div> onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors`)}
</div> >
</div> <ArrowLeft className="mr-2 h-4 w-4" />
); Back to Connectors
} </Button>
return ( <motion.div
<div className="container mx-auto py-8 max-w-3xl"> initial={{ opacity: 0, y: 20 }}
<Button animate={{ opacity: 1, y: 0 }}
variant="ghost" transition={{ duration: 0.5 }}
className="mb-6" >
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors`)} <Card className="border-2 border-border">
> <CardHeader>
<ArrowLeft className="mr-2 h-4 w-4" /> <CardTitle className="text-2xl font-bold">
Back to Connectors Edit {connector ? getConnectorTypeDisplay(connector.connector_type) : ""} Connector
</Button> </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 <Form {...form}>
initial={{ opacity: 0, y: 20 }} <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
animate={{ opacity: 1, y: 0 }} <FormField
transition={{ duration: 0.5 }} control={form.control}
> name="name"
<Card className="border-2 border-border"> render={({ field }) => (
<CardHeader> <FormItem>
<CardTitle className="text-2xl font-bold"> <FormLabel>Connector Name</FormLabel>
Edit{" "} <FormControl>
{connector <Input placeholder="My API Connector" {...field} />
? getConnectorTypeDisplay(connector.connector_type) </FormControl>
: ""}{" "} <FormDescription>A friendly name to identify this connector.</FormDescription>
Connector <FormMessage />
</CardTitle> </FormItem>
<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}> <FormField
<form control={form.control}
onSubmit={form.handleSubmit(onSubmit)} name="api_key"
className="space-y-6" render={({ field }) => (
> <FormItem>
<FormField <FormLabel>
control={form.control} {connector?.connector_type === "SLACK_CONNECTOR"
name="name" ? "Slack Bot Token"
render={({ field }) => ( : connector?.connector_type === "NOTION_CONNECTOR"
<FormItem> ? "Notion Integration Token"
<FormLabel>Connector Name</FormLabel> : connector?.connector_type === "GITHUB_CONNECTOR"
<FormControl> ? "GitHub Personal Access Token (PAT)"
<Input placeholder="My API Connector" {...field} /> : connector?.connector_type === "LINKUP_API"
</FormControl> ? "Linkup API Key"
<FormDescription> : "API Key"}
A friendly name to identify this connector. </FormLabel>
</FormDescription> <FormControl>
<FormMessage /> <Input
</FormItem> 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 <div className="flex justify-end">
control={form.control} <Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
name="api_key" {isSubmitting ? (
render={({ field }) => ( <>
<FormItem> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
<FormLabel> Updating...
{connector?.connector_type === "SLACK_CONNECTOR" </>
? "Slack Bot Token" ) : (
: connector?.connector_type === "NOTION_CONNECTOR" <>
? "Notion Integration Token" <Check className="mr-2 h-4 w-4" />
: connector?.connector_type === "GITHUB_CONNECTOR" Update Connector
? "GitHub Personal Access Token (PAT)" </>
: connector?.connector_type === "LINKUP_API" )}
? "Linkup API Key" </Button>
: "API Key"} </div>
</FormLabel> </form>
<FormControl> </Form>
<Input </CardContent>
type="password" </Card>
placeholder={ </motion.div>
connector?.connector_type === "SLACK_CONNECTOR" </div>
? "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>
);
} }

View file

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

View file

@ -1,315 +1,345 @@
"use client"; "use client";
import { useState } from "react";
import { useRouter, useParams } from "next/navigation";
import { motion } from "framer-motion";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { motion } from "framer-motion";
import * as z from "zod";
import { toast } from "sonner";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react"; import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors"; import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { import {
Form, Accordion,
FormControl, AccordionContent,
FormDescription, AccordionItem,
FormField, AccordionTrigger,
FormItem, } from "@/components/ui/accordion";
FormLabel, import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardFooter, CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { import {
Alert, Form,
AlertDescription, FormControl,
AlertTitle, FormDescription,
} from "@/components/ui/alert"; FormField,
import { FormItem,
Accordion, FormLabel,
AccordionContent, FormMessage,
AccordionItem, } from "@/components/ui/form";
AccordionTrigger, import { Input } from "@/components/ui/input";
} from "@/components/ui/accordion";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
// Define the form schema with Zod // Define the form schema with Zod
const discordConnectorFormSchema = z.object({ const discordConnectorFormSchema = z.object({
name: z.string().min(3, { name: z.string().min(3, {
message: "Connector name must be at least 3 characters.", message: "Connector name must be at least 3 characters.",
}), }),
bot_token: z.string() bot_token: z
.min(50, { message: "Discord Bot Token appears to be too short." }) .string()
.regex(/^[A-Za-z0-9._-]+$/, { message: "Discord Bot Token contains invalid characters." }), .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 // Define the type for the form values
type DiscordConnectorFormValues = z.infer<typeof discordConnectorFormSchema>; type DiscordConnectorFormValues = z.infer<typeof discordConnectorFormSchema>;
export default function DiscordConnectorPage() { export default function DiscordConnectorPage() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const searchSpaceId = params.search_space_id as string; const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const { createConnector } = useSearchSourceConnectors(); const { createConnector } = useSearchSourceConnectors();
// Initialize the form // Initialize the form
const form = useForm<DiscordConnectorFormValues>({ const form = useForm<DiscordConnectorFormValues>({
resolver: zodResolver(discordConnectorFormSchema), resolver: zodResolver(discordConnectorFormSchema),
defaultValues: { defaultValues: {
name: "Discord Connector", name: "Discord Connector",
bot_token: "", bot_token: "",
}, },
}); });
// Handle form submission // Handle form submission
const onSubmit = async (values: DiscordConnectorFormValues) => { const onSubmit = async (values: DiscordConnectorFormValues) => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await createConnector({ await createConnector({
name: values.name, name: values.name,
connector_type: "DISCORD_CONNECTOR", connector_type: "DISCORD_CONNECTOR",
config: { config: {
DISCORD_BOT_TOKEN: values.bot_token, DISCORD_BOT_TOKEN: values.bot_token,
}, },
is_indexable: true, is_indexable: true,
last_indexed_at: null, last_indexed_at: null,
}); });
toast.success("Discord connector created successfully!"); toast.success("Discord connector created successfully!");
router.push(`/dashboard/${searchSpaceId}/connectors`); router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) { } catch (error) {
console.error("Error creating connector:", error); console.error("Error creating connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to create connector"); toast.error(error instanceof Error ? error.message : "Failed to create connector");
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
return ( return (
<div className="container mx-auto py-8 max-w-3xl"> <div className="container mx-auto py-8 max-w-3xl">
<Button <Button
variant="ghost" variant="ghost"
className="mb-6" className="mb-6"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)} onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
> >
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
Back to Connectors Back to Connectors
</Button> </Button>
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
> >
<Tabs defaultValue="connect" className="w-full"> <Tabs defaultValue="connect" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6"> <TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="connect">Connect</TabsTrigger> <TabsTrigger value="connect">Connect</TabsTrigger>
<TabsTrigger value="documentation">Documentation</TabsTrigger> <TabsTrigger value="documentation">Documentation</TabsTrigger>
</TabsList> </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>
<Form {...form}> <TabsContent value="connect">
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <Card className="border-2 border-border">
<FormField <CardHeader>
control={form.control} <CardTitle className="text-2xl font-bold">Connect Discord Server</CardTitle>
name="name" <CardDescription>
render={({ field }) => ( Integrate with Discord to search and retrieve information from your servers and
<FormItem> channels. This connector can index your Discord messages for search.
<FormLabel>Connector Name</FormLabel> </CardDescription>
<FormControl> </CardHeader>
<Input placeholder="My Discord Connector" {...field} /> <CardContent>
</FormControl> <Alert className="mb-6 bg-muted">
<FormDescription> <Info className="h-4 w-4" />
A friendly name to identify this connector. <AlertTitle>Bot Token Required</AlertTitle>
</FormDescription> <AlertDescription>
<FormMessage /> You'll need a Discord Bot Token to use this connector. You can create a Discord
</FormItem> 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 <Form {...form}>
control={form.control} <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
name="bot_token" <FormField
render={({ field }) => ( control={form.control}
<FormItem> name="name"
<FormLabel>Discord Bot Token</FormLabel> render={({ field }) => (
<FormControl> <FormItem>
<Input <FormLabel>Connector Name</FormLabel>
type="password" <FormControl>
placeholder="Bot Token..." <Input placeholder="My Discord Connector" {...field} />
{...field} </FormControl>
/> <FormDescription>
</FormControl> A friendly name to identify this connector.
<FormDescription> </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. <FormMessage />
</FormDescription> </FormItem>
<FormMessage /> )}
</FormItem> />
)}
/>
<div className="flex justify-end"> <FormField
<Button control={form.control}
type="submit" name="bot_token"
disabled={isSubmitting} render={({ field }) => (
className="w-full sm:w-auto" <FormItem>
> <FormLabel>Discord Bot Token</FormLabel>
{isSubmitting ? ( <FormControl>
<> <Input type="password" placeholder="Bot Token..." {...field} />
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> </FormControl>
Connecting... <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.
<Check className="mr-2 h-4 w-4" /> </FormDescription>
Connect Discord <FormMessage />
</> </FormItem>
)} )}
</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>
<Alert className="bg-muted"> <div className="flex justify-end">
<Info className="h-4 w-4" /> <Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
<AlertTitle>Important: Bot Channel Access</AlertTitle> {isSubmitting ? (
<AlertDescription> <>
After connecting, ensure the bot has access to all channels you want to index. You may need to adjust channel permissions in Discord. <Loader2 className="mr-2 h-4 w-4 animate-spin" />
</AlertDescription> Connecting...
</Alert> </>
) : (
<>
<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"> <TabsContent value="documentation">
<Info className="h-4 w-4" /> <Card className="border-2 border-border">
<AlertTitle>First Indexing</AlertTitle> <CardHeader>
<AlertDescription> <CardTitle className="text-2xl font-bold">
The first indexing pulls all accessible channels and may take longer than future updates. Only channels where the bot has access will be indexed. Discord Connector Documentation
</AlertDescription> </CardTitle>
</Alert> <CardDescription>
Learn how to set up and use the Discord connector to index your server data.
<div className="mt-4"> </CardDescription>
<h4 className="font-medium mb-2">Troubleshooting:</h4> </CardHeader>
<ul className="list-disc pl-5 space-y-2 text-muted-foreground"> <CardContent className="space-y-6">
<li> <div>
<strong>Missing messages:</strong> If you don't see messages from a channel, check the bot's permissions for that channel. <h3 className="text-xl font-semibold mb-2">How it works</h3>
</li> <p className="text-muted-foreground">
<li> The Discord connector indexes all accessible channels for a given bot in your
<strong>Bot not responding:</strong> Make sure the bot is online and the token is correct. servers.
</li> </p>
<li> <ul className="mt-2 list-disc pl-5 text-muted-foreground">
<strong>Private channels:</strong> The bot must be explicitly granted access to private channels. <li>Upcoming: Support for private channels by granting the bot access.</li>
</li> </ul>
</ul> </div>
</div>
</AccordionContent> <Accordion type="single" collapsible className="w-full">
</AccordionItem> <AccordionItem value="authorization">
</Accordion> <AccordionTrigger className="text-lg font-medium">
</CardContent> Authorization
</Card> </AccordionTrigger>
</TabsContent> <AccordionContent className="space-y-4">
</Tabs> <Alert className="bg-muted">
</motion.div> <Info className="h-4 w-4" />
</div> <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"; "use client";
import { useState } from "react";
import { useRouter, useParams } from "next/navigation";
import { motion } from "framer-motion";
import { zodResolver } from "@hookform/resolvers/zod"; 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 { useForm } from "react-hook-form";
import * as z from "zod";
import { toast } from "sonner"; import { toast } from "sonner";
import { ArrowLeft, Check, Info, Loader2, Github, CircleAlert, ListChecks } from "lucide-react"; import * as z from "zod";
// Assuming useSearchSourceConnectors hook exists and works similarly
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
import { import {
Form, Accordion,
FormControl, AccordionContent,
FormDescription, AccordionItem,
FormField, AccordionTrigger,
FormItem, } from "@/components/ui/accordion";
FormLabel, import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardFooter, CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } 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 { 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 // Define the form schema with Zod for GitHub PAT entry step
const githubPatFormSchema = z.object({ const githubPatFormSchema = z.object({
name: z.string().min(3, { name: z.string().min(3, {
message: "Connector name must be at least 3 characters.", message: "Connector name must be at least 3 characters.",
}), }),
github_pat: z.string() github_pat: z
.min(20, { // Apply min length first .string()
message: "GitHub Personal Access Token seems too short.", .min(20, {
}) // Apply min length first
.refine(pat => pat.startsWith('ghp_') || pat.startsWith('github_pat_'), { // Then refine the pattern message: "GitHub Personal Access Token seems too short.",
message: "GitHub PAT should start with 'ghp_' or 'github_pat_'", })
}), .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 // Define the type for the form values
@ -63,394 +61,468 @@ type GithubPatFormValues = z.infer<typeof githubPatFormSchema>;
// Type for fetched GitHub repositories // Type for fetched GitHub repositories
interface GithubRepo { interface GithubRepo {
id: number; id: number;
name: string; name: string;
full_name: string; full_name: string;
private: boolean; private: boolean;
url: string; url: string;
description: string | null; description: string | null;
last_updated: string | null; last_updated: string | null;
} }
export default function GithubConnectorPage() { export default function GithubConnectorPage() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const searchSpaceId = params.search_space_id as string; const searchSpaceId = params.search_space_id as string;
const [step, setStep] = useState<'enter_pat' | 'select_repos'>('enter_pat'); const [step, setStep] = useState<"enter_pat" | "select_repos">("enter_pat");
const [isFetchingRepos, setIsFetchingRepos] = useState(false); const [isFetchingRepos, setIsFetchingRepos] = useState(false);
const [isCreatingConnector, setIsCreatingConnector] = useState(false); const [isCreatingConnector, setIsCreatingConnector] = useState(false);
const [repositories, setRepositories] = useState<GithubRepo[]>([]); const [repositories, setRepositories] = useState<GithubRepo[]>([]);
const [selectedRepos, setSelectedRepos] = useState<string[]>([]); const [selectedRepos, setSelectedRepos] = useState<string[]>([]);
const [connectorName, setConnectorName] = useState<string>("GitHub Connector"); const [connectorName, setConnectorName] = useState<string>("GitHub Connector");
const [validatedPat, setValidatedPat] = useState<string>(""); // Store the validated PAT const [validatedPat, setValidatedPat] = useState<string>(""); // Store the validated PAT
const { createConnector } = useSearchSourceConnectors(); const { createConnector } = useSearchSourceConnectors();
// Initialize the form for PAT entry // Initialize the form for PAT entry
const form = useForm<GithubPatFormValues>({ const form = useForm<GithubPatFormValues>({
resolver: zodResolver(githubPatFormSchema), resolver: zodResolver(githubPatFormSchema),
defaultValues: { defaultValues: {
name: connectorName, name: connectorName,
github_pat: "", github_pat: "",
}, },
}); });
// Function to fetch repositories using the new backend endpoint // Function to fetch repositories using the new backend endpoint
const fetchRepositories = async (values: GithubPatFormValues) => { const fetchRepositories = async (values: GithubPatFormValues) => {
setIsFetchingRepos(true); setIsFetchingRepos(true);
setConnectorName(values.name); // Store the name setConnectorName(values.name); // Store the name
setValidatedPat(values.github_pat); // Store the PAT temporarily setValidatedPat(values.github_pat); // Store the PAT temporarily
try { try {
const token = localStorage.getItem('surfsense_bearer_token'); const token = localStorage.getItem("surfsense_bearer_token");
if (!token) { if (!token) {
throw new Error('No authentication token found'); throw new Error("No authentication token found");
} }
const response = await fetch( const response = await fetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/github/repositories/`, `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/github/repositories/`,
{ {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
'Authorization': `Bearer ${token}` Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify({ github_pat: values.github_pat }) body: JSON.stringify({ github_pat: values.github_pat }),
} }
); );
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
throw new Error(errorData.detail || `Failed to fetch repositories: ${response.statusText}`); throw new Error(errorData.detail || `Failed to fetch repositories: ${response.statusText}`);
} }
const data: GithubRepo[] = await response.json(); const data: GithubRepo[] = await response.json();
setRepositories(data); setRepositories(data);
setStep('select_repos'); // Move to the next step setStep("select_repos"); // Move to the next step
toast.success(`Found ${data.length} repositories.`); toast.success(`Found ${data.length} repositories.`);
} catch (error) { } catch (error) {
console.error("Error fetching GitHub repositories:", 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."; const errorMessage =
toast.error(errorMessage); error instanceof Error
} finally { ? error.message
setIsFetchingRepos(false); : "Failed to fetch repositories. Please check the PAT and try again.";
} toast.error(errorMessage);
}; } finally {
setIsFetchingRepos(false);
}
};
// Handle final connector creation // Handle final connector creation
const handleCreateConnector = async () => { const handleCreateConnector = async () => {
if (selectedRepos.length === 0) { if (selectedRepos.length === 0) {
toast.warning("Please select at least one repository to index."); toast.warning("Please select at least one repository to index.");
return; return;
} }
setIsCreatingConnector(true); setIsCreatingConnector(true);
try { try {
await createConnector({ await createConnector({
name: connectorName, // Use the stored name name: connectorName, // Use the stored name
connector_type: "GITHUB_CONNECTOR", connector_type: "GITHUB_CONNECTOR",
config: { config: {
GITHUB_PAT: validatedPat, // Use the stored validated PAT GITHUB_PAT: validatedPat, // Use the stored validated PAT
repo_full_names: selectedRepos, // Add the selected repo names repo_full_names: selectedRepos, // Add the selected repo names
}, },
is_indexable: true, is_indexable: true,
last_indexed_at: null, last_indexed_at: null,
}); });
toast.success("GitHub connector created successfully!"); toast.success("GitHub connector created successfully!");
router.push(`/dashboard/${searchSpaceId}/connectors`); router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) { } catch (error) {
console.error("Error creating GitHub connector:", error); console.error("Error creating GitHub connector:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to create GitHub connector."; const errorMessage =
toast.error(errorMessage); error instanceof Error ? error.message : "Failed to create GitHub connector.";
} finally { toast.error(errorMessage);
setIsCreatingConnector(false); } finally {
} setIsCreatingConnector(false);
}; }
};
// Handle checkbox changes // Handle checkbox changes
const handleRepoSelection = (repoFullName: string, checked: boolean) => { const handleRepoSelection = (repoFullName: string, checked: boolean) => {
setSelectedRepos(prev => setSelectedRepos((prev) =>
checked checked ? [...prev, repoFullName] : prev.filter((name) => name !== repoFullName)
? [...prev, repoFullName] );
: prev.filter(name => name !== repoFullName) };
);
};
return ( return (
<div className="container mx-auto py-8 max-w-3xl"> <div className="container mx-auto py-8 max-w-3xl">
<Button <Button
variant="ghost" variant="ghost"
className="mb-6" className="mb-6"
onClick={() => { onClick={() => {
if (step === 'select_repos') { if (step === "select_repos") {
// Go back to PAT entry, clear sensitive/fetched data // Go back to PAT entry, clear sensitive/fetched data
setStep('enter_pat'); setStep("enter_pat");
setRepositories([]); setRepositories([]);
setSelectedRepos([]); setSelectedRepos([]);
setValidatedPat(""); setValidatedPat("");
// Reset form PAT field, keep name // Reset form PAT field, keep name
form.reset({ name: connectorName, github_pat: "" }); form.reset({ name: connectorName, github_pat: "" });
} else { } else {
router.push(`/dashboard/${searchSpaceId}/connectors/add`); router.push(`/dashboard/${searchSpaceId}/connectors/add`);
} }
}} }}
> >
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
{step === 'select_repos' ? "Back to PAT Entry" : "Back to Add Connectors"} {step === "select_repos" ? "Back to PAT Entry" : "Back to Add Connectors"}
</Button> </Button>
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
> >
<Tabs defaultValue="connect" className="w-full"> <Tabs defaultValue="connect" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6"> <TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="connect">Connect GitHub</TabsTrigger> <TabsTrigger value="connect">Connect GitHub</TabsTrigger>
<TabsTrigger value="documentation">Setup Guide</TabsTrigger> <TabsTrigger value="documentation">Setup Guide</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="connect"> <TabsContent value="connect">
<Card className="border-2 border-border"> <Card className="border-2 border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-2xl font-bold flex items-center gap-2"> <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" ? (
{step === 'enter_pat' ? "Connect GitHub Account" : "Select Repositories to Index"} <Github className="h-6 w-6" />
</CardTitle> ) : (
<CardDescription> <ListChecks className="h-6 w-6" />
{step === 'enter_pat' )}
? "Provide a name and GitHub Personal Access Token (PAT) to fetch accessible repositories." {step === "enter_pat" ? "Connect GitHub Account" : "Select Repositories to Index"}
: `Select which repositories you want SurfSense to index for search. Found ${repositories.length} repositories accessible via your PAT.` </CardTitle>
} <CardDescription>
</CardDescription> {step === "enter_pat"
</CardHeader> ? "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}> <Form {...form}>
{step === 'enter_pat' && ( {step === "enter_pat" && (
<CardContent> <CardContent>
<Alert className="mb-6 bg-muted"> <Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
<AlertTitle>GitHub Personal Access Token (PAT) Required</AlertTitle> <AlertTitle>GitHub Personal Access Token (PAT) Required</AlertTitle>
<AlertDescription> <AlertDescription>
You'll need a GitHub PAT with the appropriate scopes (e.g., 'repo') to fetch repositories. You can create one from your{' '} You'll need a GitHub PAT with the appropriate scopes (e.g., 'repo') to fetch
<a repositories. You can create one from your{" "}
href="https://github.com/settings/personal-access-tokens" <a
target="_blank" href="https://github.com/settings/personal-access-tokens"
rel="noopener noreferrer" target="_blank"
className="font-medium underline underline-offset-4" 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. GitHub Developer Settings
</AlertDescription> </a>
</Alert> . 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"> <form onSubmit={form.handleSubmit(fetchRepositories)} className="space-y-6">
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Connector Name</FormLabel> <FormLabel>Connector Name</FormLabel>
<FormControl> <FormControl>
<Input placeholder="My GitHub Connector" {...field} /> <Input placeholder="My GitHub Connector" {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
A friendly name to identify this GitHub connection. A friendly name to identify this GitHub connection.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="github_pat" name="github_pat"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>GitHub Personal Access Token (PAT)</FormLabel> <FormLabel>GitHub Personal Access Token (PAT)</FormLabel>
<FormControl> <FormControl>
<Input <Input
type="password" type="password"
placeholder="ghp_... or github_pat_..." placeholder="ghp_... or github_pat_..."
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Enter your GitHub PAT here to fetch your repositories. It will be stored encrypted later. Enter your GitHub PAT here to fetch your repositories. It will be
</FormDescription> stored encrypted later.
<FormMessage /> </FormDescription>
</FormItem> <FormMessage />
)} </FormItem>
/> )}
/>
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
type="submit" type="submit"
disabled={isFetchingRepos} disabled={isFetchingRepos}
className="w-full sm:w-auto" className="w-full sm:w-auto"
> >
{isFetchingRepos ? ( {isFetchingRepos ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Fetching Repositories... Fetching Repositories...
</> </>
) : ( ) : (
"Fetch Repositories" "Fetch Repositories"
)} )}
</Button> </Button>
</div> </div>
</form> </form>
</CardContent> </CardContent>
)} )}
{step === 'select_repos' && ( {step === "select_repos" && (
<CardContent> <CardContent>
{repositories.length === 0 ? ( {repositories.length === 0 ? (
<Alert variant="destructive"> <Alert variant="destructive">
<CircleAlert className="h-4 w-4" /> <CircleAlert className="h-4 w-4" />
<AlertTitle>No Repositories Found</AlertTitle> <AlertTitle>No Repositories Found</AlertTitle>
<AlertDescription> <AlertDescription>
No repositories were found or accessible with the provided PAT. Please check the token and its permissions, then go back and try again. No repositories were found or accessible with the provided PAT. Please
</AlertDescription> check the token and its permissions, then go back and try again.
</Alert> </AlertDescription>
) : ( </Alert>
<div className="space-y-4"> ) : (
<FormLabel>Repositories ({selectedRepos.length} selected)</FormLabel> <div className="space-y-4">
<div className="h-64 w-full rounded-md border p-4 overflow-y-auto"> <FormLabel>Repositories ({selectedRepos.length} selected)</FormLabel>
{repositories.map((repo) => ( <div className="h-64 w-full rounded-md border p-4 overflow-y-auto">
<div key={repo.id} className="flex items-center space-x-2 mb-2 py-1"> {repositories.map((repo) => (
<Checkbox <div key={repo.id} className="flex items-center space-x-2 mb-2 py-1">
id={`repo-${repo.id}`} <Checkbox
checked={selectedRepos.includes(repo.full_name)} id={`repo-${repo.id}`}
onCheckedChange={(checked) => handleRepoSelection(repo.full_name, !!checked)} checked={selectedRepos.includes(repo.full_name)}
/> onCheckedChange={(checked) =>
<label handleRepoSelection(repo.full_name, !!checked)
htmlFor={`repo-${repo.id}`} }
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" />
> <label
{repo.full_name} {repo.private && "(Private)"} htmlFor={`repo-${repo.id}`}
</label> className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
</div> >
))} {repo.full_name} {repo.private && "(Private)"}
</div> </label>
<FormDescription> </div>
Select the repositories you wish to index. Only checked repositories will be processed. ))}
</FormDescription> </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"> <div className="flex justify-between items-center pt-4">
<Button <Button
variant="outline" variant="outline"
onClick={() => { onClick={() => {
setStep('enter_pat'); setStep("enter_pat");
setRepositories([]); setRepositories([]);
setSelectedRepos([]); setSelectedRepos([]);
setValidatedPat(""); setValidatedPat("");
form.reset({ name: connectorName, github_pat: "" }); form.reset({ name: connectorName, github_pat: "" });
}} }}
> >
Back Back
</Button> </Button>
<Button <Button
onClick={handleCreateConnector} onClick={handleCreateConnector}
disabled={isCreatingConnector || selectedRepos.length === 0} disabled={isCreatingConnector || selectedRepos.length === 0}
className="w-full sm:w-auto" className="w-full sm:w-auto"
> >
{isCreatingConnector ? ( {isCreatingConnector ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating Connector... Creating Connector...
</> </>
) : ( ) : (
<> <>
<Check className="mr-2 h-4 w-4" /> <Check className="mr-2 h-4 w-4" />
Create Connector Create Connector
</> </>
)} )}
</Button> </Button>
</div> </div>
</div> </div>
)} )}
</CardContent> </CardContent>
)} )}
</Form> </Form>
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4"> <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> <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"> <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>Search through code and documentation in your selected repositories</li>
<li>Access READMEs, Markdown files, and common code files</li> <li>Access READMEs, Markdown files, and common code files</li>
<li>Connect your project knowledge directly to your search space</li> <li>Connect your project knowledge directly to your search space</li>
<li>Index your selected repositories for enhanced search capabilities</li> <li>Index your selected repositories for enhanced search capabilities</li>
</ul> </ul>
</CardFooter> </CardFooter>
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent value="documentation"> <TabsContent value="documentation">
<Card className="border-2 border-border"> <Card className="border-2 border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-2xl font-bold">GitHub Connector Setup Guide</CardTitle> <CardTitle className="text-2xl font-bold">GitHub Connector Setup Guide</CardTitle>
<CardDescription> <CardDescription>
Learn how to generate a Personal Access Token (PAT) and connect your GitHub account. Learn how to generate a Personal Access Token (PAT) and connect your GitHub
</CardDescription> account.
</CardHeader> </CardDescription>
<CardContent className="space-y-6"> </CardHeader>
<div> <CardContent className="space-y-6">
<h3 className="text-xl font-semibold mb-2">How it works</h3> <div>
<p className="text-muted-foreground"> <h3 className="text-xl font-semibold mb-2">How it works</h3>
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 className="text-muted-foreground">
</p> The GitHub connector uses a Personal Access Token (PAT) to authenticate with the
<ul className="mt-2 list-disc pl-5 text-muted-foreground"> GitHub API. First, it fetches a list of repositories accessible to the token.
<li>The connector indexes files based on common code and documentation extensions.</li> You then select which repositories you want to index. The connector indexes
<li>Large files (over 1MB) are skipped during indexing.</li> relevant files (code, markdown, text) from only the selected repositories.
<li>Only selected repositories are indexed.</li> </p>
<li>Indexing runs periodically (check connector settings for frequency) to keep content up-to-date.</li> <ul className="mt-2 list-disc pl-5 text-muted-foreground">
</ul> <li>
</div> 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"> <Accordion type="single" collapsible className="w-full">
<AccordionItem value="create_pat"> <AccordionItem value="create_pat">
<AccordionTrigger className="text-lg font-medium">Step 1: Generate GitHub PAT</AccordionTrigger> <AccordionTrigger className="text-lg font-medium">
<AccordionContent> Step 1: Generate GitHub PAT
<div className="space-y-6"> </AccordionTrigger>
<div> <AccordionContent>
<h4 className="font-medium mb-2">Generating a Token:</h4> <div className="space-y-6">
<ol className="list-decimal pl-5 space-y-3"> <div>
<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> <h4 className="font-medium mb-2">Generating a Token:</h4>
<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> <ol className="list-decimal pl-5 space-y-3">
<li>Click <strong>Generate new token</strong> (and choose the appropriate type).</li> <li>
<li>Give your token a descriptive name (e.g., "SurfSense Connector").</li> Go to your GitHub{" "}
<li>Set an expiration date for the token (recommended for security).</li> <a
<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> href="https://github.com/settings/tokens"
<li>Click <strong>Generate token</strong>.</li> target="_blank"
<li><strong>Important:</strong> Copy your new PAT immediately. You won't be able to see it again after leaving the page.</li> rel="noopener noreferrer"
</ol> className="font-medium underline underline-offset-4"
</div> >
</div> Developer settings
</AccordionContent> </a>
</AccordionItem> .
</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"> <AccordionItem value="connect_app">
<AccordionTrigger className="text-lg font-medium">Step 2: Connect in SurfSense</AccordionTrigger> <AccordionTrigger className="text-lg font-medium">
<AccordionContent className="space-y-4"> Step 2: Connect in SurfSense
<ol className="list-decimal pl-5 space-y-3"> </AccordionTrigger>
<li>Navigate to the "Connect GitHub" tab.</li> <AccordionContent className="space-y-4">
<li>Enter a name for your connector.</li> <ol className="list-decimal pl-5 space-y-3">
<li>Paste the copied GitHub PAT into the "GitHub Personal Access Token (PAT)" field.</li> <li>Navigate to the "Connect GitHub" tab.</li>
<li>Click <strong>Fetch Repositories</strong>.</li> <li>Enter a name for your connector.</li>
<li>If the PAT is valid, you'll see a list of your accessible repositories.</li> <li>
<li>Select the repositories you want SurfSense to index using the checkboxes.</li> Paste the copied GitHub PAT into the "GitHub Personal Access Token (PAT)"
<li>Click the <strong>Create Connector</strong> button.</li> field.
<li>If the connection is successful, you will be redirected and can start indexing from the Connectors page.</li> </li>
</ol> <li>
</AccordionContent> Click <strong>Fetch Repositories</strong>.
</AccordionItem> </li>
</Accordion> <li>
</CardContent> If the PAT is valid, you'll see a list of your accessible repositories.
</Card> </li>
</TabsContent> <li>
</Tabs> Select the repositories you want SurfSense to index using the checkboxes.
</motion.div> </li>
</div> <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"; "use client";
import { useState } from "react";
import { useRouter, useParams } from "next/navigation";
import { motion } from "framer-motion";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { motion } from "framer-motion";
import * as z from "zod";
import { toast } from "sonner";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react"; import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors"; import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { import {
Form, Accordion,
FormControl, AccordionContent,
FormDescription, AccordionItem,
FormField, AccordionTrigger,
FormItem, } from "@/components/ui/accordion";
FormLabel, import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardFooter, CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { import {
Accordion, Form,
AccordionContent, FormControl,
AccordionItem, FormDescription,
AccordionTrigger, FormField,
} from "@/components/ui/accordion"; FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
// Define the form schema with Zod // Define the form schema with Zod
const jiraConnectorFormSchema = z.object({ const jiraConnectorFormSchema = z.object({
name: z.string().min(3, { name: z.string().min(3, {
message: "Connector name must be at least 3 characters.", message: "Connector name must be at least 3 characters.",
}), }),
base_url: z base_url: z
.string() .string()
.url({ .url({
message: message: "Please enter a valid Jira URL (e.g., https://yourcompany.atlassian.net)",
"Please enter a valid Jira URL (e.g., https://yourcompany.atlassian.net)", })
}) .refine(
.refine( (url) => {
(url) => { return url.includes("atlassian.net") || url.includes("jira");
return url.includes("atlassian.net") || url.includes("jira"); },
}, {
{ message: "Please enter a valid Jira instance URL",
message: "Please enter a valid Jira instance URL", }
}, ),
), email: z.string().email({
email: z.string().email({ message: "Please enter a valid email address.",
message: "Please enter a valid email address.", }),
}), api_token: z.string().min(10, {
api_token: z.string().min(10, { message: "Jira API Token is required and must be valid.",
message: "Jira API Token is required and must be valid.", }),
}),
}); });
// Define the type for the form values // Define the type for the form values
type JiraConnectorFormValues = z.infer<typeof jiraConnectorFormSchema>; type JiraConnectorFormValues = z.infer<typeof jiraConnectorFormSchema>;
export default function JiraConnectorPage() { export default function JiraConnectorPage() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const searchSpaceId = params.search_space_id as string; const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const { createConnector } = useSearchSourceConnectors(); const { createConnector } = useSearchSourceConnectors();
// Initialize the form // Initialize the form
const form = useForm<JiraConnectorFormValues>({ const form = useForm<JiraConnectorFormValues>({
resolver: zodResolver(jiraConnectorFormSchema), resolver: zodResolver(jiraConnectorFormSchema),
defaultValues: { defaultValues: {
name: "Jira Connector", name: "Jira Connector",
base_url: "", base_url: "",
email: "", email: "",
api_token: "", api_token: "",
}, },
}); });
// Handle form submission // Handle form submission
const onSubmit = async (values: JiraConnectorFormValues) => { const onSubmit = async (values: JiraConnectorFormValues) => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await createConnector({ await createConnector({
name: values.name, name: values.name,
connector_type: "JIRA_CONNECTOR", connector_type: "JIRA_CONNECTOR",
config: { config: {
JIRA_BASE_URL: values.base_url, JIRA_BASE_URL: values.base_url,
JIRA_EMAIL: values.email, JIRA_EMAIL: values.email,
JIRA_API_TOKEN: values.api_token, JIRA_API_TOKEN: values.api_token,
}, },
is_indexable: true, is_indexable: true,
last_indexed_at: null, last_indexed_at: null,
}); });
toast.success("Jira connector created successfully!"); toast.success("Jira connector created successfully!");
// Navigate back to connectors page // Navigate back to connectors page
router.push(`/dashboard/${searchSpaceId}/connectors`); router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) { } catch (error) {
console.error("Error creating connector:", error); console.error("Error creating connector:", error);
toast.error( toast.error(error instanceof Error ? error.message : "Failed to create connector");
error instanceof Error ? error.message : "Failed to create connector", } finally {
); setIsSubmitting(false);
} finally { }
setIsSubmitting(false); };
}
};
return ( return (
<div className="container mx-auto py-8 max-w-3xl"> <div className="container mx-auto py-8 max-w-3xl">
<Button <Button
variant="ghost" variant="ghost"
className="mb-6" className="mb-6"
onClick={() => onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
router.push(`/dashboard/${searchSpaceId}/connectors/add`) >
} <ArrowLeft className="mr-2 h-4 w-4" />
> Back to Connectors
<ArrowLeft className="mr-2 h-4 w-4" /> </Button>
Back to Connectors
</Button>
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
> >
<Tabs defaultValue="connect" className="w-full"> <Tabs defaultValue="connect" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6"> <TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="connect">Connect</TabsTrigger> <TabsTrigger value="connect">Connect</TabsTrigger>
<TabsTrigger value="documentation">Documentation</TabsTrigger> <TabsTrigger value="documentation">Documentation</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="connect"> <TabsContent value="connect">
<Card className="border-2 border-border"> <Card className="border-2 border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-2xl font-bold"> <CardTitle className="text-2xl font-bold">Connect Jira Instance</CardTitle>
Connect Jira Instance <CardDescription>
</CardTitle> Integrate with Jira to search and retrieve information from your issues, tickets,
<CardDescription> and comments. This connector can index your Jira content for search.
Integrate with Jira to search and retrieve information from </CardDescription>
your issues, tickets, and comments. This connector can index </CardHeader>
your Jira content for search. <CardContent>
</CardDescription> <Alert className="mb-6 bg-muted">
</CardHeader> <Info className="h-4 w-4" />
<CardContent> <AlertTitle>Jira Personal Access Token Required</AlertTitle>
<Alert className="mb-6 bg-muted"> <AlertDescription>
<Info className="h-4 w-4" /> You'll need a Jira Personal Access Token to use this connector. You can create
<AlertTitle>Jira Personal Access Token Required</AlertTitle> one from{" "}
<AlertDescription> <a
You'll need a Jira Personal Access Token to use this href="https://id.atlassian.com/manage-profile/security/api-tokens"
connector. You can create one from{" "} target="_blank"
<a rel="noopener noreferrer"
href="https://id.atlassian.com/manage-profile/security/api-tokens" className="font-medium underline underline-offset-4"
target="_blank" >
rel="noopener noreferrer" Atlassian Account Settings
className="font-medium underline underline-offset-4" </a>
> </AlertDescription>
Atlassian Account Settings </Alert>
</a>
</AlertDescription>
</Alert>
<Form {...form}> <Form {...form}>
<form <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
onSubmit={form.handleSubmit(onSubmit)} <FormField
className="space-y-6" control={form.control}
> name="name"
<FormField render={({ field }) => (
control={form.control} <FormItem>
name="name" <FormLabel>Connector Name</FormLabel>
render={({ field }) => ( <FormControl>
<FormItem> <Input placeholder="My Jira Connector" {...field} />
<FormLabel>Connector Name</FormLabel> </FormControl>
<FormControl> <FormDescription>
<Input placeholder="My Jira Connector" {...field} /> A friendly name to identify this connector.
</FormControl> </FormDescription>
<FormDescription> <FormMessage />
A friendly name to identify this connector. </FormItem>
</FormDescription> )}
<FormMessage /> />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="base_url" name="base_url"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Jira Instance URL</FormLabel> <FormLabel>Jira Instance URL</FormLabel>
<FormControl> <FormControl>
<Input <Input placeholder="https://yourcompany.atlassian.net" {...field} />
placeholder="https://yourcompany.atlassian.net" </FormControl>
{...field} <FormDescription>
/> Your Jira instance URL. For Atlassian Cloud, this is typically
</FormControl> https://yourcompany.atlassian.net
<FormDescription> </FormDescription>
Your Jira instance URL. For Atlassian Cloud, this is <FormMessage />
typically https://yourcompany.atlassian.net </FormItem>
</FormDescription> )}
<FormMessage /> />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="email" name="email"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Email Address</FormLabel> <FormLabel>Email Address</FormLabel>
<FormControl> <FormControl>
<Input <Input type="email" placeholder="your.email@company.com" {...field} />
type="email" </FormControl>
placeholder="your.email@company.com" <FormDescription>Your Atlassian account email address.</FormDescription>
{...field} <FormMessage />
/> </FormItem>
</FormControl> )}
<FormDescription> />
Your Atlassian account email address.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="api_token" name="api_token"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>API Token</FormLabel> <FormLabel>API Token</FormLabel>
<FormControl> <FormControl>
<Input <Input type="password" placeholder="Your Jira API Token" {...field} />
type="password" </FormControl>
placeholder="Your Jira API Token" <FormDescription>
{...field} Your Jira API Token will be encrypted and stored securely.
/> </FormDescription>
</FormControl> <FormMessage />
<FormDescription> </FormItem>
Your Jira API Token will be encrypted and stored securely. )}
</FormDescription> />
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
type="submit" {isSubmitting ? (
disabled={isSubmitting} <>
className="w-full sm:w-auto" <Loader2 className="mr-2 h-4 w-4 animate-spin" />
> Connecting...
{isSubmitting ? ( </>
<> ) : (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <>
Connecting... <Check className="mr-2 h-4 w-4" />
</> Connect Jira
) : ( </>
<> )}
<Check className="mr-2 h-4 w-4" /> </Button>
Connect Jira </div>
</> </form>
)} </Form>
</Button> </CardContent>
</div> <CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
</form> <h4 className="text-sm font-medium">What you get with Jira integration:</h4>
</Form> <ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
</CardContent> <li>Search through all your Jira issues and tickets</li>
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4"> <li>Access issue descriptions, comments, and full discussion threads</li>
<h4 className="text-sm font-medium"> <li>Connect your team's project management directly to your search space</li>
What you get with Jira integration: <li>Keep your search results up-to-date with latest Jira content</li>
</h4> <li>Index your Jira issues for enhanced search capabilities</li>
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground"> <li>Search by issue keys, status, priority, and assignee information</li>
<li>Search through all your Jira issues and tickets</li> </ul>
<li> </CardFooter>
Access issue descriptions, comments, and full discussion </Card>
threads </TabsContent>
</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"> <TabsContent value="documentation">
<Card className="border-2 border-border"> <Card className="border-2 border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-2xl font-bold"> <CardTitle className="text-2xl font-bold">Jira Connector Documentation</CardTitle>
Jira Connector Documentation <CardDescription>
</CardTitle> Learn how to set up and use the Jira connector to index your project management
<CardDescription> data.
Learn how to set up and use the Jira connector to index your </CardDescription>
project management data. </CardHeader>
</CardDescription> <CardContent className="space-y-6">
</CardHeader> <div>
<CardContent className="space-y-6"> <h3 className="text-xl font-semibold mb-2">How it works</h3>
<div> <p className="text-muted-foreground">
<h3 className="text-xl font-semibold mb-2">How it works</h3> The Jira connector uses the Jira REST API with Basic Authentication to fetch all
<p className="text-muted-foreground"> issues and comments that your account has access to within your Jira instance.
The Jira connector uses the Jira REST API with Basic Authentication </p>
to fetch all issues and comments that your account has <ul className="mt-2 list-disc pl-5 text-muted-foreground">
access to within your Jira instance. <li>
</p> For follow up indexing runs, the connector retrieves issues and comments that
<ul className="mt-2 list-disc pl-5 text-muted-foreground"> have been updated since the last indexing attempt.
<li> </li>
For follow up indexing runs, the connector retrieves <li>
issues and comments that have been updated since the last Indexing is configured to run periodically, so updates should appear in your
indexing attempt. search results within minutes.
</li> </li>
<li> </ul>
Indexing is configured to run periodically, so updates </div>
should appear in your search results within minutes.
</li>
</ul>
</div>
<Accordion type="single" collapsible className="w-full"> <Accordion type="single" collapsible className="w-full">
<AccordionItem value="authorization"> <AccordionItem value="authorization">
<AccordionTrigger className="text-lg font-medium"> <AccordionTrigger className="text-lg font-medium">
Authorization Authorization
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="space-y-4"> <AccordionContent className="space-y-4">
<Alert className="bg-muted"> <Alert className="bg-muted">
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
<AlertTitle>Read-Only Access is Sufficient</AlertTitle> <AlertTitle>Read-Only Access is Sufficient</AlertTitle>
<AlertDescription> <AlertDescription>
You only need read access for this connector to work. You only need read access for this connector to work. The API Token will
The API Token will only be used to read your Jira data. only be used to read your Jira data.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h4 className="font-medium mb-2"> <h4 className="font-medium mb-2">Step 1: Create an API Token</h4>
Step 1: Create an API Token <ol className="list-decimal pl-5 space-y-3">
</h4> <li>Log in to your Atlassian account</li>
<ol className="list-decimal pl-5 space-y-3"> <li>
<li>Log in to your Atlassian account</li> Navigate to{" "}
<li> <a
Navigate to{" "} href="https://id.atlassian.com/manage-profile/security/api-tokens"
<a target="_blank"
href="https://id.atlassian.com/manage-profile/security/api-tokens" rel="noopener noreferrer"
target="_blank" className="font-medium underline underline-offset-4"
rel="noopener noreferrer" >
className="font-medium underline underline-offset-4" https://id.atlassian.com/manage-profile/security/api-tokens
> </a>
https://id.atlassian.com/manage-profile/security/api-tokens </li>
</a> <li>
</li> Click <strong>Create API token</strong>
<li> </li>
Click <strong>Create API token</strong> <li>Enter a label for your token (like "SurfSense Connector")</li>
</li> <li>
<li> Click <strong>Create</strong>
Enter a label for your token (like "SurfSense </li>
Connector") <li>Copy the generated token as it will only be shown once</li>
</li> </ol>
<li> </div>
Click <strong>Create</strong>
</li>
<li>
Copy the generated token as it will only be shown
once
</li>
</ol>
</div>
<div> <div>
<h4 className="font-medium mb-2"> <h4 className="font-medium mb-2">Step 2: Grant necessary access</h4>
Step 2: Grant necessary access <p className="text-muted-foreground mb-3">
</h4> The API Token will have access to all projects and issues that your user
<p className="text-muted-foreground mb-3"> account can see. Make sure your account has appropriate permissions for
The API Token will have access to all projects and the projects you want to index.
issues that your user account can see. Make sure your </p>
account has appropriate permissions for the projects <Alert className="bg-muted">
you want to index. <Info className="h-4 w-4" />
</p> <AlertTitle>Data Privacy</AlertTitle>
<Alert className="bg-muted"> <AlertDescription>
<Info className="h-4 w-4" /> Only issues, comments, and basic metadata will be indexed. Jira
<AlertTitle>Data Privacy</AlertTitle> attachments and linked files are not indexed by this connector.
<AlertDescription> </AlertDescription>
Only issues, comments, and basic metadata will be </Alert>
indexed. Jira attachments and linked files are not </div>
indexed by this connector. </div>
</AlertDescription> </AccordionContent>
</Alert> </AccordionItem>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="indexing"> <AccordionItem value="indexing">
<AccordionTrigger className="text-lg font-medium"> <AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
Indexing <AccordionContent className="space-y-4">
</AccordionTrigger> <ol className="list-decimal pl-5 space-y-3">
<AccordionContent className="space-y-4"> <li>
<ol className="list-decimal pl-5 space-y-3"> Navigate to the Connector Dashboard and select the <strong>Jira</strong>{" "}
<li> Connector.
Navigate to the Connector Dashboard and select the{" "} </li>
<strong>Jira</strong> Connector. <li>
</li> Enter your <strong>Jira Instance URL</strong> (e.g.,
<li> https://yourcompany.atlassian.net)
Enter your <strong>Jira Instance URL</strong> (e.g., </li>
https://yourcompany.atlassian.net) <li>
</li> Place your <strong>Personal Access Token</strong> in the form field.
<li> </li>
Place your <strong>Personal Access Token</strong> in <li>
the form field. Click <strong>Connect</strong> to establish the connection.
</li> </li>
<li> <li>Once connected, your Jira issues will be indexed automatically.</li>
Click <strong>Connect</strong> to establish the </ol>
connection.
</li>
<li>
Once connected, your Jira issues will be indexed
automatically.
</li>
</ol>
<Alert className="bg-muted"> <Alert className="bg-muted">
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
<AlertTitle>What Gets Indexed</AlertTitle> <AlertTitle>What Gets Indexed</AlertTitle>
<AlertDescription> <AlertDescription>
<p className="mb-2"> <p className="mb-2">The Jira connector indexes the following data:</p>
The Jira connector indexes the following data: <ul className="list-disc pl-5">
</p> <li>Issue keys and summaries (e.g., PROJ-123)</li>
<ul className="list-disc pl-5"> <li>Issue descriptions</li>
<li>Issue keys and summaries (e.g., PROJ-123)</li> <li>Issue comments and discussion threads</li>
<li>Issue descriptions</li> <li>Issue status, priority, and type information</li>
<li>Issue comments and discussion threads</li> <li>Assignee and reporter information</li>
<li> <li>Project information</li>
Issue status, priority, and type information </ul>
</li> </AlertDescription>
<li>Assignee and reporter information</li> </Alert>
<li>Project information</li> </AccordionContent>
</ul> </AccordionItem>
</AlertDescription> </Accordion>
</Alert> </CardContent>
</AccordionContent> </Card>
</AccordionItem> </TabsContent>
</Accordion> </Tabs>
</CardContent> </motion.div>
</Card> </div>
</TabsContent> );
</Tabs>
</motion.div>
</div>
);
} }

View file

@ -1,321 +1,353 @@
"use client"; "use client";
import { useState } from "react";
import { useRouter, useParams } from "next/navigation";
import { motion } from "framer-motion";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { motion } from "framer-motion";
import * as z from "zod";
import { toast } from "sonner";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react"; import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors"; import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { import {
Form, Accordion,
FormControl, AccordionContent,
FormDescription, AccordionItem,
FormField, AccordionTrigger,
FormItem, } from "@/components/ui/accordion";
FormLabel, import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardFooter, CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { import {
Alert, Form,
AlertDescription, FormControl,
AlertTitle, FormDescription,
} from "@/components/ui/alert"; FormField,
import { FormItem,
Accordion, FormLabel,
AccordionContent, FormMessage,
AccordionItem, } from "@/components/ui/form";
AccordionTrigger, import { Input } from "@/components/ui/input";
} from "@/components/ui/accordion";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
// Define the form schema with Zod // Define the form schema with Zod
const linearConnectorFormSchema = z.object({ const linearConnectorFormSchema = z.object({
name: z.string().min(3, { name: z.string().min(3, {
message: "Connector name must be at least 3 characters.", message: "Connector name must be at least 3 characters.",
}), }),
api_key: z.string().min(10, { api_key: z
message: "Linear API Key is required and must be valid.", .string()
}).regex(/^lin_api_/, { .min(10, {
message: "Linear API Key should start with 'lin_api_'", 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 // Define the type for the form values
type LinearConnectorFormValues = z.infer<typeof linearConnectorFormSchema>; type LinearConnectorFormValues = z.infer<typeof linearConnectorFormSchema>;
export default function LinearConnectorPage() { export default function LinearConnectorPage() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const searchSpaceId = params.search_space_id as string; const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const { createConnector } = useSearchSourceConnectors(); const { createConnector } = useSearchSourceConnectors();
// Initialize the form // Initialize the form
const form = useForm<LinearConnectorFormValues>({ const form = useForm<LinearConnectorFormValues>({
resolver: zodResolver(linearConnectorFormSchema), resolver: zodResolver(linearConnectorFormSchema),
defaultValues: { defaultValues: {
name: "Linear Connector", name: "Linear Connector",
api_key: "", api_key: "",
}, },
}); });
// Handle form submission // Handle form submission
const onSubmit = async (values: LinearConnectorFormValues) => { const onSubmit = async (values: LinearConnectorFormValues) => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await createConnector({ await createConnector({
name: values.name, name: values.name,
connector_type: "LINEAR_CONNECTOR", connector_type: "LINEAR_CONNECTOR",
config: { config: {
LINEAR_API_KEY: values.api_key, LINEAR_API_KEY: values.api_key,
}, },
is_indexable: true, is_indexable: true,
last_indexed_at: null, last_indexed_at: null,
}); });
toast.success("Linear connector created successfully!"); 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);
}
};
return ( // Navigate back to connectors page
<div className="container mx-auto py-8 max-w-3xl"> router.push(`/dashboard/${searchSpaceId}/connectors`);
<Button } catch (error) {
variant="ghost" console.error("Error creating connector:", error);
className="mb-6" toast.error(error instanceof Error ? error.message : "Failed to create connector");
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)} } finally {
> setIsSubmitting(false);
<ArrowLeft className="mr-2 h-4 w-4" /> }
Back to Connectors };
</Button>
<motion.div return (
initial={{ opacity: 0, y: 20 }} <div className="container mx-auto py-8 max-w-3xl">
animate={{ opacity: 1, y: 0 }} <Button
transition={{ duration: 0.5 }} variant="ghost"
> className="mb-6"
<Tabs defaultValue="connect" className="w-full"> onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
<TabsList className="grid w-full grid-cols-2 mb-6"> >
<TabsTrigger value="connect">Connect</TabsTrigger> <ArrowLeft className="mr-2 h-4 w-4" />
<TabsTrigger value="documentation">Documentation</TabsTrigger> Back to Connectors
</TabsList> </Button>
<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>
<Form {...form}> <motion.div
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> initial={{ opacity: 0, y: 20 }}
<FormField animate={{ opacity: 1, y: 0 }}
control={form.control} transition={{ duration: 0.5 }}
name="name" >
render={({ field }) => ( <Tabs defaultValue="connect" className="w-full">
<FormItem> <TabsList className="grid w-full grid-cols-2 mb-6">
<FormLabel>Connector Name</FormLabel> <TabsTrigger value="connect">Connect</TabsTrigger>
<FormControl> <TabsTrigger value="documentation">Documentation</TabsTrigger>
<Input placeholder="My Linear Connector" {...field} /> </TabsList>
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField <TabsContent value="connect">
control={form.control} <Card className="border-2 border-border">
name="api_key" <CardHeader>
render={({ field }) => ( <CardTitle className="text-2xl font-bold">Connect Linear Workspace</CardTitle>
<FormItem> <CardDescription>
<FormLabel>Linear API Key</FormLabel> Integrate with Linear to search and retrieve information from your issues and
<FormControl> comments. This connector can index your Linear content for search.
<Input </CardDescription>
type="password" </CardHeader>
placeholder="lin_api_..." <CardContent>
{...field} <Alert className="mb-6 bg-muted">
/> <Info className="h-4 w-4" />
</FormControl> <AlertTitle>Linear API Key Required</AlertTitle>
<FormDescription> <AlertDescription>
Your Linear API Key will be encrypted and stored securely. It typically starts with "lin_api_". You'll need a Linear API Key to use this connector. You can create a Linear API
</FormDescription> key from{" "}
<FormMessage /> <a
</FormItem> 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"> <Form {...form}>
<Button <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
type="submit" <FormField
disabled={isSubmitting} control={form.control}
className="w-full sm:w-auto" name="name"
> render={({ field }) => (
{isSubmitting ? ( <FormItem>
<> <FormLabel>Connector Name</FormLabel>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <FormControl>
Connecting... <Input placeholder="My Linear Connector" {...field} />
</> </FormControl>
) : ( <FormDescription>
<> A friendly name to identify this connector.
<Check className="mr-2 h-4 w-4" /> </FormDescription>
Connect Linear <FormMessage />
</> </FormItem>
)} )}
</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> <FormField
<h4 className="font-medium mb-2">Step 2: Grant necessary access</h4> control={form.control}
<p className="text-muted-foreground mb-3"> name="api_key"
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. render={({ field }) => (
</p> <FormItem>
<Alert className="bg-muted"> <FormLabel>Linear API Key</FormLabel>
<Info className="h-4 w-4" /> <FormControl>
<AlertTitle>Data Privacy</AlertTitle> <Input type="password" placeholder="lin_api_..." {...field} />
<AlertDescription> </FormControl>
Only issues and comments will be indexed. Linear attachments and linked files are not indexed by this connector. <FormDescription>
</AlertDescription> Your Linear API Key will be encrypted and stored securely. It typically
</Alert> starts with "lin_api_".
</div> </FormDescription>
</div> <FormMessage />
</AccordionContent> </FormItem>
</AccordionItem> )}
/>
<AccordionItem value="indexing">
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger> <div className="flex justify-end">
<AccordionContent className="space-y-4"> <Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
<ol className="list-decimal pl-5 space-y-3"> {isSubmitting ? (
<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> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
<li>Click <strong>Connect</strong> to establish the connection.</li> Connecting...
<li>Once connected, your Linear issues will be indexed automatically.</li> </>
</ol> ) : (
<>
<Alert className="bg-muted"> <Check className="mr-2 h-4 w-4" />
<Info className="h-4 w-4" /> Connect Linear
<AlertTitle>What Gets Indexed</AlertTitle> </>
<AlertDescription> )}
<p className="mb-2">The Linear connector indexes the following data:</p> </Button>
<ul className="list-disc pl-5"> </div>
<li>Issue titles and identifiers (e.g., PROJ-123)</li> </form>
<li>Issue descriptions</li> </Form>
<li>Issue comments</li> </CardContent>
<li>Issue status and metadata</li> <CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
</ul> <h4 className="text-sm font-medium">What you get with Linear integration:</h4>
</AlertDescription> <ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
</Alert> <li>Search through all your Linear issues and comments</li>
</AccordionContent> <li>Access issue titles, descriptions, and full discussion threads</li>
</AccordionItem> <li>Connect your team's project management directly to your search space</li>
</Accordion> <li>Keep your search results up-to-date with latest Linear content</li>
</CardContent> <li>Index your Linear issues for enhanced search capabilities</li>
</Card> </ul>
</TabsContent> </CardFooter>
</Tabs> </Card>
</motion.div> </TabsContent>
</div>
); <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"; "use client";
import { useState } from "react";
import { useRouter, useParams } from "next/navigation";
import { motion } from "framer-motion";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { motion } from "framer-motion";
import * as z from "zod";
import { toast } from "sonner";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react"; import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors"; import { useState } from "react";
import { import { useForm } from "react-hook-form";
Form, import { toast } from "sonner";
FormControl, import * as z from "zod";
FormDescription, import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardFooter, CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { import {
Alert, Form,
AlertDescription, FormControl,
AlertTitle, FormDescription,
} from "@/components/ui/alert"; 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 // Define the form schema with Zod
const linkupApiFormSchema = z.object({ const linkupApiFormSchema = z.object({
name: z.string().min(3, { name: z.string().min(3, {
message: "Connector name must be at least 3 characters.", message: "Connector name must be at least 3 characters.",
}), }),
api_key: z.string().min(10, { api_key: z.string().min(10, {
message: "API key is required and must be valid.", message: "API key is required and must be valid.",
}), }),
}); });
// Define the type for the form values // Define the type for the form values
type LinkupApiFormValues = z.infer<typeof linkupApiFormSchema>; type LinkupApiFormValues = z.infer<typeof linkupApiFormSchema>;
export default function LinkupApiPage() { export default function LinkupApiPage() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const searchSpaceId = params.search_space_id as string; const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const { createConnector } = useSearchSourceConnectors(); const { createConnector } = useSearchSourceConnectors();
// Initialize the form // Initialize the form
const form = useForm<LinkupApiFormValues>({ const form = useForm<LinkupApiFormValues>({
resolver: zodResolver(linkupApiFormSchema), resolver: zodResolver(linkupApiFormSchema),
defaultValues: { defaultValues: {
name: "Linkup API Connector", name: "Linkup API Connector",
api_key: "", api_key: "",
}, },
}); });
// Handle form submission // Handle form submission
const onSubmit = async (values: LinkupApiFormValues) => { const onSubmit = async (values: LinkupApiFormValues) => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await createConnector({ await createConnector({
name: values.name, name: values.name,
connector_type: "LINKUP_API", connector_type: "LINKUP_API",
config: { config: {
LINKUP_API_KEY: values.api_key, LINKUP_API_KEY: values.api_key,
}, },
is_indexable: false, is_indexable: false,
last_indexed_at: null, last_indexed_at: null,
}); });
toast.success("Linkup API connector created successfully!"); 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);
}
};
return ( // Navigate back to connectors page
<div className="container mx-auto py-8 max-w-3xl"> router.push(`/dashboard/${searchSpaceId}/connectors`);
<Button } catch (error) {
variant="ghost" console.error("Error creating connector:", error);
className="mb-6" toast.error(error instanceof Error ? error.message : "Failed to create connector");
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)} } finally {
> setIsSubmitting(false);
<ArrowLeft className="mr-2 h-4 w-4" /> }
Back to Connectors };
</Button>
<motion.div return (
initial={{ opacity: 0, y: 20 }} <div className="container mx-auto py-8 max-w-3xl">
animate={{ opacity: 1, y: 0 }} <Button
transition={{ duration: 0.5 }} variant="ghost"
> className="mb-6"
<Card className="border-2 border-border"> onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
<CardHeader> >
<CardTitle className="text-2xl font-bold">Connect Linkup API</CardTitle> <ArrowLeft className="mr-2 h-4 w-4" />
<CardDescription> Back to Connectors
Integrate with Linkup API to enhance your search capabilities with AI-powered search results. </Button>
</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>
<Form {...form}> <motion.div
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> initial={{ opacity: 0, y: 20 }}
<FormField animate={{ opacity: 1, y: 0 }}
control={form.control} transition={{ duration: 0.5 }}
name="name" >
render={({ field }) => ( <Card className="border-2 border-border">
<FormItem> <CardHeader>
<FormLabel>Connector Name</FormLabel> <CardTitle className="text-2xl font-bold">Connect Linkup API</CardTitle>
<FormControl> <CardDescription>
<Input placeholder="My Linkup API Connector" {...field} /> Integrate with Linkup API to enhance your search capabilities with AI-powered search
</FormControl> results.
<FormDescription> </CardDescription>
A friendly name to identify this connector. </CardHeader>
</FormDescription> <CardContent>
<FormMessage /> <Alert className="mb-6 bg-muted">
</FormItem> <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 <Form {...form}>
control={form.control} <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
name="api_key" <FormField
render={({ field }) => ( control={form.control}
<FormItem> name="name"
<FormLabel>Linkup API Key</FormLabel> render={({ field }) => (
<FormControl> <FormItem>
<Input <FormLabel>Connector Name</FormLabel>
type="password" <FormControl>
placeholder="Enter your Linkup API key" <Input placeholder="My Linkup API Connector" {...field} />
{...field} </FormControl>
/> <FormDescription>A friendly name to identify this connector.</FormDescription>
</FormControl> <FormMessage />
<FormDescription> </FormItem>
Your API key will be encrypted and stored securely. )}
</FormDescription> />
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end"> <FormField
<Button control={form.control}
type="submit" name="api_key"
disabled={isSubmitting} render={({ field }) => (
className="w-full sm:w-auto" <FormItem>
> <FormLabel>Linkup API Key</FormLabel>
{isSubmitting ? ( <FormControl>
<> <Input type="password" placeholder="Enter your Linkup API key" {...field} />
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> </FormControl>
Connecting... <FormDescription>
</> Your API key will be encrypted and stored securely.
) : ( </FormDescription>
<> <FormMessage />
<Check className="mr-2 h-4 w-4" /> </FormItem>
Connect Linkup API )}
</> />
)}
</Button> <div className="flex justify-end">
</div> <Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
</form> {isSubmitting ? (
</Form> <>
</CardContent> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4"> Connecting...
<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> <Check className="mr-2 h-4 w-4" />
<li>Enhanced search capabilities for your projects</li> Connect Linkup API
</ul> </>
</CardFooter> )}
</Card> </Button>
</motion.div> </div>
</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"; "use client";
import { useState } from "react";
import { useRouter, useParams } from "next/navigation";
import { motion } from "framer-motion";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { motion } from "framer-motion";
import * as z from "zod";
import { toast } from "sonner";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react"; import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors"; import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { import {
Form, Accordion,
FormControl, AccordionContent,
FormDescription, AccordionItem,
FormField, AccordionTrigger,
FormItem, } from "@/components/ui/accordion";
FormLabel, import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardFooter, CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { import {
Alert, Form,
AlertDescription, FormControl,
AlertTitle, FormDescription,
} from "@/components/ui/alert"; FormField,
import { FormItem,
Accordion, FormLabel,
AccordionContent, FormMessage,
AccordionItem, } from "@/components/ui/form";
AccordionTrigger, import { Input } from "@/components/ui/input";
} from "@/components/ui/accordion";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
// Define the form schema with Zod // Define the form schema with Zod
const notionConnectorFormSchema = z.object({ const notionConnectorFormSchema = z.object({
name: z.string().min(3, { name: z.string().min(3, {
message: "Connector name must be at least 3 characters.", message: "Connector name must be at least 3 characters.",
}), }),
integration_token: z.string().min(10, { integration_token: z.string().min(10, {
message: "Notion Integration Token is required and must be valid.", message: "Notion Integration Token is required and must be valid.",
}), }),
}); });
// Define the type for the form values // Define the type for the form values
type NotionConnectorFormValues = z.infer<typeof notionConnectorFormSchema>; type NotionConnectorFormValues = z.infer<typeof notionConnectorFormSchema>;
export default function NotionConnectorPage() { export default function NotionConnectorPage() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const searchSpaceId = params.search_space_id as string; const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const { createConnector } = useSearchSourceConnectors(); const { createConnector } = useSearchSourceConnectors();
// Initialize the form // Initialize the form
const form = useForm<NotionConnectorFormValues>({ const form = useForm<NotionConnectorFormValues>({
resolver: zodResolver(notionConnectorFormSchema), resolver: zodResolver(notionConnectorFormSchema),
defaultValues: { defaultValues: {
name: "Notion Connector", name: "Notion Connector",
integration_token: "", integration_token: "",
}, },
}); });
// Handle form submission // Handle form submission
const onSubmit = async (values: NotionConnectorFormValues) => { const onSubmit = async (values: NotionConnectorFormValues) => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await createConnector({ await createConnector({
name: values.name, name: values.name,
connector_type: "NOTION_CONNECTOR", connector_type: "NOTION_CONNECTOR",
config: { config: {
NOTION_INTEGRATION_TOKEN: values.integration_token, NOTION_INTEGRATION_TOKEN: values.integration_token,
}, },
is_indexable: true, is_indexable: true,
last_indexed_at: null, last_indexed_at: null,
}); });
toast.success("Notion connector created successfully!"); toast.success("Notion connector created successfully!");
// Navigate back to connectors page
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to create connector");
} finally {
setIsSubmitting(false);
}
};
return ( // Navigate back to connectors page
<div className="container mx-auto py-8 max-w-3xl"> router.push(`/dashboard/${searchSpaceId}/connectors`);
<Button } catch (error) {
variant="ghost" console.error("Error creating connector:", error);
className="mb-6" toast.error(error instanceof Error ? error.message : "Failed to create connector");
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)} } finally {
> setIsSubmitting(false);
<ArrowLeft className="mr-2 h-4 w-4" /> }
Back to Connectors };
</Button>
<motion.div return (
initial={{ opacity: 0, y: 20 }} <div className="container mx-auto py-8 max-w-3xl">
animate={{ opacity: 1, y: 0 }} <Button
transition={{ duration: 0.5 }} variant="ghost"
> className="mb-6"
<Tabs defaultValue="connect" className="w-full"> onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
<TabsList className="grid w-full grid-cols-2 mb-6"> >
<TabsTrigger value="connect">Connect</TabsTrigger> <ArrowLeft className="mr-2 h-4 w-4" />
<TabsTrigger value="documentation">Documentation</TabsTrigger> Back to Connectors
</TabsList> </Button>
<TabsContent value="connect">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">Connect Notion Workspace</CardTitle>
<CardDescription>
Integrate with Notion to search and retrieve information from your workspace pages and databases. This connector can index your Notion content for search.
</CardDescription>
</CardHeader>
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Notion Integration Token Required</AlertTitle>
<AlertDescription>
You'll need a Notion Integration Token to use this connector. You can create a Notion integration and get the token from{" "}
<a
href="https://www.notion.so/my-integrations"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Notion Integrations Dashboard
</a>
</AlertDescription>
</Alert>
<Form {...form}> <motion.div
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> initial={{ opacity: 0, y: 20 }}
<FormField animate={{ opacity: 1, y: 0 }}
control={form.control} transition={{ duration: 0.5 }}
name="name" >
render={({ field }) => ( <Tabs defaultValue="connect" className="w-full">
<FormItem> <TabsList className="grid w-full grid-cols-2 mb-6">
<FormLabel>Connector Name</FormLabel> <TabsTrigger value="connect">Connect</TabsTrigger>
<FormControl> <TabsTrigger value="documentation">Documentation</TabsTrigger>
<Input placeholder="My Notion Connector" {...field} /> </TabsList>
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField <TabsContent value="connect">
control={form.control} <Card className="border-2 border-border">
name="integration_token" <CardHeader>
render={({ field }) => ( <CardTitle className="text-2xl font-bold">Connect Notion Workspace</CardTitle>
<FormItem> <CardDescription>
<FormLabel>Notion Integration Token</FormLabel> Integrate with Notion to search and retrieve information from your workspace pages
<FormControl> and databases. This connector can index your Notion content for search.
<Input </CardDescription>
type="password" </CardHeader>
placeholder="ntn_.." <CardContent>
{...field} <Alert className="mb-6 bg-muted">
/> <Info className="h-4 w-4" />
</FormControl> <AlertTitle>Notion Integration Token Required</AlertTitle>
<FormDescription> <AlertDescription>
Your Notion Integration Token will be encrypted and stored securely. It typically starts with "ntn_". You'll need a Notion Integration Token to use this connector. You can create a
</FormDescription> Notion integration and get the token from{" "}
<FormMessage /> <a
</FormItem> 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"> <Form {...form}>
<Button <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
type="submit" <FormField
disabled={isSubmitting} control={form.control}
className="w-full sm:w-auto" name="name"
> render={({ field }) => (
{isSubmitting ? ( <FormItem>
<> <FormLabel>Connector Name</FormLabel>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <FormControl>
Connecting... <Input placeholder="My Notion Connector" {...field} />
</> </FormControl>
) : ( <FormDescription>
<> A friendly name to identify this connector.
<Check className="mr-2 h-4 w-4" /> </FormDescription>
Connect Notion <FormMessage />
</> </FormItem>
)} )}
</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> <FormField
<h4 className="font-medium mb-2">Step 2: Share pages/databases with your integration</h4> control={form.control}
<p className="text-muted-foreground mb-3"> name="integration_token"
To keep your information secure, integrations don't have access to any pages or databases in the workspace at first. render={({ field }) => (
You must share specific pages with an integration in order for the connector to access those pages. <FormItem>
</p> <FormLabel>Notion Integration Token</FormLabel>
<ol className="list-decimal pl-5 space-y-3"> <FormControl>
<li>Go to the page/database in your workspace.</li> <Input type="password" placeholder="ntn_.." {...field} />
<li>Click the <code></code> on the top right corner of the page.</li> </FormControl>
<li>Scroll to the bottom of the pop-up and click <strong>Add connections</strong>.</li> <FormDescription>
<li>Search for and select the new integration in the <code>Search for connections...</code> menu.</li> Your Notion Integration Token will be encrypted and stored securely. It
<li> typically starts with "ntn_".
<strong>Important:</strong> </FormDescription>
<ul className="list-disc pl-5 mt-1"> <FormMessage />
<li>If you've added a page, all child pages also become accessible.</li> </FormItem>
<li>If you've added a database, all rows (and their children) become accessible.</li> )}
</ul> />
</li>
</ol> <div className="flex justify-end">
</div> <Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
</div> {isSubmitting ? (
</AccordionContent> <>
</AccordionItem> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
<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"> <Check className="mr-2 h-4 w-4" />
<li>Navigate to the Connector Dashboard and select the <strong>Notion</strong> Connector.</li> Connect Notion
<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> </Button>
</div>
<Alert className="bg-muted"> </form>
<Info className="h-4 w-4" /> </Form>
<AlertTitle>Indexing Behavior</AlertTitle> </CardContent>
<AlertDescription> <CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
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. <h4 className="text-sm font-medium">What you get with Notion integration:</h4>
</AlertDescription> <ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
</Alert> <li>Search through your Notion pages and databases</li>
</AccordionContent> <li>Access documents, wikis, and knowledge bases</li>
</AccordionItem> <li>Connect your team's knowledge directly to your search space</li>
</Accordion> <li>Keep your search results up-to-date with latest Notion content</li>
</CardContent> <li>Index your Notion documents for enhanced search capabilities</li>
</Card> </ul>
</TabsContent> </CardFooter>
</Tabs> </Card>
</motion.div> </TabsContent>
</div>
); <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"; "use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { import {
Card, IconBook,
CardContent, IconBrandDiscord,
CardFooter, IconBrandGithub,
CardHeader, IconBrandNotion,
} from "@/components/ui/card"; IconBrandSlack,
import { IconBrandWindows,
Collapsible, IconBrandZoom,
CollapsibleContent, IconChevronDown,
CollapsibleTrigger, IconChevronRight,
} from "@/components/ui/collapsible"; IconLayoutKanban,
import { IconLinkPlus,
IconBook, IconMail,
IconBrandDiscord, IconTicket,
IconBrandGithub, IconWorldWww,
IconBrandNotion,
IconBrandSlack,
IconBrandWindows,
IconBrandZoom,
IconChevronDown,
IconChevronRight,
IconMail,
IconWorldWww,
IconTicket,
IconLayoutKanban,
IconLinkPlus,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion, type Variants } from "framer-motion";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useState } from "react"; 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 // Define the Connector type
interface Connector { interface Connector {
id: string; id: string;
title: string; title: string;
description: string; description: string;
icon: React.ReactNode; icon: React.ReactNode;
status: "available" | "coming-soon" | "connected"; status: "available" | "coming-soon" | "connected";
} }
interface ConnectorCategory { interface ConnectorCategory {
id: string; id: string;
title: string; title: string;
connectors: Connector[]; connectors: Connector[];
} }
// Define connector categories and their connectors // Define connector categories and their connectors
const connectorCategories: ConnectorCategory[] = [ const connectorCategories: ConnectorCategory[] = [
{ {
id: "search-engines", id: "search-engines",
title: "Search Engines", title: "Search Engines",
connectors: [ connectors: [
{ {
id: "tavily-api", id: "tavily-api",
title: "Tavily API", title: "Tavily API",
description: "Search the web using the Tavily API", description: "Search the web using the Tavily API",
icon: <IconWorldWww className="h-6 w-6" />, icon: <IconWorldWww className="h-6 w-6" />,
status: "available", status: "available",
}, },
{ {
id: "linkup-api", id: "linkup-api",
title: "Linkup API", title: "Linkup API",
description: "Search the web using the Linkup API", description: "Search the web using the Linkup API",
icon: <IconLinkPlus className="h-6 w-6" />, icon: <IconLinkPlus className="h-6 w-6" />,
status: "available", status: "available",
}, },
], ],
}, },
{ {
id: "team-chats", id: "team-chats",
title: "Team Chats", title: "Team Chats",
connectors: [ connectors: [
{ {
id: "slack-connector", id: "slack-connector",
title: "Slack", title: "Slack",
description: description: "Connect to your Slack workspace to access messages and channels.",
"Connect to your Slack workspace to access messages and channels.", icon: <IconBrandSlack className="h-6 w-6" />,
icon: <IconBrandSlack className="h-6 w-6" />, status: "available",
status: "available", },
}, {
{ id: "ms-teams",
id: "ms-teams", title: "Microsoft Teams",
title: "Microsoft Teams", description: "Connect to Microsoft Teams to access your team's conversations.",
description: icon: <IconBrandWindows className="h-6 w-6" />,
"Connect to Microsoft Teams to access your team's conversations.", status: "coming-soon",
icon: <IconBrandWindows className="h-6 w-6" />, },
status: "coming-soon", {
}, id: "discord-connector",
{ title: "Discord",
id: "discord-connector", description: "Connect to Discord servers to access messages and channels.",
title: "Discord", icon: <IconBrandDiscord className="h-6 w-6" />,
description: status: "available",
"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: "project-management", {
title: "Project Management", id: "linear-connector",
connectors: [ title: "Linear",
{ description: "Connect to Linear to search issues, comments and project data.",
id: "linear-connector", icon: <IconLayoutKanban className="h-6 w-6" />,
title: "Linear", status: "available",
description: },
"Connect to Linear to search issues, comments and project data.", {
icon: <IconLayoutKanban className="h-6 w-6" />, id: "jira-connector",
status: "available", title: "Jira",
}, description: "Connect to Jira to search issues, tickets and project data.",
{ icon: <IconTicket className="h-6 w-6" />,
id: "jira-connector", status: "available",
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",
id: "knowledge-bases", title: "Notion",
title: "Knowledge Bases", description: "Connect to your Notion workspace to access pages and databases.",
connectors: [ icon: <IconBrandNotion className="h-6 w-6" />,
{ status: "available",
id: "notion-connector", },
title: "Notion", {
description: id: "github-connector",
"Connect to your Notion workspace to access pages and databases.", title: "GitHub",
icon: <IconBrandNotion className="h-6 w-6" />, description: "Connect a GitHub PAT to index code and docs from accessible repositories.",
status: "available", icon: <IconBrandGithub className="h-6 w-6" />,
}, status: "available",
{ },
id: "github-connector", {
title: "GitHub", id: "confluence-connector",
description: title: "Confluence",
"Connect a GitHub PAT to index code and docs from accessible repositories.", description: "Connect to Confluence to search pages, comments and documentation.",
icon: <IconBrandGithub className="h-6 w-6" />, icon: <IconBook className="h-6 w-6" />,
status: "available", status: "available",
}, },
{ ],
id: "confluence-connector", },
title: "Confluence", {
description: id: "communication",
"Connect to Confluence to search pages, comments and documentation.", title: "Communication",
icon: <IconBook className="h-6 w-6" />, connectors: [
status: "available", {
}, id: "gmail",
], title: "Gmail",
}, description: "Connect to your Gmail account to access emails.",
{ icon: <IconMail className="h-6 w-6" />,
id: "communication", status: "coming-soon",
title: "Communication", },
connectors: [ {
{ id: "zoom",
id: "gmail", title: "Zoom",
title: "Gmail", description: "Connect to Zoom to access meeting recordings and transcripts.",
description: "Connect to your Gmail account to access emails.", icon: <IconBrandZoom className="h-6 w-6" />,
icon: <IconMail className="h-6 w-6" />, status: "coming-soon",
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 // Animation variants
const fadeIn = { const fadeIn = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.4 } }, visible: { opacity: 1, transition: { duration: 0.4 } },
}; };
const staggerContainer = { const staggerContainer = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
visible: { visible: {
opacity: 1, opacity: 1,
transition: { transition: {
staggerChildren: 0.1, staggerChildren: 0.1,
}, },
}, },
}; };
const cardVariants = { const cardVariants: Variants = {
hidden: { opacity: 0, y: 20 }, hidden: { opacity: 0, y: 20 },
visible: { visible: {
opacity: 1, opacity: 1,
y: 0, y: 0,
transition: { transition: {
type: "spring", type: "spring",
stiffness: 260, stiffness: 260,
damping: 20, damping: 20,
}, },
}, },
hover: { hover: {
scale: 1.02, scale: 1.02,
boxShadow: boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
"0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)", transition: {
transition: { type: "spring",
type: "spring", stiffness: 400,
stiffness: 400, damping: 10,
damping: 10, },
}, },
},
}; };
export default function ConnectorsPage() { export default function ConnectorsPage() {
const params = useParams(); const params = useParams();
const searchSpaceId = params.search_space_id as string; const searchSpaceId = params.search_space_id as string;
const [expandedCategories, setExpandedCategories] = useState<string[]>([ const [expandedCategories, setExpandedCategories] = useState<string[]>([
"search-engines", "search-engines",
"knowledge-bases", "knowledge-bases",
"project-management", "project-management",
"team-chats", "team-chats",
]); ]);
const toggleCategory = (categoryId: string) => { const toggleCategory = (categoryId: string) => {
setExpandedCategories((prev) => setExpandedCategories((prev) =>
prev.includes(categoryId) prev.includes(categoryId) ? prev.filter((id) => id !== categoryId) : [...prev, categoryId]
? prev.filter((id) => id !== categoryId) );
: [...prev, categoryId], };
);
};
return ( return (
<div className="container mx-auto py-12 max-w-6xl"> <div className="container mx-auto py-12 max-w-6xl">
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ transition={{
duration: 0.6, duration: 0.6,
ease: [0.22, 1, 0.36, 1], ease: [0.22, 1, 0.36, 1],
}} }}
className="mb-12 text-center" 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"> <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 Connect Your Tools
</h1> </h1>
<p className="text-muted-foreground mt-3 text-lg max-w-2xl mx-auto"> <p className="text-muted-foreground mt-3 text-lg max-w-2xl mx-auto">
Integrate with your favorite services to enhance your research Integrate with your favorite services to enhance your research capabilities.
capabilities. </p>
</p> </motion.div>
</motion.div>
<motion.div <motion.div
className="space-y-8" className="space-y-8"
initial="hidden" initial="hidden"
animate="visible" animate="visible"
variants={staggerContainer} variants={staggerContainer}
> >
{connectorCategories.map((category) => ( {connectorCategories.map((category) => (
<motion.div <motion.div
key={category.id} key={category.id}
variants={fadeIn} variants={fadeIn}
className="rounded-lg border bg-card text-card-foreground shadow-sm" className="rounded-lg border bg-card text-card-foreground shadow-sm"
> >
<Collapsible <Collapsible
open={expandedCategories.includes(category.id)} open={expandedCategories.includes(category.id)}
onOpenChange={() => toggleCategory(category.id)} onOpenChange={() => toggleCategory(category.id)}
className="w-full" className="w-full"
> >
<div className="flex items-center justify-between space-x-4 p-4"> <div className="flex items-center justify-between space-x-4 p-4">
<h3 className="text-xl font-semibold">{category.title}</h3> <h3 className="text-xl font-semibold">{category.title}</h3>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button <Button variant="ghost" size="sm" className="w-9 p-0 hover:bg-muted">
variant="ghost" <motion.div
size="sm" animate={{
className="w-9 p-0 hover:bg-muted" rotate: expandedCategories.includes(category.id) ? 180 : 0,
> }}
<motion.div transition={{ duration: 0.3, ease: "easeInOut" }}
animate={{ >
rotate: expandedCategories.includes(category.id) <IconChevronDown className="h-5 w-5" />
? 180 </motion.div>
: 0, <span className="sr-only">Toggle</span>
}} </Button>
transition={{ duration: 0.3, ease: "easeInOut" }} </CollapsibleTrigger>
> </div>
<IconChevronDown className="h-5 w-5" />
</motion.div>
<span className="sr-only">Toggle</span>
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent> <CollapsibleContent>
<AnimatePresence> <AnimatePresence>
<motion.div <motion.div
className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 p-4" className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 p-4"
variants={staggerContainer} variants={staggerContainer}
initial="hidden" initial="hidden"
animate="visible" animate="visible"
exit="hidden" exit="hidden"
> >
{category.connectors.map((connector) => ( {category.connectors.map((connector) => (
<motion.div <motion.div
key={connector.id} key={connector.id}
variants={cardVariants} variants={cardVariants}
whileHover="hover" whileHover="hover"
className="col-span-1" className="col-span-1"
> >
<Card className="h-full flex flex-col overflow-hidden border-transparent transition-all duration-200 hover:border-primary/50"> <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"> <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"> <div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 dark:bg-primary/20">
<motion.div <motion.div
whileHover={{ rotate: 5, scale: 1.1 }} whileHover={{ rotate: 5, scale: 1.1 }}
className="text-primary" className="text-primary"
> >
{connector.icon} {connector.icon}
</motion.div> </motion.div>
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="font-medium"> <h3 className="font-medium">{connector.title}</h3>
{connector.title} {connector.status === "coming-soon" && (
</h3> <Badge
{connector.status === "coming-soon" && ( variant="outline"
<Badge className="text-xs bg-amber-100 dark:bg-amber-950 text-amber-800 dark:text-amber-300 border-amber-200 dark:border-amber-800"
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>
Coming soon )}
</Badge> {connector.status === "connected" && (
)} <Badge
{connector.status === "connected" && ( variant="outline"
<Badge className="text-xs bg-green-100 dark:bg-green-950 text-green-800 dark:text-green-300 border-green-200 dark:border-green-800"
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>
Connected )}
</Badge> </div>
)} </div>
</div> </CardHeader>
</div>
</CardHeader>
<CardContent className="pb-4"> <CardContent className="pb-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">{connector.description}</p>
{connector.description} </CardContent>
</p>
</CardContent>
<CardFooter className="mt-auto pt-2"> <CardFooter className="mt-auto pt-2">
{connector.status === "available" && ( {connector.status === "available" && (
<Link <Link
href={`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`} href={`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`}
className="w-full" className="w-full"
> >
<Button <Button variant="default" className="w-full group">
variant="default" <span>Connect</span>
className="w-full group" <motion.div
> className="ml-1"
<span>Connect</span> initial={{ x: 0 }}
<motion.div whileHover={{ x: 3 }}
className="ml-1" transition={{
initial={{ x: 0 }} type: "spring",
whileHover={{ x: 3 }} stiffness: 400,
transition={{ damping: 10,
type: "spring", }}
stiffness: 400, >
damping: 10, <IconChevronRight className="h-4 w-4" />
}} </motion.div>
> </Button>
<IconChevronRight className="h-4 w-4" /> </Link>
</motion.div> )}
</Button> {connector.status === "coming-soon" && (
</Link> <Button variant="outline" disabled className="w-full opacity-70">
)} Coming Soon
{connector.status === "coming-soon" && ( </Button>
<Button )}
variant="outline" {connector.status === "connected" && (
disabled <Button
className="w-full opacity-70" variant="outline"
> className="w-full border-green-500 text-green-600 hover:bg-green-50 dark:hover:bg-green-950"
Coming Soon >
</Button> Manage
)} </Button>
{connector.status === "connected" && ( )}
<Button </CardFooter>
variant="outline" </Card>
className="w-full border-green-500 text-green-600 hover:bg-green-50 dark:hover:bg-green-950" </motion.div>
> ))}
Manage </motion.div>
</Button> </AnimatePresence>
)} </CollapsibleContent>
</CardFooter> </Collapsible>
</Card> </motion.div>
</motion.div> ))}
))} </motion.div>
</motion.div> </div>
</AnimatePresence> );
</CollapsibleContent>
</Collapsible>
</motion.div>
))}
</motion.div>
</div>
);
} }

View file

@ -1,207 +1,193 @@
"use client"; "use client";
import { useState } from "react";
import { useRouter, useParams } from "next/navigation";
import { motion } from "framer-motion";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { motion } from "framer-motion";
import * as z from "zod";
import { toast } from "sonner";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react"; import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors"; import { useState } from "react";
import { import { useForm } from "react-hook-form";
Form, import { toast } from "sonner";
FormControl, import * as z from "zod";
FormDescription, import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardFooter, CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { import {
Alert, Form,
AlertDescription, FormControl,
AlertTitle, FormDescription,
} from "@/components/ui/alert"; 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 // Define the form schema with Zod
const serperApiFormSchema = z.object({ const serperApiFormSchema = z.object({
name: z.string().min(3, { name: z.string().min(3, {
message: "Connector name must be at least 3 characters.", message: "Connector name must be at least 3 characters.",
}), }),
api_key: z.string().min(10, { api_key: z.string().min(10, {
message: "API key is required and must be valid.", message: "API key is required and must be valid.",
}), }),
}); });
// Define the type for the form values // Define the type for the form values
type SerperApiFormValues = z.infer<typeof serperApiFormSchema>; type SerperApiFormValues = z.infer<typeof serperApiFormSchema>;
export default function SerperApiPage() { export default function SerperApiPage() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const searchSpaceId = params.search_space_id as string; const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const { createConnector } = useSearchSourceConnectors(); const { createConnector } = useSearchSourceConnectors();
// Initialize the form // Initialize the form
const form = useForm<SerperApiFormValues>({ const form = useForm<SerperApiFormValues>({
resolver: zodResolver(serperApiFormSchema), resolver: zodResolver(serperApiFormSchema),
defaultValues: { defaultValues: {
name: "Serper API Connector", name: "Serper API Connector",
api_key: "", api_key: "",
}, },
}); });
// Handle form submission // Handle form submission
const onSubmit = async (values: SerperApiFormValues) => { const onSubmit = async (values: SerperApiFormValues) => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await createConnector({ await createConnector({
name: values.name, name: values.name,
connector_type: "SERPER_API", connector_type: "SERPER_API",
config: { config: {
SERPER_API_KEY: values.api_key, SERPER_API_KEY: values.api_key,
}, },
is_indexable: false, is_indexable: false,
last_indexed_at: null, last_indexed_at: null,
}); });
toast.success("Serper API connector created successfully!"); toast.success("Serper API connector created successfully!");
// Navigate back to connectors page
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to create connector");
} finally {
setIsSubmitting(false);
}
};
return ( // Navigate back to connectors page
<div className="container mx-auto py-8 max-w-3xl"> router.push(`/dashboard/${searchSpaceId}/connectors`);
<Button } catch (error) {
variant="ghost" console.error("Error creating connector:", error);
className="mb-6" toast.error(error instanceof Error ? error.message : "Failed to create connector");
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)} } finally {
> setIsSubmitting(false);
<ArrowLeft className="mr-2 h-4 w-4" /> }
Back to Connectors };
</Button>
<motion.div return (
initial={{ opacity: 0, y: 20 }} <div className="container mx-auto py-8 max-w-3xl">
animate={{ opacity: 1, y: 0 }} <Button
transition={{ duration: 0.5 }} variant="ghost"
> className="mb-6"
<Card className="border-2 border-border"> onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
<CardHeader> >
<CardTitle className="text-2xl font-bold">Connect Serper API</CardTitle> <ArrowLeft className="mr-2 h-4 w-4" />
<CardDescription> Back to Connectors
Integrate with Serper API to enhance your search capabilities with Google search results. </Button>
</CardDescription>
</CardHeader>
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>API Key Required</AlertTitle>
<AlertDescription>
You'll need a Serper API key to use this connector. You can get one by signing up at{" "}
<a
href="https://serper.dev"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
serper.dev
</a>
</AlertDescription>
</Alert>
<Form {...form}> <motion.div
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> initial={{ opacity: 0, y: 20 }}
<FormField animate={{ opacity: 1, y: 0 }}
control={form.control} transition={{ duration: 0.5 }}
name="name" >
render={({ field }) => ( <Card className="border-2 border-border">
<FormItem> <CardHeader>
<FormLabel>Connector Name</FormLabel> <CardTitle className="text-2xl font-bold">Connect Serper API</CardTitle>
<FormControl> <CardDescription>
<Input placeholder="My Serper API Connector" {...field} /> Integrate with Serper API to enhance your search capabilities with Google search
</FormControl> results.
<FormDescription> </CardDescription>
A friendly name to identify this connector. </CardHeader>
</FormDescription> <CardContent>
<FormMessage /> <Alert className="mb-6 bg-muted">
</FormItem> <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 <Form {...form}>
control={form.control} <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
name="api_key" <FormField
render={({ field }) => ( control={form.control}
<FormItem> name="name"
<FormLabel>Serper API Key</FormLabel> render={({ field }) => (
<FormControl> <FormItem>
<Input <FormLabel>Connector Name</FormLabel>
type="password" <FormControl>
placeholder="Enter your Serper API key" <Input placeholder="My Serper API Connector" {...field} />
{...field} </FormControl>
/> <FormDescription>A friendly name to identify this connector.</FormDescription>
</FormControl> <FormMessage />
<FormDescription> </FormItem>
Your API key will be encrypted and stored securely. )}
</FormDescription> />
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end"> <FormField
<Button control={form.control}
type="submit" name="api_key"
disabled={isSubmitting} render={({ field }) => (
className="w-full sm:w-auto" <FormItem>
> <FormLabel>Serper API Key</FormLabel>
{isSubmitting ? ( <FormControl>
<> <Input type="password" placeholder="Enter your Serper API key" {...field} />
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> </FormControl>
Connecting... <FormDescription>
</> Your API key will be encrypted and stored securely.
) : ( </FormDescription>
<> <FormMessage />
<Check className="mr-2 h-4 w-4" /> </FormItem>
Connect Serper API )}
</> />
)}
</Button> <div className="flex justify-end">
</div> <Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
</form> {isSubmitting ? (
</Form> <>
</CardContent> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4"> Connecting...
<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> <Check className="mr-2 h-4 w-4" />
<li>Enhanced search capabilities for your projects</li> Connect Serper API
</ul> </>
</CardFooter> )}
</Card> </Button>
</motion.div> </div>
</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"; "use client";
import { useState } from "react";
import { useRouter, useParams } from "next/navigation";
import { motion } from "framer-motion";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { motion } from "framer-motion";
import * as z from "zod";
import { toast } from "sonner";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react"; import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors"; import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { import {
Form, Accordion,
FormControl, AccordionContent,
FormDescription, AccordionItem,
FormField, AccordionTrigger,
FormItem, } from "@/components/ui/accordion";
FormLabel, import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardFooter, CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { import {
Alert, Form,
AlertDescription, FormControl,
AlertTitle, FormDescription,
} from "@/components/ui/alert"; FormField,
import { FormItem,
Accordion, FormLabel,
AccordionContent, FormMessage,
AccordionItem, } from "@/components/ui/form";
AccordionTrigger, import { Input } from "@/components/ui/input";
} from "@/components/ui/accordion";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
// Define the form schema with Zod // Define the form schema with Zod
const slackConnectorFormSchema = z.object({ const slackConnectorFormSchema = z.object({
name: z.string().min(3, { name: z.string().min(3, {
message: "Connector name must be at least 3 characters.", message: "Connector name must be at least 3 characters.",
}), }),
bot_token: z.string().min(10, { bot_token: z.string().min(10, {
message: "Bot User OAuth Token is required and must be valid.", message: "Bot User OAuth Token is required and must be valid.",
}), }),
}); });
// Define the type for the form values // Define the type for the form values
type SlackConnectorFormValues = z.infer<typeof slackConnectorFormSchema>; type SlackConnectorFormValues = z.infer<typeof slackConnectorFormSchema>;
export default function SlackConnectorPage() { export default function SlackConnectorPage() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const searchSpaceId = params.search_space_id as string; const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const { createConnector } = useSearchSourceConnectors(); const { createConnector } = useSearchSourceConnectors();
// Initialize the form // Initialize the form
const form = useForm<SlackConnectorFormValues>({ const form = useForm<SlackConnectorFormValues>({
resolver: zodResolver(slackConnectorFormSchema), resolver: zodResolver(slackConnectorFormSchema),
defaultValues: { defaultValues: {
name: "Slack Connector", name: "Slack Connector",
bot_token: "", bot_token: "",
}, },
}); });
// Handle form submission // Handle form submission
const onSubmit = async (values: SlackConnectorFormValues) => { const onSubmit = async (values: SlackConnectorFormValues) => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await createConnector({ await createConnector({
name: values.name, name: values.name,
connector_type: "SLACK_CONNECTOR", connector_type: "SLACK_CONNECTOR",
config: { config: {
SLACK_BOT_TOKEN: values.bot_token, SLACK_BOT_TOKEN: values.bot_token,
}, },
is_indexable: true, is_indexable: true,
last_indexed_at: null, last_indexed_at: null,
}); });
toast.success("Slack connector created successfully!"); toast.success("Slack connector created successfully!");
// Navigate back to connectors page
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to create connector");
} finally {
setIsSubmitting(false);
}
};
return ( // Navigate back to connectors page
<div className="container mx-auto py-8 max-w-3xl"> router.push(`/dashboard/${searchSpaceId}/connectors`);
<Button } catch (error) {
variant="ghost" console.error("Error creating connector:", error);
className="mb-6" toast.error(error instanceof Error ? error.message : "Failed to create connector");
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)} } finally {
> setIsSubmitting(false);
<ArrowLeft className="mr-2 h-4 w-4" /> }
Back to Connectors };
</Button>
<motion.div return (
initial={{ opacity: 0, y: 20 }} <div className="container mx-auto py-8 max-w-3xl">
animate={{ opacity: 1, y: 0 }} <Button
transition={{ duration: 0.5 }} variant="ghost"
> className="mb-6"
<Tabs defaultValue="connect" className="w-full"> onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
<TabsList className="grid w-full grid-cols-2 mb-6"> >
<TabsTrigger value="connect">Connect</TabsTrigger> <ArrowLeft className="mr-2 h-4 w-4" />
<TabsTrigger value="documentation">Documentation</TabsTrigger> Back to Connectors
</TabsList> </Button>
<TabsContent value="connect">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">Connect Slack Workspace</CardTitle>
<CardDescription>
Integrate with Slack to search and retrieve information from your workspace channels and conversations. This connector can index your Slack messages for search.
</CardDescription>
</CardHeader>
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Bot User OAuth Token Required</AlertTitle>
<AlertDescription>
You'll need a Slack Bot User OAuth Token to use this connector. You can create a Slack app and get the token from{" "}
<a
href="https://api.slack.com/apps"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Slack API Dashboard
</a>
</AlertDescription>
</Alert>
<Form {...form}> <motion.div
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> initial={{ opacity: 0, y: 20 }}
<FormField animate={{ opacity: 1, y: 0 }}
control={form.control} transition={{ duration: 0.5 }}
name="name" >
render={({ field }) => ( <Tabs defaultValue="connect" className="w-full">
<FormItem> <TabsList className="grid w-full grid-cols-2 mb-6">
<FormLabel>Connector Name</FormLabel> <TabsTrigger value="connect">Connect</TabsTrigger>
<FormControl> <TabsTrigger value="documentation">Documentation</TabsTrigger>
<Input placeholder="My Slack Connector" {...field} /> </TabsList>
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField <TabsContent value="connect">
control={form.control} <Card className="border-2 border-border">
name="bot_token" <CardHeader>
render={({ field }) => ( <CardTitle className="text-2xl font-bold">Connect Slack Workspace</CardTitle>
<FormItem> <CardDescription>
<FormLabel>Slack Bot User OAuth Token</FormLabel> Integrate with Slack to search and retrieve information from your workspace
<FormControl> channels and conversations. This connector can index your Slack messages for
<Input search.
type="password" </CardDescription>
placeholder="xoxb-..." </CardHeader>
{...field} <CardContent>
/> <Alert className="mb-6 bg-muted">
</FormControl> <Info className="h-4 w-4" />
<FormDescription> <AlertTitle>Bot User OAuth Token Required</AlertTitle>
Your Bot User OAuth Token will be encrypted and stored securely. It typically starts with "xoxb-". <AlertDescription>
</FormDescription> You'll need a Slack Bot User OAuth Token to use this connector. You can create a
<FormMessage /> Slack app and get the token from{" "}
</FormItem> <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"> <Form {...form}>
<Button <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
type="submit" <FormField
disabled={isSubmitting} control={form.control}
className="w-full sm:w-auto" name="name"
> render={({ field }) => (
{isSubmitting ? ( <FormItem>
<> <FormLabel>Connector Name</FormLabel>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <FormControl>
Connecting... <Input placeholder="My Slack Connector" {...field} />
</> </FormControl>
) : ( <FormDescription>
<> A friendly name to identify this connector.
<Check className="mr-2 h-4 w-4" /> </FormDescription>
Connect Slack <FormMessage />
</> </FormItem>
)} )}
</Button> />
</div>
</form> <FormField
</Form> control={form.control}
</CardContent> name="bot_token"
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4"> render={({ field }) => (
<h4 className="text-sm font-medium">What you get with Slack integration:</h4> <FormItem>
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground"> <FormLabel>Slack Bot User OAuth Token</FormLabel>
<li>Search through your Slack channels and conversations</li> <FormControl>
<li>Access historical messages and shared files</li> <Input type="password" placeholder="xoxb-..." {...field} />
<li>Connect your team's knowledge directly to your search space</li> </FormControl>
<li>Keep your search results up-to-date with latest communications</li> <FormDescription>
<li>Index your Slack messages for enhanced search capabilities</li> Your Bot User OAuth Token will be encrypted and stored securely. It
</ul> typically starts with "xoxb-".
</CardFooter> </FormDescription>
</Card> <FormMessage />
</TabsContent> </FormItem>
)}
<TabsContent value="documentation"> />
<Card className="border-2 border-border">
<CardHeader> <div className="flex justify-end">
<CardTitle className="text-2xl font-bold">Slack Connector Documentation</CardTitle> <Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
<CardDescription> {isSubmitting ? (
Learn how to set up and use the Slack connector to index your workspace data. <>
</CardDescription> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
</CardHeader> Connecting...
<CardContent className="space-y-6"> </>
<div> ) : (
<h3 className="text-xl font-semibold mb-2">How it works</h3> <>
<p className="text-muted-foreground"> <Check className="mr-2 h-4 w-4" />
The Slack connector indexes all public channels for a given workspace. Connect Slack
</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> </Button>
</ul> </div>
</div> </form>
</Form>
<Accordion type="single" collapsible className="w-full"> </CardContent>
<AccordionItem value="authorization"> <CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
<AccordionTrigger className="text-lg font-medium">Authorization</AccordionTrigger> <h4 className="text-sm font-medium">What you get with Slack integration:</h4>
<AccordionContent className="space-y-4"> <ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
<Alert className="bg-muted"> <li>Search through your Slack channels and conversations</li>
<Info className="h-4 w-4" /> <li>Access historical messages and shared files</li>
<AlertTitle>Admin Access Required</AlertTitle> <li>Connect your team's knowledge directly to your search space</li>
<AlertDescription> <li>Keep your search results up-to-date with latest communications</li>
You must be an admin of the Slack workspace to set up the connector. <li>Index your Slack messages for enhanced search capabilities</li>
</AlertDescription> </ul>
</Alert> </CardFooter>
</Card>
<ol className="list-decimal pl-5 space-y-3"> </TabsContent>
<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> <TabsContent value="documentation">
Create a new Slack app: <Card className="border-2 border-border">
<ul className="list-disc pl-5 mt-1"> <CardHeader>
<li>Click the <strong>Create New App</strong> button in the top right.</li> <CardTitle className="text-2xl font-bold">Slack Connector Documentation</CardTitle>
<li>Select <strong>From an app manifest</strong> option.</li> <CardDescription>
<li>Select the relevant workspace from the dropdown and click <strong>Next</strong>.</li> Learn how to set up and use the Slack connector to index your workspace data.
</ul> </CardDescription>
</li> </CardHeader>
<li> <CardContent className="space-y-6">
Select the "YAML" tab, paste the following manifest into the text box, and click <strong>Next</strong>: <div>
<div className="bg-muted p-4 rounded-md mt-2 overflow-x-auto"> <h3 className="text-xl font-semibold mb-2">How it works</h3>
<pre className="text-xs"> <p className="text-muted-foreground">
{`display_information: 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 name: SlackConnector
description: ReadOnly Connector for indexing description: ReadOnly Connector for indexing
features: features:
@ -287,65 +302,94 @@ settings:
org_deploy_enabled: false org_deploy_enabled: false
socket_mode_enabled: false socket_mode_enabled: false
token_rotation_enabled: false`} token_rotation_enabled: false`}
</pre> </pre>
</div> </div>
</li> </li>
<li>Click the <strong>Create</strong> button.</li> <li>
<li>In the app page, navigate to the <strong>OAuth & Permissions</strong> tab under the <strong>Features</strong> header.</li> Click the <strong>Create</strong> button.
<li>Copy the <strong>Bot User OAuth Token</strong>, this will be used to access Slack.</li> </li>
</ol> <li>
</AccordionContent> In the app page, navigate to the <strong>OAuth & Permissions</strong> tab
</AccordionItem> under the <strong>Features</strong> header.
</li>
<AccordionItem value="indexing"> <li>
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger> Copy the <strong>Bot User OAuth Token</strong>, this will be used to
<AccordionContent className="space-y-4"> access Slack.
<ol className="list-decimal pl-5 space-y-3"> </li>
<li>Navigate to the Connector Dashboard and select the <strong>Slack</strong> Connector.</li> </ol>
<li>Place the <strong>Bot User OAuth Token</strong> under <strong>Step 1 Provide Credentials</strong>.</li> </AccordionContent>
<li>Click <strong>Connect</strong> to establish the connection.</li> </AccordionItem>
</ol>
<AccordionItem value="indexing">
<Alert className="bg-muted"> <AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
<Info className="h-4 w-4" /> <AccordionContent className="space-y-4">
<AlertTitle>Important: Invite Bot to Channels</AlertTitle> <ol className="list-decimal pl-5 space-y-3">
<AlertDescription> <li>
After connecting, you must invite the bot to each channel you want to index. In each Slack channel, type: Navigate to the Connector Dashboard and select the <strong>Slack</strong>{" "}
<pre className="mt-2 bg-background p-2 rounded-md text-xs">/invite @YourBotName</pre> Connector.
<p className="mt-2">Without this step, you'll get a "not_in_channel" error when the connector tries to access channel messages.</p> </li>
</AlertDescription> <li>
</Alert> Place the <strong>Bot User OAuth Token</strong> under{" "}
<strong>Step 1 Provide Credentials</strong>.
<Alert className="bg-muted mt-4"> </li>
<Info className="h-4 w-4" /> <li>
<AlertTitle>First Indexing</AlertTitle> Click <strong>Connect</strong> to establish the connection.
<AlertDescription> </li>
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. </ol>
</AlertDescription>
</Alert> <Alert className="bg-muted">
<Info className="h-4 w-4" />
<div className="mt-4"> <AlertTitle>Important: Invite Bot to Channels</AlertTitle>
<h4 className="font-medium mb-2">Troubleshooting:</h4> <AlertDescription>
<ul className="list-disc pl-5 space-y-2 text-muted-foreground"> After connecting, you must invite the bot to each channel you want to
<li> index. In each Slack channel, type:
<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. <pre className="mt-2 bg-background p-2 rounded-md text-xs">
</li> /invite @YourBotName
<li> </pre>
<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. <p className="mt-2">
</li> Without this step, you'll get a "not_in_channel" error when the
<li> connector tries to access channel messages.
<strong>For private channels:</strong> The bot must always be invited using the <code>/invite</code> command. </p>
</li> </AlertDescription>
</ul> </Alert>
</div>
</AccordionContent> <Alert className="bg-muted mt-4">
</AccordionItem> <Info className="h-4 w-4" />
</Accordion> <AlertTitle>First Indexing</AlertTitle>
</CardContent> <AlertDescription>
</Card> The first indexing pulls all of the public channels and takes longer than
</TabsContent> future updates. Only channels where the bot has been invited will be fully
</Tabs> indexed.
</motion.div> </AlertDescription>
</div> </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"; "use client";
import { useState } from "react";
import { useRouter, useParams } from "next/navigation";
import { motion } from "framer-motion";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { motion } from "framer-motion";
import * as z from "zod";
import { toast } from "sonner";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react"; import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors"; import { useState } from "react";
import { import { useForm } from "react-hook-form";
Form, import { toast } from "sonner";
FormControl, import * as z from "zod";
FormDescription, import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardFooter, CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { import {
Alert, Form,
AlertDescription, FormControl,
AlertTitle, FormDescription,
} from "@/components/ui/alert"; 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 // Define the form schema with Zod
const tavilyApiFormSchema = z.object({ const tavilyApiFormSchema = z.object({
name: z.string().min(3, { name: z.string().min(3, {
message: "Connector name must be at least 3 characters.", message: "Connector name must be at least 3 characters.",
}), }),
api_key: z.string().min(10, { api_key: z.string().min(10, {
message: "API key is required and must be valid.", message: "API key is required and must be valid.",
}), }),
}); });
// Define the type for the form values // Define the type for the form values
type TavilyApiFormValues = z.infer<typeof tavilyApiFormSchema>; type TavilyApiFormValues = z.infer<typeof tavilyApiFormSchema>;
export default function TavilyApiPage() { export default function TavilyApiPage() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const searchSpaceId = params.search_space_id as string; const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const { createConnector } = useSearchSourceConnectors(); const { createConnector } = useSearchSourceConnectors();
// Initialize the form // Initialize the form
const form = useForm<TavilyApiFormValues>({ const form = useForm<TavilyApiFormValues>({
resolver: zodResolver(tavilyApiFormSchema), resolver: zodResolver(tavilyApiFormSchema),
defaultValues: { defaultValues: {
name: "Tavily API Connector", name: "Tavily API Connector",
api_key: "", api_key: "",
}, },
}); });
// Handle form submission // Handle form submission
const onSubmit = async (values: TavilyApiFormValues) => { const onSubmit = async (values: TavilyApiFormValues) => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await createConnector({ await createConnector({
name: values.name, name: values.name,
connector_type: "TAVILY_API", connector_type: "TAVILY_API",
config: { config: {
TAVILY_API_KEY: values.api_key, TAVILY_API_KEY: values.api_key,
}, },
is_indexable: false, is_indexable: false,
last_indexed_at: null, last_indexed_at: null,
}); });
toast.success("Tavily API connector created successfully!"); toast.success("Tavily API connector created successfully!");
// Navigate back to connectors page
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to create connector");
} finally {
setIsSubmitting(false);
}
};
return ( // Navigate back to connectors page
<div className="container mx-auto py-8 max-w-3xl"> router.push(`/dashboard/${searchSpaceId}/connectors`);
<Button } catch (error) {
variant="ghost" console.error("Error creating connector:", error);
className="mb-6" toast.error(error instanceof Error ? error.message : "Failed to create connector");
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)} } finally {
> setIsSubmitting(false);
<ArrowLeft className="mr-2 h-4 w-4" /> }
Back to Connectors };
</Button>
<motion.div return (
initial={{ opacity: 0, y: 20 }} <div className="container mx-auto py-8 max-w-3xl">
animate={{ opacity: 1, y: 0 }} <Button
transition={{ duration: 0.5 }} variant="ghost"
> className="mb-6"
<Card className="border-2 border-border"> onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
<CardHeader> >
<CardTitle className="text-2xl font-bold">Connect Tavily API</CardTitle> <ArrowLeft className="mr-2 h-4 w-4" />
<CardDescription> Back to Connectors
Integrate with Tavily API to enhance your search capabilities with AI-powered search results. </Button>
</CardDescription>
</CardHeader>
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>API Key Required</AlertTitle>
<AlertDescription>
You'll need a Tavily API key to use this connector. You can get one by signing up at{" "}
<a
href="https://tavily.com"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
tavily.com
</a>
</AlertDescription>
</Alert>
<Form {...form}> <motion.div
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> initial={{ opacity: 0, y: 20 }}
<FormField animate={{ opacity: 1, y: 0 }}
control={form.control} transition={{ duration: 0.5 }}
name="name" >
render={({ field }) => ( <Card className="border-2 border-border">
<FormItem> <CardHeader>
<FormLabel>Connector Name</FormLabel> <CardTitle className="text-2xl font-bold">Connect Tavily API</CardTitle>
<FormControl> <CardDescription>
<Input placeholder="My Tavily API Connector" {...field} /> Integrate with Tavily API to enhance your search capabilities with AI-powered search
</FormControl> results.
<FormDescription> </CardDescription>
A friendly name to identify this connector. </CardHeader>
</FormDescription> <CardContent>
<FormMessage /> <Alert className="mb-6 bg-muted">
</FormItem> <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 <Form {...form}>
control={form.control} <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
name="api_key" <FormField
render={({ field }) => ( control={form.control}
<FormItem> name="name"
<FormLabel>Tavily API Key</FormLabel> render={({ field }) => (
<FormControl> <FormItem>
<Input <FormLabel>Connector Name</FormLabel>
type="password" <FormControl>
placeholder="Enter your Tavily API key" <Input placeholder="My Tavily API Connector" {...field} />
{...field} </FormControl>
/> <FormDescription>A friendly name to identify this connector.</FormDescription>
</FormControl> <FormMessage />
<FormDescription> </FormItem>
Your API key will be encrypted and stored securely. )}
</FormDescription> />
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end"> <FormField
<Button control={form.control}
type="submit" name="api_key"
disabled={isSubmitting} render={({ field }) => (
className="w-full sm:w-auto" <FormItem>
> <FormLabel>Tavily API Key</FormLabel>
{isSubmitting ? ( <FormControl>
<> <Input type="password" placeholder="Enter your Tavily API key" {...field} />
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> </FormControl>
Connecting... <FormDescription>
</> Your API key will be encrypted and stored securely.
) : ( </FormDescription>
<> <FormMessage />
<Check className="mr-2 h-4 w-4" /> </FormItem>
Connect Tavily API )}
</> />
)}
</Button> <div className="flex justify-end">
</div> <Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
</form> {isSubmitting ? (
</Form> <>
</CardContent> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4"> Connecting...
<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> <Check className="mr-2 h-4 w-4" />
<li>Enhanced search capabilities for your projects</li> Connect Tavily API
</ul> </>
</CardFooter> )}
</Card> </Button>
</motion.div> </div>
</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"; "use client";
import { useState } from 'react'; import { type Tag, TagInput } from "emblor";
import { useParams, useRouter } from 'next/navigation';
import { Tag, TagInput } from "emblor";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
import { Globe, Loader2 } from "lucide-react"; 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 // URL validation regex
const urlRegex = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/; const urlRegex = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/;
export default function WebpageCrawler() { export default function WebpageCrawler() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const search_space_id = params.search_space_id as string; const search_space_id = params.search_space_id as string;
const [urlTags, setUrlTags] = useState<Tag[]>([]);
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Function to validate a URL const [urlTags, setUrlTags] = useState<Tag[]>([]);
const isValidUrl = (url: string): boolean => { const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
return urlRegex.test(url); const [isSubmitting, setIsSubmitting] = useState(false);
}; const [error, setError] = useState<string | null>(null);
// Function to handle URL submission // Function to validate a URL
const handleSubmit = async () => { const isValidUrl = (url: string): boolean => {
// Validate that we have at least one URL return urlRegex.test(url);
if (urlTags.length === 0) { };
setError("Please add at least one URL");
return;
}
// Validate all URLs // Function to handle URL submission
const invalidUrls = urlTags.filter(tag => !isValidUrl(tag.text)); const handleSubmit = async () => {
if (invalidUrls.length > 0) { // Validate that we have at least one URL
setError(`Invalid URLs detected: ${invalidUrls.map(tag => tag.text).join(', ')}`); if (urlTags.length === 0) {
return; setError("Please add at least one URL");
} return;
}
setError(null); // Validate all URLs
setIsSubmitting(true); const invalidUrls = urlTags.filter((tag) => !isValidUrl(tag.text));
if (invalidUrls.length > 0) {
setError(`Invalid URLs detected: ${invalidUrls.map((tag) => tag.text).join(", ")}`);
return;
}
try { setError(null);
toast("URL Crawling", { setIsSubmitting(true);
description: "Starting URL crawling process...",
});
// Extract URLs from tags try {
const urls = urlTags.map(tag => tag.text); toast("URL Crawling", {
description: "Starting URL crawling process...",
});
// Make API call to backend // Extract URLs from tags
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/`, { const urls = urlTags.map((tag) => tag.text);
method: "POST",
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem("surfsense_bearer_token")}`
},
body: JSON.stringify({
"document_type": "CRAWLED_URL",
"content": urls,
"search_space_id": parseInt(search_space_id)
}),
});
if (!response.ok) { // Make API call to backend
throw new Error("Failed to crawl URLs"); 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", { await response.json();
description: "URLs have been submitted for crawling",
});
// Redirect to documents page toast("Crawling Successful", {
router.push(`/dashboard/${search_space_id}/documents`); description: "URLs have been submitted for crawling",
} catch (error: any) { });
setError(error.message || "An error occurred while crawling URLs");
toast("Crawling Error", {
description: `Error crawling URLs: ${error.message}`,
});
} finally {
setIsSubmitting(false);
}
};
// Function to add a new URL tag // Redirect to documents page
const handleAddTag = (text: string) => { router.push(`/dashboard/${search_space_id}/documents`);
// Basic URL validation } catch (error: any) {
if (!isValidUrl(text)) { setError(error.message || "An error occurred while crawling URLs");
toast("Invalid URL", { toast("Crawling Error", {
description: "Please enter a valid URL", description: `Error crawling URLs: ${error.message}`,
}); });
return; } finally {
} setIsSubmitting(false);
}
};
// Check for duplicates // Function to add a new URL tag
if (urlTags.some(tag => tag.text === text)) { const handleAddTag = (text: string) => {
toast("Duplicate URL", { // Basic URL validation
description: "This URL has already been added", if (!isValidUrl(text)) {
}); toast("Invalid URL", {
return; description: "Please enter a valid URL",
} });
return;
}
// Add the new tag // Check for duplicates
const newTag: Tag = { if (urlTags.some((tag) => tag.text === text)) {
id: Date.now().toString(), toast("Duplicate URL", {
text: text, 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 ( setUrlTags([...urlTags, newTag]);
<div className="container mx-auto py-8"> };
<Card className="max-w-2xl mx-auto">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5" />
Add Webpages for Crawling
</CardTitle>
<CardDescription>
Enter URLs to crawl and add to your document collection
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="url-input">Enter URLs to crawl</Label>
<TagInput
id="url-input"
tags={urlTags}
setTags={setUrlTags}
placeholder="Enter a URL and press Enter"
onAddTag={handleAddTag}
styleClasses={{
inlineTagsContainer:
"border-input rounded-lg bg-background shadow-sm shadow-black/5 transition-shadow focus-within:border-ring focus-within:outline-none focus-within:ring-[3px] focus-within:ring-ring/20 p-1 gap-1",
input: "w-full min-w-[80px] focus-visible:outline-none shadow-none px-2 h-7",
tag: {
body: "h-7 relative bg-background border border-input hover:bg-background rounded-md font-medium text-xs ps-2 pe-7 flex",
closeButton:
"absolute -inset-y-px -end-px p-0 rounded-e-lg flex size-7 transition-colors outline-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 text-muted-foreground/80 hover:text-foreground",
},
}}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
/>
<p className="text-xs text-muted-foreground mt-1">
Add multiple URLs by pressing Enter after each one
</p>
</div>
{error && ( return (
<div className="text-sm text-red-500 mt-2"> <div className="container mx-auto py-8">
{error} <Card className="max-w-2xl mx-auto">
</div> <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"> {error && <div className="text-sm text-red-500 mt-2">{error}</div>}
<h4 className="font-medium mb-2">Tips for URL crawling:</h4>
<ul className="list-disc pl-5 space-y-1 text-muted-foreground"> <div className="bg-muted/50 rounded-lg p-4 text-sm">
<li>Enter complete URLs including http:// or https://</li> <h4 className="font-medium mb-2">Tips for URL crawling:</h4>
<li>Make sure the websites allow crawling</li> <ul className="list-disc pl-5 space-y-1 text-muted-foreground">
<li>Public webpages work best</li> <li>Enter complete URLs including http:// or https://</li>
<li>Crawling may take some time depending on the website size</li> <li>Make sure the websites allow crawling</li>
</ul> <li>Public webpages work best</li>
</div> <li>Crawling may take some time depending on the website size</li>
</div> </ul>
</CardContent> </div>
<CardFooter className="flex justify-between"> </div>
<Button </CardContent>
variant="outline" <CardFooter className="flex justify-between">
onClick={() => router.push(`/dashboard/${search_space_id}/documents`)} <Button
> variant="outline"
Cancel onClick={() => router.push(`/dashboard/${search_space_id}/documents`)}
</Button> >
<Button Cancel
onClick={handleSubmit} </Button>
disabled={isSubmitting || urlTags.length === 0} <Button onClick={handleSubmit} disabled={isSubmitting || urlTags.length === 0}>
> {isSubmitting ? (
{isSubmitting ? ( <>
<> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Submitting...
Submitting... </>
</> ) : (
) : ( "Submit URLs for Crawling"
'Submit URLs for Crawling' )}
)} </Button>
</Button> </CardFooter>
</CardFooter> </Card>
</Card> </div>
</div> );
); }
}

View file

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

View file

@ -1,20 +1,24 @@
import { Suspense } from 'react'; import { Suspense } from "react";
import PodcastsPageClient from './podcasts-client'; import PodcastsPageClient from "./podcasts-client";
interface PageProps { interface PageProps {
params: { params: {
search_space_id: string; search_space_id: string;
}; };
} }
export default async function PodcastsPage({ params }: PageProps) { export default async function PodcastsPage({ params }: PageProps) {
const { search_space_id: searchSpaceId } = await Promise.resolve(params); const { search_space_id: searchSpaceId } = await Promise.resolve(params);
return ( return (
<Suspense fallback={<div className="flex items-center justify-center h-[60vh]"> <Suspense
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div> fallback={
</div>}> <div className="flex items-center justify-center h-[60vh]">
<PodcastsPageClient searchSpaceId={searchSpaceId} /> <div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
</Suspense> </div>
); }
>
<PodcastsPageClient searchSpaceId={searchSpaceId} />
</Suspense>
);
} }

View file

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

View file

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

View file

@ -1,204 +1,185 @@
"use client"; "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 { IconCheck, IconCopy, IconKey } from "@tabler/icons-react";
import { import { AnimatePresence, motion } from "framer-motion";
Tooltip, import { ArrowLeft } from "lucide-react";
TooltipContent, import { useRouter } from "next/navigation";
TooltipProvider, import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
TooltipTrigger, import { Button } from "@/components/ui/button";
} from "@/components/ui/tooltip"; 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"; import { useApiKey } from "@/hooks/use-api-key";
const fadeIn = { const fadeIn = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.4 } }, visible: { opacity: 1, transition: { duration: 0.4 } },
}; };
const staggerContainer = { const staggerContainer = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
visible: { visible: {
opacity: 1, opacity: 1,
transition: { transition: {
staggerChildren: 0.1, staggerChildren: 0.1,
}, },
}, },
}; };
const ApiKeyClient = () => { const ApiKeyClient = () => {
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey(); const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
const router = useRouter(); const router = useRouter();
return ( return (
<div className="flex justify-center w-full min-h-screen py-10 px-4"> <div className="flex justify-center w-full min-h-screen py-10 px-4">
<motion.div <motion.div
className="w-full max-w-3xl" className="w-full max-w-3xl"
initial="hidden" initial="hidden"
animate="visible" animate="visible"
variants={staggerContainer} variants={staggerContainer}
> >
<motion.div className="mb-8 text-center" variants={fadeIn}> <motion.div className="mb-8 text-center" variants={fadeIn}>
<h1 className="text-3xl font-bold tracking-tight">API Key</h1> <h1 className="text-3xl font-bold tracking-tight">API Key</h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2">
Your API key for authenticating with the SurfSense API. Your API key for authenticating with the SurfSense API.
</p> </p>
</motion.div> </motion.div>
<motion.div variants={fadeIn}> <motion.div variants={fadeIn}>
<Alert className="mb-8"> <Alert className="mb-8">
<IconKey className="h-4 w-4" /> <IconKey className="h-4 w-4" />
<AlertTitle>Important</AlertTitle> <AlertTitle>Important</AlertTitle>
<AlertDescription> <AlertDescription>
Your API key grants full access to your account. Never share it Your API key grants full access to your account. Never share it publicly or with
publicly or with unauthorized users. unauthorized users.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</motion.div> </motion.div>
<motion.div variants={fadeIn}> <motion.div variants={fadeIn}>
<Card> <Card>
<CardHeader className="text-center"> <CardHeader className="text-center">
<CardTitle>Your API Key</CardTitle> <CardTitle>Your API Key</CardTitle>
<CardDescription> <CardDescription>Use this key to authenticate your API requests.</CardDescription>
Use this key to authenticate your API requests. </CardHeader>
</CardDescription> <CardContent>
</CardHeader> <AnimatePresence mode="wait">
<CardContent> {isLoading ? (
<AnimatePresence mode="wait"> <motion.div
{isLoading ? ( key="loading"
<motion.div initial={{ opacity: 0 }}
key="loading" animate={{ opacity: 1 }}
initial={{ opacity: 0 }} exit={{ opacity: 0 }}
animate={{ opacity: 1 }} className="h-10 w-full bg-muted animate-pulse rounded-md"
exit={{ opacity: 0 }} />
className="h-10 w-full bg-muted animate-pulse rounded-md" ) : apiKey ? (
/> <motion.div
) : apiKey ? ( key="api-key"
<motion.div initial={{ opacity: 0, y: 10 }}
key="api-key" animate={{ opacity: 1, y: 0 }}
initial={{ opacity: 0, y: 10 }} exit={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }} transition={{ type: "spring", stiffness: 500, damping: 30 }}
exit={{ opacity: 0, y: -10 }} className="flex items-center space-x-2"
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
<div className="bg-muted p-3 rounded-md flex-1 font-mono text-sm overflow-x-auto whitespace-nowrap"> initial={{ opacity: 0 }}
<motion.div animate={{ opacity: 1 }}
initial={{ opacity: 0 }} transition={{ duration: 0.5 }}
animate={{ opacity: 1 }} >
transition={{ duration: 0.5 }} {apiKey}
> </motion.div>
{apiKey} </div>
</motion.div> <TooltipProvider>
</div> <Tooltip>
<TooltipProvider> <TooltipTrigger asChild>
<Tooltip> <Button
<TooltipTrigger asChild> variant="outline"
<Button size="icon"
variant="outline" onClick={copyToClipboard}
size="icon" className="flex-shrink-0"
onClick={copyToClipboard} >
className="flex-shrink-0" <motion.div
> whileTap={{ scale: 0.9 }}
<motion.div animate={copied ? { scale: [1, 1.2, 1] } : {}}
whileTap={{ scale: 0.9 }} transition={{ duration: 0.2 }}
animate={copied ? { scale: [1, 1.2, 1] } : {}} >
transition={{ duration: 0.2 }} {copied ? (
> <IconCheck className="h-4 w-4" />
{copied ? ( ) : (
<IconCheck className="h-4 w-4" /> <IconCopy className="h-4 w-4" />
) : ( )}
<IconCopy className="h-4 w-4" /> </motion.div>
)} </Button>
</motion.div> </TooltipTrigger>
</Button> <TooltipContent>
</TooltipTrigger> <p>{copied ? "Copied!" : "Copy to clipboard"}</p>
<TooltipContent> </TooltipContent>
<p>{copied ? "Copied!" : "Copy to clipboard"}</p> </Tooltip>
</TooltipContent> </TooltipProvider>
</Tooltip> </motion.div>
</TooltipProvider> ) : (
</motion.div> <motion.div
) : ( key="no-key"
<motion.div initial={{ opacity: 0 }}
key="no-key" animate={{ opacity: 1 }}
initial={{ opacity: 0 }} exit={{ opacity: 0 }}
animate={{ opacity: 1 }} className="text-muted-foreground text-center"
exit={{ opacity: 0 }} >
className="text-muted-foreground text-center" No API key found.
> </motion.div>
No API key found. )}
</motion.div> </AnimatePresence>
)} </CardContent>
</AnimatePresence> </Card>
</CardContent> </motion.div>
</Card>
</motion.div>
<motion.div <motion.div
className="mt-8" className="mt-8"
variants={fadeIn} variants={fadeIn}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }} transition={{ delay: 0.3 }}
> >
<h2 className="text-xl font-semibold mb-4 text-center"> <h2 className="text-xl font-semibold mb-4 text-center">How to use your API key</h2>
How to use your API key <Card>
</h2> <CardContent className="pt-6">
<Card> <motion.div
<CardContent className="pt-6"> className="space-y-4"
<motion.div initial="hidden"
className="space-y-4" animate="visible"
initial="hidden" variants={staggerContainer}
animate="visible" >
variants={staggerContainer} <motion.div variants={fadeIn}>
> <h3 className="font-medium mb-2 text-center">Authentication</h3>
<motion.div variants={fadeIn}> <p className="text-sm text-muted-foreground text-center">
<h3 className="font-medium mb-2 text-center"> Include your API key in the Authorization header of your requests:
Authentication </p>
</h3> <motion.pre
<p className="text-sm text-muted-foreground text-center"> className="bg-muted p-3 rounded-md mt-2 overflow-x-auto"
Include your API key in the Authorization header of your whileHover={{ scale: 1.01 }}
requests: transition={{ type: "spring", stiffness: 400, damping: 10 }}
</p> >
<motion.pre <code className="text-xs">
className="bg-muted p-3 rounded-md mt-2 overflow-x-auto" Authorization: Bearer {apiKey || "YOUR_API_KEY"}
whileHover={{ scale: 1.01 }} </code>
transition={{ type: "spring", stiffness: 400, damping: 10 }} </motion.pre>
> </motion.div>
<code className="text-xs"> </motion.div>
Authorization: Bearer {apiKey || "YOUR_API_KEY"} </CardContent>
</code> </Card>
</motion.pre> </motion.div>
</motion.div> </motion.div>
</motion.div> <div>
</CardContent> <button
</Card> onClick={() => router.push("/dashboard")}
</motion.div> className="flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 hover:bg-primary/30 transition-colors"
</motion.div> aria-label="Back to Dashboard"
<div> type="button"
<button >
onClick={() => router.push("/dashboard")} <ArrowLeft className="h-5 w-5 text-primary" />
className="flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 hover:bg-primary/30 transition-colors" </button>
aria-label="Back to Dashboard" </div>
type="button" </div>
> );
<ArrowLeft className="h-5 w-5 text-primary" />
</button>
</div>
</div>
);
}; };
export default ApiKeyClient; 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 // Loading component with animation
const LoadingComponent = () => ( const LoadingComponent = () => (
<div className="flex flex-col justify-center items-center min-h-screen"> <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> <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> <p className="text-muted-foreground">Loading API Key Management...</p>
</div> </div>
) );
// Dynamically import the ApiKeyClient component // Dynamically import the ApiKeyClient component
const ApiKeyClient = dynamic(() => import('./api-key-client'), { const ApiKeyClient = dynamic(() => import("./api-key-client"), {
ssr: false, ssr: false,
loading: () => <LoadingComponent /> loading: () => <LoadingComponent />,
}) });
export default function ClientWrapper() { export default function ClientWrapper() {
const [isMounted, setIsMounted] = useState(false) const [isMounted, setIsMounted] = useState(false);
useEffect(() => { useEffect(() => {
setIsMounted(true) setIsMounted(true);
}, []) }, []);
if (!isMounted) { if (!isMounted) {
return <LoadingComponent /> return <LoadingComponent />;
} }
return <ApiKeyClient /> return <ApiKeyClient />;
} }

View file

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

View file

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

View file

@ -1,43 +1,47 @@
"use client"; "use client";
import React, { useEffect, useState } from 'react' import { motion, type Variants } from "framer-motion";
import Link from 'next/link' import { AlertCircle, Loader2, Plus, Search, Trash2 } from "lucide-react";
import { motion } from 'framer-motion' import Image from "next/image";
import { Button } from '@/components/ui/button' import Link from "next/link";
import { Plus, Search, Trash2, AlertCircle, Loader2 } from 'lucide-react' import { useRouter } from "next/navigation";
import { Tilt } from '@/components/ui/tilt' import { useEffect, useState } from "react";
import { Spotlight } from '@/components/ui/spotlight' import { toast } from "sonner";
import { Logo } from '@/components/Logo'; import { Logo } from "@/components/Logo";
import { ThemeTogglerComponent } from '@/components/theme/theme-toggle'; import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
import { UserDropdown } from '@/components/UserDropdown'; import { UserDropdown } from "@/components/UserDropdown";
import { toast } from 'sonner'; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
AlertDialogContent, AlertDialogContent,
AlertDialogDescription, AlertDialogDescription,
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog'; } from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { import {
Alert, Card,
AlertDescription, CardContent,
AlertTitle, CardDescription,
} from "@/components/ui/alert"; CardFooter,
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; CardHeader,
import { useSearchSpaces } from '@/hooks/use-search-spaces'; CardTitle,
import { apiClient } from '@/lib/api'; } from "@/components/ui/card";
import { useRouter } from 'next/navigation'; 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 { interface User {
id: string; id: string;
email: string; email: string;
is_active: boolean; is_active: boolean;
is_superuser: boolean; is_superuser: boolean;
is_verified: boolean; is_verified: boolean;
} }
/** /**
@ -46,356 +50,354 @@ interface User {
* @returns Formatted date string (e.g., "Jan 1, 2023") * @returns Formatted date string (e.g., "Jan 1, 2023")
*/ */
const formatDate = (dateString: string): string => { const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleDateString('en-US', { return new Date(dateString).toLocaleDateString("en-US", {
year: 'numeric', year: "numeric",
month: 'short', month: "short",
day: 'numeric' day: "numeric",
}); });
}; };
/** /**
* Loading screen component with animation * Loading screen component with animation
*/ */
const LoadingScreen = () => { const LoadingScreen = () => {
return ( return (
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4"> <div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4">
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.8 }} initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
> >
<Card className="w-[350px] bg-background/60 backdrop-blur-sm"> <Card className="w-[350px] bg-background/60 backdrop-blur-sm">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-xl font-medium">Loading</CardTitle> <CardTitle className="text-xl font-medium">Loading</CardTitle>
<CardDescription>Fetching your search spaces...</CardDescription> <CardDescription>Fetching your search spaces...</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex justify-center py-6"> <CardContent className="flex justify-center py-6">
<motion.div <motion.div
animate={{ rotate: 360 }} animate={{ rotate: 360 }}
transition={{ duration: 1.5, repeat: Infinity, ease: "linear" }} transition={{ duration: 1.5, repeat: Infinity, ease: "linear" }}
> >
<Loader2 className="h-12 w-12 text-primary" /> <Loader2 className="h-12 w-12 text-primary" />
</motion.div> </motion.div>
</CardContent> </CardContent>
<CardFooter className="border-t pt-4 text-sm text-muted-foreground"> <CardFooter className="border-t pt-4 text-sm text-muted-foreground">
This may take a moment This may take a moment
</CardFooter> </CardFooter>
</Card> </Card>
</motion.div> </motion.div>
</div> </div>
); );
}; };
/** /**
* Error screen component with animation * Error screen component with animation
*/ */
const ErrorScreen = ({ message }: { message: string }) => { const ErrorScreen = ({ message }: { message: string }) => {
const router = useRouter(); const router = useRouter();
return ( return (
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4"> <div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4">
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
> >
<Card className="w-[400px] bg-background/60 backdrop-blur-sm border-destructive/20"> <Card className="w-[400px] bg-background/60 backdrop-blur-sm border-destructive/20">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-destructive" /> <AlertCircle className="h-5 w-5 text-destructive" />
<CardTitle className="text-xl font-medium">Error</CardTitle> <CardTitle className="text-xl font-medium">Error</CardTitle>
</div> </div>
<CardDescription>Something went wrong</CardDescription> <CardDescription>Something went wrong</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Alert variant="destructive" className="bg-destructive/10 border-destructive/30"> <Alert variant="destructive" className="bg-destructive/10 border-destructive/30">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertTitle>Error Details</AlertTitle> <AlertTitle>Error Details</AlertTitle>
<AlertDescription className="mt-2"> <AlertDescription className="mt-2">{message}</AlertDescription>
{message} </Alert>
</AlertDescription> </CardContent>
</Alert> <CardFooter className="flex justify-end gap-2 border-t pt-4">
</CardContent> <Button variant="outline" onClick={() => router.refresh()}>
<CardFooter className="flex justify-end gap-2 border-t pt-4"> Try Again
<Button variant="outline" onClick={() => router.refresh()}> </Button>
Try Again <Button onClick={() => router.push("/")}>Go Home</Button>
</Button> </CardFooter>
<Button onClick={() => router.push('/')}> </Card>
Go Home </motion.div>
</Button> </div>
</CardFooter> );
</Card>
</motion.div>
</div>
);
}; };
const DashboardPage = () => { const DashboardPage = () => {
// Animation variants // Animation variants
const containerVariants = { const containerVariants: Variants = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
visible: { visible: {
opacity: 1, opacity: 1,
transition: { transition: {
staggerChildren: 0.1, staggerChildren: 0.1,
}, },
}, },
}; };
const itemVariants = { const itemVariants: Variants = {
hidden: { y: 20, opacity: 0 }, hidden: { y: 20, opacity: 0 },
visible: { visible: {
y: 0, y: 0,
opacity: 1, opacity: 1,
transition: { transition: {
type: "spring", type: "spring",
stiffness: 300, stiffness: 300,
damping: 24, damping: 24,
}, },
}, },
}; };
const router = useRouter(); const { searchSpaces, loading, error, refreshSearchSpaces } = useSearchSpaces();
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);
// Fetch user details // User state management
useEffect(() => { const [user, setUser] = useState<User | null>(null);
const fetchUser = async () => { const [isLoadingUser, setIsLoadingUser] = useState(true);
try { const [userError, setUserError] = useState<string | null>(null);
if (typeof window === 'undefined') return;
try { // Fetch user details
const userData = await apiClient.get<User>('users/me'); useEffect(() => {
setUser(userData); const fetchUser = async () => {
setUserError(null); try {
} catch (error) { if (typeof window === "undefined") return;
console.error('Error fetching user:', error);
setUserError(error instanceof Error ? error.message : 'Unknown error occurred');
} finally {
setIsLoadingUser(false);
}
} catch (error) {
console.error('Error in fetchUser:', error);
setIsLoadingUser(false);
}
};
fetchUser(); 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 fetchUser();
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
};
if (loading) return <LoadingScreen />; // Create user object for UserDropdown
if (error) return <ErrorScreen message={error} />; 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) => { if (loading) return <LoadingScreen />;
// Send DELETE request to the API if (error) return <ErrorScreen message={error} />;
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${localStorage.getItem('surfsense_bearer_token')}`,
},
});
if (!response.ok) {
toast.error("Failed to delete search space");
throw new Error("Failed to delete search space");
}
// Refresh the search spaces list after successful deletion
refreshSearchSpaces();
} catch (error) {
console.error('Error deleting search space:', error);
toast.error("An error occurred while deleting the search space");
return;
}
toast.success("Search space deleted successfully");
};
return ( const handleDeleteSearchSpace = async (id: number) => {
<motion.div // Send DELETE request to the API
className="container mx-auto py-10" try {
initial="hidden" const response = await fetch(
animate="visible" `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${id}`,
variants={containerVariants} {
> method: "DELETE",
<motion.div className="flex flex-col space-y-6" variants={itemVariants}> headers: {
<div className="flex flex-row space-x-4 justify-between"> Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
<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>
<div className="flex flex-col space-y-6 mt-6"> if (!response.ok) {
<div className="flex justify-between items-center"> toast.error("Failed to delete search space");
<h2 className="text-2xl font-semibold">Your Search Spaces</h2> throw new Error("Failed to delete search space");
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> }
<Link href="/dashboard/searchspaces">
<Button className="h-10">
<Plus className="mr-2 h-4 w-4" />
Create Search Space
</Button>
</Link>
</motion.div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> // Refresh the search spaces list after successful deletion
{searchSpaces && searchSpaces.map((space) => ( refreshSearchSpaces();
<Link href={`/dashboard/${space.id}/documents`} key={space.id}> } catch (error) {
<motion.div console.error("Error deleting search space:", error);
key={space.id} toast.error("An error occurred while deleting the search space");
variants={itemVariants} return;
className="aspect-[4/3]" }
> toast.success("Search space deleted successfully");
};
<Tilt return (
rotationFactor={6} <motion.div
isRevese className="container mx-auto py-10"
springOptions={{ initial="hidden"
stiffness: 26.7, animate="visible"
damping: 4.1, variants={containerVariants}
mass: 0.2, >
}} <motion.div className="flex flex-col space-y-6" variants={itemVariants}>
className="group relative rounded-lg h-full" <div className="flex flex-row space-x-4 justify-between">
> <div className="flex flex-row space-x-4">
<Spotlight <Logo className="w-10 h-10 rounded-md" />
className="z-10 from-blue-500/20 via-blue-300/10 to-blue-200/5 blur-2xl" <div className="flex flex-col space-y-2">
size={248} <h1 className="text-4xl font-bold">SurfSense Dashboard</h1>
springOptions={{ <p className="text-muted-foreground">Welcome to your SurfSense dashboard.</p>
stiffness: 26.7, </div>
damping: 4.1, </div>
mass: 0.2, <div className="flex items-center space-x-3">
}} <UserDropdown user={customUser} />
/> <ThemeTogglerComponent />
<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>
<div className="relative h-32 w-full overflow-hidden"> </div>
<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>
</motion.div> <div className="flex flex-col space-y-6 mt-6">
</Link> <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 && ( <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<motion.div {searchSpaces &&
variants={itemVariants} searchSpaces.length > 0 &&
className="col-span-full flex flex-col items-center justify-center p-12 text-center" searchSpaces.map((space) => (
> <Link href={`/dashboard/${space.id}/documents`} key={space.id}>
<div className="rounded-full bg-muted/50 p-4 mb-4"> <motion.div key={space.id} variants={itemVariants} className="aspect-[4/3]">
<Search className="h-8 w-8 text-muted-foreground" /> <Tilt
</div> rotationFactor={6}
<h3 className="text-lg font-medium mb-2">No search spaces found</h3> isRevese
<p className="text-muted-foreground mb-6">Create your first search space to get started</p> springOptions={{
<Link href="/dashboard/searchspaces"> stiffness: 26.7,
<Button> damping: 4.1,
<Plus className="mr-2 h-4 w-4" /> mass: 0.2,
Create Search Space }}
</Button> className="group relative rounded-lg h-full"
</Link> >
</motion.div> <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 && ( <div className="flex flex-1 flex-col justify-between p-4">
<motion.div <div>
variants={itemVariants} <h3 className="font-medium text-lg">{space.name}</h3>
className="aspect-[4/3]" <p className="mt-1 text-sm text-muted-foreground">
> {space.description}
<Tilt </p>
rotationFactor={6} </div>
isRevese <div className="mt-4 flex justify-between text-xs text-muted-foreground">
springOptions={{ {/* <span>{space.title}</span> */}
stiffness: 26.7, <span>Created {formatDate(space.created_at)}</span>
damping: 4.1, </div>
mass: 0.2, </div>
}} </div>
className="group relative rounded-lg h-full" </Tilt>
> </motion.div>
<Link href="/dashboard/searchspaces" className="flex h-full"> </Link>
<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 {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"; "use client";
import { toast } from "sonner";
import { SearchSpaceForm } from "@/components/search-space-form";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { SearchSpaceForm } from "@/components/search-space-form";
export default function SearchSpacesPage() { export default function SearchSpacesPage() {
const router = useRouter(); const router = useRouter();
const handleCreateSearchSpace = async (data: { name: string; description: string }) => { const handleCreateSearchSpace = async (data: { name: string; description: string }) => {
try { try {
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`, { const response = await fetch(
method: 'POST', `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`,
headers: { {
'Content-Type': 'application/json', method: "POST",
Authorization: `Bearer ${localStorage.getItem('surfsense_bearer_token')}`, headers: {
}, "Content-Type": "application/json",
body: JSON.stringify(data), Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
}); },
body: JSON.stringify(data),
if (!response.ok) { }
toast.error("Failed to create search space"); );
throw new Error("Failed to create search space");
}
const result = await response.json();
toast.success("Search space created successfully", {
description: `"${data.name}" has been created.`,
});
router.push(`/dashboard`); if (!response.ok) {
toast.error("Failed to create search space");
return result; throw new Error("Failed to create search space");
} catch (error: any) { }
console.error('Error creating search space:', error);
throw error;
}
};
return ( const result = await response.json();
<motion.div
className="container mx-auto py-10" toast.success("Search space created successfully", {
initial={{ opacity: 0 }} description: `"${data.name}" has been created.`,
animate={{ opacity: 1 }} });
transition={{ duration: 0.5 }}
> router.push(`/dashboard`);
<div className="mx-auto max-w-5xl">
<SearchSpaceForm onSubmit={handleCreateSearchSpace} /> return result;
</div> } catch (error) {
</motion.div> 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 { import { notFound } from "next/navigation";
DocsBody, import { source } from "@/lib/source";
DocsDescription, import { getMDXComponents } from "@/mdx-components";
DocsPage,
DocsTitle, export default async function Page(props: { params: Promise<{ slug?: string[] }> }) {
} from 'fumadocs-ui/page'; const params = await props.params;
import { notFound } from 'next/navigation'; const page = source.getPage(params.slug);
import { getMDXComponents } from '@/mdx-components'; if (!page) notFound();
export default async function Page(props: { const MDX = page.data.body;
params: Promise<{ slug?: string[] }>;
}) { return (
const params = await props.params; <DocsPage toc={page.data.toc} full={page.data.full}>
const page = source.getPage(params.slug); <DocsTitle>{page.data.title}</DocsTitle>
if (!page) notFound(); <DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
const MDX = page.data.body; <MDX components={getMDXComponents()} />
</DocsBody>
return ( </DocsPage>
<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() { 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 { DocsLayout } from 'fumadocs-ui/layouts/docs'; import type { ReactNode } from "react";
import type { ReactNode } from 'react'; import { baseOptions } from "@/app/layout.config";
import { baseOptions } from '@/app/layout.config'; import { source } from "@/lib/source";
export default function Layout({ children }: { children: ReactNode }) { export default function Layout({ children }: { children: ReactNode }) {
return ( return (
<DocsLayout tree={source.pageTree} {...baseOptions}> <DocsLayout tree={source.pageTree} {...baseOptions}>
{children} {children}
</DocsLayout> </DocsLayout>
); );
} }

View file

@ -1,160 +1,160 @@
@import 'tailwindcss'; @import "tailwindcss";
@import 'fumadocs-ui/css/neutral.css'; @import "fumadocs-ui/css/neutral.css";
@import 'fumadocs-ui/css/preset.css'; @import "fumadocs-ui/css/preset.css";
@plugin "tailwindcss-animate"; @plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme { @theme {
--font-sans: var(--font-geist-sans); --font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
} }
:root { :root {
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.145 0 0);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0); --primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0); --muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0); --accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0); --border: oklch(0.922 0 0);
--input: oklch(0.922 0 0); --input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0); --ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116); --chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704); --chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392); --chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429); --chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08); --chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem; --radius: 0.625rem;
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.708 0 0);
--syntax-bg: #f5f5f5; --syntax-bg: #f5f5f5;
} }
.dark { .dark {
--background: oklch(0.145 0 0); --background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0); --card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0); --popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0); --primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0); --primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0); --secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0); --muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0); --accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723); --destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331); --destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0); --border: oklch(0.269 0 0);
--input: oklch(0.269 0 0); --input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0); --ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376); --chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48); --chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08); --chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9); --chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439); --chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0); --sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0); --sidebar-ring: oklch(0.439 0 0);
--syntax-bg: #1e1e1e; --syntax-bg: #1e1e1e;
} }
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
--color-chart-1: var(--chart-1); --color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar); --color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
:root { :root {
--sidebar-background: 0 0% 98%; --sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%; --sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%; --sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%; --sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%; --sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%; --sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%; --sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%; --sidebar-ring: 217.2 91.2% 59.8%;
} }
.dark { .dark {
--sidebar-background: 240 5.9% 10%; --sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%; --sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%; --sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%; --sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%; --sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%; --sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%; --sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%; --sidebar-ring: 217.2 91.2% 59.8%;
} }
} }
button { 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 = { export const baseOptions: BaseLayoutProps = {
nav: { nav: {
title: 'SurfSense Documentation', title: "SurfSense Documentation",
}, },
}; };

View file

@ -1,108 +1,102 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import { cn } from "@/lib/utils"; import { RootProvider } from "fumadocs-ui/provider";
import { Roboto } from "next/font/google"; import { Roboto } from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
import { ThemeProvider } from "@/components/theme/theme-provider"; 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({ const roboto = Roboto({
subsets: ["latin"], subsets: ["latin"],
weight: ["400", "500", "700"], weight: ["400", "500", "700"],
display: 'swap', display: "swap",
variable: '--font-roboto', variable: "--font-roboto",
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "SurfSense Customizable AI Research & Knowledge Management Assistant", title: "SurfSense Customizable AI Research & Knowledge Management Assistant",
description: 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.", "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: [ keywords: [
"SurfSense", "SurfSense",
"AI research assistant", "AI research assistant",
"AI knowledge management", "AI knowledge management",
"AI document assistant", "AI document assistant",
"customizable AI assistant", "customizable AI assistant",
"notion integration", "notion integration",
"slack integration", "slack integration",
"github integration", "github integration",
"hybrid search", "hybrid search",
"vector search", "vector search",
"RAG", "RAG",
"LangChain", "LangChain",
"FastAPI", "FastAPI",
"LLM apps", "LLM apps",
"AI document chat", "AI document chat",
"knowledge management AI", "knowledge management AI",
"AI-powered document search", "AI-powered document search",
"personal AI assistant", "personal AI assistant",
"AI research tools", "AI research tools",
"AI podcast generator", "AI podcast generator",
"AI knowledge base", "AI knowledge base",
"AI document assistant tools", "AI document assistant tools",
"AI-powered search assistant", "AI-powered search assistant",
], ],
openGraph: { openGraph: {
title: "SurfSense AI Research & Knowledge Management Assistant", title: "SurfSense AI Research & Knowledge Management Assistant",
description: 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.", "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", url: "https://surfsense.net",
siteName: "SurfSense", siteName: "SurfSense",
type: "website", type: "website",
images: [ images: [
{ {
url: "https://surfsense.net/og-image.png", url: "https://surfsense.net/og-image.png",
width: 1200, width: 1200,
height: 630, height: 630,
alt: "SurfSense AI Research Assistant", alt: "SurfSense AI Research Assistant",
}, },
], ],
locale: "en_US", locale: "en_US",
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: "SurfSense AI Assistant for Research & Knowledge Management", title: "SurfSense AI Assistant for Research & Knowledge Management",
description: description:
"Have your own NotebookLM or Perplexity, but better. SurfSense connects external tools, allows chat with your documents, and generates fast, high-quality podcasts.", "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", creator: "https://surfsense.net",
site: "https://surfsense.net", site: "https://surfsense.net",
images: [ images: [
{ {
url: "https://surfsense.net/og-image-twitter.png", url: "https://surfsense.net/og-image-twitter.png",
width: 1200, width: 1200,
height: 630, height: 630,
alt: "SurfSense AI Assistant Preview", alt: "SurfSense AI Assistant Preview",
}, },
], ],
} },
}; };
export default async function RootLayout({ export default async function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body <body className={cn(roboto.className, "bg-white dark:bg-black antialiased h-full w-full")}>
className={cn( <ThemeProvider
roboto.className, attribute="class"
"bg-white dark:bg-black antialiased h-full w-full" enableSystem
)} disableTransitionOnChange
> defaultTheme="light"
<ThemeProvider >
attribute="class" <RootProvider>
enableSystem {children}
disableTransitionOnChange <Toaster />
defaultTheme="light" </RootProvider>
> </ThemeProvider>
<RootProvider> </body>
{children} </html>
<Toaster /> );
</RootProvider>
</ThemeProvider>
</body>
</html>
);
} }

View file

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

View file

@ -1,92 +1,99 @@
"use client"; "use client";
import React from "react";
import { IconBrandGoogleFilled } from "@tabler/icons-react"; import { IconBrandGoogleFilled } from "@tabler/icons-react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { AmbientBackground } from "./AmbientBackground"; import { AmbientBackground } from "./AmbientBackground";
export function GoogleLoginButton() { export function GoogleLoginButton() {
const handleGoogleLogin = () => { const handleGoogleLogin = () => {
// Redirect to Google OAuth authorization URL // Redirect to Google OAuth authorization URL
fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/google/authorize`) fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/google/authorize`)
.then((response) => { .then((response) => {
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to get authorization URL'); throw new Error("Failed to get authorization URL");
} }
return response.json(); return response.json();
}) })
.then((data) => { .then((data) => {
if (data.authorization_url) { if (data.authorization_url) {
window.location.href = data.authorization_url; window.location.href = data.authorization_url;
} else { } else {
console.error('No authorization URL received'); console.error("No authorization URL received");
} }
}) })
.catch((error) => { .catch((error) => {
console.error('Error during Google login:', error); console.error("Error during Google login:", error);
}); });
} };
return ( return (
<div className="relative w-full overflow-hidden"> <div className="relative w-full overflow-hidden">
<AmbientBackground /> <AmbientBackground />
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center"> <div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
<Logo className="rounded-md" /> <Logo className="rounded-md" />
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl"> <h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
Welcome Back Welcome Back
</h1> </h1>
<motion.div <motion.div
initial={{ opacity: 0, y: -5 }} initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }} 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" 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 <motion.div
className="flex items-center gap-2 p-4" className="flex items-center gap-2 p-4"
initial={{ x: -5 }} initial={{ x: -5 }}
animate={{ x: 0 }} animate={{ x: 0 }}
transition={{ delay: 0.1, duration: 0.2 }} transition={{ delay: 0.1, duration: 0.2 }}
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="16" width="16"
height="16" height="16"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="2" strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className="flex-shrink-0" 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"/> <title>Google Logo</title>
<line x1="12" y1="9" x2="12" y2="13"/> <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="17" x2="12.01" y2="17"/> <line x1="12" y1="9" x2="12" y2="13" />
</svg> <line x1="12" y1="17" x2="12.01" y2="17" />
<div className="ml-1"> </svg>
<p className="text-sm font-medium"> <div className="ml-1">
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 className="text-sm font-medium">
</p> SurfSense Cloud is currently in development. Check{" "}
</div> <a
</motion.div> href="/docs"
</motion.div> className="text-blue-600 underline dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
<motion.button Docs
whileHover={{ scale: 1.02 }} </a>{" "}
whileTap={{ scale: 0.98 }} for more information on Self-Hosted version.
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" </p>
onClick={handleGoogleLogin} </div>
> </motion.div>
<div className="absolute inset-0 h-full w-full transform opacity-0 transition duration-200 group-hover/btn:opacity-100"> </motion.div>
<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> <motion.button
<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> whileHover={{ scale: 1.02 }}
<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> whileTap={{ scale: 0.98 }}
</div> 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"
<IconBrandGoogleFilled className="h-5 w-5 text-neutral-700 dark:text-neutral-200" /> onClick={handleGoogleLogin}
<span className="text-base font-medium">Continue with Google</span> >
</motion.button> <div className="absolute inset-0 h-full w-full transform opacity-0 transition duration-200 group-hover/btn:opacity-100">
</div> <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> <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"; "use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
export function LocalLoginForm() { export function LocalLoginForm() {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [authType, setAuthType] = useState<string | null>(null); const [authType, setAuthType] = useState<string | null>(null);
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
// Get the auth type from environment variables // Get the auth type from environment variables
setAuthType(process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE"); setAuthType(process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE");
}, []); }, []);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setIsLoading(true); setIsLoading(true);
setError(""); setError("");
try { try {
// Create form data for the API request // Create form data for the API request
const formData = new URLSearchParams(); const formData = new URLSearchParams();
formData.append("username", username); formData.append("username", username);
formData.append("password", password); formData.append("password", password);
formData.append("grant_type", "password"); formData.append("grant_type", "password");
const response = await fetch( const response = await fetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/jwt/login`, `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/jwt/login`,
{ {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
}, },
body: formData.toString(), body: formData.toString(),
} }
); );
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
throw new Error(data.detail || "Failed to login"); throw new Error(data.detail || "Failed to login");
} }
router.push("/auth/callback?token=" + data.access_token); router.push(`/auth/callback?token=${data.access_token}`);
} catch (err: any) { } catch (err) {
setError(err.message || "An error occurred during login"); const errorMessage = err instanceof Error ? err.message : "An error occurred during login";
} finally { setError(errorMessage);
setIsLoading(false); } finally {
} setIsLoading(false);
}; }
};
return ( return (
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{error && ( {error && (
<div className="rounded-md bg-red-50 p-4 text-sm text-red-500 dark:bg-red-900/20 dark:text-red-200"> <div className="rounded-md bg-red-50 p-4 text-sm text-red-500 dark:bg-red-900/20 dark:text-red-200">
{error} {error}
</div> </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>
<div> <div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300"> <label
Password htmlFor="email"
</label> className="block text-sm font-medium text-gray-700 dark:text-gray-300"
<input >
id="password" Email
type="password" </label>
required <input
value={password} id="email"
onChange={(e) => setPassword(e.target.value)} type="email"
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" required
/> value={username}
</div> 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 <div>
type="submit" <label
disabled={isLoading} htmlFor="password"
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" className="block text-sm font-medium text-gray-700 dark:text-gray-300"
> >
{isLoading ? "Signing in..." : "Sign in"} Password
</button> </label>
</form> <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" && ( <button
<div className="mt-4 text-center text-sm"> type="submit"
<p className="text-gray-600 dark:text-gray-400"> disabled={isLoading}
Don&apos;t have an account?{" "} 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"
<Link href="/register" className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"> >
Register here {isLoading ? "Signing in..." : "Sign in"}
</Link> </button>
</p> </form>
</div>
)} {authType === "LOCAL" && (
</div> <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"; "use client";
import { useState, useEffect, Suspense } from "react"; import { Loader2 } from "lucide-react";
import { GoogleLoginButton } from "./GoogleLoginButton"; import { useSearchParams } from "next/navigation";
import { LocalLoginForm } from "./LocalLoginForm"; import { Suspense, useEffect, useState } from "react";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { AmbientBackground } from "./AmbientBackground"; import { AmbientBackground } from "./AmbientBackground";
import { useSearchParams } from "next/navigation"; import { GoogleLoginButton } from "./GoogleLoginButton";
import { Loader2 } from "lucide-react"; import { LocalLoginForm } from "./LocalLoginForm";
function LoginContent() { function LoginContent() {
const [authType, setAuthType] = useState<string | null>(null); const [authType, setAuthType] = useState<string | null>(null);
const [registrationSuccess, setRegistrationSuccess] = useState(false); const [registrationSuccess, setRegistrationSuccess] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const searchParams = useSearchParams(); const searchParams = useSearchParams();
useEffect(() => { useEffect(() => {
// Check if the user was redirected from registration // Check if the user was redirected from registration
if (searchParams.get("registered") === "true") { if (searchParams.get("registered") === "true") {
setRegistrationSuccess(true); setRegistrationSuccess(true);
} }
// Get the auth type from environment variables // Get the auth type from environment variables
setAuthType(process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE"); setAuthType(process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE");
setIsLoading(false); setIsLoading(false);
}, [searchParams]); }, [searchParams]);
// Show loading state while determining auth type // Show loading state while determining auth type
if (isLoading) { if (isLoading) {
return ( return (
<div className="relative w-full overflow-hidden"> <div className="relative w-full overflow-hidden">
<AmbientBackground /> <AmbientBackground />
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center"> <div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
<Logo className="rounded-md" /> <Logo className="rounded-md" />
<div className="mt-8 flex items-center space-x-2"> <div className="mt-8 flex items-center space-x-2">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /> <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">Loading...</span> <span className="text-muted-foreground">Loading...</span>
</div> </div>
</div> </div>
</div> </div>
); );
} }
if (authType === "GOOGLE") { if (authType === "GOOGLE") {
return <GoogleLoginButton />; return <GoogleLoginButton />;
} }
return ( return (
<div className="relative w-full overflow-hidden"> <div className="relative w-full overflow-hidden">
<AmbientBackground /> <AmbientBackground />
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center"> <div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
<Logo className="rounded-md" /> <Logo className="rounded-md" />
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl"> <h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
Sign In Sign In
</h1> </h1>
{registrationSuccess && ( {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"> <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. Registration successful! You can now sign in with your credentials.
</div> </div>
)} )}
<LocalLoginForm /> <LocalLoginForm />
</div> </div>
</div> </div>
); );
} }
// Loading fallback for Suspense // Loading fallback for Suspense
const LoadingFallback = () => ( const LoadingFallback = () => (
<div className="relative w-full overflow-hidden"> <div className="relative w-full overflow-hidden">
<AmbientBackground /> <AmbientBackground />
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center"> <div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
<Logo className="rounded-md" /> <Logo className="rounded-md" />
<div className="mt-8 flex items-center space-x-2"> <div className="mt-8 flex items-center space-x-2">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /> <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">Loading...</span> <span className="text-muted-foreground">Loading...</span>
</div> </div>
</div> </div>
</div> </div>
); );
export default function LoginPage() { export default function LoginPage() {
return ( return (
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
<LoginContent /> <LoginContent />
</Suspense> </Suspense>
); );
} }

View file

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

View file

@ -1,16 +1,15 @@
"use client"; "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 { Footer } from "@/components/Footer";
import { ModernHeroWithGradients } from "@/components/ModernHeroWithGradients";
import { Navbar } from "@/components/Navbar";
export default function HomePage() { export default function HomePage() {
return ( 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"> <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 /> <Navbar />
<ModernHeroWithGradients /> <ModernHeroWithGradients />
<Footer /> <Footer />
</main> </main>
); );
} }

View file

@ -1,146 +1,190 @@
import { Metadata } from "next"; import type { Metadata } from "next";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Privacy Policy | SurfSense", title: "Privacy Policy | SurfSense",
description: "Privacy Policy for SurfSense application", description: "Privacy Policy for SurfSense application",
}; };
export default function PrivacyPolicy() { export default function PrivacyPolicy() {
return ( return (
<div className="container max-w-4xl mx-auto py-12 px-4"> <div className="container max-w-4xl mx-auto py-12 px-4">
<h1 className="text-4xl font-bold mb-8">Privacy Policy</h1> <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>
<section className="mb-8"> <div className="prose dark:prose-invert max-w-none">
<h2 className="text-2xl font-semibold mb-4">1. Introduction</h2> <p className="text-lg mb-6">Last updated: {new Date().toLocaleDateString()}</p>
<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"> <section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">2. Data We Collect</h2> <h2 className="text-2xl font-semibold mb-4">1. Introduction</h2>
<p> <p>
We may collect, use, store and transfer different kinds of personal data about you which we have Welcome to SurfSense. We respect your privacy and are committed to protecting your
grouped together as follows: personal data. This privacy policy will inform you about how we look after your personal
</p> data when you visit our website and tell you about your privacy rights and how the law
<ul className="list-disc pl-6 my-4 space-y-2"> protects you.
<li><strong>Identity Data</strong> includes first name, last name, username or similar identifier.</li> </p>
<li><strong>Contact Data</strong> includes email address and telephone numbers.</li> <p className="mt-4">
<li><strong>Technical Data</strong> includes internet protocol (IP) address, your login data, browser type and version, By using our services, you acknowledge that you have read and understood this Privacy
time zone setting and location, browser plug-in types and versions, operating system and platform, Policy. We reserve the right to modify this policy at any time, and such modifications
and other technology on the devices you use to access this website.</li> shall be effective immediately upon posting the modified policy on this website.
<li><strong>Usage Data</strong> includes information about how you use our website and services.</li> </p>
<li><strong>Surf Data</strong> includes information about surf sessions, preferences, and equipment settings.</li> </section>
<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"> <section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">3. How We Use Your Data</h2> <h2 className="text-2xl font-semibold mb-4">2. Data We Collect</h2>
<p> <p>
We will only use your personal data when the law allows us to. Most commonly, we will use your We may collect, use, store and transfer different kinds of personal data about you which
personal data in the following circumstances: we have grouped together as follows:
</p> </p>
<ul className="list-disc pl-6 my-4 space-y-2"> <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>
<li>Where it is necessary for our legitimate interests (or those of a third party) and your interests <strong>Identity Data</strong> includes first name, last name, username or similar
and fundamental rights do not override those interests.</li> identifier.
<li>Where we need to comply with a legal obligation.</li> </li>
<li>To provide and maintain our services, including to monitor the usage of our service.</li> <li>
<li>To improve our services, products, marketing, and customer relationships and experiences.</li> <strong>Contact Data</strong> includes email address and telephone numbers.
<li>To communicate with you about updates, security alerts, and support messages.</li> </li>
<li>To provide customer support and respond to your requests or inquiries.</li> <li>
<li>For business transfers, such as in connection with a merger, sale of company assets, financing, or acquisition.</li> <strong>Technical Data</strong> includes internet protocol (IP) address, your login
</ul> data, browser type and version, time zone setting and location, browser plug-in types
<p className="mt-4"> and versions, operating system and platform, and other technology on the devices you
We may use your information for marketing purposes, such as sending you information about our products, services, use to access this website.
promotions, and events. You can opt-out of receiving these communications at any time. </li>
</p> <li>
</section> <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"> <section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">4. Data Security</h2> <h2 className="text-2xl font-semibold mb-4">3. How We Use Your Data</h2>
<p> <p>
We have put in place appropriate security measures to prevent your personal data from being We will only use your personal data when the law allows us to. Most commonly, we will
accidentally lost, used or accessed in an unauthorized way, altered or disclosed. In addition, use your personal data in the following circumstances:
we limit access to your personal data to those employees, agents, contractors and other third </p>
parties who have a business need to know. <ul className="list-disc pl-6 my-4 space-y-2">
</p> <li>
<p className="mt-4"> Where we need to perform the contract we are about to enter into or have entered into
While we implement safeguards designed to protect your information, no security system is impenetrable with you.
and due to the inherent nature of the Internet, we cannot guarantee that information, during transmission </li>
through the Internet or while stored on our systems, is absolutely safe from intrusion by others. <li>
</p> Where it is necessary for our legitimate interests (or those of a third party) and
</section> 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"> <section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">5. Data Retention</h2> <h2 className="text-2xl font-semibold mb-4">4. Data Security</h2>
<p> <p>
We will only retain your personal data for as long as necessary to fulfill the purposes we collected it for, We have put in place appropriate security measures to prevent your personal data from
including for the purposes of satisfying any legal, accounting, or reporting requirements. To determine the appropriate being accidentally lost, used or accessed in an unauthorized way, altered or disclosed.
retention period for personal data, we consider the amount, nature, and sensitivity of the personal data, the In addition, we limit access to your personal data to those employees, agents,
potential risk of harm from unauthorized use or disclosure of your personal data, the purposes for which we process contractors and other third parties who have a business need to know.
your personal data and whether we can achieve those purposes through other means, and the applicable legal requirements. </p>
</p> <p className="mt-4">
</section> 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"> <section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">6. Your Legal Rights</h2> <h2 className="text-2xl font-semibold mb-4">5. Data Retention</h2>
<p> <p>
Under certain circumstances, you have rights under data protection laws in relation to your personal data, including: We will only retain your personal data for as long as necessary to fulfill the purposes
</p> we collected it for, including for the purposes of satisfying any legal, accounting, or
<ul className="list-disc pl-6 my-4 space-y-2"> reporting requirements. To determine the appropriate retention period for personal data,
<li>The right to request access to your personal data.</li> we consider the amount, nature, and sensitivity of the personal data, the potential risk
<li>The right to request correction of your personal data.</li> of harm from unauthorized use or disclosure of your personal data, the purposes for
<li>The right to request erasure of your personal data.</li> which we process your personal data and whether we can achieve those purposes through
<li>The right to object to processing of your personal data.</li> other means, and the applicable legal requirements.
<li>The right to request restriction of processing your personal data.</li> </p>
<li>The right to request transfer of your personal data.</li> </section>
<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"> <section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">7. Third-Party Services</h2> <h2 className="text-2xl font-semibold mb-4">6. Your Legal Rights</h2>
<p> <p>
Our service may contain links to other websites that are not operated by us. If you click on a third-party link, Under certain circumstances, you have rights under data protection laws in relation to
you will be directed to that third party's site. We strongly advise you to review the Privacy Policy of every site you visit. your personal data, including:
We have no control over and assume no responsibility for the content, privacy policies, or practices of any third-party </p>
sites or services. <ul className="list-disc pl-6 my-4 space-y-2">
</p> <li>The right to request access to your personal data.</li>
</section> <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"> <section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">8. Contact Us</h2> <h2 className="text-2xl font-semibold mb-4">7. Third-Party Services</h2>
<p> <p>
If you have any questions about this privacy policy or our privacy practices, please contact us at: Our service may contain links to other websites that are not operated by us. If you
</p> click on a third-party link, you will be directed to that third party's site. We
<p className="mt-2"> strongly advise you to review the Privacy Policy of every site you visit. We have no
<strong>Email:</strong> vermarohanfinal@gmail.com control over and assume no responsibility for the content, privacy policies, or
</p> practices of any third-party sites or services.
</section> </p>
</div> </section>
</div>
); <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"; "use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { AmbientBackground } from "../login/AmbientBackground"; import { AmbientBackground } from "../login/AmbientBackground";
export default function RegisterPage() { export default function RegisterPage() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const router = useRouter(); const router = useRouter();
// Check authentication type and redirect if not LOCAL // Check authentication type and redirect if not LOCAL
useEffect(() => { useEffect(() => {
const authType = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE"; const authType = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE";
if (authType !== "LOCAL") { if (authType !== "LOCAL") {
router.push("/login"); router.push("/login");
} }
}, [router]); }, [router]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
// Form validation
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
setIsLoading(true); // Form validation
setError(""); if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
try { setIsLoading(true);
const response = await fetch( setError("");
`${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,
}),
}
);
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) { const data = await response.json();
throw new Error(data.detail || "Registration failed");
}
// Redirect to login page after successful registration if (!response.ok) {
router.push("/login?registered=true"); throw new Error(data.detail || "Registration failed");
} catch (err: any) { }
setError(err.message || "An error occurred during registration");
} finally {
setIsLoading(false);
}
};
return ( // Redirect to login page after successful registration
<div className="relative w-full overflow-hidden"> router.push("/login?registered=true");
<AmbientBackground /> } catch (err: unknown) {
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center"> const errorMessage =
<Logo className="rounded-md" /> err instanceof Error ? err.message : "An error occurred during registration";
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl"> setError(errorMessage);
Create an Account } finally {
</h1> setIsLoading(false);
}
};
<div className="w-full max-w-md"> return (
<form onSubmit={handleSubmit} className="space-y-4"> <div className="relative w-full overflow-hidden">
{error && ( <AmbientBackground />
<div className="rounded-md bg-red-50 p-4 text-sm text-red-500 dark:bg-red-900/20 dark:text-red-200"> <div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
{error} <Logo className="rounded-md" />
</div> <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="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>
<div> <div className="w-full max-w-md">
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300"> <form onSubmit={handleSubmit} className="space-y-4">
Password {error && (
</label> <div className="rounded-md bg-red-50 p-4 text-sm text-red-500 dark:bg-red-900/20 dark:text-red-200">
<input {error}
id="password" </div>
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> <div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300"> <label
Confirm Password htmlFor="email"
</label> className="block text-sm font-medium text-gray-700 dark:text-gray-300"
<input >
id="confirmPassword" Email
type="password" </label>
required <input
value={confirmPassword} id="email"
onChange={(e) => setConfirmPassword(e.target.value)} type="email"
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" required
/> value={email}
</div> 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 <div>
type="submit" <label
disabled={isLoading} htmlFor="password"
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" className="block text-sm font-medium text-gray-700 dark:text-gray-300"
> >
{isLoading ? "Creating account..." : "Register"} Password
</button> </label>
</form> <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"> <div>
<p className="text-gray-600 dark:text-gray-400"> <label
Already have an account?{" "} htmlFor="confirmPassword"
<Link href="/login" className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"> className="block text-sm font-medium text-gray-700 dark:text-gray-300"
Sign in >
</Link> Confirm Password
</p> </label>
</div> <input
</div> id="confirmPassword"
</div> type="password"
</div> 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"; "use client";
import React from 'react'; import { ArrowLeft, Bot, Brain, Settings } from "lucide-react"; // Import ArrowLeft icon
import { useRouter } from 'next/navigation'; // Add this import import { useRouter } from "next/navigation"; // Add this import
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { LLMRoleManager } from "@/components/settings/llm-role-manager";
import { Separator } from '@/components/ui/separator'; import { ModelConfigManager } from "@/components/settings/model-config-manager";
import { Bot, Settings, Brain, ArrowLeft } from 'lucide-react'; // Import ArrowLeft icon import { Separator } from "@/components/ui/separator";
import { ModelConfigManager } from '@/components/settings/model-config-manager'; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { LLMRoleManager } from '@/components/settings/llm-role-manager';
export default function SettingsPage() { export default function SettingsPage() {
const router = useRouter(); // Initialize router const router = useRouter(); // Initialize router
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<div className="container max-w-7xl mx-auto p-6 lg:p-8"> <div className="container max-w-7xl mx-auto p-6 lg:p-8">
<div className="space-y-8"> <div className="space-y-8">
{/* Header Section */} {/* Header Section */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
{/* Back Button */} {/* Back Button */}
<button <button
onClick={() => router.push('/dashboard')} 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" 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" aria-label="Back to Dashboard"
type="button" type="button"
> >
<ArrowLeft className="h-5 w-5 text-primary" /> <ArrowLeft className="h-5 w-5 text-primary" />
</button> </button>
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10"> <div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<Settings className="h-6 w-6 text-primary" /> <Settings className="h-6 w-6 text-primary" />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">Settings</h1> <h1 className="text-3xl font-bold tracking-tight">Settings</h1>
<p className="text-lg text-muted-foreground"> <p className="text-lg text-muted-foreground">
Manage your LLM configurations and role assignments. Manage your LLM configurations and role assignments.
</p> </p>
</div> </div>
</div> </div>
<Separator className="my-6" /> <Separator className="my-6" />
</div> </div>
{/* Settings Content */} {/* Settings Content */}
<Tabs defaultValue="models" className="space-y-8"> <Tabs defaultValue="models" className="space-y-8">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<TabsList className="grid w-full min-w-fit grid-cols-2 lg:w-auto lg:inline-grid"> <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"> <TabsTrigger value="models" className="flex items-center gap-2 text-sm">
<Bot className="h-4 w-4" /> <Bot className="h-4 w-4" />
<span className="hidden sm:inline">Model Configs</span> <span className="hidden sm:inline">Model Configs</span>
<span className="sm:hidden">Models</span> <span className="sm:hidden">Models</span>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="roles" className="flex items-center gap-2 text-sm"> <TabsTrigger value="roles" className="flex items-center gap-2 text-sm">
<Brain className="h-4 w-4" /> <Brain className="h-4 w-4" />
<span className="hidden sm:inline">LLM Roles</span> <span className="hidden sm:inline">LLM Roles</span>
<span className="sm:hidden">Roles</span> <span className="sm:hidden">Roles</span>
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</div> </div>
<TabsContent value="models" className="space-y-6"> <TabsContent value="models" className="space-y-6">
<ModelConfigManager /> <ModelConfigManager />
</TabsContent> </TabsContent>
<TabsContent value="roles" className="space-y-6"> <TabsContent value="roles" className="space-y-6">
<LLMRoleManager /> <LLMRoleManager />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
</div> </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 { export default function sitemap(): MetadataRoute.Sitemap {
return [ return [
{ {
url: 'https://www.surfsense.net/', url: "https://www.surfsense.net/",
lastModified: new Date(), lastModified: new Date(),
changeFrequency: 'yearly', changeFrequency: "yearly",
priority: 1, priority: 1,
}, },
{ {
url: 'https://www.surfsense.net/privacy', url: "https://www.surfsense.net/privacy",
lastModified: new Date(), lastModified: new Date(),
changeFrequency: 'monthly', changeFrequency: "monthly",
priority: 0.9, priority: 0.9,
}, },
{ {
url: 'https://www.surfsense.net/terms', url: "https://www.surfsense.net/terms",
lastModified: new Date(), lastModified: new Date(),
changeFrequency: 'monthly', changeFrequency: "monthly",
priority: 0.9, priority: 0.9,
}, },
{ {
url: 'https://www.surfsense.net/docs', url: "https://www.surfsense.net/docs",
lastModified: new Date(), lastModified: new Date(),
changeFrequency: 'weekly', changeFrequency: "weekly",
priority: 0.9, priority: 0.9,
}, },
{ {
url: 'https://www.surfsense.net/docs/installation', url: "https://www.surfsense.net/docs/installation",
lastModified: new Date(), lastModified: new Date(),
changeFrequency: 'weekly', changeFrequency: "weekly",
priority: 0.9, priority: 0.9,
}, },
{ {
url: 'https://www.surfsense.net/docs/docker-installation', url: "https://www.surfsense.net/docs/docker-installation",
lastModified: new Date(), lastModified: new Date(),
changeFrequency: 'weekly', changeFrequency: "weekly",
priority: 0.9, priority: 0.9,
}, },
{ {
url: 'https://www.surfsense.net/docs/manual-installation', url: "https://www.surfsense.net/docs/manual-installation",
lastModified: new Date(), lastModified: new Date(),
changeFrequency: 'weekly', changeFrequency: "weekly",
priority: 0.9, priority: 0.9,
}, },
] ];
} }

View file

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

View file

@ -1,106 +1,97 @@
"use client"; "use client";
import { cn } from "@/lib/utils";
import { import {
IconBrandDiscord, IconBrandDiscord,
IconBrandGithub, IconBrandGithub,
IconBrandLinkedin, IconBrandLinkedin,
IconBrandTwitter, IconBrandTwitter,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import Link from "next/link"; import Link from "next/link";
import React from "react"; import type React from "react";
import { cn } from "@/lib/utils";
export function Footer() { export function Footer() {
const pages = [ const pages = [
{ {
title: "Privacy", title: "Privacy",
href: "/privacy", href: "/privacy",
}, },
{ {
title: "Terms", title: "Terms",
href: "/terms", href: "/terms",
}, },
]; ];
return ( 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="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="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="flex flex-col items-center justify-center w-full relative">
<div className="mr-0 md:mr-4 md:flex mb-4"> <div className="mr-0 md:mr-4 md:flex mb-4">
<div className="flex items-center"> <div className="flex items-center">
<span className="font-medium text-black dark:text-white ml-2">SurfSense</span> <span className="font-medium text-black dark:text-white ml-2">SurfSense</span>
</div> </div>
</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"> <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) => ( {pages.map((page) => (
<li key={"pages" + idx} className="list-none"> <li key={`pages-${page.title}`} className="list-none">
<Link <Link className="transition-colors hover:text-text-neutral-800" href={page.href}>
className="transition-colors hover:text-text-neutral-800" {page.title}
href={page.href} </Link>
> </li>
{page.title} ))}
</Link> </ul>
</li>
))}
</ul>
<GridLineHorizontal className="max-w-7xl mx-auto mt-8" /> <GridLineHorizontal className="max-w-7xl mx-auto mt-8" />
</div> </div>
<div className="flex sm:flex-row flex-col justify-between mt-8 items-center w-full"> <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"> <p className="text-neutral-500 dark:text-neutral-400 mb-8 sm:mb-0">
&copy; SurfSense 2025 &copy; SurfSense 2025
</p> </p>
<div className="flex gap-4"> <div className="flex gap-4">
<Link href="https://x.com/mod_setter"> <Link href="https://x.com/mod_setter">
<IconBrandTwitter className="h-6 w-6 text-neutral-500 dark:text-neutral-300" /> <IconBrandTwitter className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
</Link> </Link>
<Link href="https://www.linkedin.com/in/rohan-verma-sde/"> <Link href="https://www.linkedin.com/in/rohan-verma-sde/">
<IconBrandLinkedin className="h-6 w-6 text-neutral-500 dark:text-neutral-300" /> <IconBrandLinkedin className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
</Link> </Link>
<Link href="https://github.com/MODSetter"> <Link href="https://github.com/MODSetter">
<IconBrandGithub className="h-6 w-6 text-neutral-500 dark:text-neutral-300" /> <IconBrandGithub className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
</Link> </Link>
<Link href="https://discord.gg/ejRNvftDp9"> <Link href="https://discord.gg/ejRNvftDp9">
<IconBrandDiscord className="h-6 w-6 text-neutral-500 dark:text-neutral-300" /> <IconBrandDiscord className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
</Link> </Link>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
} }
const GridLineHorizontal = ({ const GridLineHorizontal = ({ className, offset }: { className?: string; offset?: string }) => {
className, return (
offset, <div
}: { style={
className?: string; {
offset?: string; "--background": "#ffffff",
}) => { "--color": "rgba(0, 0, 0, 0.2)",
return ( "--height": "1px",
<div "--width": "5px",
style={ "--fade-stop": "90%",
{ "--offset": offset || "200px", //-100px if you want to keep the line inside
"--background": "#ffffff", "--color-dark": "rgba(255, 255, 255, 0.2)",
"--color": "rgba(0, 0, 0, 0.2)", maskComposite: "exclude",
"--height": "1px", } as React.CSSProperties
"--width": "5px", }
"--fade-stop": "90%", className={cn(
"--offset": offset || "200px", //-100px if you want to keep the line inside "w-[calc(100%+var(--offset))] h-[var(--height)]",
"--color-dark": "rgba(255, 255, 255, 0.2)", "bg-[linear-gradient(to_right,var(--color),var(--color)_50%,transparent_0,transparent)]",
maskComposite: "exclude", "[background-size:var(--width)_var(--height)]",
} as React.CSSProperties "[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]",
className={cn( "z-30",
"w-[calc(100%+var(--offset))] h-[var(--height)]", "dark:bg-[linear-gradient(to_right,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
"bg-[linear-gradient(to_right,var(--color),var(--color)_50%,transparent_0,transparent)]", className
"[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)]", ></div>
"[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"; "use client";
import Link from "next/link";
import React from "react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export const Logo = ({ className }: { className?: string }) => { export const Logo = ({ className }: { className?: string }) => {
return ( return (
<Link <Link href="/">
href="/" <Image src="/icon-128.png" className={cn(className)} alt="logo" width={128} height={128} />
> </Link>
<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"; "use client";
import { cn } from "@/lib/utils"; import { IconMenu2, IconUser, IconX } from "@tabler/icons-react";
import { IconMenu2, IconX, IconBrandGoogleFilled, IconUser } from "@tabler/icons-react"; import { AnimatePresence, motion, useMotionValueEvent, useScroll } from "framer-motion";
import {
motion,
AnimatePresence,
useScroll,
useMotionValueEvent,
} from "framer-motion";
import Link from "next/link"; import Link from "next/link";
import React, { useRef, useState } from "react"; import { useRef, useState } from "react";
import { Button } from "./ui/button"; import { cn } from "@/lib/utils";
import { Logo } from "./Logo"; import { Logo } from "./Logo";
import { ThemeTogglerComponent } from "./theme/theme-toggle"; import { ThemeTogglerComponent } from "./theme/theme-toggle";
import { Button } from "./ui/button";
interface NavbarProps { interface NavbarProps {
navItems: { navItems: {
name: string; name: string;
link: string; link: string;
}[]; }[];
visible: boolean; visible: boolean;
} }
export const Navbar = () => { export const Navbar = () => {
const navItems = [ const navItems = [
{ {
name: "Docs", name: "Docs",
link: "/docs", link: "/docs",
}, },
// { // {
// name: "Product", // name: "Product",
// link: "/#product", // link: "/#product",
// }, // },
// { // {
// name: "Pricing", // name: "Pricing",
// link: "/#pricing", // link: "/#pricing",
// }, // },
]; ];
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const { scrollY } = useScroll({ const { scrollY } = useScroll({
target: ref, target: ref,
offset: ["start start", "end start"], offset: ["start start", "end start"],
}); });
const [visible, setVisible] = useState<boolean>(false); const [visible, setVisible] = useState<boolean>(false);
useMotionValueEvent(scrollY, "change", (latest) => { useMotionValueEvent(scrollY, "change", (latest) => {
if (latest > 100) { if (latest > 100) {
setVisible(true); setVisible(true);
} else { } else {
setVisible(false); setVisible(false);
} }
}); });
return ( return (
<motion.div ref={ref} className="w-full fixed top-2 inset-x-0 z-50"> <motion.div ref={ref} className="w-full fixed top-2 inset-x-0 z-50">
<DesktopNav visible={visible} navItems={navItems} /> <DesktopNav visible={visible} navItems={navItems} />
<MobileNav visible={visible} navItems={navItems} /> <MobileNav visible={visible} navItems={navItems} />
</motion.div> </motion.div>
); );
}; };
const DesktopNav = ({ navItems, visible }: NavbarProps) => { const DesktopNav = ({ navItems, visible }: NavbarProps) => {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null); const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const handleGoogleLogin = () => { const handleGoogleLogin = () => {
// Redirect to the login page // Redirect to the login page
window.location.href = '/login'; window.location.href = "/login";
}; };
return ( return (
<motion.div <motion.div
onMouseLeave={() => setHoveredIndex(null)} onMouseLeave={() => setHoveredIndex(null)}
animate={{ animate={{
backdropFilter: "blur(16px)", backdropFilter: "blur(16px)",
background: visible background: visible
? "rgba(var(--background-rgb), 0.8)" ? "rgba(var(--background-rgb), 0.8)"
: "rgba(var(--background-rgb), 0.6)", : "rgba(var(--background-rgb), 0.6)",
width: visible ? "38%" : "80%", width: visible ? "38%" : "80%",
height: visible ? "48px" : "64px", height: visible ? "48px" : "64px",
y: visible ? 8 : 0, y: visible ? 8 : 0,
}} }}
initial={{ initial={{
width: "80%", width: "80%",
height: "64px", height: "64px",
background: "rgba(var(--background-rgb), 0.6)", background: "rgba(var(--background-rgb), 0.6)",
}} }}
transition={{ transition={{
type: "spring", type: "spring",
stiffness: 400, stiffness: 400,
damping: 30, damping: 30,
}} }}
className={cn( 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]", "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" visible ? "border dark:border-white/10 border-gray-300/30" : "border-0"
)} )}
style={{ style={
"--background-rgb": "var(--tw-dark) ? '0, 0, 0' : '255, 255, 255'", {
} as React.CSSProperties} "--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 className="flex flex-row items-center gap-2">
</div> <Logo className="h-8 w-8 rounded-md" />
<div className="flex items-center gap-4"> <span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
<motion.div </div>
className="lg:flex flex-row items-center justify-end space-x-1 text-sm" <div className="flex items-center gap-4">
animate={{ <motion.div
scale: visible ? 0.9 : 1, 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}`} {navItems.map((navItem, idx) => (
onHoverStart={() => setHoveredIndex(idx)} <motion.div
className="relative" key={`nav-item-${navItem.name}`}
> onHoverStart={() => setHoveredIndex(idx)}
<Link className="relative"
className="dark:text-white/90 text-gray-800 relative px-3 py-1.5 transition-colors" >
href={navItem.link} <Link
> className="dark:text-white/90 text-gray-800 relative px-3 py-1.5 transition-colors"
<span className="relative z-10">{navItem.name}</span> href={navItem.link}
{hoveredIndex === idx && ( >
<motion.div <span className="relative z-10">{navItem.name}</span>
layoutId="menu-hover" {hoveredIndex === idx && (
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" <motion.div
initial={{ opacity: 0, scale: 0.8 }} layoutId="menu-hover"
animate={{ 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"
opacity: 1, initial={{ opacity: 0, scale: 0.8 }}
scale: 1.1, animate={{
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%)", opacity: 1,
}} scale: 1.1,
exit={{ background:
opacity: 0, "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%)",
scale: 0.8, }}
transition: { exit={{
duration: 0.2, opacity: 0,
}, scale: 0.8,
}} transition: {
transition={{ duration: 0.2,
type: "spring", },
bounce: 0.4, }}
duration: 0.4, transition={{
}} type: "spring",
/> bounce: 0.4,
)} duration: 0.4,
</Link> }}
</motion.div> />
))} )}
</motion.div> </Link>
<ThemeTogglerComponent /> </motion.div>
<AnimatePresence mode="popLayout" initial={false}> ))}
{!visible && ( </motion.div>
<motion.div <ThemeTogglerComponent />
initial={{ scale: 0.8, opacity: 0 }} <AnimatePresence mode="popLayout" initial={false}>
animate={{ {!visible && (
scale: 1, <motion.div
opacity: 1, initial={{ scale: 0.8, opacity: 0 }}
transition: { animate={{
type: "spring", scale: 1,
stiffness: 400, opacity: 1,
damping: 25, transition: {
}, type: "spring",
}} stiffness: 400,
exit={{ damping: 25,
scale: 0.8, },
opacity: 0, }}
transition: { exit={{
duration: 0.2, 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" <Button
> onClick={handleGoogleLogin}
<IconUser className="h-4 w-4" /> variant="outline"
<span>Sign in</span> 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"
</Button> >
</motion.div> <IconUser className="h-4 w-4" />
)} <span>Sign in</span>
</AnimatePresence> </Button>
</div> </motion.div>
</motion.div> )}
); </AnimatePresence>
</div>
</motion.div>
);
}; };
const MobileNav = ({ navItems, visible }: NavbarProps) => { const MobileNav = ({ navItems, visible }: NavbarProps) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const handleGoogleLogin = () => { const handleGoogleLogin = () => {
// Redirect to the login page // Redirect to the login page
window.location.href = "./login"; window.location.href = "./login";
}; };
return ( return (
<> <motion.div
<motion.div animate={{
animate={{ backdropFilter: "blur(16px)",
backdropFilter: "blur(16px)", background: visible
background: visible ? "rgba(var(--background-rgb), 0.8)"
? "rgba(var(--background-rgb), 0.8)" : "rgba(var(--background-rgb), 0.6)",
: "rgba(var(--background-rgb), 0.6)", width: visible ? "80%" : "90%",
width: visible ? "80%" : "90%", y: visible ? 0 : 8,
y: visible ? 0 : 8, borderRadius: open ? "24px" : "full",
borderRadius: open ? "24px" : "full", padding: "8px 16px",
padding: "8px 16px", }}
}} initial={{
initial={{ width: "80%",
width: "80%", background: "rgba(var(--background-rgb), 0.6)",
background: "rgba(var(--background-rgb), 0.6)", }}
}} transition={{
transition={{ type: "spring",
type: "spring", stiffness: 400,
stiffness: 400, damping: 30,
damping: 30, }}
}} className={cn(
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",
"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"
visible ? "border border-solid dark:border-white/40 border-gray-300/30" : "border-0" )}
)} style={
style={{ {
"--background-rgb": "var(--tw-dark) ? '0, 0, 0' : '255, 255, 255'", "--background-rgb": "var(--tw-dark) ? '0, 0, 0' : '255, 255, 255'",
} as React.CSSProperties} } 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 flex-row justify-between items-center w-full">
<div className="flex items-center gap-2"> <Logo className="h-8 w-8 rounded-md" />
<ThemeTogglerComponent /> <div className="flex items-center gap-2">
{open ? ( <ThemeTogglerComponent />
<IconX className="dark:text-white/90 text-gray-800" onClick={() => setOpen(!open)} /> {open ? (
) : ( <IconX className="dark:text-white/90 text-gray-800" onClick={() => setOpen(!open)} />
<IconMenu2 ) : (
className="dark:text-white/90 text-gray-800" <IconMenu2
onClick={() => setOpen(!open)} className="dark:text-white/90 text-gray-800"
/> onClick={() => setOpen(!open)}
)} />
</div> )}
</div> </div>
</div>
<AnimatePresence> <AnimatePresence>
{open && ( {open && (
<motion.div <motion.div
initial={{ initial={{
opacity: 0, opacity: 0,
y: -20, y: -20,
}} }}
animate={{ animate={{
opacity: 1, opacity: 1,
y: 0, y: 0,
}} }}
exit={{ exit={{
opacity: 0, opacity: 0,
y: -20, y: -20,
}} }}
transition={{ transition={{
type: "spring", type: "spring",
stiffness: 400, stiffness: 400,
damping: 30, 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" 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( {navItems.map((navItem: { link: string; name: string }) => (
(navItem: { link: string; name: string }, idx: number) => ( <Link
<Link key={`link-${navItem.name}`}
key={`link=${idx}`} href={navItem.link}
href={navItem.link} onClick={() => setOpen(false)}
onClick={() => setOpen(false)} className="relative dark:text-white/90 text-gray-800 hover:text-gray-900 dark:hover:text-white transition-colors"
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>
<motion.span className="block">{navItem.name}</motion.span> </Link>
</Link> ))}
) <Button
)} onClick={handleGoogleLogin}
<Button variant="outline"
onClick={handleGoogleLogin} 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"
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>
<IconUser className="h-4 w-4" /> </Button>
<span>Sign in</span> </motion.div>
</Button> )}
</motion.div> </AnimatePresence>
)} </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 { interface TokenHandlerProps {
redirectPath?: string; // Path to redirect after storing token redirectPath?: string; // Path to redirect after storing token
tokenParamName?: string; // Name of the URL parameter containing the token tokenParamName?: string; // Name of the URL parameter containing the token
storageKey?: string; // Key to use when storing in localStorage storageKey?: string; // Key to use when storing in localStorage
} }
/** /**
* Client component that extracts a token from URL parameters and stores it 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 redirectPath - Path to redirect after storing token (default: '/')
* @param tokenParamName - Name of the URL parameter containing the token (default: 'token') * @param tokenParamName - Name of the URL parameter containing the token (default: 'token')
* @param storageKey - Key to use when storing in localStorage (default: 'auth_token') * @param storageKey - Key to use when storing in localStorage (default: 'auth_token')
*/ */
const TokenHandler = ({ const TokenHandler = ({
redirectPath = '/', redirectPath = "/",
tokenParamName = 'token', tokenParamName = "token",
storageKey = 'surfsense_bearer_token' storageKey = "surfsense_bearer_token",
}: TokenHandlerProps) => { }: TokenHandlerProps) => {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
useEffect(() => { useEffect(() => {
// Only run on client-side // Only run on client-side
if (typeof window === 'undefined') return; if (typeof window === "undefined") return;
// Get token from URL parameters // Get token from URL parameters
const token = searchParams.get(tokenParamName); const token = searchParams.get(tokenParamName);
if (token) { if (token) {
try { try {
// Store token in localStorage // Store token in localStorage
localStorage.setItem(storageKey, token); localStorage.setItem(storageKey, token);
// console.log(`Token stored in localStorage with key: ${storageKey}`); // console.log(`Token stored in localStorage with key: ${storageKey}`);
// Redirect to specified path
router.push(redirectPath);
} catch (error) {
console.error('Error storing token in localStorage:', error);
}
}
}, [searchParams, tokenParamName, storageKey, redirectPath, router]);
return ( // Redirect to specified path
<div className="flex items-center justify-center min-h-[200px]"> router.push(redirectPath);
<p className="text-gray-500">Processing authentication...</p> } catch (error) {
</div> 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 { import {
BadgeCheck, DropdownMenu,
ChevronsUpDown, DropdownMenuContent,
LogOut, DropdownMenuGroup,
Settings, DropdownMenuItem,
} from "lucide-react" DropdownMenuLabel,
DropdownMenuSeparator,
import { DropdownMenuTrigger,
Avatar, } from "@/components/ui/dropdown-menu";
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"
export function UserDropdown({ export function UserDropdown({
user, user,
}: { }: {
user: { user: {
name: string name: string;
email: string email: string;
avatar: string avatar: string;
} };
}) { }) {
const router = useRouter() const router = useRouter();
const handleLogout = () => { const handleLogout = () => {
try { try {
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
localStorage.removeItem('surfsense_bearer_token'); localStorage.removeItem("surfsense_bearer_token");
router.push('/'); router.push("/");
} }
} catch (error) { } catch (error) {
console.error('Error during logout:', error); console.error("Error during logout:", error);
// Optionally, provide user feedback // Optionally, provide user feedback
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
alert('Logout failed. Please try again.'); alert("Logout failed. Please try again.");
router.push('/'); router.push("/");
} }
} }
}; };
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button variant="ghost" className="relative h-10 w-10 rounded-full">
variant="ghost" <Avatar className="h-8 w-8">
className="relative h-10 w-10 rounded-full" <AvatarImage src={user.avatar} alt={user.name} />
> <AvatarFallback>{user.name.charAt(0)?.toUpperCase() || "?"}</AvatarFallback>
<Avatar className="h-8 w-8"> </Avatar>
<AvatarImage src={user.avatar} alt={user.name} /> </Button>
<AvatarFallback>{user.name.charAt(0)?.toUpperCase() || '?'}</AvatarFallback> </DropdownMenuTrigger>
</Avatar> <DropdownMenuContent className="w-56" align="end" forceMount>
</Button> <DropdownMenuLabel className="font-normal">
</DropdownMenuTrigger> <div className="flex flex-col space-y-1">
<DropdownMenuContent <p className="text-sm font-medium leading-none">{user.name}</p>
className="w-56" <p className="text-xs leading-none text-muted-foreground">{user.email}</p>
align="end" </div>
forceMount </DropdownMenuLabel>
> <DropdownMenuSeparator />
<DropdownMenuLabel className="font-normal"> <DropdownMenuGroup>
<div className="flex flex-col space-y-1"> <DropdownMenuItem onClick={() => router.push(`/dashboard/api-key`)}>
<p className="text-sm font-medium leading-none">{user.name}</p> <BadgeCheck className="mr-2 h-4 w-4" />
<p className="text-xs leading-none text-muted-foreground"> API Key
{user.email} </DropdownMenuItem>
</p> </DropdownMenuGroup>
</div> <DropdownMenuSeparator />
</DropdownMenuLabel> <DropdownMenuItem onClick={() => router.push(`/settings`)}>
<DropdownMenuSeparator /> <Settings className="mr-2 h-4 w-4" />
<DropdownMenuGroup> Settings
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push(`/dashboard/api-key`)}> <DropdownMenuItem onClick={handleLogout}>
<BadgeCheck className="mr-2 h-4 w-4" /> <LogOut className="mr-2 h-4 w-4" />
API Key Log out
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuGroup> </DropdownMenu>
<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"; "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 { 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 { useSidebar } from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
// Font configuration - could be moved to a global font config file // Font configuration - could be moved to a global font config file
const manrope = Manrope({ const manrope = Manrope({
subsets: ["latin"], subsets: ["latin"],
weight: ["400", "700"], weight: ["400", "700"],
display: "swap", // Optimize font loading display: "swap", // Optimize font loading
variable: "--font-manrope" variable: "--font-manrope",
}); });
// Constants for timing - makes it easier to adjust and more maintainable // Constants for timing - makes it easier to adjust and more maintainable
const TIMING = { const TIMING = {
SIDEBAR_TRANSITION: 300, // Wait for sidebar transition + buffer SIDEBAR_TRANSITION: 300, // Wait for sidebar transition + buffer
LAYOUT_SETTLE: 100, // Small delay to ensure layout is fully settled LAYOUT_SETTLE: 100, // Small delay to ensure layout is fully settled
} as const; } as const;
// Animation configuration // Animation configuration
const ANIMATION_CONFIG = { const ANIMATION_CONFIG = {
HIGHLIGHT: { HIGHLIGHT: {
type: "highlight" as const, type: "highlight" as const,
animationDuration: 2000, animationDuration: 2000,
iterations: 3, iterations: 3,
color: "#3b82f680", color: "#3b82f680",
multiline: true, multiline: true,
}, },
UNDERLINE: { UNDERLINE: {
type: "underline" as const, type: "underline" as const,
animationDuration: 2000, animationDuration: 2000,
iterations: 3, iterations: 3,
color: "#10b981", color: "#10b981",
}, },
} as const; } as const;
// State management with useReducer for better organization // State management with useReducer for better organization
interface HighlightState { interface HighlightState {
shouldShowHighlight: boolean; shouldShowHighlight: boolean;
layoutStable: boolean; layoutStable: boolean;
} }
type HighlightAction = type HighlightAction =
| { type: "SIDEBAR_CHANGED" } | { type: "SIDEBAR_CHANGED" }
| { type: "LAYOUT_STABILIZED" } | { type: "LAYOUT_STABILIZED" }
| { type: "SHOW_HIGHLIGHT" } | { type: "SHOW_HIGHLIGHT" }
| { type: "HIDE_HIGHLIGHT" }; | { type: "HIDE_HIGHLIGHT" };
const highlightReducer = ( const highlightReducer = (state: HighlightState, action: HighlightAction): HighlightState => {
state: HighlightState, switch (action.type) {
action: HighlightAction case "SIDEBAR_CHANGED":
): HighlightState => { return {
switch (action.type) { shouldShowHighlight: false,
case "SIDEBAR_CHANGED": layoutStable: false,
return { };
shouldShowHighlight: false, case "LAYOUT_STABILIZED":
layoutStable: false, return {
}; ...state,
case "LAYOUT_STABILIZED": layoutStable: true,
return { };
...state, case "SHOW_HIGHLIGHT":
layoutStable: true, return {
}; ...state,
case "SHOW_HIGHLIGHT": shouldShowHighlight: true,
return { };
...state, case "HIDE_HIGHLIGHT":
shouldShowHighlight: true, return {
}; ...state,
case "HIDE_HIGHLIGHT": shouldShowHighlight: false,
return { };
...state, default:
shouldShowHighlight: false, return state;
}; }
default:
return state;
}
}; };
const initialState: HighlightState = { const initialState: HighlightState = {
shouldShowHighlight: false, shouldShowHighlight: false,
layoutStable: true, layoutStable: true,
}; };
export function AnimatedEmptyState() { export function AnimatedEmptyState() {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref); const isInView = useInView(ref);
const { state: sidebarState } = useSidebar(); const { state: sidebarState } = useSidebar();
const [{ shouldShowHighlight, layoutStable }, dispatch] = useReducer( const [{ shouldShowHighlight, layoutStable }, dispatch] = useReducer(
highlightReducer, highlightReducer,
initialState initialState
); );
// Memoize class names to prevent unnecessary recalculations // Memoize class names to prevent unnecessary recalculations
const headingClassName = useMemo(() => cn( const headingClassName = useMemo(
"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, 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(() => const paragraphClassName = useMemo(
"text-lg sm:text-xl text-neutral-600 dark:text-neutral-300 mb-8 max-w-2xl mx-auto", () => "text-lg sm:text-xl text-neutral-600 dark:text-neutral-300 mb-8 max-w-2xl mx-auto",
[]); []
);
// Handle sidebar state changes // Handle sidebar state changes
useEffect(() => { useEffect(() => {
dispatch({ type: "SIDEBAR_CHANGED" }); dispatch({ type: "SIDEBAR_CHANGED" });
const stabilizeTimer = setTimeout(() => { const stabilizeTimer = setTimeout(() => {
dispatch({ type: "LAYOUT_STABILIZED" }); dispatch({ type: "LAYOUT_STABILIZED" });
}, TIMING.SIDEBAR_TRANSITION); }, TIMING.SIDEBAR_TRANSITION);
return () => clearTimeout(stabilizeTimer); return () => clearTimeout(stabilizeTimer);
}, [sidebarState]); }, []);
// Handle highlight visibility based on layout stability and viewport visibility // Handle highlight visibility based on layout stability and viewport visibility
useEffect(() => { useEffect(() => {
if (!layoutStable || !isInView) { if (!layoutStable || !isInView) {
dispatch({ type: "HIDE_HIGHLIGHT" }); dispatch({ type: "HIDE_HIGHLIGHT" });
return; return;
} }
const showTimer = setTimeout(() => { const showTimer = setTimeout(() => {
dispatch({ type: "SHOW_HIGHLIGHT" }); dispatch({ type: "SHOW_HIGHLIGHT" });
}, TIMING.LAYOUT_SETTLE); }, TIMING.LAYOUT_SETTLE);
return () => clearTimeout(showTimer); return () => clearTimeout(showTimer);
}, [layoutStable, isInView]); }, [layoutStable, isInView]);
return ( return (
<div <div ref={ref} className="flex-1 flex items-center justify-center w-full min-h-[400px]">
ref={ref} <div className="max-w-4xl mx-auto px-4 py-10 text-center">
className="flex-1 flex items-center justify-center w-full min-h-[400px]" <RoughNotationGroup show={shouldShowHighlight}>
> <h1 className={headingClassName}>
<div className="max-w-4xl mx-auto px-4 py-10 text-center"> <RoughNotation {...ANIMATION_CONFIG.HIGHLIGHT}>
<RoughNotationGroup show={shouldShowHighlight}> <span>SurfSense</span>
<h1 className={headingClassName}> </RoughNotation>
<RoughNotation {...ANIMATION_CONFIG.HIGHLIGHT}> </h1>
<span>SurfSense</span>
</RoughNotation>
</h1>
<p className={paragraphClassName}> <p className={paragraphClassName}>
<RoughNotation {...ANIMATION_CONFIG.UNDERLINE}> <RoughNotation {...ANIMATION_CONFIG.UNDERLINE}>Let's Start Surfing</RoughNotation>{" "}
Let's Start Surfing through your knowledge base.
</RoughNotation>{" "} </p>
through your knowledge base. </RoughNotationGroup>
</p> </div>
</RoughNotationGroup> </div>
</div> );
</div>
);
} }

View file

@ -1,20 +1,14 @@
"use client"; "use client";
import React from "react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ExternalLink } from "lucide-react"; 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 }> = ({ export const CitationDisplay: React.FC<{ index: number; node: any }> = ({ index, node }) => {
index,
node,
}) => {
const truncateText = (text: string, maxLength: number = 200) => { const truncateText = (text: string, maxLength: number = 200) => {
if (text.length <= maxLength) return text; if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + "..."; return `${text.substring(0, maxLength)}...`;
}; };
const handleUrlClick = (e: React.MouseEvent, url: string) => { 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"> <PopoverContent className="w-80 p-4 space-y-3 relative" align="start">
{/* External Link Button - Top Right */} {/* External Link Button - Top Right */}
{node?.url && ( {node?.url && (
<button <Button
size="icon"
variant="ghost"
onClick={(e) => handleUrlClick(e, node.url)} 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" 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" title="Open in new tab"
> >
<ExternalLink size={14} /> <ExternalLink size={14} />
</button> </Button>
)} )}
{/* Heading */} {/* Heading */}

View file

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

View file

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

View file

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

View file

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

View file

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

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